Skip to content

RFC: Allow processors to associate child files with real filesystem paths for typed linting #20378

@CyberT33N

Description

@CyberT33N

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 VFile and finally to the parser as filePath, so that tools like @typescript-eslint/parser with project / projectService can 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.ts
  • file.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 files patterns matching virtual child names.

However, for typed linting with TypeScript, there is a fundamental limitation:

  • @typescript-eslint/parser with parserOptions.project or parserOptions.projectService expects a stable, real filePath on disk that belongs to a TypeScript project (via tsconfig).
  • 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/.mdc examples, 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/parser and 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:

  • filename continues to be the virtual child path used for:
    • files pattern matching.
    • ESLint’s reported filename in diagnostics.
  • physicalFilename, when provided, represents the actual on-disk path that parsers and other tools should use.
  • If physicalFilename is 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 VFile as:

      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 filePath derived from file.path (the logical filename).

  • Proposal: when file.physicalPath is present and differs from file.path, ESLint should prefer it as filePath for 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 same filePath as before.
  • For processor children with physicalFilename, parsers receive the real on-disk path (e.g. a materialized temp .ts file inside .eslint-markdown-temp/**), which can be included in tsconfig and 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 .ts files 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 physicalFilename through to the parser.

  • A tsconfig.json includes .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/markdown to materialize code blocks and return physicalFilename (see eslint/markdown#595).

In practice, this enabled:

  • Typed rules from @typescript-eslint/eslint-plugin to 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.path is still derived from parent.path plus an index-prefixed filename.
    • VFile.physicalPath still defaults to the parent file’s physical path.
    • Parsers see the same filePath as today.
  • The new physicalFilename field 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

  1. Rely on allowDefaultProject or 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.ts cannot be added to tsconfig and do not exist on disk.
  2. 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.md and particular blocks).
    • It would be invasive and error‑prone.
  3. 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/markdown implemented an opt‑in materialization mode leveraging the same concept, see eslint/markdown#595.

This issue is intended to:

  1. Clearly describe the problem space and proposed extension to ESLint’s processor model.
  2. 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.
  3. 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

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:
    • files pattern matching,
    • configuration selection, and
    • diagnostics and reporting.
  • Thread the optional physical filename through to the internal VFile.physicalPath and pass it as parserOptions.filePath to 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.ts
  • file.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 files patterns 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.project or parserOptions.projectService is enabled, @typescript-eslint/parser expects a stable, real filePath on disk that:
    • can be included in a tsconfig.json, and
    • can be opened by the TypeScript language service.
  • Virtual child names like file.md/0.ts (or file.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 .ts files 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 / .mdc examples.
  • 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 / .mdc documents 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/parser with parserOptions.projectService: true,
    • @eslint/markdown to extract code blocks from .md / .mdc.

The goal is to:

  • Materialize each TS code block as a real .ts file 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:

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 files patterns 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.physicalPath is set to block.physicalFilename when provided,
    • otherwise defaults to the parent’s physicalPath as 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, so filePathForParser is unchanged.
  • For processor children with physicalFilename set:
    • file.path remains the virtual child path (e.g. file.md/0.ts),
    • file.physicalPath is the real file on disk (e.g. .eslint-markdown-temp/.../0_0.ts),
    • parserOptions.filePath is 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.filename
  • context.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.filename is the virtual child (e.g. some-doc.mdc/0_0.ts),
    • context.physicalFilename is 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.md or equivalent):
    • Define the object shape { text, filename, physicalFilename? }.
    • Clarify the roles of filename (virtual) vs physicalFilename (real).
    • Mention primary use cases (e.g. typed linting with @typescript-eslint/parser and temp-file materialization).
  • Potentially, the rule author docs:
    • Note that context.physicalFilename can differ from context.filename for processor children,
    • Encourage rule authors to use physicalFilename if they interact with the filesystem.

6. Example: Markdown processor with temp-file materialization

A Markdown processor (such as @eslint/markdown) could:

  1. Optionally materialize each fenced TypeScript code block as a .ts file under a project-local directory (e.g. .eslint-markdown-temp/**).

  2. Return, for each block:

    {
      text: blockText,
      filename: virtualFilename,           // e.g. "0.ts"
      physicalFilename: materializedPath,  // e.g. ".eslint-markdown-temp/<sanitized>/<index>_0.ts"
    }
  3. Consumers add .eslint-markdown-temp/**/*.ts to tsconfig.include.

  4. ESLint passes materializedPath as filePath to @typescript-eslint/parser.

  5. 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.
  • Existing configs and rules:
    • Continue to see the same context.filename values.
    • Continue to match the same files patterns.
    • See identical behavior unless a processor explicitly sets physicalFilename.

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.ts cannot be added to tsconfig and 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 filePath passed 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 filename and physicalFilename for advanced use cases.
  • Introduces one more way that context.filename and context.physicalFilename can 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 / physicalPath concepts already present in ESLint.

Adoption Strategy

  1. Accept this RFC and finalize the API naming (physicalFilename, physicalPath).
  2. Implement core changes in ESLint:
    • Processor service extension to set VFile.physicalPath from physicalFilename.
    • JS language integration to prefer physicalPath for parser filePath.
    • Tests covering:
      • Processors with and without physicalFilename.
      • Integrations where context.filename and context.physicalFilename differ.
  3. Document the new contract in the custom processors documentation.
  4. Coordinate with @eslint/markdown (and potentially other processors) to:
    • Implement opt-in materialization and physicalFilename support.
    • Document recommended patterns for tempDir (e.g. project-local .eslint-markdown-temp folder, ignored in VCS, explicitly included in tsconfig).
  5. Encourage ecosystem experiments:
    • Other processors (MDX, custom DSLs, etc.) can adopt the pattern to enable typed linting.

Unresolved Questions

  • Naming:
    • Is physicalFilename the right field name on the processor block object?
    • Is physicalPath the right name internally on VFile?
  • 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

https://github.com/CyberT33N/eslint/tree/feat/ESLINT-001/processor-physical-filename-child-files/main

Participation

  • I am willing to submit a pull request for this issue.

Additional comments

No response

Metadata

Metadata

Assignees

Labels

coreRelates to ESLint's core APIs and featuresfeatureThis change adds a new feature to ESLint

Type

No type

Projects

Status

Complete

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions