Skip to content

feat: run TypeScript 7 content mappers - #1166

Draft
wagenet wants to merge 4 commits into
oxc-project:codex/migrate-typescript-submodulefrom
wagenet:wagenet/tsgolint-content-mappers
Draft

wagenet wants to merge 4 commits into
oxc-project:codex/migrate-typescript-submodulefrom
wagenet:wagenet/tsgolint-content-mappers

Conversation

@wagenet

@wagenet wagenet commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Stacked on #1143, and targets its branch rather than main, so the diff here is only the four
commits on top. #1143 is waiting on a TS 7.0.3 tag, so this cannot merge before it. Draft mainly so
the two design questions below can be settled while that waits.

Downstream: oxc-project/oxc#26236 (oxlint --run-external-code and the LSP option) and oxc-project/oxc-vscode#374 (the VS Code setting behind it).

internal/utils/host.go stubs out the content-mapper methods on CompilerHost:
GetContentMappedSourceFiles returns ErrProjectUnavailable and ContentMapperProject returns
nil. A tsconfig with contentMappers therefore does not merely go unsupported, it fails. The
TS100024 error about needing --runExternalCode lands in configParseResult.Errors,
CreateProgram bails on those, and the whole project reports "Invalid tsconfig" and lints nothing.

This implements both methods and threads runExternalCode through, so files in a mapped extension get
transformed, type-checked, and reported at their original offsets.

None of it is specific to a particular mapper. Extensions come from the tsconfig, the mapper is
resolved by upstream tsoptions, and the diagnostic handling keys off span-map fidelity. The tests
register a made-up .ext extension against a made-up mapper package. I used Ember .gts via
ember-content-mapper as the real-world check
because it is the mapper that exists today.

Overlap with #1165

The middle commit here is the same panic fix as #1165. This branch predates that PR and is based on
#1143 rather than main, so it carries its own copy. Two differences, both deliberate:

If #1165 lands first I will rebase and drop the commit, keeping the mapper-aware help text as a small
follow-up. If this lands first, #1165 becomes a no-op. Reviewing #1165 on its own is easier; it is two
files against main.

What it does

Mapper processes

internal/utils/content_mapper.go is new. It holds an os/exec spawner and a process-wide
contentmapper.Host, created on first use and torn down by ShutdownContentMappers at the end of a
run. One host per process is deliberate: mapper processes are consolidated by mapper identity, so
several programs configuring the same mapper share a single child process.

Host methods

GetContentMappedSourceFiles reads the file and delegates to contentmapper.TransformAndParse
followed by CheckSupplementalFileNameCollisions, matching tsc's own host in
internal/compiler/host.go.

The project cannot be a constructor argument the way it is in tsc. tsgolint hands the same host to
GetParsedCommandLineOfConfigFile as the ParseConfigHost, so the config that names the mappers is
parsed by the host that needs them. SetContentMapperProject installs it between parsing and
NewProgram.

runExternalCode, in two places

CreateProgram is the obvious one. The other is NewTsConfigResolver's SessionOptions. Without it
the resolver parses the config with mappers dropped, a mapped file is not among that config's file
names, and it gets routed to the inferred project instead. Missing that second call site produces no
error, just files that quietly stop being linted, so it is worth a look during review.

Diagnostic mapping

Rule diagnostics are mapped back in emitDiagnostic, compiler diagnostics in
reportTypeScriptDiagnostics, and the CLI code frame renders against OriginalText(). Three rules
apply, in increasing strictness:

  1. Compiler diagnostics drop only FidelityNone, matching tsc's diagnostic writer. Diagnostics the
    mapper produced itself already carry original offsets, which you can tell from a non-empty
    Source(), and they pass through unmapped. Mapping those a second time silently loses them.
  2. Lint diagnostics also drop empty mapped ranges and anything under an Ignore directive.
  3. Fixes and suggestions survive only where every range maps FidelityExact, so a mapped file's
    copied script content stays fixable while synthesized output does not.

Permission model

runExternalCode is off by default and comes from whoever invoked tsgolint: run_external_code in
the headless payload, --runExternalCode on the CLI.

An earlier revision defaulted it on, arguing that a deliberately invoked CLI is unlike tsserver
opening whatever repository you clicked on, and that resolving a mapper out of node_modules implies
an install already ran its lifecycle scripts. Both are wrong. tsc is invoked just as deliberately
and still requires the flag, and pnpm does not run dependency lifecycle scripts by default, so a
dev can reasonably hold some assumption of safety (this repo's own pnpm-workspace.yaml is an
allowBuilds allowlist). Thanks to @DanielRosenwasser for both.

The point that settles it is where TypeScript put the option. runExternalCode is declared
IsCommandLineOnly: true, so a tsconfig can never grant it, and tsserver takes it from the client's
initializationOptions for the client to gate on workspace trust. Defaulting it on here let a
checked-in tsconfig reconstitute exactly the capability that placement exists to deny.

Denial is not fatal

Being denied costs a project its mapped files and nothing else, which is what TypeScript does.
TS100024 is reported, the mappers are dropped, their extensions stay unregistered, and everything else
is still checked:

$ tsc                                  # no --runExternalCode
src/plain.ts(1,14): error TS2322: Type 'string' is not assignable to type 'number'.
tsconfig.json(10,3): error TS100024: Content mappers require the '--runExternalCode' ...

tsgolint did not match that, because CreateProgram bails on any tsconfig error and returns no
program. A project that declared content mappers without the flag lost all of its type-aware linting,
not just its .gts files: the TS2322 above simply disappeared. TS100024 is now separated from fatal
config errors and reported alongside a program that lints everything the mappers did not claim.

This is a narrow fix for one diagnostic code, not a change to how tsgolint treats tsconfig errors in
general. The broader behaviour is the #351 class of problem and is worth its own change. A mapper that
fails to start still produces a program diagnostic and so still takes the run down; that has the same
shape and I have left it alone here.

The remaining decision worth arguing about

Suppressing scaffolding-anchored lint diagnostics

Most of a mapper's span mappings point at synthesized code. Where a mapper cannot express an original
range that strictly contains another, it collapses the enclosing range to a zero-length anchor. The
protocol forces this today; microsoft/TypeScript#63936 removes the need. A zero-length anchor is a
reasonable fallback for a type error. For a lint finding it means pointing at a zero-width slice of a
file the user did not write.

Measured against the Ember fixture: 17 findings unfiltered, 3 with the filter. All 14 that were
dropped are scaffolding, including prefer-readonly-parameter-types on Glint's generated function
parameters and no-unsafe-type-assertion on its DSL casts.

It does cost something. Findings anchored on synthesized template code go away even when the
underlying problem is real. In the fixture those were cascade findings duplicating a type error that
still reports on its own, but nothing guarantees that in general.

Verification

internal/linter/content_mapper_test.go runs a dependency-free mapper, checked in under testdata,
as a real child process over the protocol. It reproduces the three mapping shapes a real mapper
produces (verbatim, unmapped, zero-length anchor) and asserts the exact original substring each
diagnostic lands on, that scaffolding is dropped, and that a fix in verbatim content still maps. A
second test covers mapper-produced diagnostics passing through unmapped. Both skip cleanly when node
is unavailable.

By hand against ember-content-mapper@0.3.1, using e2e/fixtures/content-mappers, which is not wired
into the e2e suite because it needs a real npm install and Node 22.21 or newer:

  • {{this.cuont}} against a class declaring count reports TS2551 at [138,143), which is
    indexOf('cuont') in the .gts. Byte-identical to tsc --runExternalCode on the same fixture.
  • no-unnecessary-condition and strict-boolean-expressions fire inside <template> at [154,165),
    exactly this.always.
  • no-unnecessary-type-assertion in a .gts script section produces a fix deleting " as number" at
    the right original offsets. Applying it leaves <template> byte-identical and the file re-lints
    clean.
  • source_overrides works, so the editor path does too. Overriding the buffer changes the reported
    diagnostic to match the overridden text at the overridden offsets.
  • Without run_external_code, the same payload reports TS100024, leaves the .gts out of the program,
    and still finds a type error in a plain .ts in the same project.

Verification used a compiler built from the submodule, because the released typescript@7.0.2 rejects
--runExternalCode.

Someone also ran this end to end from the oxlint side against a matching oxlint branch, with the
diagnostics rendering at the right offsets inside <template> and --fix leaving <template>
byte-identical.

Known gaps

When a contentMappers entry names a package that does not resolve, TypeScript unregisters the
mapper's extensions. The files it would have claimed then match no tsconfig, no program is built for
that config, and the "could not be resolved" error never surfaces. The user sees only the generic
unsupported-extension diagnostic, which does not mention the mapper at all.

Whether the real error appears turns on the set of files being linted rather than on the tsconfig's
contents. A program is built for the config only when some natively parseable file from it is also in
the lint set. A project-wide run usually has one; a single-file lint does not, which means the whole
editor path is affected. Documented but not fixed. A fix needs a new TsConfigResolver entry point to
replay a would-be config's errors for files dropped before any program existed, plus dedup against
configs that did get one. Happy to do it here or separately, whichever you prefer.

Supplemental outputs are parsed and collision-checked, but their diagnostics are dropped by the
existing filename filter. No mapper I tested emits any.

Incidental

shim/locale is new, because contentmapper.NewHost takes a locale.Locale. Regenerating the shims
is byte-stable; I confirmed that by running just shim on a clean tree first, before adding it.

internal/linter/testdata/contentmapper/node_modules/ is checked in and un-ignored in .gitignore.
Content mappers resolve through node module resolution, so the test fixture has to look like a real
installed package. It is two files and no dependencies.

🤖 Generated with Claude Code

