diff --git a/packages/cli/index.ts b/packages/cli/index.ts index c92a93f..5203b11 100644 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -35,6 +35,7 @@ Options: --failures-only Only print errors and messages (skip warnings and suggestions) --list-rules After linting, print each rule's classification (syntactic / type-aware) --debug-estree After linting, print the actual ESTree node types converted by @tsslint/compat-eslint and their counts + --tsgo Use @typescript/native-preview as the type-check backend (experimental; plain-TS only, no Vue/MDX/Astro, no layer-2 cache) -h, --help Show this help message Examples: diff --git a/packages/cli/lib/real-ts.ts b/packages/cli/lib/real-ts.ts new file mode 100644 index 0000000..826ace9 --- /dev/null +++ b/packages/cli/lib/real-ts.ts @@ -0,0 +1,11 @@ +// Captures the real `typescript` module reference BEFORE the tsgo facade +// installs its `Module._resolveFilename` hook. Imported at worker top-level +// so the cache entry is the genuine ts module; subsequent imports of this +// file from anywhere (including code that runs after the facade installs) +// receive the captured-at-load reference unchanged. +// +// Use this from any internal CLI code that needs real ts behaviour +// (parser, binder, scanner) — `require('typescript')` from those callsites +// would otherwise hit the facade and return the tsgo-shaped substitute. +import ts = require('typescript'); +export = ts; diff --git a/packages/cli/lib/tsgo-backend.ts b/packages/cli/lib/tsgo-backend.ts new file mode 100644 index 0000000..e76a31b --- /dev/null +++ b/packages/cli/lib/tsgo-backend.ts @@ -0,0 +1,1002 @@ +// tsgo backend — alternative to ts.createProgram for the linter's +// `ctx.program()` thunk. Activated by `--tsgo`. Spawns the +// @typescript/native-preview binary, holds a Snapshot/Project, and +// presents `Project.program` + `Project.checker` as a ts.Program / +// ts.TypeChecker subset that satisfies the linter's contract. +// +// Two non-obvious invariants: +// +// 1. Symbol resolution batching. tsgo Checker calls are sync RPCs. +// `Checker.getSymbolAtLocation([nodes])` and +// `Checker.getSymbolAtPosition(file, [positions])` are array +// overloads — N nodes resolved in 1 RPC. We do a per-file prepass: +// walk the AST, collect every Identifier, batched-resolve once, +// stash in `nodeToSymbol`. Rules then call `getSymbolAtLocation` +// synchronously and read from the Map. +// +// 2. `getSymbolAtLocation` doesn't resolve identifiers in +// import/export specifier position (~76% miss rate on type-heavy +// TS files). `getSymbolAtPosition(file, [endOffsets])` does. The +// prepass uses the position-based API as primary and falls back +// to location-based for the small remainder (mostly object-spread +// method names where position is "between siblings"). +// +// AST node identity is preserved across calls — tsgo's SourceFileCache +// hands back the same parsed SF object for the same path within a +// snapshot, and we reuse those Node references as Map keys. + +import path = require('path'); +import ts = require('typescript'); + +// `@typescript/native-preview` ships ESM-only. Under Node16 module +// resolution, type-only imports of an ESM package from this CJS file +// require the `'resolution-mode': 'import'` attribute. We thread that +// through once via `import(..., { with: ... })` aliases and reuse them. +type TsgoSync = typeof import('@typescript/native-preview/sync', { with: { 'resolution-mode': 'import' } }); +type API = InstanceType; +type Snapshot = InstanceType; +type Project = InstanceType; +type TsgoSymbol = InstanceType; +type TsgoAst = typeof import('@typescript/native-preview/ast', { with: { 'resolution-mode': 'import' } }); +type Node = import('@typescript/native-preview/ast', { with: { 'resolution-mode': 'import' } }).Node; + +export interface TsgoBackend { + // ts.Program-shape adapter, fed to LinterContext.program(). + getProgram(): ts.Program; + // Per-file setup before rules run: prototype-patches the tsgo Node + // hierarchy on first call, then bind-via-real-ts the file so the + // JS-side scope walker can answer in-process Symbol queries. + // Idempotent on unchanged text (cached). + prepareFile(fileName: string): void; + // Drop the JS-side bind + position maps for one file. Call after + // the file's lint pass completes so the bound SF doesn't pin in + // memory across the rest of the project's lint. Subsequent lint of + // the SAME file (rare, e.g. `--fix` rewrite + re-lint) re-binds + // from current text via prepareFile. + releaseFile(fileName: string): void; + // Drop the JS-side bind cache for a file. Call after `--fix` + // rewrites file content so the next `prepareFile` re-binds against + // the new text. + invalidateFile(fileName: string): void; + // Tear down child process + free snapshot refs. + close(): void; +} + +// Process-level guards. All prototype patches are one-shot per process +// (tsgo's class shapes are stable per binary version). +let nodeProtoPatched = false; +let typeProtoPatched = false; +let nodeHandleProtoPatched = false; +let symbolProtoPatched = false; +let nodeListSpeciesPatched = false; +let signatureProtoPatched = false; + +function patchTsgoNodeListSpecies(sample: object): void { + if (nodeListSpeciesPatched) return; + const ctor = (sample as { constructor?: any }).constructor; + if (!ctor) return; + if (ctor[Symbol.species] !== Array) { + Object.defineProperty(ctor, Symbol.species, { + configurable: true, + get: () => Array, + }); + } + nodeListSpeciesPatched = true; +} + +// tsgo's Node interface exposes `pos` / `end` (raw parser offsets, +// leading trivia included), `parent`, `kind`, `forEachChild`, +// `getSourceFile()`. It does NOT provide ts.Node's instance methods +// `getStart` / `getEnd` / `getText` — TS adds these on the runtime +// NodeObject prototype, and rule code (lazy-estree's range computation, +// plenty of compat-eslint utilities) calls them as if every Node is a +// ts.Node. +// +// Tsgo nodes returned from the API are `RemoteNode` / `RemoteSourceFile` +// instances (separate class hierarchy from the locally-instantiable +// `NodeObject` that `/ast/factory` exposes). The Remote classes live at +// dist paths NOT listed in the package's `exports` map — we can't +// `require` them by name. Instead we walk up the prototype chain from a +// live Node sample to the topmost non-Object prototype (RemoteNodeBase) +// and patch there. One-time; the chain shape is stable per tsgo version. +// +// Math: `getStart` = `pos` advanced past leading trivia (whitespace + +// comments), `getEnd` = `end`, `getText` = `sf.text.slice(getStart, end)`. +// tsgo's scanner emits standard TS trivia, so reusing real `ts.skipTrivia` +// gives bit-identical positions to ts.Node. +function patchTsgoNodeProto(sample: Node): void { + if (nodeProtoPatched) return; + let proto: any = Object.getPrototypeOf(sample); + while (proto && Object.getPrototypeOf(proto) !== Object.prototype) { + proto = Object.getPrototypeOf(proto); + } + if (!proto) { + throw new Error('tsgo backend: could not locate Node prototype to patch'); + } + // `skipTrivia` is technically `@internal` in ts's published .d.ts but + // has been runtime-exported since 0.x — every linter / codemod tool + // uses it. The runtime check survives if a future ts removes it. + const skipTrivia = (ts as unknown as { + skipTrivia?: ( + text: string, + pos: number, + stopAfterLineBreak?: boolean, + stopAtComments?: boolean, + ) => number; + }).skipTrivia; + if (!skipTrivia) { + throw new Error('tsgo backend: ts.skipTrivia not available — getStart shim cannot be installed'); + } + if (typeof proto.getStart !== 'function') { + proto.getStart = function ( + sf?: { text: string }, + includeJsDocComments?: boolean, + ): number { + const text = (sf ?? this.getSourceFile()).text; + return skipTrivia(text, this.pos, false, includeJsDocComments); + }; + } + if (typeof proto.getEnd !== 'function') { + proto.getEnd = function (): number { + return this.end; + }; + } + if (typeof proto.getText !== 'function') { + proto.getText = function (sf?: { text: string }): string { + const file = sf ?? this.getSourceFile(); + return file.text.slice(this.getStart(file), this.end); + }; + } + if (typeof proto.getFullStart !== 'function') { + proto.getFullStart = function (): number { + return this.pos; + }; + } + if (typeof proto.getFullText !== 'function') { + proto.getFullText = function (sf?: { text: string }): string { + const file = sf ?? this.getSourceFile(); + return file.text.slice(this.pos, this.end); + }; + } + if (typeof proto.getWidth !== 'function') { + proto.getWidth = function (sf?: { text: string }): number { + return this.end - this.getStart(sf); + }; + } + if (typeof proto.getFullWidth !== 'function') { + proto.getFullWidth = function (): number { + return this.end - this.pos; + }; + } + // `SourceFile.getLineAndCharacterOfPosition(pos)` — used by + // compat-eslint (and by ts itself for diagnostic span rendering) + // to convert offsets to line/character. Real ts caches `lineMap` on + // the SF; tsgo doesn't, so we compute lineStarts lazily and stash + // on the SF instance the first time it's asked. + if (typeof proto.getLineAndCharacterOfPosition !== 'function') { + proto.getLineAndCharacterOfPosition = function ( + this: { text?: string; getSourceFile(): { text: string }; _lineStarts?: number[] }, + position: number, + ): { line: number; character: number } { + const text = this.text ?? this.getSourceFile().text; + let starts = this._lineStarts; + if (!starts) { + starts = [0]; + for (let i = 0; i < text.length; i++) { + const c = text.charCodeAt(i); + if (c === 10) starts.push(i + 1); + else if (c === 13) { + if (text.charCodeAt(i + 1) === 10) i++; + starts.push(i + 1); + } + } + this._lineStarts = starts; + } + // Binary search for the largest lineStart ≤ position. + let lo = 0, hi = starts.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >>> 1; + if (starts[mid] <= position) lo = mid; else hi = mid - 1; + } + return { line: lo, character: position - starts[lo] }; + }; + } + if (typeof proto.getLineStarts !== 'function') { + proto.getLineStarts = function (this: { _lineStarts?: number[]; getLineAndCharacterOfPosition(p: number): unknown }) { + // Trigger the lazy build via a no-op call; cache lives on `_lineStarts`. + this.getLineAndCharacterOfPosition(0); + return this._lineStarts!; + }; + } + // Inverse: convert (line, character) → position. compat-eslint's + // ESLint→TSSLint report converter calls this to map ESTree's + // loc-based descriptors back to file offsets. Without it, the + // converter's swallowing try/catch defaults start/end to 0 → all + // diagnostics collapse to (line=1, col=1) at file start. + if (typeof proto.getPositionOfLineAndCharacter !== 'function') { + proto.getPositionOfLineAndCharacter = function ( + this: { getLineStarts(): number[] }, + line: number, + character: number, + ): number { + const starts = this.getLineStarts(); + return (starts[line] ?? 0) + character; + }; + } + nodeProtoPatched = true; +} + +// `ts.Type` exposes a clutch of flag-based predicates as instance +// methods (`isLiteral`, `isStringLiteral`, `isUnion`, `getSymbol`, …). +// Rule code (typescript-eslint's `no-unnecessary-type-assertion`, +// many compat-eslint paths) calls these. tsgo's TypeObject only has +// `getSymbol` and the data fields; we patch the missing predicates onto +// its prototype using tsgo's TypeFlags enum values (different from ts). +// +// Located via prototype walk from a sample Type — TypeObject isn't in +// the package exports map. One-shot per process. +function patchTsgoTypeProto(sample: object, sync: TsgoSync): void { + if (typeProtoPatched) return; + let proto: any = Object.getPrototypeOf(sample); + while (proto && Object.getPrototypeOf(proto) !== Object.prototype) { + proto = Object.getPrototypeOf(proto); + } + if (!proto) return; + const TF = (sync as any).TypeFlags as Record; + const has = (flag: number) => function (this: { flags: number }) { return (this.flags & flag) !== 0; }; + if (!proto.isStringLiteral) proto.isStringLiteral = has(TF.StringLiteral); + if (!proto.isNumberLiteral) proto.isNumberLiteral = has(TF.NumberLiteral); + if (!proto.isBooleanLiteral) proto.isBooleanLiteral = has(TF.BooleanLiteral); + if (!proto.isBigIntLiteral) proto.isBigIntLiteral = has(TF.BigIntLiteral); + if (!proto.isEnumLiteral) proto.isEnumLiteral = has(TF.EnumLiteral); + if (!proto.isLiteral) proto.isLiteral = has( + TF.StringLiteral | TF.NumberLiteral | TF.BigIntLiteral | TF.BooleanLiteral, + ); + if (!proto.isUnion) proto.isUnion = has(TF.Union); + if (!proto.isIntersection) proto.isIntersection = has(TF.Intersection); + if (!proto.isUnionOrIntersection) proto.isUnionOrIntersection = has(TF.UnionOrIntersection ?? (TF.Union | TF.Intersection)); + if (!proto.isTypeParameter) proto.isTypeParameter = has(TF.TypeParameter); + if (!proto.isClassOrInterface) proto.isClassOrInterface = function () { return false; }; // structural; would need objectFlags + if (!proto.isClass) proto.isClass = function () { return false; }; + if (!proto.isIndexType) proto.isIndexType = has(TF.Index); + if (!proto.getFlags) proto.getFlags = function (this: { flags: number }) { return this.flags; }; + if (!proto.isNullableType) proto.isNullableType = has((TF.Null ?? 0) | (TF.Undefined ?? 0)); + // `types` property — typescript-eslint's ts-api-utils + // (`unionConstituents`) reads `type.types` directly on Union / + // Intersection types. tsgo exposes the constituents via `getTypes()` + // instead. Lazy getter preserves the no-RPC-on-bind contract. + if (!Object.getOwnPropertyDescriptor(proto, 'types')) { + Object.defineProperty(proto, 'types', { + configurable: true, + get(this: { getTypes?: () => unknown[] }) { + return this.getTypes ? this.getTypes() : undefined; + }, + }); + } + // `getCallSignatures()` / `getConstructSignatures()` — instance shims + // that delegate to the Checker. We can't reach the Checker from here + // without a closure; install via patchTsgoTypeProtoWithChecker + // (separate hook called from wrapChecker). + typeProtoPatched = true; +} + +// Type instance methods that need a Checker reference (signatures, +// properties). Patched once on first checker query that returns a Type. +let typeCheckerMethodsPatched = false; +function patchTsgoTypeCheckerMethods(sample: object, sync: TsgoSync, project: Project): void { + if (typeCheckerMethodsPatched) return; + let proto: any = Object.getPrototypeOf(sample); + while (proto && Object.getPrototypeOf(proto) !== Object.prototype) { + proto = Object.getPrototypeOf(proto); + } + if (!proto) return; + const SK = (sync as any).SignatureKind as Record; + if (!proto.getCallSignatures) { + proto.getCallSignatures = function (this: { id: string }) { + return currentProjectRef.project!.checker.getSignaturesOfType(this as any, SK.Call); + }; + } + if (!proto.getConstructSignatures) { + proto.getConstructSignatures = function (this: { id: string }) { + return currentProjectRef.project!.checker.getSignaturesOfType(this as any, SK.Construct); + }; + } + if (!proto.getProperties) { + proto.getProperties = function (this: any) { + return currentProjectRef.project!.checker.getPropertiesOfType(this); + }; + } + if (!proto.getProperty) { + proto.getProperty = function (this: any, name: string) { + return this.getProperties().find((p: any) => p.name === name); + }; + } + if (!proto.getBaseTypes) { + proto.getBaseTypes = function (this: any) { + return currentProjectRef.project!.checker.getBaseTypes(this); + }; + } + if (!proto.getNonNullableType) { + proto.getNonNullableType = function (this: any) { + return currentProjectRef.project!.checker.getNonNullableType(this); + }; + } + void project; + typeCheckerMethodsPatched = true; +} + +// `Signature` on tsgo lacks ts.Signature's accessor methods +// (`getReturnType`, `getDeclaration`, `getTypeParameters`, +// `getParameters`). Add thin wrappers — `getReturnType` delegates via +// the current project's checker; the rest read existing data fields. +function patchTsgoSignatureProto(sync: TsgoSync): void { + if (signatureProtoPatched) return; + const Signature = (sync as any).Signature; + if (!Signature?.prototype) return; + const proto = Signature.prototype; + if (!proto.getReturnType) { + proto.getReturnType = function (this: { id: string }) { + return currentProjectRef.project!.checker.getReturnTypeOfSignature(this as any); + }; + } + if (!proto.getDeclaration) { + proto.getDeclaration = function (this: { declaration: unknown }) { + return this.declaration; + }; + } + if (!proto.getTypeParameters) { + proto.getTypeParameters = function (this: { typeParameters: unknown[] }) { + return this.typeParameters; + }; + } + if (!proto.getParameters) { + proto.getParameters = function (this: { parameters: unknown[] }) { + return this.parameters; + }; + } + signatureProtoPatched = true; +} + +// `Symbol` on tsgo carries data fields and a few RPC-backed methods, +// but is missing ts.Symbol's instance-method facade (`getDeclarations`, +// `getName`, `getEscapedName`, `getFlags`). Rule code reads those — add +// thin getters that read the data fields. +function patchTsgoSymbolProto(sync: TsgoSync): void { + if (symbolProtoPatched) return; + const Symbol = (sync as any).Symbol; + if (!Symbol?.prototype) return; + const proto = Symbol.prototype; + if (!proto.getDeclarations) { + proto.getDeclarations = function (this: { declarations: unknown[] }) { + return this.declarations; + }; + } + if (!proto.getName) { + proto.getName = function (this: { name: string }) { + return this.name; + }; + } + if (!proto.getEscapedName) { + // tsgo doesn't have escapedName / __String distinction the way + // ts does; the regular `name` is fine for rule comparisons. + proto.getEscapedName = function (this: { name: string }) { + return this.name; + }; + } + if (!proto.getFlags) { + proto.getFlags = function (this: { flags: number }) { + return this.flags; + }; + } + // Mirror `escapedName` field too — typescript-estree reads it + // directly on the symbol object. + if (!Object.getOwnPropertyDescriptor(proto, 'escapedName')) { + Object.defineProperty(proto, 'escapedName', { + configurable: true, + get(this: { name: string }) { return this.name; }, + }); + } + symbolProtoPatched = true; +} + +// `Symbol.declarations` on tsgo is `NodeHandle[]` — lazy stubs with +// `kind / pos / end / path` and a `resolve(project)` method. Rule code +// expects real `ts.Node[]` and reads `.parent` / calls `.getSourceFile()` +// directly. Patch NodeHandle's prototype to upgrade-on-access: +// +// - `getSourceFile()` short-circuits to `project.program.getSourceFile(path)` +// — common in scope-manager lib-symbol checks; doesn't need full Node +// materialisation since `project.isSourceFileDefaultLibrary(sf)` is +// fed straight back to the wrapped Program. +// +// - `parent` getter resolves the handle once via `resolve(project)`, then +// reads parent off the resolved Node. Cached on the instance so repeat +// reads skip the `findDescendant` walk. +// +// Multi-project: `currentProjectRef.project` is rebound in createTsgoBackend() +// every setup. The prototype patch closes over the holder, not the project +// instance — so NodeHandles produced under project A but accessed after +// the worker switches to project B route through B's live API. Safe +// because lint() processes one file at a time within one project and +// hands no cross-project handles around. +const currentProjectRef: { project: Project | undefined } = { project: undefined }; + +function installNodeHandleHooks(sync: TsgoSync): void { + if (nodeHandleProtoPatched) return; + const NodeHandle = (sync as any).NodeHandle; + if (!NodeHandle?.prototype) return; + const proto = NodeHandle.prototype; + if (typeof proto.getSourceFile !== 'function') { + proto.getSourceFile = function (this: { path: string }) { + const project = currentProjectRef.project; + if (!project) return undefined; + return project.program.getSourceFile(this.path); + }; + } + if (!Object.getOwnPropertyDescriptor(proto, 'parent')) { + Object.defineProperty(proto, 'parent', { + configurable: true, + get(this: { _resolvedNode?: Node | null; resolve: (p: Project) => Node | undefined }) { + if (this._resolvedNode === undefined) { + const project = currentProjectRef.project; + this._resolvedNode = project ? this.resolve(project) ?? null : null; + } + return this._resolvedNode?.parent; + }, + }); + } + nodeHandleProtoPatched = true; +} + +export function createTsgoBackend(tsconfig: string): TsgoBackend { + // Lazy require so users without the optional peer dep don't crash on + // load. The CLI gates this behind `--tsgo` so non-tsgo users never + // reach here. + const trace = process.env.TSSLINT_TIME_TSGO === '1'; + const t0 = Date.now(); + const { API: APICtor } = require('@typescript/native-preview/sync') as TsgoSync; + const tImport = Date.now(); + const api: API = new APICtor({}); + const tApi = Date.now(); + const snapshot: Snapshot = api.updateSnapshot({ openProject: tsconfig }); + const tSnap = Date.now(); + const project = snapshot.getProject(tsconfig); + const tProject = Date.now(); + if (!project) { + api.close(); + throw new Error(`tsgo: project not found for ${tsconfig}`); + } + if (trace) { + console.error( + `[tsgo-time] createBackend total=${tProject - t0}ms ` + + `(import=${tImport - t0} apiCtor=${tApi - tImport} ` + + `updateSnapshot=${tSnap - tApi} getProject=${tProject - tSnap})`, + ); + } + + // Per-fileName Symbol cache, populated by `prepareFile`. Keyed by the + // tsgo Node object reference (not its position) — the AST tree is + // hydrated client-side and walks return the same Node instances each + // time within a snapshot. + const nodeToSymbol = new WeakMap(); + // Files prepass'd this snapshot. Skip re-walk on repeat lint() calls. + const preparedFiles = new Set(); + + // Per-backend JS Symbol resolver. Owns the bound-SF + position-map + // caches; releases them on close(). Replaces the module-level singleton + // so two backends in the same worker (multi-project lint) don't share + // stale caches across snapshots. + const jsSymbolResolver: import('./tsgo-js-symbols.js').JsSymbolResolver + = require('./tsgo-js-symbols.js').createJsSymbolResolver({ + tsgoSyntaxKind: require('@typescript/native-preview/ast').SyntaxKind, + }); + jsSymbolResolverRef.current = jsSymbolResolver; + + const program = wrapProgram(project, nodeToSymbol); + currentProjectRef.project = project; + installNodeHandleHooks(require('@typescript/native-preview/sync') as TsgoSync); + + let prepareTotalMs = 0; + let prepareCount = 0; + return { + getProgram: () => program, + prepareFile(fileName: string) { + if (preparedFiles.has(fileName)) return; + preparedFiles.add(fileName); + const t = Date.now(); + prepareFile(project, fileName, jsSymbolResolver); + prepareTotalMs += Date.now() - t; + prepareCount++; + if (trace && (prepareCount % 100 === 0 || prepareCount === 1)) { + console.error(`[tsgo-time] prepareFile #${prepareCount} cumul=${prepareTotalMs}ms`); + } + }, + // Drop the JS-side bind for a single file. Called by the worker + // after `--fix` rewrites file content, so the next prepareFile + // re-binds against the new text and returns fresh symbols. + invalidateFile(fileName: string) { + preparedFiles.delete(fileName); + jsSymbolResolver.invalidate(fileName); + // `nodeToSymbol` is a WeakMap keyed by tsgo Node references; + // after the next ensureProgram() rebuild those Node refs are + // new, so the stale entries become garbage automatically. + }, + // Drop the JS-side bind for a file after its lint pass is done. + // Distinct from invalidateFile: keeps `preparedFiles` membership + // (so a stray re-lint of the same file in the same backend + // re-binds rather than no-ops), but releases the bound SF + + // position maps for GC. Without this, all 5000 Dify files' + // bound SFs sit in memory simultaneously across a lint pass. + releaseFile(fileName: string) { + preparedFiles.delete(fileName); + jsSymbolResolver.invalidate(fileName); + }, + close() { + if (trace) { + const s = getPrepareTimingSnapshot(); + console.error( + `[tsgo-time] final prepareFiles=${prepareCount} ` + + `prepareTotal=${prepareTotalMs}ms ` + + `(getSF=${s.getSF}ms bind=${s.bind}ms)`, + ); + } + jsSymbolResolver.clear(); + if (jsSymbolResolverRef.current === jsSymbolResolver) { + jsSymbolResolverRef.current = undefined; + } + api.close(); + }, + }; +} + +// Bridge so wrapChecker.getSymbolAtLocation can reach the active +// backend's resolver without re-threading the wiring through every +// adapter method. Set by createTsgoBackend on construction; cleared on +// close(). Multi-project worker swaps it on each setup. +const jsSymbolResolverRef: { current: import('./tsgo-js-symbols.js').JsSymbolResolver | undefined } = { current: undefined }; + + +// Cumulative timers — printed by createBackend.close() under +// TSSLINT_TIME_TSGO=1. Negligible cost when the flag is off (single +// env-var read per call). +let _prepareGetSF = 0; +let _prepareBind = 0; + +export function getPrepareTimingSnapshot() { + return { getSF: _prepareGetSF, bind: _prepareBind }; +} + +// Per-file setup before rules run. Two pieces of essential work: +// +// 1. Prototype patches on the tsgo Node hierarchy — adds the +// ts.Node-shaped instance methods (`getStart` / `getEnd` / +// `getText` / `getLineAndCharacterOfPosition` / etc.) that rule +// code calls directly. One-shot per process; subsequent calls +// short-circuit inside the patch helpers. +// +// 2. Real-ts bind of the file. Symbol resolution then runs in-process +// via `wrapChecker.getSymbolAtLocation` — JS-side scope walker +// first, tsgo IPC fallback only on miss. Replaces the previous +// tsgo `getSymbolAtPosition` batched RPC prepass which cost ~11s +// on Dify (5000 files); JS-side bind costs ~1.8s for the same +// workload and produces real ts.Symbol objects with stable +// identity. +function prepareFile( + project: Project, + fileName: string, + jsSymbolResolver: import('./tsgo-js-symbols.js').JsSymbolResolver, +): void { + const trace = process.env.TSSLINT_TIME_TSGO === '1'; + const t0 = trace ? Date.now() : 0; + const sf = project.program.getSourceFile(fileName); + if (trace) _prepareGetSF += Date.now() - t0; + if (!sf) return; + + patchTsgoNodeProto(sf); + // RemoteNodeList extends Array; without species override, derived + // methods (`statements.map(...)` etc.) try to construct a fresh + // RemoteNodeList and crash in its binary-view getter. Override to + // plain Array. + const sample = (sf as unknown as { statements?: object }).statements; + if (sample) patchTsgoNodeListSpecies(sample); + + const text = (sf as unknown as { text: string }).text; + const tBind = trace ? Date.now() : 0; + jsSymbolResolver.prepareFile(fileName, text); + if (trace) _prepareBind += Date.now() - tBind; +} + +// Wraps tsgo Program + Checker as a `ts.Program`-shape. Only the methods +// tsslint actually consumes are populated; the rest throw on access so +// any caller pulling on a missing capability fails loudly instead of +// returning silent garbage. +function wrapProgram( + project: Project, + nodeToSymbol: WeakMap, +): ts.Program { + const checker = wrapChecker(project, nodeToSymbol); + const cwd = path.dirname(project.configFileName); + + // tsgo's lib files live inside the binary's own bundled stdlib. The + // path check looks at whether the SF path traces to a /lib.*.d.ts + // inside the tsgo executable's directory, the only place defaultlib + // SFs originate. + const isLib = (sf: ts.SourceFile) => { + const fn = sf.fileName; + return /\/lib\.[^/]+\.d\.ts$/.test(fn); + }; + + const stub = (name: string) => () => { + throw new Error(`tsgo backend: ts.Program.${name}() not implemented`); + }; + + const program: Partial = { + getSourceFile(fileName: string) { + return project.program.getSourceFile(fileName) as unknown as ts.SourceFile | undefined; + }, + getSourceFiles() { + // tsgo's Program doesn't expose all SFs in one call; pull via + // rootFiles plus their transitive deps. For the linter's + // purpose (cache-flow / BuilderProgram drain) this is fine — + // the hot path is per-file lookup. + const out: ts.SourceFile[] = []; + for (const fn of project.rootFiles) { + const sf = project.program.getSourceFile(fn); + if (sf) out.push(sf as unknown as ts.SourceFile); + } + return out; + }, + getRootFileNames() { + return project.rootFiles as readonly string[]; + }, + getCurrentDirectory() { + return cwd; + }, + getCompilerOptions() { + return project.compilerOptions as ts.CompilerOptions; + }, + getTypeChecker() { + return checker; + }, + isSourceFileDefaultLibrary: isLib, + isSourceFileFromExternalLibrary(sf: ts.SourceFile) { + return /\/node_modules\//.test(sf.fileName); + }, + // Methods the linter never calls but ts.Program's interface + // declares. Stub them so a stray dynamic-typed access blows up + // with a clear message rather than `undefined is not a function`. + getSemanticDiagnostics: stub('getSemanticDiagnostics') as any, + getSyntacticDiagnostics: stub('getSyntacticDiagnostics') as any, + getDeclarationDiagnostics: stub('getDeclarationDiagnostics') as any, + getGlobalDiagnostics: stub('getGlobalDiagnostics') as any, + getConfigFileParsingDiagnostics: stub('getConfigFileParsingDiagnostics') as any, + emit: stub('emit') as any, + }; + + return program as ts.Program; +} + +function wrapChecker( + project: Project, + nodeToSymbol: WeakMap, +): ts.TypeChecker { + const sync = require('@typescript/native-preview/sync') as TsgoSync; + const ast = require('@typescript/native-preview/ast') as TsgoAst; + const stub = (name: string) => () => { + throw new Error(`tsgo backend: ts.TypeChecker.${name}() not implemented`); + }; + const fixupType = (t: unknown) => { + if (t && typeof t === 'object') { + patchTsgoTypeProto(t, sync); + patchTsgoTypeCheckerMethods(t, sync, project); + } + return t; + }; + patchTsgoSymbolProto(sync); + patchTsgoSignatureProto(sync); + + // Forward to tsgo's Checker, casting Node/Symbol/Type shapes (tsgo's + // runtime classes are structurally compatible with ts.* for the + // methods we proxy — tsgo Symbol carries `name`/`flags`/`declarations`, + // tsgo Type carries `flags` plus the prototype shims from + // patchTsgoTypeProto). Non-existent methods surface as throw or soft + // no-op depending on caller tolerance. + const fwd = (name: K, fixup?: (r: unknown) => void) => + (...args: unknown[]) => { + const fn = (project.checker as any)[name]; + if (typeof fn !== 'function') return undefined; + const r = fn.apply(project.checker, args); + if (fixup) fixup(r); + return r; + }; + + const checker: Partial = { + getSymbolAtLocation(node: ts.Node) { + const tsgoNode = node as unknown as Node; + // Cache hit returns synchronously, no further work. + if (nodeToSymbol.has(tsgoNode)) { + return nodeToSymbol.get(tsgoNode) as unknown as ts.Symbol | undefined; + } + const tsgoSf = (tsgoNode as unknown as { getSourceFile?: () => { fileName: string; text: string } }) + .getSourceFile?.(); + // In-process JS scope walker first. Layer A (variable refs, + // declarations, in-file specifiers, in-file type refs) + // resolves locally — no IPC. The walker returns a real + // ts.Symbol with identity stable across calls. + const resolver = jsSymbolResolverRef.current; + if (resolver && tsgoSf) { + const local = resolver.resolveIdentifier(tsgoNode, tsgoSf.fileName, tsgoSf.text); + if (local) { + nodeToSymbol.set(tsgoNode, local as unknown as TsgoSymbol); + return local as unknown as ts.Symbol; + } + } + // Layer C fallback. tsgo has two checker entry points with + // different coverage — `getSymbolAtPosition` resolves + // declaration-position identifiers (import/export specifier + // names) that the node-based API misses; `getSymbolAtLocation` + // covers a small remainder (object-spread method names etc.). + // Try position first to recover the previous prepass's recall + // without paying its eager batched cost. + let sym: TsgoSymbol | undefined; + if (tsgoSf) { + sym = project.checker.getSymbolAtPosition(tsgoSf.fileName, tsgoNode.end); + } + if (!sym) { + sym = project.checker.getSymbolAtLocation(tsgoNode); + } + nodeToSymbol.set(tsgoNode, sym); + return sym as unknown as ts.Symbol | undefined; + }, + getTypeAtLocation(node: ts.Node) { + // Semantic divergences between ts and tsgo for + // `getTypeAtLocation`. ts returns the type of the expression + // AS IT EVALUATES; tsgo's default returns the type of the + // node STRUCTURALLY (inner expression's type for assertions, + // the function type for calls). typescript-eslint rules + // depend on the ts semantic. Route per-kind: + // + // AsExpression / TypeAssertion / Satisfies → getTypeFromTypeNode(.type) + // CallExpression / NewExpression → getReturnTypeOfSignature(getResolvedSignature(node)) + // NonNullExpression → getNonNullableType(getTypeAtLocation(.expression)) + // + // All other kinds use tsgo's default (which IS the right + // thing for variable references, member accesses, literals, + // etc.). + const tsgoNode = node as unknown as Node; + const k = tsgoNode.kind; + const SK = ast.SyntaxKind; + if ( + (k === SK.AsExpression + || k === SK.TypeAssertionExpression + || k === SK.SatisfiesExpression) + && (tsgoNode as unknown as { type?: Node }).type + ) { + const t = project.checker.getTypeFromTypeNode( + (tsgoNode as unknown as { type: Node }).type as any, + ); + fixupType(t); + return t as unknown as ts.Type; + } + if (k === SK.CallExpression || k === SK.NewExpression) { + // Two attempts to recover the call's RETURN type: + // 1) `getResolvedSignature(node)` — the canonical way, + // but tsgo panics on some method-call sites. + // 2) Walk via the function type's call signatures — + // uses two `getCallSignatures(funcType)` and one + // `getReturnTypeOfSignature(sig)` calls. Slightly + // less precise (picks the first overload) but + // doesn't trigger the panic. + try { + const sig = project.checker.getResolvedSignature(tsgoNode); + if (sig) { + const t = project.checker.getReturnTypeOfSignature(sig); + fixupType(t); + return t as unknown as ts.Type; + } + } + catch { /* fall through to plan B */ } + const sk = (sync as any).SignatureKind as Record; + try { + const funcType = project.checker.getTypeAtLocation(tsgoNode); + if (funcType) { + const sigs = project.checker.getSignaturesOfType(funcType, sk.Call); + if (sigs && sigs.length > 0) { + const t = project.checker.getReturnTypeOfSignature(sigs[0]); + fixupType(t); + return t as unknown as ts.Type; + } + } + } + catch { /* try plan C */ } + // Plan C: tsgo's getTypeAtLocation(call) sometimes returns + // the callee's RECEIVER type rather than the function + // type (observed for nested method calls like + // `a.b.c()`). Pull the method's symbol off the callee + // expression and read its type instead — this resolves + // to the function type even in the receiver-collapsed + // case. + try { + const callee = (tsgoNode as unknown as { expression?: Node }).expression; + if (callee) { + // For property-access callees (`a.b.c()`), tsgo's + // `getSymbolAtLocation(propAccess)` returns the + // symbol of the LEFT side (`a.b`'s symbol — + // e.g. `languageService`). The METHOD's symbol + // is on the right (`callee.name`). Probe both + // shapes — direct identifier callees use the + // node itself. + const targetForSymbol = + (callee as unknown as { name?: Node }).name ?? callee; + const sym = project.checker.getSymbolAtLocation(targetForSymbol); + if (sym) { + const methodType = project.checker.getTypeOfSymbolAtLocation(sym, callee); + if (methodType) { + const sigs = project.checker.getSignaturesOfType(methodType, sk.Call); + if (sigs && sigs.length > 0) { + const t = project.checker.getReturnTypeOfSignature(sigs[0]); + fixupType(t); + return t as unknown as ts.Type; + } + } + } + } + } + catch { /* fall through to default */ } + } + if (k === SK.PropertyAccessExpression || k === SK.ElementAccessExpression) { + // tsgo's `getTypeAtLocation(propAccess)` returns wrong + // types in some real-codebase contexts (e.g. inside + // argument lists of generic-typed call expressions — + // observed `string[]` / `string` when the declared type + // is `string | undefined`). The same property at the + // same position via `getTypeAtPosition(file, end)` + // returns the correct type. Minimal repros don't + // trigger the wrong path; the divergence requires + // surrounding-context complexity we haven't isolated. + // Position-based query is the workaround. + const sfPath = ((tsgoNode as unknown as { getSourceFile?: () => { fileName: string } }) + .getSourceFile?.() ?? { fileName: '' }).fileName; + if (sfPath) { + const t = project.checker.getTypeAtPosition(sfPath, tsgoNode.end); + if (t) { + fixupType(t); + return t as unknown as ts.Type; + } + } + } + if (k === SK.NonNullExpression) { + const inner = (tsgoNode as unknown as { expression: Node }).expression; + // Recurse through the adapter so nested CallExpression / + // AsExpression / NonNull get their own routing applied + // before we strip nullability. e.g. `someMap.get(k)!` — + // the inner CallExpression needs Call routing to get the + // return type (`T | undefined`), and only then does + // `getNonNullableType` produce `T`. + const innerT = checker.getTypeAtLocation!(inner as unknown as ts.Node); + if (innerT) { + const t = project.checker.getNonNullableType(innerT as unknown as any); + fixupType(t); + return t as unknown as ts.Type; + } + } + const t = project.checker.getTypeAtLocation(tsgoNode); + fixupType(t); + return t as unknown as ts.Type; + }, + getShorthandAssignmentValueSymbol(node) { + if (!node) return undefined; + return project.checker.getShorthandAssignmentValueSymbol(node as unknown as Node) as unknown as ts.Symbol | undefined; + }, + getTypeOfSymbolAtLocation(symbol, location) { + const t = project.checker.getTypeOfSymbolAtLocation( + symbol as unknown as TsgoSymbol, + location as unknown as Node, + ); + fixupType(t); + return t as unknown as ts.Type; + }, + // Direct forwards — tsgo Checker has these on its surface. + getTypeOfSymbol: fwd('getTypeOfSymbol', fixupType) as any, + getDeclaredTypeOfSymbol: fwd('getDeclaredTypeOfSymbol', fixupType) as any, + getSignaturesOfType: fwd('getSignaturesOfType') as any, + getResolvedSignature: fwd('getResolvedSignature') as any, + getReturnTypeOfSignature: fwd('getReturnTypeOfSignature', fixupType) as any, + getTypePredicateOfSignature: fwd('getTypePredicateOfSignature') as any, + getNonNullableType: fwd('getNonNullableType', fixupType) as any, + getBaseTypes: fwd('getBaseTypes') as any, + getPropertiesOfType: fwd('getPropertiesOfType') as any, + getIndexInfosOfType: fwd('getIndexInfosOfType') as any, + getTypeArguments: fwd('getTypeArguments') as any, + getWidenedType: fwd('getWidenedType', fixupType) as any, + getTypeFromTypeNode: fwd('getTypeFromTypeNode', fixupType) as any, + getContextualType: fwd('getContextualType', fixupType) as any, + typeToString: fwd('typeToString') as any, + isArrayLikeType: fwd('isArrayLikeType') as any, + // Type-parameter constraint — tsgo only has the type-parameter + // variant; for non-TypeParameter inputs ts returns undefined too. + getBaseConstraintOfType: ((type: any) => { + if ((type?.flags & (require('@typescript/native-preview/sync') as TsgoSync).TypeFlags.TypeParameter) !== 0) { + const r = project.checker.getConstraintOfTypeParameter(type); + fixupType(r); + return r; + } + return undefined; + }) as any, + // Apparent type: ts boxes primitives (`5` → Number), + // walks type-parameter constraints, and unwraps generic + // instantiations for property lookup. tsgo has no direct API. + // Compose what tsgo DOES expose: + // 1. TypeParameter → its constraint (fall through if absent). + // 2. Literal types → their widened primitive base + // (`getWidenedType` exists in tsgo). + // 3. Otherwise return the input type. + // Imperfect (can't fully box `string` → `String` interface w/o + // host symbol resolution we don't have), but materially better + // than identity-fallback for rule logic that switches on + // "primitive vs object" or "constrained-T vs raw T". + getApparentType: ((type: any) => { + if (!type) return type; + const TF = (sync as any).TypeFlags as Record; + if ((type.flags & TF.TypeParameter) !== 0) { + const c = project.checker.getConstraintOfTypeParameter(type); + if (c) { fixupType(c); return c; } + return type; + } + const literalMask = TF.StringLiteral | TF.NumberLiteral + | TF.BooleanLiteral | TF.BigIntLiteral | TF.EnumLiteral; + if ((type.flags & literalMask) !== 0) { + const w = project.checker.getWidenedType(type); + if (w) { fixupType(w); return w; } + } + return type; + }) as any, + // tsgo's Checker doesn't expose these. compat-eslint's callsites + // (parameter-property shadowing, ExportSpecifier alias unwrap) + // have fallback paths that handle empty / undefined gracefully — + // degrades scope-manager precision in those edge cases but keeps + // the rest of the pipeline functional. + getSymbolsInScope: ((..._args: unknown[]) => []) as any, + getExportSpecifierLocalTargetSymbol: ((..._args: unknown[]) => undefined) as any, + // `isTypeAssignableTo` — tsgo doesn't expose subtype checking. + // Best-effort structural cover: identity, any/unknown/never + // sentinels, union decomposition (∀ on source / ∃ on target), + // literal-to-base widening. Returns `false` for the long tail + // of structural compatibility (object shape compat, signature + // variance, conditional types) that requires the full checker + // subtype machinery — sound (no false `true`) over those + // branches; consumers should treat unknown answers as "can't + // prove" rather than "definitely false". + isTypeAssignableTo: ((source: any, target: any): boolean => { + if (!source || !target) return false; + if (source === target || source.id === target.id) return true; + const TF = (sync as any).TypeFlags as Record; + if ((target.flags & (TF.Any | TF.Unknown)) !== 0) return true; + if ((source.flags & TF.Never) !== 0) return true; + if ((source.flags & TF.Any) !== 0) return true; + const self = (s: any, t: any): boolean => (checker.isTypeAssignableTo as any)(s, t); + if ((source.flags & TF.Union) !== 0) { + const ts_ = source.getTypes?.() as any[] | undefined; + if (ts_) return ts_.every(s => self(s, target)); + } + if ((target.flags & TF.Union) !== 0) { + const tt = target.getTypes?.() as any[] | undefined; + if (tt) return tt.some(t => self(source, t)); + } + const literalMask = TF.StringLiteral | TF.NumberLiteral + | TF.BooleanLiteral | TF.BigIntLiteral; + if ((source.flags & literalMask) !== 0) { + const widened = project.checker.getWidenedType(source); + if (widened && widened.id !== source.id) { + return self(widened, target); + } + } + return false; + }) as any, + }; + // `stub` is held for future use as gaps surface; reference it here + // to satisfy noUnusedLocals without a separate unused-method line. + void stub; + + return checker as ts.TypeChecker; +} diff --git a/packages/cli/lib/tsgo-js-symbols.ts b/packages/cli/lib/tsgo-js-symbols.ts new file mode 100644 index 0000000..954c5bc --- /dev/null +++ b/packages/cli/lib/tsgo-js-symbols.ts @@ -0,0 +1,192 @@ +// JS-side Symbol provider for the tsgo backend. +// +// Architecture: tsgo provides AST + Type (cross-file aware via RPC). Symbol +// resolution at the binder level — variable references, declaration names, +// import bindings, in-file type references — runs entirely in-process via +// real ts.createSourceFile + ts.bindSourceFile + a scope walker. +// +// Why: the previous tsgo-Symbol prepass cost 11s on Dify web/ (5000 files) +// for batched cross-process `getSymbolAtPosition` calls. Real ts in-process +// answers the same questions in ~360ms. Symbol is binder-level — type +// computation isn't required, so the JS-side checker is bypassed entirely +// (we use bind only, no createTypeChecker). + +// Use captured-at-startup real ts. Plain `require('typescript')` here +// would route through the tsgo facade installed by the worker — that +// shape doesn't behave correctly for parse/bind work. +import ts = require('./real-ts.js'); + +// Bind option set: minimal — we only want symbol/locals on the AST. +const BIND_OPTIONS: ts.CompilerOptions = { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + jsx: ts.JsxEmit.Preserve, + allowJs: true, +}; + +type PosKey = string; + +function key(pos: number, end: number, kind: number): PosKey { + return pos + ':' + end + ':' + kind; +} + +// Scope walk: nearest enclosing scope's locals.has(name). +function resolveByScope(jsNode: ts.Identifier): ts.Symbol | undefined { + const name = jsNode.text; + for (let n: ts.Node | undefined = jsNode; n; n = n.parent) { + const locals = (n as unknown as { locals?: ts.SymbolTable }).locals; + if (locals?.has(name as ts.__String)) { + return locals.get(name as ts.__String); + } + } + return undefined; +} + +export interface JsSymbolResolverOptions { + tsgoSyntaxKind: Record; +} + +export type JsSymbolResolver = ReturnType; + +export function createJsSymbolResolver(opts: JsSymbolResolverOptions) { + // All caches live in this closure. Each backend gets its own resolver + // instance; backend.close() invokes resolver.clear() to release memory. + const jsSourceFiles = new Map(); + + // Per-file position → JS Node lookup. Built lazily on first symbol + // query for a file. Walks the JS AST once and indexes nodes by + // `pos:end:tsKind`. Two index entries per node are populated when both + // kinds map (the node's tsgo-equivalent kind and an unkeyed (pos,end,0) + // fallback for unmapped tsgo kinds — see lookup logic below). + const positionMaps = new Map>(); + // Position-only fallback (pos:end → first node at that span). Used when + // the tsgo SyntaxKind name has no ts equivalent (e.g. tsgo-only + // `JSImportDeclaration`); we still return SOMETHING reasonable so the + // scope walker can attempt resolution. + const positionMapsFallback = new Map>(); + + // tsgo SyntaxKind value → ts SyntaxKind value, by name correspondence. + // 98% overlap; gaps fall through to the position-only fallback. + let kindRemap: Map | undefined; + function getKindRemap(): Map { + if (kindRemap) return kindRemap; + const m = new Map(); + for (const k of Object.keys(opts.tsgoSyntaxKind)) { + const v = opts.tsgoSyntaxKind[k]; + if (typeof v !== 'number') continue; + const tsValue = (ts.SyntaxKind as unknown as Record)[k]; + if (typeof tsValue === 'number') m.set(v, tsValue); + } + kindRemap = m; + return m; + } + + function bindFile(fileName: string, text: string): ts.SourceFile { + const sf = ts.createSourceFile(fileName, text, BIND_OPTIONS.target!, /*setParentNodes*/ true); + (ts as any).bindSourceFile(sf, BIND_OPTIONS); + return sf; + } + + function getJsSourceFile(fileName: string, text: string): ts.SourceFile { + const cached = jsSourceFiles.get(fileName); + // Text-equality check: detects --fix rewrites (worker stashes new + // text in `fileTextOverrides`, next prepareFile passes the new + // text through to us). Stale binding would resolve to the wrong + // scope / declarations. + if (cached && cached.text === text) return cached; + if (cached) { + // Text changed; drop position maps too — node positions may + // have shifted. + positionMaps.delete(fileName); + positionMapsFallback.delete(fileName); + } + const sf = bindFile(fileName, text); + jsSourceFiles.set(fileName, sf); + return sf; + } + + function getPositionMap(sf: ts.SourceFile): Map { + let map = positionMaps.get(sf.fileName); + if (map) return map; + map = new Map(); + const fb = new Map(); + (function walk(n: ts.Node) { + map!.set(key(n.pos, n.end, n.kind), n); + // Position-only fallback: keep the first node we see at this + // span. Multiple nodes can share `pos:end` (e.g. an Identifier + // and its parent ExpressionStatement); first-write-wins is + // arbitrary but stable. + const k = n.pos + ':' + n.end; + if (!fb.has(k)) fb.set(k, n); + ts.forEachChild(n, walk); + })(sf); + positionMaps.set(sf.fileName, map); + positionMapsFallback.set(sf.fileName, fb); + return map; + } + + return { + // Parse + bind a file (idempotent on unchanged text). On text + // change (e.g. --fix rewrite), drops the cached SF + position + // maps and re-binds. + prepareFile(fileName: string, text: string): void { + getJsSourceFile(fileName, text); + }, + // Resolve an Identifier node's symbol via the JS-side bound AST. + // `tsgoNode` is from tsgo's AST; we map by (pos, end, kind) to the + // corresponding JS node, then run the standard binder lookups. + // Returns ts.Symbol (real one from JS bind) or undefined if no + // in-file binding (caller decides whether to fall back). + resolveIdentifier( + tsgoNode: { kind: number; pos: number; end: number }, + fileName: string, + text: string, + ): ts.Symbol | undefined { + const sf = getJsSourceFile(fileName, text); + const map = getPositionMap(sf); + const remap = getKindRemap(); + const tsKind = remap.get(tsgoNode.kind); + let jsNode = tsKind !== undefined + ? map.get(key(tsgoNode.pos, tsgoNode.end, tsKind)) + : undefined; + // Position-only fallback when kind name didn't map (rare; + // covers tsgo-only kinds like JSImportDeclaration). + if (!jsNode) { + jsNode = positionMapsFallback.get(sf.fileName)!.get(tsgoNode.pos + ':' + tsgoNode.end); + } + if (!jsNode) return undefined; + // Declaration name: parent has the symbol directly. + const parent = jsNode.parent; + if (parent && (parent as any).name === jsNode && (parent as any).symbol) { + return (parent as any).symbol; + } + // Specifier (import/export): parent has the symbol. + if (parent && (parent.kind === ts.SyntaxKind.ImportSpecifier + || parent.kind === ts.SyntaxKind.ExportSpecifier)) { + return (parent as any).symbol; + } + // Otherwise: scope walk for value/type references. + if (jsNode.kind === ts.SyntaxKind.Identifier) { + return resolveByScope(jsNode as ts.Identifier); + } + return undefined; + }, + // Drop a single file's bind + maps. Used by the worker after + // --fix writes new content, so the next prepareFile re-binds + // against fresh text. Idempotent. + invalidate(fileName: string): void { + jsSourceFiles.delete(fileName); + positionMaps.delete(fileName); + positionMapsFallback.delete(fileName); + }, + // Drop everything. Called by backend.close() to release per-CLI + // invocation memory and avoid retaining ~MBs of bound ASTs across + // project setups in a long-running worker. + clear(): void { + jsSourceFiles.clear(); + positionMaps.clear(); + positionMapsFallback.clear(); + kindRemap = undefined; + }, + }; +} diff --git a/packages/cli/lib/tsgo-typescript-facade.ts b/packages/cli/lib/tsgo-typescript-facade.ts new file mode 100644 index 0000000..961286c --- /dev/null +++ b/packages/cli/lib/tsgo-typescript-facade.ts @@ -0,0 +1,208 @@ +// `typescript` module facade for the --tsgo path. Substituted into +// `Module._cache[require.resolve('typescript')]` before tsslint config +// loads, so all rule code (compat-eslint, ESLint utility ports, custom +// rules) sees tsgo's enums, type guards, and walkers when it does +// `require('typescript')` / `import * as ts from 'typescript'`. +// +// This is selective: the worker / cache-flow / core consumed `ts.X` at +// module load time, before `--tsgo` substitution kicks in, so they keep +// the real ts for things tsgo doesn't expose (skipTrivia, +// createSemanticDiagnosticsBuilderProgram, parseJsonConfigFileContent, +// etc.). Only later-loaded code (the dynamic `import(configFile)` inside +// setup() and everything it transitively pulls in) gets this facade. +// +// Why facade vs in-place mutation: `ts.SyntaxKind.Identifier` etc. need +// tsgo's offset values for rule-side comparisons to hit, but the +// worker's already-bound `ts.skipTrivia` etc. need the real ts.SyntaxKind +// internally. A separate module object satisfies both. + +import ts = require('typescript'); + +interface TsgoModules { + ast: any; // /ast — SyntaxKind, NodeFlags, all is.* guards, visitor, utils, scanner + factory: any; // /ast/factory — node creation helpers + NodeObject + sync: any; // /api/sync — SymbolFlags, TypeFlags, DiagnosticCategory, ... +} + +function loadTsgoModules(): TsgoModules { + return { + ast: require('@typescript/native-preview/ast'), + factory: require('@typescript/native-preview/ast/factory'), + sync: require('@typescript/native-preview/sync'), + }; +} + +// Build a facade that mimics `typeof typescript`. Properties tsgo +// supplies route to tsgo; everything else falls back to real ts so the +// worker-internal calls keep working when accidental cross-imports happen. +export function createTypescriptFacade(): typeof ts { + const tsgo = loadTsgoModules(); + const { ast, factory, sync } = tsgo; + + // Free-function `forEachChild` shape compat-eslint / typescript-eslint + // expect: `ts.forEachChild(node, cbNode, cbNodes?)`. tsgo nodes carry + // the method themselves; just delegate. + const forEachChild = function (node: any, cbNode: any, cbNodes?: any) { + if (node && typeof node.forEachChild === 'function') { + return node.forEachChild(cbNode, cbNodes); + } + // Fall back to real ts for non-tsgo nodes (shouldn't happen on + // this path, but cheap insurance). + return (ts as any).forEachChild(node, cbNode, cbNodes); + }; + + // Free-function `getTokenAtPosition`-style helpers and a few of the + // most-used ts.* utilities don't exist in tsgo's exports. Pass through + // to real ts and accept the kind-mismatch fallout — flag them as gaps. + + // Plain object copy of ts. Why not `Object.create(ts)` (prototype + // chain) or `Object.assign({}, ts)` (single shallow copy)? + // + // CJS interop helpers like tslib's `__importStar` iterate + // `Object.getOwnPropertyNames(mod)` — they consume only OWN + // properties, so prototype-inherited fallbacks are invisible to them. + // typescript-estree compiles `import * as ts from 'typescript'` into + // `__importStar(require('typescript'))`, so the consuming module sees + // a snapshot built only from our own keys. Anything we don't own + // (and don't surface as own) ends up `undefined` downstream — e.g. + // `ts.Extension` → `undefined.Cjs` crash from the user's stack. + // + // So: copy every ts own-property up front. Some are getters; reading + // them triggers the getter and gives us the value. Then overlay tsgo's + // values — `SyntaxKind`, type guards, etc. — on top. + const facade: any = {}; + for (const k of Object.getOwnPropertyNames(ts)) { + try { + facade[k] = (ts as any)[k]; + } + catch { + // Some ts internals throw on access (rare; defensive). + } + } + + // Enums from /ast (already aggregated): SyntaxKind, NodeFlags, + // ModifierFlags, ScriptKind, ScriptTarget, TokenFlags, LanguageVariant, + // RegularExpressionFlags, CommentDirectiveType, CharacterCodes. + // Plus: all `is.*` predicates, visitor (visitNode/visitNodes/visitEachChild), + // scanner, AST utils. + for (const k of Object.keys(ast)) { + const v = ast[k]; + // Null-tolerant wrap for `is*` predicates. tsgo emits these as + // `node.kind === SyntaxKind.X` without a null guard; ts's + // versions tolerate `undefined`. ESLint rule code commonly walks + // `node.parent.parent.parent…` and tests on the result, hitting + // undefined at SourceFile-root and beyond. Wrap every is* fn to + // short-circuit-return false on falsy input. + if (typeof v === 'function' && /^is[A-Z]/.test(k)) { + facade[k] = (n: unknown, ...rest: unknown[]) => n ? v(n, ...rest) : false; + } + else { + facade[k] = v; + } + } + + // Enums from /api/sync that aren't in /ast: SymbolFlags, TypeFlags, + // ObjectFlags, ElementFlags, SignatureKind, SignatureFlags, + // NodeBuilderFlags, TypePredicateKind, DiagnosticCategory. + for (const k of Object.keys(sync)) { + // Skip API-level classes (API, Snapshot, Project, Program, Checker) + // — those aren't typescript-module shape; only enum values are + // useful here. Enums are objects with both numeric and reverse + // string keys. + const v = sync[k]; + if (typeof v !== 'object' || v === null) continue; + // Heuristic: enum-shaped object has at least one numeric value. + const hasNumeric = Object.values(v).some(x => typeof x === 'number'); + if (!hasNumeric) continue; + // Don't clobber if already supplied by /ast. + if (k in facade) continue; + facade[k] = v; + } + + // /ast/factory exports `factory` namespace + creation helpers. Real ts + // rule code uses `ts.factory.createX(...)` for code-fix output. Wire + // the namespace through. + if (factory.factory) { + facade.factory = factory.factory; + } + // Free-function `createX` + `updateX` from factory module too. + for (const k of Object.keys(factory)) { + if (k === 'factory' || k === 'NodeObject') continue; + if (typeof factory[k] !== 'function') continue; + if (k in facade && !k.startsWith('create')) continue; + facade[k] = factory[k]; + } + + // `forEachChild` override — must come AFTER /ast spread (which may + // already export it; tsgo's `/ast/visitor` exports `visitEachChild` + // not `forEachChild`). We add the free-function form rule code expects. + facade.forEachChild = forEachChild; + + // Scanner API: tsgo renamed `setTextPos(pos)` to `resetTokenState(pos)`. + // Wrap `createScanner` so returned scanners carry both names — + // keeps compat-eslint/lib/tokens.ts (and any other ts.createScanner + // consumer) working without per-callsite changes. + if (typeof ast.createScanner === 'function') { + const origCreateScanner = ast.createScanner; + facade.createScanner = function (...args: unknown[]) { + const scanner = (origCreateScanner as (...a: unknown[]) => any).apply(null, args); + if (scanner && typeof scanner.setTextPos !== 'function' && typeof scanner.resetTokenState === 'function') { + scanner.setTextPos = scanner.resetTokenState.bind(scanner); + } + return scanner; + }; + } + + // Sentinel marker so the worker can detect this isn't real ts when + // debugging cache-substitution issues. + facade.__tsgoFacade__ = true; + + return facade as typeof ts; +} + +// Install the facade so `require('typescript')` returns it from anywhere +// in the dependency graph — including transitive dependencies that pnpm +// resolves to a sibling typescript instance under `.pnpm/typescript@x.y`. +// Cache-slot substitution alone misses those because they're keyed by a +// different absolute path than `require.resolve('typescript')` from our +// cwd. We override `Module._resolveFilename` so every literal +// `'typescript'` request resolves to a single canonical id, then prime +// that one cache slot with the facade. +// +// Limitations: does NOT intercept resolutions like +// `require('typescript/lib/typescript')` (literal subpath) — those keep +// the real ts. That's intentional: anything reaching for an internal +// path probably wants real ts implementation, not enum re-routing. +export function installFacade(): typeof ts { + const Module = require('module') as any; + const FACADE_ID = '@tsslint-tsgo-facade'; + if (Module._cache[FACADE_ID]?.exports?.__tsgoFacade__) { + return Module._cache[FACADE_ID].exports; + } + const facade = createTypescriptFacade(); + // Synthetic cache entry — not a real disk path, but Node's loader + // only consults `_cache[id]` when looking up an already-resolved + // module, so any string id is fine as long as the slot exists. + const wrapper = new Module(FACADE_ID, null); + wrapper.id = FACADE_ID; + wrapper.filename = FACADE_ID; + wrapper.loaded = true; + wrapper.exports = facade; + Module._cache[FACADE_ID] = wrapper; + + // Resolve hook — must be installed AFTER the facade is in cache so + // the very first `require('typescript')` after this point hits the + // facade slot. + const origResolve = Module._resolveFilename; + Module._resolveFilename = function ( + request: string, + parent: unknown, + ...rest: unknown[] + ) { + if (request === 'typescript') { + return FACADE_ID; + } + return origResolve.call(this, request, parent, ...rest); + }; + return facade; +} diff --git a/packages/cli/lib/worker.ts b/packages/cli/lib/worker.ts index 537244d..eb89de9 100644 --- a/packages/cli/lib/worker.ts +++ b/packages/cli/lib/worker.ts @@ -1,3 +1,10 @@ +// Eager-load `./real-ts.js` so its module cache entry — capturing the +// genuine `typescript` exports BEFORE the tsgo facade installs its +// `Module._resolveFilename` hook — is in place for any later in-process +// code that needs real ts behaviour (parser/binder/scanner) regardless +// of facade activation. +import _ = require('./real-ts.js'); +void _; import ts = require('typescript'); import type config = require('@tsslint/config'); import core = require('@tsslint/core'); @@ -10,23 +17,56 @@ import cacheFlow = require('./cache-flow.js'); import incrementalState = require('./incremental-state.js'); import type { FileCache } from './cache.js'; import type { IncrementalState } from './incremental-state.js'; +import type { TsgoBackend } from './tsgo-backend.js'; + +// `--tsgo` opts the worker into the @typescript/native-preview backend. +// Detected once at module load — switching mid-run isn't supported (the +// backend owns the spawned tsgo process and its snapshot ref-graph). +const useTsgo = process.argv.includes('--tsgo'); +let tsgoBackend: TsgoBackend | undefined; // Fallback if `ts.sys.createHash` is undefined on this host (Node ≥ 22.6 // always provides it via crypto, but the type is optional). sha256 hex. const defaultHash = (s: string) => crypto.createHash('sha256').update(s).digest('hex'); import { createLanguage, FileMap, isCodeActionsEnabled, type Language } from '@volar/language-core'; -import { createProxyLanguageService, decorateLanguageServiceHost, resolveFileLanguageId } from '@volar/typescript'; +import { resolveFileLanguageId } from '@volar/typescript'; import { transformDiagnostic, transformFileTextChanges } from '@volar/typescript/lib/node/transform'; -let projectVersion = 0; -let typeRootsVersion = 0; let options: ts.CompilerOptions = {}; let fileNames: string[] = []; +// Volar Language handle — populated by `proxyCreateProgram`'s `setup` +// callback when a language plugin is active (Vue / MDX / Astro); +// undefined for plain-TS projects. let language: Language | undefined; let linter: core.Linter; -let linterLanguageService!: ts.LanguageService; -// Layer 2 state. We wrap the LS program in a SemanticDiagnostics- +// Cached Program instance + dirty flag. Pre-3.2 the worker wrapped a +// LanguageService over a LanguageServiceHost; getProgram() implicitly +// rebuilt when the host's projectVersion bumped. We've collapsed that +// down to direct `ts.createProgram` calls — the LS provided no +// linter-relevant capability beyond program-rebuild-on-version-bump, +// and it pulled in completion / refactor / navigation machinery we +// never used. `--fix` rewrites a file → bumps `programDirty` → next +// `ensureProgram()` rebuilds with `oldProgram` for incremental binder +// reuse (TS reuses unchanged SourceFiles' bound state, only re-binds +// the modified file). +let currentProgram: ts.Program | undefined; +let programDirty = true; +// In-session content overrides for `--fix`-modified files. The +// CompilerHost's readFile / getSourceFile consult this map first so +// the next program rebuild sees the post-fix text without disk I/O. +const fileTextOverrides = new Map(); +// Process-level SourceFile cache, shared across projects in the same +// CLI invocation (`tsslint --project a/tsconfig.json --project b/tsconfig.json` +// runs in one worker). Pre-3.2 the LS instance survived setup() calls +// and its internal SourceFile cache reused lib.es5.d.ts / shared +// node_modules types across projects; with the LS gone, `oldProgram`- +// based reuse only works within a project (TS bails when +// compilerOptions differ across tsconfigs). This keeps lib + shared +// types from re-parsing per project. Invalidated by content change +// (text-equality check on lookup) so `--fix` rewrites are seen. +const sourceFileCache = new Map(); +// Layer 2 state. We wrap the program in a SemanticDiagnostics- // BuilderProgram (with the prev session's BP fed back via TS's internal // `tsBuildInfoText` round-trip) and walk affected files once. cache- // flow consults this set to decide whether type-aware rules can be @@ -37,65 +77,121 @@ let affectedFiles: Set | undefined; // capture its updated buildinfo text for next session's persistence. let currentBuilder: ts.SemanticDiagnosticsBuilderProgram | undefined; -const snapshots = new Map(); -const versions = new Map(); -const originalHost: ts.LanguageServiceHost = { - ...ts.sys, - useCaseSensitiveFileNames() { - return ts.sys.useCaseSensitiveFileNames; - }, - getProjectVersion() { - return projectVersion.toString(); - }, - getTypeRootsVersion() { - return typeRootsVersion; - }, - getCompilationSettings() { - return options; - }, - getScriptFileNames() { - return fileNames; - }, - getScriptVersion(fileName) { - // In-session bumps win — `--fix` updates this map after writing - // the file. Otherwise fall back to the on-disk mtime so the - // version reflects content across CLI invocations. Layer 2's - // BuilderProgram diff relies on this — without it, every cross- - // session file looks unchanged (always '0') even when the - // content moved on disk. - const inSession = versions.get(fileName); - if (inSession !== undefined) return inSession.toString(); - const stat = fs.statSync(fileName, { throwIfNoEntry: false }); - return stat ? stat.mtimeMs.toString() : '0'; - }, - getScriptSnapshot(fileName) { - if (!snapshots.has(fileName)) { - snapshots.set(fileName, ts.ScriptSnapshot.fromString(ts.sys.readFile(fileName)!)); +// CompilerHost: lower-level than LanguageServiceHost. Just +// readFile / writeFile / fileExists / lib-file resolution / case +// sensitivity. We override `readFile` (and `getSourceFile`, which +// internally reads via readFile) to consult `fileTextOverrides` AND +// to virtualise Vue / MDX / Astro files via the active language plugin. +let compilerHost: ts.CompilerHost = createCompilerHost(); + +function createCompilerHost(): ts.CompilerHost { + // `setParentNodes: true` — compat-eslint's bottom-up materialise + // walks ts.Node.parent chains; without parent pointers it crashes. + // `ts.createLanguageService` set this implicitly; `ts.createProgram` + // via `createCompilerHost` defaults false, so we set it explicitly. + const host = ts.createCompilerHost(options, true); + const originalReadFile = host.readFile.bind(host); + const originalGetSourceFile = host.getSourceFile.bind(host); + const hash = ts.sys.createHash ?? defaultHash; + host.readFile = (fileName: string) => { + const override = fileTextOverrides.get(fileName); + if (override !== undefined) return override; + return originalReadFile(fileName); + }; + host.getSourceFile = (fileName, languageVersion, onError, shouldCreate) => { + // Vue / MDX / Astro virtualisation. We replicate `proxyCreateProgram` + // but DO NOT apply its `decorateProgram` step — that wraps + // `program.getSemanticDiagnostics` to call `fillSourceFileText`, + // which mutates `SourceFile.text` in place to splice the original + // .vue text back over the leading-offset spaces. The mutation + // happens AFTER the AST has been parsed, so the rule walks an AST + // whose positions now point at characters in the post-mutation + // text — the rule reports diagnostics at offsets that no longer + // match the AST it was given. Master got away with this by going + // through `decorateLanguageServiceHost` (LS-side, doesn't touch + // the program) instead. + // + // Virtualised SFs are NOT cached cross-project: the plugin's + // `getServiceScript` output depends on the per-project `language` + // instance, so two projects with different language plugins can + // produce different virtual TS for the same fileName. + if (language) { + const sourceScript = language.scripts.get(fileName); + const tsAdapter = sourceScript?.generated?.languagePlugin.typescript; + if (sourceScript && tsAdapter) { + const orig = originalGetSourceFile(fileName, languageVersion, onError, shouldCreate); + if (!orig) return orig; + const serviceScript = tsAdapter.getServiceScript(sourceScript.generated!.root); + if (serviceScript) { + // Two layouts depending on the plugin: + // - !preventLeadingOffset: replace original-text positions + // with whitespace (preserves source-map offsets), then + // append the plugin's emitted TS. + // - preventLeadingOffset: just emit the plugin's TS. + const virtualText = !serviceScript.preventLeadingOffset + ? orig.text.split('\n').map(l => ' '.repeat(l.length)).join('\n') + + serviceScript.code.snapshot.getText(0, serviceScript.code.snapshot.getLength()) + : serviceScript.code.snapshot.getText(0, serviceScript.code.snapshot.getLength()); + const virtual = ts.createSourceFile( + fileName, + virtualText, + languageVersion, + /*setParentNodes*/ true, + serviceScript.scriptKind, + ); + (virtual as unknown as { version: string }).version = hash(virtualText); + return virtual; + } + } + } + // Plain-TS path. Cross-project SF cache: same path + same content + // → return the cached SF unchanged. Skips re-parse for lib.es5.d.ts + // and any node_modules types both projects pull in. The text- + // equality check is what invalidates after `--fix` (override + // changes the text → cache miss → fresh parse). + const text = host.readFile(fileName); + if (text === undefined) { + sourceFileCache.delete(fileName); + return undefined; } - return snapshots.get(fileName); - }, - getScriptKind(fileName) { - const languageId = resolveFileLanguageId(fileName); - switch (languageId) { - case 'javascript': - return ts.ScriptKind.JS; - case 'javascriptreact': - return ts.ScriptKind.JSX; - case 'typescript': - return ts.ScriptKind.TS; - case 'typescriptreact': - return ts.ScriptKind.TSX; - case 'json': - return ts.ScriptKind.JSON; + const cached = sourceFileCache.get(fileName); + if (cached && cached.text === text) { + return cached; } - return ts.ScriptKind.Unknown; - }, - getDefaultLibFileName(options) { - return ts.getDefaultLibFilePath(options); - }, -}; -const linterHost: ts.LanguageServiceHost = { ...originalHost }; -const originalService = ts.createLanguageService(linterHost); + const orig = originalGetSourceFile(fileName, languageVersion, onError, shouldCreate); + if (!orig) return orig; + // BuilderProgram requires `SourceFile.version` (Debug.checkDefined + // throws otherwise). The LS-host path got this via the host's + // `getScriptVersion`; raw CompilerHost has no equivalent, so we + // stamp a content hash here. Same value across runs as long as + // content matches → BuilderProgram's reference-graph diff + // correctly identifies unchanged files. + if ((orig as unknown as { version?: string }).version === undefined) { + (orig as unknown as { version: string }).version = hash(orig.text); + } + sourceFileCache.set(fileName, orig); + return orig; + }; + return host; +} + +function ensureProgram(): ts.Program { + if (programDirty || !currentProgram) { + // `oldProgram` lets `ts.createProgram` reuse SourceFiles whose + // text hasn't changed (text-equality check vs old SF) and skip + // re-parsing + re-binding for those. Modified files (via + // `fileTextOverrides`) get re-parsed; unchanged files are zero- + // cost. + currentProgram = ts.createProgram({ + rootNames: fileNames, + options, + host: compilerHost, + oldProgram: currentProgram, + }); + programDirty = false; + } + return currentProgram; +} // Linter is single-threaded by design. The previous version split into a // worker_threads worker for TTY mode (so the spinner could update during a @@ -137,6 +233,15 @@ async function setup( initialTypeAwareRules: readonly string[], prevIncrementalState: IncrementalState | undefined, ): Promise { + if (useTsgo) { + // Install the tsgo `typescript` facade BEFORE loading tsslint + // config — config + compat-eslint + rules will then `require('typescript')` + // and get tsgo enums + type guards + walkers, so kind-value + // comparisons line up with tsgo Node objects. Idempotent across + // setup() calls (first install wins; subsequent ones short-circuit). + require('./tsgo-typescript-facade.js').installFacade(); + } + let config: config.Config | config.Config[]; try { config = (await import(url.pathToFileURL(configFile).toString())).default; @@ -148,55 +253,54 @@ async function setup( return String(err); } - for (let key in linterHost) { - if (!(key in originalHost)) { - // @ts-ignore - delete linterHost[key]; - } - else { - // @ts-ignore - linterHost[key] = originalHost[key]; - } - } - linterLanguageService = originalService; - language = undefined; - // Reset per-project state. Multi-project runs reuse the same worker // (in-process) — without this, cross-project file paths accumulate in - // `snapshots` / `versions` (memory leak) and `affectedFiles` from a - // prior project would mis-classify this project's files as cache-hit + // `fileTextOverrides` (memory leak) and `affectedFiles` from a prior + // project would mis-classify this project's files as cache-hit // candidates if their absolute paths happened to overlap. - snapshots.clear(); - versions.clear(); + fileTextOverrides.clear(); + language = undefined; affectedFiles = undefined; currentBuilder = undefined; + // `currentProgram` is intentionally NOT cleared — `ensureProgram()` + // will pass it as `oldProgram` to the next `ts.createProgram` call. + // TS reuses SourceFiles whose path + text match across the two + // programs, so lib files (lib.es5.d.ts etc., shared between every + // project) and any node_modules types both projects pull in skip + // the parse + bind cost on the second project. Pre-3.2 the LS + // instance survived across projects with the same effect; this + // preserves that. `programDirty = true` forces the rebuild so the + // new project's rootNames + options take effect. + programDirty = true; const plugins = await languagePlugins.load(tsconfig, languages); - if (plugins.length) { - const { getScriptSnapshot } = originalHost; - language = createLanguage( - [ - ...plugins, - { getLanguageId: fileName => resolveFileLanguageId(fileName) }, - ], - new FileMap(ts.sys.useCaseSensitiveFileNames), - fileName => { - const snapshot = getScriptSnapshot(fileName); - if (snapshot) { - language!.scripts.set(fileName, snapshot); - } - }, - ); - decorateLanguageServiceHost(ts, language, linterHost); + fileNames = _fileNames; - const proxy = createProxyLanguageService(linterLanguageService); - proxy.initialize(language); - linterLanguageService = proxy.proxy; + if (useTsgo) { + // Validate compatibility. The tsgo backend currently lacks two + // pieces master assumes: (1) Volar host injection, so language + // plugins (Vue / MDX / Astro / etc.) can't virtualise script + // content; (2) BuilderProgram JS API, so layer-2 affected-file + // classification is unavailable. + if (plugins.length) { + return 'tsgo backend does not yet support --vue-project / --mdx-project / --astro-project / --vue-vine-project / --ts-macro-project'; + } + // Layer 2 is disabled — every file is treated as "affected" so + // cached type-aware entries are re-validated rather than served + // from a stale snapshot. No BuilderProgram drain runs. + affectedFiles = new Set(); + tsgoBackend?.close(); + tsgoBackend = require('./tsgo-backend.js').createTsgoBackend(tsconfig) as TsgoBackend; + linter = core.createLinter( + { typescript: ts, program: tsgoBackend.getProgram }, + path.dirname(configFile), + config, + () => [], + initialTypeAwareRules, + ); + return true; } - projectVersion++; - typeRootsVersion++; - fileNames = _fileNames; // Internal API path: BuilderProgram.emitBuildInfo only produces // content when these options are set. Override the user's values // (their own tsc --incremental builds shouldn't share this file). @@ -209,11 +313,42 @@ async function setup( incremental: true, tsBuildInfoFile: incrementalState.SYNTHETIC_BUILD_INFO_PATH, }; + if (plugins.length) { + // Manual replication of `proxyCreateProgram`'s language setup — + // without its `decorateProgram` step, which mutates the program's + // `getSemanticDiagnostics` to call `fillSourceFileText` and + // breaks AST-position lookups (see `createCompilerHost`). The + // host's `getSourceFile` consults `language.scripts` to splice + // virtual TS into Vue / MDX / Astro files. + language = createLanguage( + [ + ...plugins, + { getLanguageId: fileName => resolveFileLanguageId(fileName) }, + ], + new FileMap(ts.sys.useCaseSensitiveFileNames), + (fileName, includeFsFiles) => { + if (!includeFsFiles) return; + const text = fileTextOverrides.get(fileName) ?? ts.sys.readFile(fileName); + if (text === undefined) { + language!.scripts.delete(fileName); + return; + } + language!.scripts.set(fileName, ts.ScriptSnapshot.fromString(text)); + }, + ); + } + // Compile a fresh CompilerHost AFTER `language` is wired so the + // host's getSourceFile virtualisation can read from it. `options` + // may have changed too — createCompilerHost bakes those in. + compilerHost = createCompilerHost(); linter = core.createLinter( { - languageService: linterLanguageService, - languageServiceHost: linterHost, typescript: ts, + // Thunk: each `lint()` call gets the latest Program. `--fix` + // rewrites a file mid-session → flips `programDirty` → next + // `ensureProgram()` rebuilds with `oldProgram` for incremental + // binder reuse. + program: ensureProgram, }, path.dirname(configFile), config, @@ -222,7 +357,7 @@ async function setup( ); { - const program = linterLanguageService.getProgram()!; + const program = ensureProgram(); // Reconstruct the prev session's BP from cached buildinfo text, // fall through to undefined on any failure (cold-start path). const oldBuilder = incrementalState.reconstructOldBuilder(ts, prevIncrementalState, { @@ -279,10 +414,18 @@ function buildIncrementalState(): IncrementalState | undefined { } function lint(fileName: string, fix: boolean, fileCache: FileCache, fileMtime: number) { - let newSnapshot: ts.IScriptSnapshot | undefined; + let newText: string | undefined; let diagnostics!: ts.DiagnosticWithLocation[]; let shouldCheck = true; + if (tsgoBackend) { + // Per-file batched-symbol prepass. Walks the SF locally, resolves + // every Identifier in one IPC, populates the adapter's + // `nodeToSymbol` cache. Idempotent — repeat calls for the same + // file return immediately. + tsgoBackend.prepareFile(fileName); + } + // Layer 2 signals. `incremental` is always true under the CLI now — // `--force` opts out by clearing the loaded cache instead. // typeAwareUnaffected: file's deps haven't moved since prev session, @@ -302,8 +445,7 @@ function lint(fileName: string, fix: boolean, fileCache: FileCache, fileMtime: n delete fileCache.rules[ruleId]; } } - const program = linterLanguageService.getProgram()!; - diagnostics = cacheFlow.lintWithCache(linter, fileName, fileCache, fileMtime, program, { + diagnostics = cacheFlow.lintWithCache(linter, fileName, fileCache, fileMtime, ensureProgram(), { incremental: true, typeAwareUnaffected, }); @@ -322,19 +464,25 @@ function lint(fileName: string, fix: boolean, fileCache: FileCache, fileMtime: n const textChanges = core.combineCodeFixes(fileName, fixes); if (textChanges.length) { - const oldSnapshot = snapshots.get(fileName)!; - newSnapshot = core.applyTextChanges(oldSnapshot, textChanges); - snapshots.set(fileName, newSnapshot); - versions.set(fileName, (versions.get(fileName) ?? 0) + 1); - projectVersion++; + // Apply edits to the current text (override map first, fall + // through to disk). Stash result in `fileTextOverrides` so the + // next `ensureProgram()` rebuild sees the post-fix content. + const baseText = fileTextOverrides.get(fileName) ?? ts.sys.readFile(fileName) ?? ''; + newText = core.applyTextChanges(baseText, textChanges); + fileTextOverrides.set(fileName, newText); + programDirty = true; + // On the tsgo backend, drop the JS-side bind cache for this + // file so the next prepareFile rebinds against post-fix text. + // (`programDirty` handles the tsgo-program rebuild for type + // queries; this handles the in-process Symbol cache.) + tsgoBackend?.invalidateFile(fileName); } } - if (newSnapshot) { - const newText = newSnapshot.getText(0, newSnapshot.getLength()); + if (newText !== undefined) { const oldText = ts.sys.readFile(fileName); if (newText !== oldText) { - ts.sys.writeFile(fileName, newSnapshot.getText(0, newSnapshot.getLength())); + ts.sys.writeFile(fileName, newText); // File content moved — refresh mtime so the next lint pass // invalidates layer-1 cache entries for this file. lintWithCache // compares fileCache.mtime against the fileMtime we pass in. @@ -344,8 +492,7 @@ function lint(fileName: string, fix: boolean, fileCache: FileCache, fileMtime: n } if (shouldCheck) { - const program = linterLanguageService.getProgram()!; - diagnostics = cacheFlow.lintWithCache(linter, fileName, fileCache, fileMtime, program, { + diagnostics = cacheFlow.lintWithCache(linter, fileName, fileCache, fileMtime, ensureProgram(), { incremental: true, typeAwareUnaffected, }); @@ -353,12 +500,12 @@ function lint(fileName: string, fix: boolean, fileCache: FileCache, fileMtime: n // Language-transform path (Vue/MDX/etc.): diagnostics map back from // the transformed file to the original source. The original file - // might not be in the language service's program, so we substitute a - // SourceFile-shaped POJO with the real source text — `formatDiagnostics- - // WithColorAndContext` reads `.file.text` to render code snippets. + // might not be in the program, so we substitute a SourceFile-shaped + // POJO with the real source text — `formatDiagnosticsWithColorAndContext` + // reads `.file.text` to render code snippets. if (language) { diagnostics = diagnostics - .map(d => transformDiagnostic(language!, d, (originalService as any).getCurrentProgram(), false)) + .map(d => transformDiagnostic(language!, d, ensureProgram(), false)) .filter(d => !!d); const fileShim = new Map(); const getShim = (fn: string) => { @@ -383,11 +530,17 @@ function lint(fileName: string, fix: boolean, fileCache: FileCache, fileMtime: n // diagnostics on the same file (so `formatDiagnosticsWithColorAndContext` // only computes line starts once per file). + // Done with this file — drop the JS-side bind so the bound SF and + // position maps don't pin in memory for the rest of the lint pass. + // Without this, all linted files' bound SFs accumulate (~30 KB each + // — adds up to ~150 MB on a 5000-file codebase). + tsgoBackend?.releaseFile(fileName); + return diagnostics; } function getFileText(fileName: string) { - return originalHost.getScriptSnapshot(fileName)!.getText(0, Number.MAX_VALUE); + return fileTextOverrides.get(fileName) ?? ts.sys.readFile(fileName) ?? ''; } function hasCodeFixes(fileName: string) { diff --git a/packages/cli/package.json b/packages/cli/package.json index caac3ce..3c1f16d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -30,6 +30,12 @@ "minimatch": "^10.0.1" }, "peerDependencies": { - "typescript": "*" + "typescript": "*", + "@typescript/native-preview": "*" + }, + "peerDependenciesMeta": { + "@typescript/native-preview": { + "optional": true + } } } diff --git a/packages/cli/test/cache-flow.test.ts b/packages/cli/test/cache-flow.test.ts index 2eb7cad..60841bb 100644 --- a/packages/cli/test/cache-flow.test.ts +++ b/packages/cli/test/cache-flow.test.ts @@ -43,7 +43,8 @@ function makeContext(files: Record) { fileExists: n => n in files || n === realLibPath, readFile: n => (n in files ? files[n] : (n === realLibPath ? realLibContent : undefined)), }; - return { typescript: ts, languageServiceHost: host, languageService: ts.createLanguageService(host) }; + const languageService = ts.createLanguageService(host); + return { typescript: ts, program: () => languageService.getProgram()! }; } function emptyFileCache(mtime = 0): FileCache { @@ -62,7 +63,7 @@ function emptyFileCache(mtime = 0): FileCache { }; const linter = core.createLinter(ctx, '/', config, () => []); const cache = emptyFileCache(1); - cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, ctx.languageService.getProgram()!); + cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, ctx.program()); check('syntactic rule cache entry written', !!cache.rules['syntactic']); check('cache has 1 diagnostic', cache.rules['syntactic']?.diagnostics.length === 1); check('hasFix false (no fix reported)', cache.rules['syntactic']?.hasFix === false); @@ -81,7 +82,7 @@ function emptyFileCache(mtime = 0): FileCache { }; const linter = core.createLinter(ctx, '/', config, () => []); const cache = emptyFileCache(1); - const result = cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, ctx.languageService.getProgram()!); + const result = cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, ctx.program()); check('type-aware rule still produces diagnostics', result.length === 1); check('type-aware rule NOT cached', !cache.rules['typed']); } @@ -100,7 +101,7 @@ function emptyFileCache(mtime = 0): FileCache { }; const linter = core.createLinter(ctx, '/', config, () => []); const cache = emptyFileCache(1); - const program = ctx.languageService.getProgram()!; + const program = ctx.program(); cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program); check('first call ran rule', runs === 1); @@ -128,7 +129,7 @@ function emptyFileCache(mtime = 0): FileCache { }; const linter = core.createLinter(ctx, '/', config, () => []); const cache = emptyFileCache(1); - const program = ctx.languageService.getProgram()!; + const program = ctx.program(); cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program); check('first call ran', runs === 1); @@ -156,7 +157,7 @@ function emptyFileCache(mtime = 0): FileCache { }; const linter = core.createLinter(ctx, '/', config, () => []); const cache = emptyFileCache(1); - cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, ctx.languageService.getProgram()!); + cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, ctx.program()); check('post-rule cleanup deleted entry', !cache.rules['report-then-touch']); } @@ -179,7 +180,7 @@ function emptyFileCache(mtime = 0): FileCache { const linter = core.createLinter(ctx, '/', config, () => []); const cacheA = emptyFileCache(1); const cacheB = emptyFileCache(1); - const program = ctx.languageService.getProgram()!; + const program = ctx.program(); cacheFlow.lintWithCache(linter, '/a.ts', cacheA, 1, program); cacheFlow.lintWithCache(linter, '/b.ts', cacheB, 1, program); check('a.ts no cache entry (touched)', !cacheA.rules['sometimes-typed']); @@ -221,7 +222,7 @@ function emptyFileCache(mtime = 0): FileCache { }, }, }; - const result = cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, ctx.languageService.getProgram()!); + const result = cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, ctx.program()); check('rule re-ran (stale cache ignored)', runs === 1); check('result reflects fresh run', result.length === 1); check('stale entry deleted', !cache.rules['typed']); @@ -239,7 +240,7 @@ function emptyFileCache(mtime = 0): FileCache { }; const linter = core.createLinter(ctx, '/', config, () => []); const cache = emptyFileCache(1); - cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, ctx.languageService.getProgram()!); + cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, ctx.program()); check('hasFix true after rule registered fix', cache.rules['fixable']?.hasFix === true); } @@ -261,7 +262,7 @@ function emptyFileCache(mtime = 0): FileCache { }; const linter = core.createLinter(ctx, '/', config, () => []); const cache = emptyFileCache(1); - const program = ctx.languageService.getProgram()!; + const program = ctx.program(); // First call: both run cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program); @@ -294,7 +295,7 @@ function emptyFileCache(mtime = 0): FileCache { }; const linter = core.createLinter(ctx, '/', config, () => []); const cache = emptyFileCache(1); - const program = ctx.languageService.getProgram()!; + const program = ctx.program(); cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program, { incremental: true, typeAwareUnaffected: true }); check('first call ran (no cache yet)', runs === 1); @@ -324,7 +325,7 @@ function emptyFileCache(mtime = 0): FileCache { }; const linter = core.createLinter(ctx, '/', config, () => []); const cache = emptyFileCache(1); - const program = ctx.languageService.getProgram()!; + const program = ctx.program(); cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program, { incremental: true, typeAwareUnaffected: true }); const second = cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program, { @@ -358,7 +359,7 @@ function emptyFileCache(mtime = 0): FileCache { }; const linter = core.createLinter(ctx, '/', config, () => []); const cache = emptyFileCache(1); - const program = ctx.languageService.getProgram()!; + const program = ctx.program(); // Mode B: write the entry. cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program, { incremental: true, typeAwareUnaffected: true }); @@ -383,7 +384,7 @@ function emptyFileCache(mtime = 0): FileCache { }; const linter = core.createLinter(ctx, '/', config, () => []); const cache = emptyFileCache(1); - const program = ctx.languageService.getProgram()!; + const program = ctx.program(); // No options arg → mode A. Type-aware rules never cached. cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, program); @@ -414,7 +415,7 @@ function emptyFileCache(mtime = 0): FileCache { }; const linter = core.createLinter(ctx, '/', config, () => []); const cache = emptyFileCache(1); - const program = ctx.languageService.getProgram()!; + const program = ctx.program(); // Cold session under --incremental: no prev state, file is "affected" // (unaffected=false). Must run AND write entry. @@ -454,7 +455,7 @@ function emptyFileCache(mtime = 0): FileCache { '/a.ts', cache, 1, - ctx.languageService.getProgram()!, + ctx.program(), ); check('current run returns both diagnostics', diags.length === 2); check( @@ -485,7 +486,7 @@ function emptyFileCache(mtime = 0): FileCache { }; const linter = core.createLinter(ctx, '/', config, () => []); const cache = emptyFileCache(1); - cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, ctx.languageService.getProgram()!); + cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, ctx.program()); // Second run with the same mtime — rule cache-hits, only the // persisted diagnostic comes back. @@ -495,7 +496,7 @@ function emptyFileCache(mtime = 0): FileCache { '/a.ts', cache, 1, - ctx.languageService.getProgram()!, + ctx.program(), ); check('warm cache hit returns 1 diagnostic', diags.length === 1); check('warm replay drops the marked one', diags[0]?.messageText === 'plain'); @@ -528,7 +529,7 @@ function emptyFileCache(mtime = 0): FileCache { // ── Session 1: cold, both files lint — process the early-return file // FIRST so the rule isn't yet type-aware when its entry gets written. const linter1 = core.createLinter(ctx, '/', config, () => []); - const program1 = ctx.languageService.getProgram()!; + const program1 = ctx.program(); const cacheSkip: FileCache = emptyFileCache(1); const cacheCheck: FileCache = emptyFileCache(1); cacheFlow.lintWithCache(linter1, '/skip.ts', cacheSkip, 1, program1); @@ -551,7 +552,7 @@ function emptyFileCache(mtime = 0): FileCache { // Both files unchanged. typeAwareUnaffected=true → both should // cache-hit and replay cleanly. const linter2 = core.createLinter(ctx, '/', config, () => [], ['mixed-mode']); - const program2 = ctx.languageService.getProgram()!; + const program2 = ctx.program(); let earlyReturnRanInSession2 = false; const config2: Config = { rules: { @@ -599,7 +600,7 @@ function emptyFileCache(mtime = 0): FileCache { }; const linter = core.createLinter(ctx, '/', config, () => []); const cache = emptyFileCache(1); - cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, ctx.languageService.getProgram()!); + cacheFlow.lintWithCache(linter, '/a.ts', cache, 1, ctx.program()); // rule entry exists but with 0 diagnostics — no smuggled keys. const json = JSON.stringify(cache); check('NO_CACHE marker not visible in serialised cache', !json.includes('no-cache')); diff --git a/packages/cli/test/meta-frameworks.test.ts b/packages/cli/test/meta-frameworks.test.ts new file mode 100644 index 0000000..7d57f8b --- /dev/null +++ b/packages/cli/test/meta-frameworks.test.ts @@ -0,0 +1,102 @@ +// Smoke test for the meta-framework language-plugin path. Spawns the +// real tsslint CLI against the in-tree `fixtures/meta-frameworks` fixture +// (.tsx + .vue + .vine.ts + .astro + .mdx) for each `--*-project` flag, +// asserts the no-console rule fires on EACH framework's script content. +// +// This catches the regression class where the language plugin loads but +// the script content never makes it into the linter's program — e.g. a +// virtual-script transformation that gets undone before AST walk. Pre- +// 3.2 (LS-side `decorateLanguageServiceHost`) and post-3.2 (program-side +// host wrap) both have the same user-visible contract: `console.log` +// inside `