Skip to content

v4: move to TypeScript 7, framework support via content mappers - #103

Open
johnsoncodehk wants to merge 54 commits into
masterfrom
v4
Open

johnsoncodehk wants to merge 54 commits into
masterfrom
v4

Conversation

@johnsoncodehk

@johnsoncodehk johnsoncodehk commented Sep 2, 2026 •

Copy link
Copy Markdown
Owner

Summary

TSSLint v4 runs only on TypeScript 7.1. Rules get the TypeScript 7 AST (typescript/ast), plus the Program and Checker from typescript/sync. There is no TypeScript 5/6 code path and no compatibility layer. Framework files (.vue, .astro, .mdx, …) are linted through TypeScript 7.1's Content Mapper (tsconfig contentMappers) instead of Volar language plugins, so the tsconfig is the single source of truth. The CLI cache now decides what to re-lint from a dependency graph, the way tsc --incremental does, instead of splitting rules into syntactic and type-aware.

Published so far as 4.0.0-alpha.1 (npm dist-tag alpha). This branch is 38 commits ahead of that tag, including the result API change and the new cache.

Quick start

npm install @tsslint/cli@alpha @tsslint/config@alpha --save-dev   # TypeScript 7.1 comes with them
npx tsslint --project tsconfig.json
// tsconfig.json (framework files)
{
  "contentMappers": [
    { "package": "@vue/content-mapper", "extensions": [".vue"] }
  ]
}
// tsslint.config.ts
import { defineConfig, defineRule } from '@tsslint/config';

export default defineConfig({
  rules: {
    'no-debugger': defineRule(({ typescript: ts, file, report }) => {
      file.forEachChild(function visit(node): void {
        if (node.kind === ts.SyntaxKind.DebuggerStatement) {
          report('Debugger statement is not allowed.', node.getStart(file), node.getEnd());
        }
        node.forEachChild(visit);
      });
    }),
  },
});

Breaking changes

See docs/migration-v4.md for the full guide.

  • TypeScript 7.1 is a direct dependency. Every package depends on typescript 7.1.0-beta (exact), separate from the typescript your project compiles with; the typescript peer dependency is gone. Diagnostics come from the TypeScript 7 checker and can differ from TypeScript 5/6 in edge cases.
  • Node.js 22.15.0+ is required (was 22.6.0+). The cache tracks the modules a config imports with Module.registerHooks, added in 22.15.
  • Rule context changed. typescript is now the typescript/ast module; program is a typescript/sync Program; checker is passed directly; ts.forEachChild(node, cb) becomes node.forEachChild(cb); ts.sys and friends are gone (use node:fs / node:path).
  • Results use TypeScript 7.1 types. A report is a typescript/sync Diagnostic (pos, end, text) plus ruleId. withFix / withRefactor return TextEdit[] ({ pos, end, newText }). Plugin resolveDiagnostics / resolveCodeFixes receive the lint target ({ program, checker, file }) instead of the file.
  • @tsslint/typescript-plugin and the VS Code extension are removed. TypeScript 7 has no tsserver plugin API, so v4 has no editor integration.
  • importTSLintRules and fromTSLRules are removed. Both depend on the classic typescript API.
  • --vue-project, --vue-vine-project, --mdx-project, --astro-project and --ts-macro-project are removed, together with the Volar dependencies. Declare contentMappers in the tsconfig instead; there is no replacement flag.
  • --list-rules and Reporter.withoutCache() are removed. There is no syntactic / type-aware split left to list. Rules must only depend on the program; reads through fs, environment variables and the like are not tracked, and --force re-lints every file.
  • Existing caches are discarded. The cache format is v11 (v3 shipped v2), so the first v4 run re-lints everything.
  • @typescript-eslint/eslint-plugin does not load with typescript@7: its dependencies read the classic typescript API at module load. importESLintRules still works for ESLint core rules and plugins that don't import typescript.
  • @tsslint/core API (only for embedders): createLinter takes ctx: { typescript: typeof import('typescript/ast') }, linter.lint takes { program, checker, file } instead of a file name, applyTextEdits works on strings, and linter.getTouchedFiles(fileName) is new.

Remaining work

  • Move the typescript dependency from 7.1.0-beta to the stable 7.1 release once it is published.
Done

