-
-
Notifications
You must be signed in to change notification settings - Fork 4.9k
Description
Environment
Environment
- Node version: v20.19.6
- npm version: 10.8.2
- Local ESLint version: v9.39.1
- Global ESLint version: none (ESLint not installed globally)
- Operating System: Windows (win32 10.0.26100)
What parser are you using?
@typescript-eslint/parser
What did you do?
This issue proposes an RFC-level feature for ESLint’s processor pipeline and language integration: allow processors to optionally associate each child file with a real filesystem path (a “physical filename”), in addition to the existing virtual child filename.
When a processor opts in, ESLint would:
- Keep using the existing virtual child filename for configuration matching and reporting (e.g.
file.md/0.ts,file.mdc/0_0.ts), and - Thread an optional physical filename through to the internal
VFileand finally to the parser asfilePath, so that tools like@typescript-eslint/parserwithproject/projectServicecan operate on real files on disk.
The goal is to enable advanced scenarios such as typed linting of code blocks extracted by processors (Markdown, MDX, .mdc, custom formats) while keeping existing behavior 100% unchanged for processors and configurations that do not opt in.
This issue is a follow‑up to a proof‑of‑concept PR that was closed in favor of opening an issue/RFC first: eslint/eslint#20375 and the related @eslint/markdown PR eslint/markdown#595.
Background & Problem
Today, ESLint processors can split a non-JS file (e.g. Markdown) into virtual child files like:
file.md/0.tsfile.mdc/0_0.ts
This works very well for:
- Running JS/TS lint rules on extracted code blocks.
- Mapping diagnostics back to the original document.
- Configuring ESLint via
filespatterns matching virtual child names.
However, for typed linting with TypeScript, there is a fundamental limitation:
@typescript-eslint/parserwithparserOptions.projectorparserOptions.projectServiceexpects a stable, realfilePathon disk that belongs to a TypeScript project (viatsconfig).- ESLint’s processors currently only expose virtual child paths, which:
- Do not exist on disk.
- Cannot be included in
tsconfig.json. - Cannot be resolved by the TypeScript project service.
As a result:
- Typed rules (e.g., those relying on TypeScript type information) cannot reliably run on code extracted by processors (Markdown/MDX/
.mdcexamples, embedded TS snippets, etc.). - This is becoming increasingly important for large codebases and “docs as code” setups where design standards, architecture rules, or security rules must be enforced in Markdown/MDX examples with full type information.
A prototype in a fork of ESLint and @eslint/markdown demonstrates that this limitation can be addressed in a backwards‑compatible and opt‑in way, by allowing processors to provide an additional physical filename per child and teaching ESLint core to thread that through to parsers.
Goals
- Enable typed linting on processor children (e.g. Markdown code blocks) with
@typescript-eslint/parserand a TypeScript project service. - Keep existing processor behavior unchanged by default.
- Allow processors to:
- Continue using their existing virtual filenames for UX and configuration, and
- Optionally map each virtual child to a real, materialized temp file (or other on-disk file) used by parsers.
- Provide a clear, documented contract for processors that want to opt into this advanced behavior.
Non-goals:
- Changing how ESLint reports filenames in diagnostics (the virtual child filename continues to be used).
- Forcing any existing processor or plugin to adopt this feature.
High-Level Proposal
1. Extend the processor child block object with an optional physicalFilename
Today, the “custom processor” contract (for object-returning preprocessors) effectively uses something like:
{
text: string; // content to lint
filename: string; // virtual child filename (used for matching, reporting)
}Proposal: document and support an optional physicalFilename field:
{
text: string; // required
filename: string; // required (virtual name)
physicalFilename?: string; // optional real path on disk
}Semantics:
filenamecontinues to be the virtual child path used for:filespattern matching.- ESLint’s reported filename in diagnostics.
physicalFilename, when provided, represents the actual on-disk path that parsers and other tools should use.- If
physicalFilenameis omitted, behavior remains exactly as today.
This is the same shape used in the prototype PR eslint/eslint#20375 and documented in the related Markdown PR eslint/markdown#595.
2. Thread physicalFilename into VFile.physicalPath for child files
In the processor service:
-
When a processor returns a string, behavior remains unchanged.
-
When a processor returns an object with
{ text, filename, physicalFilename? }:-
ESLint continues to compute the virtual child path as it does today (e.g.
parent.path + index + "_" + block.filename). -
ESLint constructs the child
VFileas:const virtualPath = /* existing logic using parent.path & block.filename */; const physicalPath = block.physicalFilename || parent.physicalPath; new VFile(virtualPath, block.text, { physicalPath });
-
-
This mirrors the behavior from the PoC in eslint/eslint#20375, but formalized via RFC.
3. Prefer physicalPath when selecting filePath for parsers
In the JavaScript language integration:
-
When parsing a file, ESLint currently passes a
filePathderived fromfile.path(the logical filename). -
Proposal: when
file.physicalPathis present and differs fromfile.path, ESLint should prefer it asfilePathfor parsers:const { path, physicalPath } = file; const filePathForParser = physicalPath || path; const parserOptions = { ...languageOptions.parserOptions, filePath: filePathForParser, // ... };
Effect:
- For normal files and processor children without
physicalFilename,physicalPath === path, so parsers see exactly the samefilePathas before. - For processor children with
physicalFilename, parsers receive the real on-disk path (e.g. a materialized temp.tsfile inside.eslint-markdown-temp/**), which can be included intsconfigand resolved by the TypeScript project service.
ESLint would still:
- Use the virtual child filename for:
context.filename- Configuration matching
- Reporting and diagnostics
4. Clarify rule context semantics (virtual vs physical)
ESLint already distinguishes between the reported filename and the physical filename in RuleContext via context.filename and context.physicalFilename (with physicalFilename usually defaulting to the same value).
This proposal simply leverages and documents that existing distinction:
context.filename: the ESLint "logical" or virtual filename (e.g.file.md/0.ts).context.physicalFilename: the real path on disk used for parsing (e.g..eslint-markdown-temp/some-doc/0_block.ts), when available.
Rules that care about the real filesystem path can read context.physicalFilename; most rules can continue using context.filename as they do today.
Primary Use Case: Typed linting of Markdown / MDX / .mdc code blocks
The concrete motivating use case is integration with @eslint/markdown and @typescript-eslint/parser:
-
@eslint/markdown(or similar processors) can materialize fenced code blocks as real.tsfiles under a project-local directory (e.g..eslint-markdown-temp/**). -
The processor returns, for each code block:
{ text: blockText, filename: "0.ts", // or similar virtual child name physicalFilename: pathToMaterializedTempFile // e.g. ".eslint-markdown-temp/<sanitized>/<index>_0.ts" }
-
ESLint’s processor pipeline and JS language pass
physicalFilenamethrough to the parser. -
A
tsconfig.jsonincludes.eslint-markdown-temp/**/*.ts, so the TypeScript project service sees those files and can provide full type information.
This has been prototyped in a fork with:
- A small change in ESLint core (this proposal).
- A corresponding change in
@eslint/markdownto materialize code blocks and returnphysicalFilename(see eslint/markdown#595).
In practice, this enabled:
- Typed rules from
@typescript-eslint/eslint-pluginto run on Markdown code blocks. - No changes for users who don’t enable the materialization mode.
Backwards Compatibility
The feature is designed to be fully backwards compatible:
- Processors that only return
{ text, filename }:- Continue to work exactly as they do now.
- Child
VFile.pathis still derived fromparent.pathplus an index-prefixed filename. VFile.physicalPathstill defaults to the parent file’s physical path.- Parsers see the same
filePathas today.
- The new
physicalFilenamefield is optional and only affects behavior when processors explicitly set it. - Diagnostics, rule behavior, and configuration for existing projects remain unchanged unless a processor opts into the physical filename feature.
Alternatives Considered
-
Rely on
allowDefaultProjector similar parser options in@typescript-eslint/parser- This does not solve the core problem: the TypeScript project service still needs a real file path that belongs to a project.
- Virtual paths like
file.md/0.tscannot be added totsconfigand do not exist on disk.
-
Have processors rewrite the parent filename or change how filenames are reported
- This would break the existing UX and mental model around processor children (e.g. diagnostics no longer map cleanly back to
file.mdand particular blocks). - It would be invasive and error‑prone.
- This would break the existing UX and mental model around processor children (e.g. diagnostics no longer map cleanly back to
-
Custom out-of-band file maps in plugins
- Plugins could, in theory, maintain their own mapping from virtual children to real files.
- However, the TypeScript project service is integrated at the parser layer; without a stable
filePath, it cannot participate in that mapping.
Relation to Previous PRs and Next Steps
- A prototype implementation of this idea was submitted in eslint/eslint#20375 and closed with guidance to “open an issue first” and likely an RFC.
- A companion PR in
@eslint/markdownimplemented an opt‑in materialization mode leveraging the same concept, see eslint/markdown#595.
This issue is intended to:
- Clearly describe the problem space and proposed extension to ESLint’s processor model.
- Gather feedback from the ESLint team and community on:
- Whether this is a direction ESLint is willing to support.
- Naming and API details (
physicalFilename,physicalPath, etc.). - Any additional constraints or use cases that should be considered.
- Serve as the basis for a formal RFC, as suggested in the feedback on eslint/eslint#20375.
If the maintainers agree that this is a reasonable direction, I’m happy to:
- Draft a full RFC document in the format you prefer, based on this issue.
- Iterate on the design and implementation details.
- Update the existing prototype PRs (or open new ones) to align with the agreed design.
RFC: Allow processors to associate child files with real filesystem paths for typed linting
- Start Date: 2025-12-05
- RFC PR: (TBD)
- Authors: (@CyberT33N)
- Related PRs:
- ESLint core PoC: feat: allow overriding physical filename for child files #20375
- @eslint/markdown PoC: feat: add optional temp-file materialization for markdown code blocks markdown#595
Summary
This RFC proposes an extension to ESLint’s processor pipeline and language integration to allow processors to optionally associate child files with real filesystem paths, in addition to their existing virtual child filenames.
Concretely, when a processor returns an object for each child file, it may include an optional physicalFilename field:
{
text: string; // content to lint (required)
filename: string; // virtual child filename (required, used for config + reporting)
physicalFilename?: string; // real filesystem path (optional, used for parsers)
}ESLint will:
- Keep using the virtual child filename (
filename+ internal prefix) for:filespattern matching,- configuration selection, and
- diagnostics and reporting.
- Thread the optional physical filename through to the internal
VFile.physicalPathand pass it asparserOptions.filePathto the parser.
This enables advanced scenarios — especially typed linting for code extracted by processors (Markdown, MDX, .mdc, custom formats) via @typescript-eslint/parser with parserOptions.project / parserOptions.projectService — without changing behavior for existing processors or configurations that do not opt in.
Motivation
Problem: Processors + typed TypeScript linting don’t align today
ESLint processors allow extracting embedded code from non-JS files into virtual child files such as:
file.md/0.tsfile.mdc/0_0.ts
This model works well for:
- Running ESLint rules on code blocks in Markdown/MDX/other formats.
- Mapping diagnostics back to the original document and block.
- Matching configuration via
filespatterns on those virtual child names.
However, this model is insufficient for typed TypeScript linting with @typescript-eslint/parser and the TypeScript project service:
- When
parserOptions.projectorparserOptions.projectServiceis enabled,@typescript-eslint/parserexpects a stable, realfilePathon disk that:- can be included in a
tsconfig.json, and - can be opened by the TypeScript language service.
- can be included in a
- Virtual child names like
file.md/0.ts(orfile.mdc/0_0.ts) do not exist on disk and cannot be part of a TS project. - As a result, even if a processor materializes code blocks to real
.tsfiles on disk (e.g. under.eslint-markdown-temp/**), the TypeScript project service will still see only the virtual child path from ESLint, not the real temp file.
This leads to:
- Typed rules not running (or failing) on processor children.
- Inability to enforce architectural / design / security rules with type information in Markdown / MDX /
.mdcexamples. - A gap for teams using “docs as code” approaches where examples must pass the same typed linting standards as source files.
Concrete motivating scenario
In a real-world enterprise setup:
- Markdown /
.mdcdocuments contain TypeScript code blocks that define design and architecture standards (e.g. interface rules, layering rules). - The project uses:
- ESLint with flat configs,
@typescript-eslint/parserwithparserOptions.projectService: true,@eslint/markdownto extract code blocks from.md/.mdc.
The goal is to:
- Materialize each TS code block as a real
.tsfile under a project-local directory (e.g..eslint-markdown-temp/**), - Include this directory in
tsconfig.json, and - Use TypeScript’s full type information to run typed rules on the blocks.
A prototype implementation shows that this is achievable with:
- A small extension to ESLint’s processor contract and language integration (this RFC),
- An opt-in materialization mode in
@eslint/markdown.
The PoC PRs are:
- ESLint core: eslint/eslint#20375
@eslint/markdown: eslint/markdown#595
These PRs were closed with the guidance to open an issue and likely an RFC first, which this document addresses.
Detailed Design
1. Extend the processor child block object with physicalFilename?
Current implicit contract
Today, object-returning preprocessors effectively use an object shape:
{
text: string; // content to lint
filename: string; // virtual child filename (used to select language + config)
}The virtual child path that ESLint uses internally for the VFile is derived from the parent path and child index, e.g.:
virtualPath = path.join(parent.path, `${index}_${block.filename}`);This virtualPath is:
- what rules see as
context.filename, - what appears in diagnostics,
- what
filespatterns match on.
Proposed extension
We extend and document this object shape with an optional physicalFilename:
type ProcessorChildBlock = {
text: string;
filename: string;
physicalFilename?: string;
};Semantics:
text(required): the content of the child to lint.filename(required):- remains the logical / virtual name of the child,
- includes an extension used to select the language (e.g.
.ts), - participates in constructing the virtual path (
parent.path+ index +filename).
physicalFilename(optional):- is a real filesystem path,
- is intended for parsers and tools that require a real
filePath, - is not used for configuration matching or reporting.
When physicalFilename is not provided, behavior remains identical to current behavior.
2. Processor Service: thread physicalFilename into VFile.physicalPath
In the processor service (conceptually, in lib/services/processor-service.js):
-
When the preprocessor returns a string, behavior is unchanged.
-
When the preprocessor returns an object:
// Pseudocode const blocks = preprocessResult; // array of ProcessorChildBlock or strings files: blocks.map((block, index) => { if (typeof block === "string") { // existing simple behavior const virtualPath = /* existing logic */; return new VFile(virtualPath, block, { physicalPath: parentFile.physicalPath }); } // object-returning processor const virtualPath = path.join(parentFile.path, `${index}_${block.filename}`); const physicalPath = block.physicalFilename || parentFile.physicalPath; return new VFile(virtualPath, block.text, { physicalPath }); })
Key points:
- Virtual path:
- derived exactly as today,
- remains the
VFile.path, - is what ESLint uses for:
context.filename,- configuration matching,
- reported filenames in diagnostics.
- Physical path:
VFile.physicalPathis set toblock.physicalFilenamewhen provided,- otherwise defaults to the parent’s
physicalPathas today.
This matches the behavior implemented in the PoC PR eslint/eslint#20375, but formalized as an official part of the processor contract.
3. JS language integration: prefer physicalPath as parser filePath
In the JavaScript language integration (conceptually, lib/languages/js/index.js), ESLint currently passes a filePath derived from file.path to parsers.
We change this to:
const { path, physicalPath } = file;
const filePathForParser = physicalPath || path;
const parserOptions = {
ecmaVersion,
sourceType,
...languageOptions.parserOptions,
filePath: filePathForParser,
// ...
};- For files without a special
physicalPath(most cases),physicalPath === path, sofilePathForParseris unchanged. - For processor children with
physicalFilenameset:file.pathremains the virtual child path (e.g.file.md/0.ts),file.physicalPathis the real file on disk (e.g..eslint-markdown-temp/.../0_0.ts),parserOptions.filePathis the real path — which can be part of a TypeScript project.
This allows parsers like @typescript-eslint/parser to:
- join the TypeScript project service using the real disk path,
- resolve types for processor children, while ESLint still reports diagnostics using the virtual path.
4. RuleContext semantics (virtual vs physical filename)
ESLint already exposes:
context.filenamecontext.physicalFilename
to rules, where physicalFilename currently defaults to filename unless overridden.
Under this RFC:
- For normal files and processor children without
physicalFilename:context.filename === context.physicalFilename(no change).
- For processor children with
physicalFilename:context.filenameis the virtual child (e.g.some-doc.mdc/0_0.ts),context.physicalFilenameis the real file (e.g..eslint-markdown-temp/some-doc/0_0.ts).
Rules that need filesystem semantics can explicitly use context.physicalFilename; most rules can continue to rely on context.filename as they do today.
5. Documentation updates
We would document this extension in at least:
- The “Custom Processor Specification” (
docs/src/extend/custom-processors.mdor equivalent):- Define the object shape
{ text, filename, physicalFilename? }. - Clarify the roles of
filename(virtual) vsphysicalFilename(real). - Mention primary use cases (e.g. typed linting with
@typescript-eslint/parserand temp-file materialization).
- Define the object shape
- Potentially, the rule author docs:
- Note that
context.physicalFilenamecan differ fromcontext.filenamefor processor children, - Encourage rule authors to use
physicalFilenameif they interact with the filesystem.
- Note that
6. Example: Markdown processor with temp-file materialization
A Markdown processor (such as @eslint/markdown) could:
-
Optionally materialize each fenced TypeScript code block as a
.tsfile under a project-local directory (e.g..eslint-markdown-temp/**). -
Return, for each block:
{ text: blockText, filename: virtualFilename, // e.g. "0.ts" physicalFilename: materializedPath, // e.g. ".eslint-markdown-temp/<sanitized>/<index>_0.ts" }
-
Consumers add
.eslint-markdown-temp/**/*.tstotsconfig.include. -
ESLint passes
materializedPathasfilePathto@typescript-eslint/parser. -
Typed rules run on Markdown code blocks with full type information, while diagnostics still reference
some-doc.mdc/0.ts.
This is essentially what has been prototyped in eslint/markdown#595, but would become officially supported once this RFC is accepted and implemented in ESLint core.
Backwards Compatibility
This RFC is designed to be fully backwards compatible:
- Existing processors:
- If they only return
{ text, filename }, nothing changes. - They do not need to be updated.
- If they only return
- Existing configs and rules:
- Continue to see the same
context.filenamevalues. - Continue to match the same
filespatterns. - See identical behavior unless a processor explicitly sets
physicalFilename.
- Continue to see the same
The only behavior change occurs when:
- A processor opt-in returns
physicalFilename, and - The parser/consumer makes use of that
filePath.
Even then:
- The reported filenames and configuration matching still use the virtual child path, preserving UX and mental models.
Alternatives
Alternative 1: Parser-side workarounds (allowDefaultProject, etc.)
One idea is to rely on parser options like allowDefaultProject in @typescript-eslint/parser or similar mechanisms.
Why this is insufficient:
- The TypeScript project service still needs a real disk path that belongs to a TS project.
- Virtual paths like
file.md/0.tscannot be added totsconfigand do not exist on disk. - Even if the parser relaxes some checks, type information for virtual children cannot be resolved without a real file in the project.
Alternative 2: Change how filenames are reported
Another idea would be to make processors change the reported filename to the real path (e.g. .eslint-markdown-temp/...), and directly expose that as context.filename.
Downsides:
- Loses the intuitive mapping from diagnostics to:
- the original Markdown file, and
- the particular code block.
- Makes UX worse for users (seeing temp paths in errors instead of
some-doc.mdc/0.ts). - Is a breaking change for existing tooling that relies on the current virtual child naming.
Alternative 3: Out-of-band mappings in plugins
Plugins could in theory maintain their own mapping between virtual and real paths and intercept TypeScript behavior externally.
Downsides:
- The TypeScript project service is tightly coupled to
filePathpassed to the parser. - Without ESLint passing the real path, the mapping cannot be reliably integrated.
- This would be complex, fragile, and not reusable across processors.
Drawbacks
- Slightly increases the conceptual surface area of processors:
- authors now need to understand the difference between
filenameandphysicalFilenamefor advanced use cases.
- authors now need to understand the difference between
- Introduces one more way that
context.filenameandcontext.physicalFilenamecan differ, which may surprise some rule authors who implicitly assume they are always equal. - Requires careful documentation and tests to ensure no regressions in existing processors and language integrations.
However, these drawbacks are mitigated by:
- Keeping the feature strictly opt-in.
- Maintaining existing behavior for all processors and rules that do not use
physicalFilename. - Leveraging existing
physicalFilename/physicalPathconcepts already present in ESLint.
Adoption Strategy
- Accept this RFC and finalize the API naming (
physicalFilename,physicalPath). - Implement core changes in ESLint:
- Processor service extension to set
VFile.physicalPathfromphysicalFilename. - JS language integration to prefer
physicalPathfor parserfilePath. - Tests covering:
- Processors with and without
physicalFilename. - Integrations where
context.filenameandcontext.physicalFilenamediffer.
- Processors with and without
- Processor service extension to set
- Document the new contract in the custom processors documentation.
- Coordinate with
@eslint/markdown(and potentially other processors) to:- Implement opt-in materialization and
physicalFilenamesupport. - Document recommended patterns for
tempDir(e.g. project-local.eslint-markdown-tempfolder, ignored in VCS, explicitly included intsconfig).
- Implement opt-in materialization and
- Encourage ecosystem experiments:
- Other processors (MDX, custom DSLs, etc.) can adopt the pattern to enable typed linting.
Unresolved Questions
- Naming:
- Is
physicalFilenamethe right field name on the processor block object? - Is
physicalPaththe right name internally onVFile?
- Is
- Scope:
- Should this behavior be restricted to JS/TS languages, or be available generically to all languages that ESLint supports?
- Additional tooling:
- Do we want helper utilities or documented patterns to help processor authors build temp-file-based workflows safely and portably?
- Versioning & scheduling:
- In which major/minor release should this land, given ongoing work in ESLint 10 and plugin compatibility?
These questions can be refined and answered during the RFC discussion and implementation design review.
What did you expect to happen?
I expected that ESLint processors could support typed linting for extracted child files (e.g. Markdown/MDX/.mdc code blocks) when those blocks are materialized as real .ts files on disk. In practice, this means a processor should be able to associate each virtual child (like file.mdc/0_0.ts) with a stable on-disk path so that @typescript-eslint/parser with parserOptions.project / projectService can treat those files as part of a TypeScript project and provide full type information for rules.
What actually happened?
In the current ESLint core, processors can only expose virtual child filenames (such as file.md/0.ts or file.mdc/0_0.ts), and that virtual path is what gets passed to the parser as filePath. Even if a processor materializes each block as a real .ts file on disk, there is no supported way to tell ESLint (and thus the parser) about that real path. As a result, the TypeScript project service cannot resolve these virtual child files, typed rules fail with “file was not found by the project service” errors, and it is effectively impossible to run type-aware @typescript-eslint rules on processor children without forking ESLint to add an ad-hoc physicalFilename threading as done in my prototype.
Link to Minimal Reproducible Example
Participation
- I am willing to submit a pull request for this issue.
Additional comments
No response
Metadata
Metadata
Assignees
Labels
Type
Projects
Status