Conversation
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>
But
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
internal/utils/host.gostubs out the content-mapper methods onCompilerHost:GetContentMappedSourceFilesreturnsErrProjectUnavailableandContentMapperProjectreturnsnil. A tsconfig withcontentMapperstherefore does not merely go unsupported, it fails. TheTS100024error about needing--runExternalCodelands inconfigParseResult.Errors,CreateProgrambails on those, and the whole project reports "Invalid tsconfig" and lints nothing.This implements both methods and threads
runExternalCodethrough, so files in a mapped extension gettransformed, 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 testsregister a made-up
.extextension against a made-up mapper package. I used Ember.gtsviaember-content-mapperas the real-world checkbecause 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:contentMappers, which is the correct advice once mappers exist. fix: report unparsable files instead of panicking #1165targets
main, where that option does not exist yet, so it uses generic wording.typoscheck. This branch predates that rename.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.gois new. It holds anos/execspawner and a process-widecontentmapper.Host, created on first use and torn down byShutdownContentMappersat the end of arun. 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
GetContentMappedSourceFilesreads the file and delegates tocontentmapper.TransformAndParsefollowed by
CheckSupplementalFileNameCollisions, matching tsc's own host ininternal/compiler/host.go.The project cannot be a constructor argument the way it is in tsc. tsgolint hands the same host to
GetParsedCommandLineOfConfigFileas theParseConfigHost, so the config that names the mappers isparsed by the host that needs them.
SetContentMapperProjectinstalls it between parsing andNewProgram.runExternalCode, in two places
CreateProgramis the obvious one. The other isNewTsConfigResolver'sSessionOptions. Without itthe 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 inreportTypeScriptDiagnostics, and the CLI code frame renders againstOriginalText(). Three rulesapply, in increasing strictness:
FidelityNone, matching tsc's diagnostic writer. Diagnostics themapper 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.Ignoredirective.FidelityExact, so a mapped file'scopied script content stays fixable while synthesized output does not.
Permission model
runExternalCodeis off by default and comes from whoever invoked tsgolint:run_external_codeinthe headless payload,
--runExternalCodeon 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_modulesimpliesan install already ran its lifecycle scripts. Both are wrong.
tscis invoked just as deliberatelyand 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.yamlis anallowBuildsallowlist). Thanks to @DanielRosenwasser for both.The point that settles it is where TypeScript put the option.
runExternalCodeis declaredIsCommandLineOnly: true, so a tsconfig can never grant it, and tsserver takes it from the client'sinitializationOptionsfor the client to gate on workspace trust. Defaulting it on here let achecked-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:
tsgolint did not match that, because
CreateProgrambails on any tsconfig error and returns noprogram. A project that declared content mappers without the flag lost all of its type-aware linting,
not just its
.gtsfiles: the TS2322 above simply disappeared. TS100024 is now separated from fatalconfig 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-typeson Glint's generated functionparameters and
no-unsafe-type-assertionon 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.goruns a dependency-free mapper, checked in undertestdata,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, usinge2e/fixtures/content-mappers, which is not wiredinto the e2e suite because it needs a real
npm installand Node 22.21 or newer:{{this.cuont}}against a class declaringcountreportsTS2551at[138,143), which isindexOf('cuont')in the.gts. Byte-identical totsc --runExternalCodeon the same fixture.no-unnecessary-conditionandstrict-boolean-expressionsfire inside<template>at[154,165),exactly
this.always.no-unnecessary-type-assertionin a.gtsscript section produces a fix deleting" as number"atthe right original offsets. Applying it leaves
<template>byte-identical and the file re-lintsclean.
source_overridesworks, so the editor path does too. Overriding the buffer changes the reporteddiagnostic to match the overridden text at the overridden offsets.
run_external_code, the same payload reports TS100024, leaves the.gtsout of the program,and still finds a type error in a plain
.tsin the same project.Verification used a compiler built from the submodule, because the released
typescript@7.0.2rejects--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--fixleaving<template>byte-identical.
Known gaps
When a
contentMappersentry names a package that does not resolve, TypeScript unregisters themapper'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
TsConfigResolverentry point toreplay 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/localeis new, becausecontentmapper.NewHosttakes alocale.Locale. Regenerating the shimsis byte-stable; I confirmed that by running
just shimon 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