Engine

  • @tsslint/types, @tsslint/core, @tsslint/config and @tsslint/cli are ported to the TypeScript 7.1 API (typescript/ast, typescript/sync).
  • The CLI runs one TypeScript 7 API process and one snapshot holding every project of the run; projects share parsed files. --fix updates the snapshot after each write before the next pass.
  • @tsslint/compat-eslint's lazy ESTree is ported to the TypeScript 7 AST.
  • typescript is a direct dependency of every package; ESLint is declared as a peer (>=9.39.0 <10.0.0) where needed.

Content Mapper support

  • Projects are loaded with runExternalCode, so mappers declared in the tsconfig run as part of loading the project; there is no flag to pass.
  • Rules run on the transformed TypeScript (file.text) and report in its coordinates. Diagnostics are mapped back to the original file through the mapper's span map; those that only cover generated code are dropped.
  • --fix applies a fix only when every edit maps exactly onto original text, and applies an edit once when a mapper copies the same code more than once.
  • Rule reports inside the mapper's diagnostic directives (e.g. @vue-ignore, @vue-expect-error) are suppressed, and compiler diagnostics of mapped files are not mapped twice.

Volar removal

  • The five framework flags, the Volar proxy language service, packages/cli/lib/languagePlugins.ts, fixtures/meta-frameworks and the languages plumbing in the cache are removed.
  • packages/typescript-plugin and packages/vscode are removed.

Cache