wagenet and others added 3 commits September 1, 2026 14:41
Implements the two CompilerHost methods that were stubbed out, so a tsconfig
declaring `contentMappers` actually gets its files transformed instead of
returning ErrProjectUnavailable.

- `internal/utils/content_mapper.go` owns the mapper processes: an os/exec
  spawner and a process-wide `contentmapper.Host`, torn down by
  `ShutdownContentMappers` at the end of a run.
- `GetContentMappedSourceFiles` delegates to `TransformAndParse` plus
  `CheckSupplementalFileNameCollisions`, matching tsc's own host.
- `CreateProgram` turns on `runExternalCode` (mappers are dropped with a
  tsconfig error without it) and opens the project-scoped mapper view. The
  tsconfig resolver needs the same flag, or a mapped file is never matched to
  the config that declares its extension. `OXLINT_TSGOLINT_DISABLE_CONTENT_MAPPERS=true`
  opts out.
- Diagnostics reported against a mapper's virtual TypeScript are mapped back to
  the original file. Lint diagnostics additionally drop the ones anchored on
  mapper scaffolding, and autofixes survive only where the mapping is verbatim.

Covered by a test that drives a dependency-free mapper as a real child process,
and by an Ember `.gts` fixture under e2e/fixtures/content-mappers that is run by
hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A file routed to tsgolint whose extension TypeScript cannot parse belongs to no
tsconfig, so it lands in the inferred project, which has no content mappers. The
parser has no script kind for it and panicked with a Go stack trace.

oxlint now forwards any file a config override routes to languageOptions.parser,
so this is reachable whenever a .gts is linted against a tsconfig that registers
no content mapper for it — including one whose mapper package fails to resolve,
which drops the mapper and unregisters its extensions.

Report an internal diagnostic naming the extension and pointing at
"contentMappers" instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An unresolvable mapper package unregisters its extensions, so the files it would
have claimed match no tsconfig and the config's own error never surfaces.

Whether it surfaces turns on the set of files being linted, not on the tsconfig's
contents: a program only gets built for the config when some natively parseable
file from it is also in the lint set. A project-wide run usually has one; linting
a single .gts does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@DanielRosenwasser

Copy link
Copy Markdown

typescript-go gates this on workspace trust because tsserver opens whatever repository you clicked on.
That reasoning does not transfer cleanly. Nothing invokes tsgolint except oxlint, which you ran
deliberately in that directory

But tsc has no notion of workspace trust and requires --runExternalCode.

and a mapper only resolves out of node_modules, which means an
install has already run that package's lifecycle scripts.

This is true, but an install may have been run without scripts enabled, so a dev might run with some assumption of trust/safety.

…atal

Follows what TypeScript itself does, in both halves.

The permission now comes from whoever invoked tsgolint, never from the project
being linted. tsc declares runExternalCode with IsCommandLineOnly, so a tsconfig
cannot grant itself the right to execute code, and tsserver takes it from the
client's initializationOptions, defaulting to off, for the client to gate on
workspace trust. Defaulting it on here let a checked-in tsconfig reconstitute
exactly the capability that placement exists to deny. It is now
`run_external_code` in the headless payload and `--runExternalCode` on the CLI,
off by default, and the environment variable escape hatch is gone.

Being denied is no longer fatal. TypeScript reports TS100024, drops the
configured mappers, leaves their extensions unregistered, and compiles the rest
of the project; tsc prints the error and still reports type errors in ordinary
.ts files. tsgolint bailed out of program creation on any tsconfig error, so a
project that declared content mappers without the flag lost all of its
type-aware linting, not just its mapped files. TS100024 is now reported
alongside a program that lints everything else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
wagenet added a commit to wagenet/oxc that referenced this pull request Sep 2, 2026
tsgolint no longer defaults `runExternalCode` on (oxc-project/tsgolint#1166),
so content mappers stay off until oxlint asks for them, and `.gts` gets
`unsupported-file-extension` instead of type-aware results.

Adds `--run-external-code`, plumbed to the tsgolint payload the same way
`--type-check` is, plus a `runExternalCode` LSP option taken from the client.

The permission comes from the invocation and nothing else. It is not readable
from `.oxlintrc.json`, and a `languageOptions.parser` override does not imply
it -- that override states intent, which decides eligibility, not trust.
TypeScript declares the underlying option `IsCommandLineOnly` precisely so a
checked-in tsconfig cannot confer it; `.oxlintrc.json` is checked in the same
way, and deriving the permission from either hands back the capability that
placement exists to deny. The LSP option follows tsserver: from the client,
defaulting off, with no config fallback (unlike `typeAware`, which has one).
A test asserts a parser override alone leaves `run_external_code` false.

Denial is non-fatal upstream now, so a project declaring `contentMappers`
without the flag keeps the rest of its type-aware results and gets one
tsconfig diagnostic naming the flag, rather than losing the whole run.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants