From 09e5721cfac969eeccab2e8f9fe9e2d833013988 Mon Sep 17 00:00:00 2001 From: Sterling Larson Date: Wed, 2 Sep 2026 14:36:30 -0600 Subject: [PATCH] typescript-eslint: sync tsgo bridge fixes from redo --- typescript-eslint/tsgo-bridge/README.md | 6 +- typescript-eslint/tsgo-bridge/src/bridge.js | 175 +++++++++++++++----- typescript-eslint/tsgo-bridge/src/compat.js | 127 ++++++++++++++ typescript-eslint/tsgo-bridge/src/index.js | 35 +++- 4 files changed, 293 insertions(+), 50 deletions(-) create mode 100644 typescript-eslint/tsgo-bridge/src/compat.js diff --git a/typescript-eslint/tsgo-bridge/README.md b/typescript-eslint/tsgo-bridge/README.md index 5e117ea..650f4a2 100644 --- a/typescript-eslint/tsgo-bridge/README.md +++ b/typescript-eslint/tsgo-bridge/README.md @@ -38,5 +38,9 @@ expose the Strada surface; declaration nodes resolve lazily and are exposed as proxied tsgo nodes. `getAwaitedType` and `getContextualTypeForArgumentAtIndex` have no tsgo equivalent and are emulated. -The API is unstable and unversioned: pin an exact `typescript` nightly and +The API is unstable and unversioned. The bridge pins no TypeScript itself (the +consumer's `@npm` does, like the native `ts_compiler` binary); it records the +nightly it was verified against in `TESTED_NATIVE_VERSION` and +`createTsgoParser` fails at startup with a descriptive error when the handed-in +modules lack an export or checker method it relies on. Pin an exact nightly and re-run the consumer's parity checks when bumping it. diff --git a/typescript-eslint/tsgo-bridge/src/bridge.js b/typescript-eslint/tsgo-bridge/src/bridge.js index 769bbe7..d507be7 100644 --- a/typescript-eslint/tsgo-bridge/src/bridge.js +++ b/typescript-eslint/tsgo-bridge/src/bridge.js @@ -1,4 +1,5 @@ /** tsgo-backed stand-ins for `ts.Program` / `ts.TypeChecker` that typescript-eslint rules can consume. */ +import fs from "node:fs"; import path from "node:path"; /** Builds the shim classes over `env` = { ts, API, remaps, collectTiming } so nothing here imports a TypeScript package. */ @@ -54,13 +55,15 @@ export function createTsgoProjectClass(env) { return remapNodeFlags(target.flags); } if (prop === "modifierFlagsCache") { - return undefined; + return; } if (prop === "escapedText" && typeof target.text === "string") { return target.text.startsWith("__") ? `_${target.text}` : target.text; } const value = - prop in target ? target[prop] : target[PROPERTY_ALIASES[prop] ?? prop]; + prop in target + ? target[prop] + : target[PROPERTY_ALIASES[prop] ?? prop]; if (typeof value === "function") { return (...args) => wrapValue(value.apply(target, args.map(unwrapArg)), project); @@ -109,25 +112,102 @@ export function createTsgoProjectClass(env) { return value; } + /** + * TypeScript <= 6 auto-includes `@types/*` packages when `types` is unset; + * TypeScript 7 does not. Under rules_javascript the Strada lint saw exactly + * the current package's direct `@types/*` deps (its virtual node_modules), so + * read those from the package manifest when the native lint action provides + * it; otherwise scan `typeRoots` / ancestor `node_modules/@types` on disk. + */ + function automaticTypes(configDir, typeRoots) { + const manifestPath = process.env.NODE_FS_PACKAGE_MANIFEST; + const currentPackage = process.env.STAGE_NM_CURRENT_PKG; + if (manifestPath && currentPackage) { + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + const deps = manifest.packages?.[currentPackage]?.deps ?? {}; + return Object.keys(deps) + .filter((name) => name.startsWith("@types/")) + .map((name) => name.slice("@types/".length)) + .sort(); + } + const roots = []; + if (typeRoots?.length) { + roots.push(...typeRoots.map((r) => path.resolve(configDir, r))); + } else { + for (let dir = configDir; ; dir = path.dirname(dir)) { + roots.push(path.join(dir, "node_modules", "@types")); + if (path.dirname(dir) === dir) { + break; + } + } + } + const names = new Set(); + for (const root of roots) { + let entries; + try { + entries = fs.readdirSync(root, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + if ( + entry.name.startsWith(".") || + (!entry.isDirectory() && !entry.isSymbolicLink()) + ) { + continue; + } + const dir = path.join(root, entry.name); + if ( + fs.existsSync(path.join(dir, "package.json")) || + fs.existsSync(path.join(dir, "index.d.ts")) + ) { + names.add(entry.name); + } + } + } + return [...names].sort(); + } + class TsgoProject { /** @param {string} tsconfigPath absolute path */ constructor( tsconfigPath, - { cwd = path.dirname(tsconfigPath), collectTiming = false } = {}, + { cwd = process.cwd(), collectTiming = false } = {}, ) { this.tsconfigPath = tsconfigPath; - this.api = new API({ cwd, collectTiming: collectTiming || env.collectTiming }); + this.api = new API({ + cwd, + collectTiming: collectTiming || env.collectTiming, + }); this.requestStats = new Map(); - if (collectTiming || env.collectTiming || process.env.TSGO_BRIDGE_TIMING === "1") { + if ( + collectTiming || + env.collectTiming || + process.env.TSGO_BRIDGE_TIMING === "1" + ) { this.instrumentClient(); } - this.snapshot = this.api.updateSnapshot({ openProjects: [tsconfigPath] }); - this.project = this.snapshot.getProject(tsconfigPath); - if (!this.project) { - throw new Error(`tsgo: no project loaded for ${tsconfigPath}`); + const parsed = this.api.parseConfigFile(tsconfigPath); + const compilerOptions = { ...parsed.options }; + if (compilerOptions.types === undefined) { + compilerOptions.types = automaticTypes( + path.dirname(tsconfigPath), + compilerOptions.typeRoots, + ); + } + if (process.env.NODE_FS_PACKAGE_MANIFEST) { + // Staged node_modules (see rules_javascript stage-nm): resolve through + // the staged symlinks like the native compile does, and drop typeRoots + // that only exist in the fs-linker virtual filesystem. + compilerOptions.preserveSymlinks = true; + delete compilerOptions.typeRoots; } + this.program = this.api.createProgram(parsed.fileNames, { + compilerOptions, + projectReferences: parsed.projectReferences, + }); + this.project = this.program.getProject(); this.checker = this.project.checker; - this.program = this.project.program; this.fileIndex = new Map(); this.nodeWrappers = new WeakMap(); this.typeWrappers = new Map(); @@ -135,7 +215,7 @@ export function createTsgoProjectClass(env) { this.signatureWrappers = new Map(); this.shimChecker = new CheckerShim(this); this.shimProgram = new ProgramShim(this); - this.compilerOptions = this.project.parsedCommandLine.options; + this.compilerOptions = compilerOptions; } close() { @@ -305,7 +385,7 @@ export function createTsgoProjectClass(env) { resolveHandle(handle) { if (!handle) { - return undefined; + return; } stats.declarationResolves++; return this.wrapNode(handle.resolve(this.project)); @@ -561,7 +641,7 @@ export function createTsgoProjectClass(env) { symbolTable(map) { if (!map) { - return undefined; + return; } const out = new Map(); for (const [k, v] of map) { @@ -571,12 +651,10 @@ export function createTsgoProjectClass(env) { } getJsDocTags() { - return this.inner - .getJsDocTags(this.project.checker) - .map((tag) => ({ - name: tag.name, - text: tag.text ? [{ kind: "text", text: tag.text }] : undefined, - })); + return this.inner.getJsDocTags(this.project.checker).map((tag) => ({ + name: tag.name, + text: tag.text ? [{ kind: "text", text: tag.text }] : undefined, + })); } getDocumentationComment() { @@ -655,7 +733,9 @@ export function createTsgoProjectClass(env) { } getSourceFile(fileName) { - return this.project.wrapNode(this.project.program.getSourceFile(fileName)); + return this.project.wrapNode( + this.project.program.getSourceFile(fileName), + ); } getRootFileNames() { @@ -673,7 +753,9 @@ export function createTsgoProjectClass(env) { } isSourceFileFromExternalLibrary(sf) { - return this.project.program.isSourceFileFromExternalLibrary(unwrapArg(sf)); + return this.project.program.isSourceFileFromExternalLibrary( + unwrapArg(sf), + ); } isSourceFileDefaultLibrary(sf) { @@ -717,7 +799,7 @@ export function createTsgoProjectClass(env) { getSymbolAtLocation(node) { if (!node) { - return undefined; + return; } return this.p.wrapSymbol(this.c.getSymbolAtLocation(this.node(node))); } @@ -758,7 +840,9 @@ export function createTsgoProjectClass(env) { } getExportsOfModule(symbol) { - return this.p.wrapSymbols(this.c.getExportsOfModule(unwrapSymbol(symbol))); + return this.p.wrapSymbols( + this.c.getExportsOfModule(unwrapSymbol(symbol)), + ); } getApparentType(type) { @@ -806,26 +890,28 @@ export function createTsgoProjectClass(env) { } getPropertyOfType(type, name) { - return this.p.wrapSymbol(this.c.getPropertyOfType(unwrapType(type), name)); + return this.p.wrapSymbol( + this.c.getPropertyOfType(unwrapType(type), name), + ); } getIndexInfosOfType(type) { - return this.c - .getIndexInfosOfType(unwrapType(type)) - .map((info) => ({ - keyType: this.p.wrapType(info.keyType), - type: this.p.wrapType(info.valueType), - isReadonly: !!info.isReadonly, - declaration: info.declaration - ? this.p.resolveHandle(info.declaration) - : undefined, - })); + return this.c.getIndexInfosOfType(unwrapType(type)).map((info) => ({ + keyType: this.p.wrapType(info.keyType), + type: this.p.wrapType(info.valueType), + isReadonly: !!info.isReadonly, + declaration: info.declaration + ? this.p.resolveHandle(info.declaration) + : undefined, + })); } getIndexInfoOfType(type, kind) { const infos = this.getIndexInfosOfType(type); const wanted = - kind === ts.IndexKind.String ? ts.TypeFlags.String : ts.TypeFlags.Number; + kind === ts.IndexKind.String + ? ts.TypeFlags.String + : ts.TypeFlags.Number; return infos.find((i) => i.keyType.flags & wanted); } @@ -874,7 +960,7 @@ export function createTsgoProjectClass(env) { getContextualType(node) { if (!node) { - return undefined; + return; } return this.p.wrapType(this.c.getContextualType(this.node(node))); } @@ -927,7 +1013,7 @@ export function createTsgoProjectClass(env) { getShorthandAssignmentValueSymbol(node) { if (!node) { - return undefined; + return; } return this.p.wrapSymbol( this.c.getShorthandAssignmentValueSymbol(this.node(node)), @@ -936,7 +1022,7 @@ export function createTsgoProjectClass(env) { getExportSpecifierLocalTargetSymbol(node) { if (!node) { - return undefined; + return; } return this.p.wrapSymbol( this.c.getExportSpecifierLocalTargetSymbol(this.node(node)), @@ -1011,7 +1097,7 @@ export function createTsgoProjectClass(env) { let current = unwrapType(type); for (let depth = 0; depth < 8; depth++) { if (seen.has(current.id)) { - return undefined; + return; } seen.add(current.id); const promised = this.promisedTypeOfPromise(current); @@ -1020,7 +1106,7 @@ export function createTsgoProjectClass(env) { } current = promised; } - return undefined; + return; } getPromisedTypeOfPromise(type) { @@ -1029,9 +1115,12 @@ export function createTsgoProjectClass(env) { } promisedTypeOfPromise(type) { - const then = this.c.getPropertyOfType(this.c.getApparentType(type), "then"); + const then = this.c.getPropertyOfType( + this.c.getApparentType(type), + "then", + ); if (!then) { - return undefined; + return; } const thenType = this.c.getTypeOfSymbol(then); const sigs = this.c.getSignaturesOfType( @@ -1059,7 +1148,7 @@ export function createTsgoProjectClass(env) { } } if (candidates.size !== 1) { - return undefined; + return; } return candidates.values().next().value; } diff --git a/typescript-eslint/tsgo-bridge/src/compat.js b/typescript-eslint/tsgo-bridge/src/compat.js new file mode 100644 index 0000000..ea665a1 --- /dev/null +++ b/typescript-eslint/tsgo-bridge/src/compat.js @@ -0,0 +1,127 @@ +/** Fails fast when the handed-in modules are not the TypeScript versions this bridge was written against. */ + +/** Nightly the bridge was verified against; `typescript/unstable/*` is unversioned and may change between nightlies. */ +export const TESTED_NATIVE_VERSION = "7.1.0-dev.20260901.1"; + +const CHECKER_METHODS = [ + "getAliasedSymbol", + "getAnyType", + "getApparentType", + "getBaseConstraintOfType", + "getBaseTypeOfLiteralType", + "getBaseTypes", + "getBigIntType", + "getBooleanType", + "getConstantValue", + "getConstraintOfTypeParameter", + "getContextualType", + "getDeclaredTypeOfSymbol", + "getDefaultFromTypeParameter", + "getESSymbolType", + "getExportSpecifierLocalTargetSymbol", + "getExportsOfModule", + "getFullyQualifiedName", + "getImmediateAliasedSymbol", + "getNeverType", + "getNonNullableType", + "getNullType", + "getNumberType", + "getParameterType", + "getPropertiesOfType", + "getPropertyOfType", + "getReducedType", + "getResolvedSignature", + "getReturnTypeOfSignature", + "getShorthandAssignmentValueSymbol", + "getSignatureFromDeclaration", + "getSignaturesOfType", + "getStringType", + "getSymbolAtLocation", + "getSymbolsInScope", + "getTypeArguments", + "getTypeAtLocation", + "getTypeFromTypeNode", + "getTypeOfSymbol", + "getTypeOfSymbolAtLocation", + "getTypePredicateOfSignature", + "getUndefinedType", + "getUnknownType", + "getVoidType", + "getWidenedType", + "isArgumentsSymbol", + "isArrayLikeType", + "isArrayType", + "isReadonlySymbol", + "isTupleType", + "isTypeAssignableTo", + "isUndefinedSymbol", + "isUnknownSymbol", + "resolveName", + "typeToString", +]; + +const SYNC_EXPORTS = [ + "API", + "Checker", + "TypeFlags", + "ObjectFlags", + "SymbolFlags", + "SignatureKind", +]; +const AST_EXPORTS = ["SyntaxKind", "NodeFlags", "ModifierFlags"]; + +function missing(object, names) { + return names.filter((name) => object?.[name] === undefined); +} + +/** Checker members are getters that build bound closures; inspect names only so nothing runs against the prototype. */ +function missingMembers(prototype, names) { + const own = new Set(Object.getOwnPropertyNames(prototype)); + return names.filter((name) => !own.has(name)); +} + +/** Throws a descriptive error if `ts`, `tsParser`, `tsgoSync` or `tsgoAst` lack what the bridge relies on. */ +export function assertCompatible({ ts, tsParser, tsgoSync, tsgoAst }) { + const problems = []; + const stradaMajor = Number.parseInt(ts?.version ?? "", 10); + if (typeof ts?.createSourceFile !== "function" || !ts?.SyntaxKind) { + problems.push( + "`ts` is not the TypeScript JS compiler module (`typescript@5.x` / `@typescript/typescript6`)", + ); + } else if (stradaMajor >= 7) { + problems.push( + `\`ts\` is typescript@${ts.version}; the rules need the JS compiler API (5.x/6.x), not the native package`, + ); + } + if (typeof tsParser?.parseForESLint !== "function") { + problems.push( + "`tsParser` must be @typescript-eslint/parser (or `tseslint.parser`) exposing parseForESLint()", + ); + } + const syncMissing = missing(tsgoSync, SYNC_EXPORTS); + if (syncMissing.length) { + problems.push( + `\`tsgoSync\` (typescript/unstable/sync) is missing ${syncMissing.join(", ")}`, + ); + } else { + const checkerMissing = missingMembers( + tsgoSync.Checker.prototype, + CHECKER_METHODS, + ); + if (checkerMissing.length) { + problems.push(`\`tsgoSync\` Checker lacks ${checkerMissing.join(", ")}`); + } + } + const astMissing = missing(tsgoAst, AST_EXPORTS); + if (astMissing.length) { + problems.push( + `\`tsgoAst\` (typescript/unstable/ast) is missing ${astMissing.join(", ")}`, + ); + } + if (problems.length) { + throw new Error( + `tsgo-bridge: incompatible modules. The bridge was verified against typescript@${TESTED_NATIVE_VERSION} (nightly; ` + + `stable 7.0.x has no \`unstable/*\` API) with typescript@5.x for the rules.\n- ${problems.join("\n- ")}`, + ); + } +} diff --git a/typescript-eslint/tsgo-bridge/src/index.js b/typescript-eslint/tsgo-bridge/src/index.js index c18a2df..969ddac 100644 --- a/typescript-eslint/tsgo-bridge/src/index.js +++ b/typescript-eslint/tsgo-bridge/src/index.js @@ -1,10 +1,21 @@ /** ESLint parser: ESTree from @typescript-eslint/parser, type information from tsgo's `typescript/unstable/sync` API. */ import path from "node:path"; import { createTsgoProjectClass } from "./bridge.js"; +import { assertCompatible, TESTED_NATIVE_VERSION } from "./compat.js"; import { createEnumRemaps } from "./enums.js"; +export { TESTED_NATIVE_VERSION }; + /** Options: `ts` (Strada module the rules import), `tsParser`, `tsgoSync`, `tsgoAst`, optional default `tsconfig`, `collectTiming`. */ -export function createTsgoParser({ ts, tsParser, tsgoSync, tsgoAst, tsconfig, collectTiming = false }) { +export function createTsgoParser({ + ts, + tsParser, + tsgoSync, + tsgoAst, + tsconfig, + collectTiming = false, +}) { + assertCompatible({ ts, tsParser, tsgoSync, tsgoAst }); const remaps = createEnumRemaps({ ts, tsgoSync, tsgoAst }); const env = { ts, API: tsgoSync.API, remaps, collectTiming }; const { TsgoProject, stats } = createTsgoProjectClass(env); @@ -39,7 +50,15 @@ export function createTsgoParser({ ts, tsParser, tsgoSync, tsgoAst, tsconfig, co function parseForESLint(code, options) { const tsconfigPath = resolveTsconfig(options); - const { project: _project, projectService: _ps, tsgoProject: _tp, programs: _programs, ...rest } = options ?? {}; + const rest = { ...options }; + for (const key of [ + "project", + "projectService", + "tsgoProject", + "programs", + ]) { + Reflect.deleteProperty(rest, key); + } const result = tsParser.parseForESLint(code, { ...rest, project: false }); const tsgo = getProject(tsconfigPath); const services = result.services; @@ -52,11 +71,15 @@ export function createTsgoParser({ ts, tsParser, tsgoSync, tsgoAst, tsconfig, co experimentalDecorators: compilerOptions.experimentalDecorators ?? false, isolatedDeclarations: compilerOptions.isolatedDeclarations ?? false, getContextualType: (node) => checker.getContextualType(maps.get(node)), - getResolvedSignature: (node) => checker.getResolvedSignature(maps.get(node)), - getSymbolAtLocation: (node) => checker.getSymbolAtLocation(maps.get(node)), + getResolvedSignature: (node) => + checker.getResolvedSignature(maps.get(node)), + getSymbolAtLocation: (node) => + checker.getSymbolAtLocation(maps.get(node)), getTypeAtLocation: (node) => checker.getTypeAtLocation(maps.get(node)), - getTypeFromTypeNode: (node) => checker.getTypeFromTypeNode(maps.get(node)), - getTypeOfSymbolAtLocation: (symbol, node) => checker.getTypeOfSymbolAtLocation(symbol, maps.get(node)), + getTypeFromTypeNode: (node) => + checker.getTypeFromTypeNode(maps.get(node)), + getTypeOfSymbolAtLocation: (symbol, node) => + checker.getTypeOfSymbolAtLocation(symbol, maps.get(node)), }); return result; }