Stored at os.tmpdir()/tsslint-cache/<tsslint version>/<typescript version>/<hash of tsconfig and config path>.cache.json, so a new TSSLint or TypeScript version starts cold. Format v11; any parse, shape or version mismatch is treated as a cold start, and writes go through a temp file and rename.

  • A file is re-linted when it changes (mtime or size), when a file it imports or references changes its declaration emit, or when a file a rule read beyond its declarations (a declaration's body, the file's source or diagnostics, a checker query by position) changes its text.
  • Declaration hashes are kept per export and per top-level declaration, and an importer is re-linted only when a name it imports reaches a change. Changes keep propagating through files whose own signature changes (e.g. re-exports).
  • Everything is re-linted when compiler options, a tsconfig, the content mappers (including the nearest lockfile above each, so upgrading a mapper's dependencies counts), tsslint.config.ts or a module it imports, the set of program files, or anything under node_modules changes; also when the declaration signature of a script or UMD module changes, or what a declare global / declare module '...' block declares.
  • Content-mapped files are compared by declaration signature like other files, so editing a function body in A.vue does not re-lint B.vue that imports it.
  • Per-name hashes are 96-bit (base64url prefix of SHA-256) to keep the cache file small.

Docs

  • docs/migration-v4.md: breaking changes, rule context mapping, flag → contentMappers, caching, ESLint interop, @tsslint/core API, framework projects.
  • README: TypeScript 7 positioning, setup without a typescript install, caching, framework support, requirements.

CI and release

  • CI runs on Node 24 against the TypeScript 7 test suite.
  • The release workflow publishes non-master branches under the alpha dist-tag (4.0.0-alpha.0, 4.0.0-alpha.1).

Tests

All CI steps pass at 2290a30 (PR CI green; also run locally on Node 24.15.0, typescript 7.1.0-beta):

  • compat-eslint parity bench: 97 rules × 33 files, clean parity; predicate coverage 152/152 ESTree types; lazy ESTree, review-bug, predicate/materializer, compat-pipeline, selector-analysis and TS-AST scan tests.
  • CLI: cache, cache-flow, invalidation (stubbed API), integration (subprocess, including a stub content mapper for .vue) and render tests.
  • pnpm run lint --force on the repo itself: 53 passed.

Notes

  • Rules can no longer read the typescript your project uses; anything they need from TypeScript comes from typescript/ast and the Program / Checker they are given.
  • The cache only tracks what rules read through the program. A rule that reads files directly needs --force.
  • tsslint.config.ts is loaded with --experimental-strip-types, unchanged from v3.
pnpm install && pnpm run build && pnpm run lint

References

- @tsslint/cli now depends on typescript-native-bridge (pinned
  6.0.3-bridge.15.tsgo.7.0.2) instead of the typescript peer dep;
  type checking runs on the TS 7 (tsgo) engine
- Remove --vue-project/--vue-vine-project/--mdx-project/--astro-project/
  --ts-macro-project flags and the Volar language plugin wiring
  (@volar/language-core, @volar/language-hub, @volar/typescript)
- Meta-framework support will be provided by the TS 7.1 content mapper
  (tsconfig contentMappers) once TNB rebases onto tsgo 7.1
- Remove fixtures/meta-frameworks and untracked tsgo/relay experiments
The file was deleted with the Volar wiring; the hardcoded corpus list
in packages/compat-eslint/test/bench/dogfood-corpus.ts still referenced
it, failing lazy-estree.test.js with ENOENT in CI.
…lumbing

- packages/cli now pins typescript-native-bridge 6.0.3-bridge.16.tsgo.7.0.2
- remove the languages parameter from the cache layer (dead since the Volar
  removal); the cache key no longer carries it, which also invalidates
  existing caches
- README: real framework-support section (tsconfig contentMappers), updated
  requirements (bundled engine, no typescript pin, musl unsupported)
- docs/migration-v4.md: breaking changes, flag -> contentMappers mapping,
  engine behavior notes, editor status, availability gates
TypeScript 7 has no tsserver plugin API, so @tsslint/typescript-plugin
and the VS Code extension built on it cannot work.
- Build the workspace with typescript@7.1.0-beta (module nodenext,
  Node >= 22.12 for require(esm)); drop typescript-native-bridge.
- @tsslint/types: SourceFile from typescript/ast, Program/Checker/
  DiagnosticCategory from typescript/sync. RuleContext gains `checker`
  (TS7 programs have no getTypeChecker); LinterContext is
  { typescript, getProgram }.
- @tsslint/core: lint() takes { program, checker, file } instead of a
  file name resolved through a LanguageService.
- Add tools/estree-oracle, a private test-only package pinning
  typescript@6 + typescript-eslint as the parity reference.
- Port core tests to real TS7 projects; drop the BuilderProgram POC.
tsl and tslint are built on the TypeScript 5/6 API and cannot run on
TS7. The eslint, ignore, category and diagnostics plugins now use
typescript/ast and typescript/sync.
- lazy-estree / tokens / ts-ast-scan read typescript/ast. TS7 shape
  differences are handled in lib/ts.ts helpers: postfixToken for ?/!,
  ModuleDeclaration.keyword, phaseModifier for type-only imports,
  heritage clauses parsed as TypeReference, synthesized (Reparsed)
  nodes, nameless array-binding holes, EndOfFile/JSDoc kinds.
- Scope analysis uses @typescript-eslint/scope-manager's analyze(),
  built lazily on first scopeManager read, replacing TsScopeManager.
- parserServices type queries go through the project checker.
- Parity tests compare against typescript-estree running on its own
  TypeScript 6 copy (tools/estree-oracle), bridged by node ranges.
  Tests depending on @typescript-eslint/eslint-plugin or the vendored
  scope-manager suite are removed: typescript-eslint cannot load TS7.
- dogfood: register all rules before the first file is linted, so the
  shared traversal sees every rule (previously only the first one ran).
- lint through typescript/sync snapshots; project file names come from the
  configured project so content-mapped files (e.g. .vue) are included
- replace the BuilderProgram incremental state with a program fingerprint
  (cache format v3)
- map diagnostics back to original files via SourceFile.spanMap; drop
  diagnostics that only cover generated code
- apply --fix only when every edit of a fix maps exactly
- render with the TS7 formatDiagnosticsWithColorAndContext
@johnsoncodehk johnsoncodehk changed the title v4: embed typescript-native-bridge, drop Volar-based framework support v4: move to TypeScript 7, framework support via content mappers Oct 22, 2026
@johnsoncodehk

Copy link
Copy Markdown
Owner Author

/release alpha 4.0.0

@github-actions

Copy link
Copy Markdown
Contributor

@johnsoncodehk

Copy link
Copy Markdown
Owner Author

/release alpha 4.0.0

@github-actions

Copy link
Copy Markdown
Contributor

@johnsoncodehk

Copy link
Copy Markdown
Owner Author

/release alpha 4.0.0

@github-actions

Copy link
Copy Markdown
Contributor

@johnsoncodehk

Copy link
Copy Markdown
Owner Author

/release alpha 4.0.0

@github-actions

Copy link
Copy Markdown
Contributor

johnsoncodehk and others added 8 commits October 22, 2026 19:46
Record the version published from f7d6918 by release run 39772141598, so the next `prerelease` release is 4.0.0-alpha.1. Only workspace specifiers change in pnpm-lock.yaml.
- Every package that loads `typescript/sync` / `typescript/ast` now depends
  on typescript 7.1.0-beta instead of peering on it. TSSLint's checker is
  its own engine; as a peer, pnpm resolved it to a host project's TS 6 and
  `require('typescript/sync')` failed.
- @tsslint/config and @tsslint/compat-eslint declare an optional eslint
  peer (>=9.39.0 <10.0.0). importESLintRules() loads core rules from
  `node_modules/eslint/lib/rules`, and both packages expose ESLint types.
- @tsslint/config peers on @tsslint/compat-eslint via `workspace:*`, so
  the published range always matches the release (it was `^3.0.0`).
…kind scans

ParenthesizedExpression, ParenthesizedType and ComputedPropertyName are
pass-through in convertChildInner: materialise returns the inner ESTree.
The scan relied on the child's target equalling the parent's to skip the
re-entry, but when the inner is a wrapper head (`(a?.b)` ->
ChainExpression, `(typeof import('x'))` -> TSTypeQuery) the parens level
expands the chain and leaves its tail as parentTarget, so the child's
visit entered the same chain again. In CPA and wildcard mode every
listener on those nodes fired twice (three times under `((a?.b))`), e.g.
no-non-null-asserted-optional-chain reported `(a?.b)!` twice.

The three kinds now join the structural pass-through list and recurse
with the parent's target unchanged.
getSemanticDiagnostics on a content-mapped file already returns positions in the original file; format.ts mapped them through spanMap again as if they were transformed coordinates, which dropped or misplaced them. Only rule reports are in transformed coordinates.
Rule reports are TypeScript's Diagnostic plus a ruleId, and fixes and
refactors return TextEdit[] for the reported file. TextSpan, TextChange,
FileTextChanges, RelatedInformation and the custom Diagnostic shape are
removed. Plugins receive the lint target instead of the file, which
replaces LinterContext.getProgram. Compiler diagnostics are passed through
as returned by TypeScript. The cache format is bumped to v4.
A crash was reported at offset 0 of the file, which pointed at an
unrelated line and was dropped when offset 0 of a content-mapped file is
generated code. It now has no fileName, so directives and ignore comments
do not apply to it, and the CLI prints it with the file name only.
Replace the syntactic / type-aware rule split and the whole-program
fingerprint with tsc-incremental-style invalidation. Each file's result
after plugins ran is cached as a whole and reused while the file is
unchanged and none of its dependencies changed their declaration
signature. A changed signature re-lints direct importers and keeps
propagating only while signatures keep changing; a declaration that names
a changed file (e.g. a re-export) counts as changed.

Compiler options, tsconfig files, content mappers, the config and every
module it imports, the program's file list, files under node_modules,
and edits to files that declare globals re-lint everything.

Core no longer probes rules for program access, and --list-rules is
removed. withoutCache() now keeps the reported file out of the cache.
Rules must only depend on the program. Inputs outside it are not tracked, and --force re-lints every file.
A rule can read another file's implementation, e.g. the body of a function it calls. Core records every other program file a rule reaches through a declaration node, a source file, diagnostics, emit, or a checker query by file, and the CLI keeps the file's result only while those files keep the same text.
Names inside `declare module './a' { ... }` resolve in './a', so the declaration emit can drop the import that names them. A signature change of './a' then changes the augmentation without changing its text.
… detection gaps

- treat a UMD module (export as namespace) as global
- depend on modules a file augments without importing them
- read import types with attributes and type references with attributes
  before types as references
- re-lint everything only when a declare global / declare module block
  changes, or a changed signature reaches what a block names; other
  changes of the module re-lint its importers
…dules

The program emits no declarations for a workspace package or a
referenced project's source behind a symlink, which fell back to a text
hash as its signature. Emit them as root files of a program with the
same options and project references.
… reaches

Per export and top-level declaration, store the hash of its declaration emit
and the names the checker resolves its source identifiers to, across files.
A name changed when a hash it reaches changed, so same-named properties no
longer pull in other declarations and a change two modules away only
re-lints the names that reach it. Cache format v9.
…ach them

Names are stored with their hashes, and what each declaration references
is resolved through the checker the first time a change reaches its
module, a dep or a module a dep re-exports. The graph also only expands
those modules, so a body edit with unchanged hashes walks no names.
Cache format v10.
The cache keeps a hash per declaration, export and import; a base64url
prefix of the SHA-256 digest is a quarter of the hex digest, which makes
the cache file a quarter smaller to load and save. Cache format v11.
Module.registerHooks, which tracks the modules a config imports, was added in Node.js 22.15. On 22.12 to 22.14 the cache ignored edits to rule files and other config imports. Also correct the README on which importers an exported type change re-lints.
Upgrading a mapper's dependencies (e.g. @vue/language-core) changed neither its package.json nor its exec files, so mapped files kept stale results. Hash the nearest lockfile above each mapper package.
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.

1 participant