From eb6de6da36a6d85508f7d51a07cfbb395341065d Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:53:49 -0400 Subject: [PATCH 1/2] Graft .gts SourceFiles onto every virtual twin, once per program MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A project program can hold one templated file under up to three paths: the .gts root, a .ts virtual created when another file resolves `import './x'` extensionless through the patched fileExists, and (on newer TS/typescript-eslint versions) a .mts twin injected as a root via the patched readDirectory. syncMtsGtsSourceFiles only grafted the first twin it found, preferring .mts. On setups where the .mts root exists, the .ts virtual — the copy every extensionless import actually resolves to — kept its own AST and therefore its own class symbols. A class with a private member is not assignable to itself across two declarations, unions of the "same" type don't dedupe, and typed rules (no-unnecessary-type-assertion, no-redundant-type-constituents) report order-dependent false positives (#229). Graft all twins so every import path reaches the same nodes and one type identity; sharing statements also drops the duplicate ASTs. The graft also ran after every parse, walking the whole program file list each time and re-copying binder state (symbol/locals) onto twins under a live type checker, leaving stale symbols in checker caches — the remaining non-determinism vector. Run it once per program instead. Regression tests are split out to a follow-up PR (they fail without this change by design). Co-Authored-By: Claude Fable 5 --- src/parser/ts-patch.js | 37 +++++++++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/src/parser/ts-patch.js b/src/parser/ts-patch.js index 7881200..400536c 100644 --- a/src/parser/ts-patch.js +++ b/src/parser/ts-patch.js @@ -116,10 +116,31 @@ try { }; /** + * Graft each real `.gts`/`.gjs` SourceFile onto its virtual twins so every + * import path yields the same AST nodes, and therefore the same type + * identity. A program can hold up to three copies of one templated file: + * the `.gts` root itself, the `.mts` twin injected via readDirectory (the + * target of explicit `import './x.gts'` specifiers, which + * replaceExtensions rewrites to `.mts`), and a `.ts` virtual created when + * another file resolves an extensionless `import './x'` through the + * patched fileExists. Distinct copies mean distinct class symbols — a + * class with a private member is then not assignable to itself across + * paths, unions of the "same" type don't dedupe, and typed rules + * (`no-unnecessary-type-assertion`, `no-redundant-type-constituents`) + * report order-dependent false positives (#229). + * + * Runs once per program: the graft mutates SourceFiles, so repeating it + * after every parse rewrites binder state (symbol/locals) under a live + * type checker — stale symbols stay cached and results become + * lint-order dependent. It also walks the whole file list, which is + * O(program files) per linted file. * * @param program {ts.Program} */ + const syncedPrograms = new WeakSet(); syncMtsGtsSourceFiles = function syncMtsGtsSourceFiles(program) { + if (syncedPrograms.has(program)) return; + syncedPrograms.add(program); const sourceFiles = program.getSourceFiles(); function syncVirtualFile(sourceFile, ext, virtualExt, virtualFlag) { // check for deleted files, need to remove virtual as well @@ -132,15 +153,15 @@ try { } } if (sourceFile.path.endsWith(`.${ext}`)) { - let virtualSourceFile = program.getSourceFile( - sourceFile.path.replace(new RegExp(`\\.${ext}$`), `.${virtualExt}`) - ); - if (!virtualSourceFile) { - virtualSourceFile = program.getSourceFile( + const virtualSourceFiles = [ + program.getSourceFile( + sourceFile.path.replace(new RegExp(`\\.${ext}$`), `.${virtualExt}`) + ), + program.getSourceFile( sourceFile.path.replace(new RegExp(`\\.${ext}$`), virtualExt === 'mts' ? '.ts' : '.js') - ); - } - if (virtualSourceFile) { + ), + ].filter(Boolean); + for (const virtualSourceFile of virtualSourceFiles) { const keep = { fileName: virtualSourceFile.fileName, path: virtualSourceFile.path, From 3860e88673b417a120ccd2bb6762ee25c008858a Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:53:35 -0400 Subject: [PATCH 2/2] Add virtual-twin type-identity regression tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three assertions, each under both project and projectService (feature-detected; v7's experimental service can't hold .gts files in-project, so the mode is registered only when a probe parse yields a program that contains virtual twins — 3 tests on tsee 7, 6 on tsee 8): - every virtual twin present shares statements with the .gts root, - a private-member class has one type identity across import paths, - an already-synced program is not re-grafted on later parses. Against the unfixed parser these fail — demonstrated on CI in #240: the re-graft canaries fail on TS 5.7, and the twin-graft/type-identity tests additionally fail on TS 6 cells where the .mts twin shadows the old single-candidate fallback. Co-Authored-By: Claude Fable 5 --- tests/fixtures/type-identity/consumer.ts | 5 + tests/fixtures/type-identity/other.gts | 6 + tests/fixtures/type-identity/priv.gts | 7 ++ tests/fixtures/type-identity/tsconfig.json | 11 ++ tests/virtual-twin-type-identity.test.js | 129 +++++++++++++++++++++ 5 files changed, 158 insertions(+) create mode 100644 tests/fixtures/type-identity/consumer.ts create mode 100644 tests/fixtures/type-identity/other.gts create mode 100644 tests/fixtures/type-identity/priv.gts create mode 100644 tests/fixtures/type-identity/tsconfig.json create mode 100644 tests/virtual-twin-type-identity.test.js diff --git a/tests/fixtures/type-identity/consumer.ts b/tests/fixtures/type-identity/consumer.ts new file mode 100644 index 0000000..4de05a6 --- /dev/null +++ b/tests/fixtures/type-identity/consumer.ts @@ -0,0 +1,5 @@ +import Priv from './priv'; + +export function take(p: Priv): Priv { + return p; +} diff --git a/tests/fixtures/type-identity/other.gts b/tests/fixtures/type-identity/other.gts new file mode 100644 index 0000000..96368e6 --- /dev/null +++ b/tests/fixtures/type-identity/other.gts @@ -0,0 +1,6 @@ +import Priv from './priv'; + +export default class Other { + prop: Priv | null = null; + +} diff --git a/tests/fixtures/type-identity/priv.gts b/tests/fixtures/type-identity/priv.gts new file mode 100644 index 0000000..1782ed9 --- /dev/null +++ b/tests/fixtures/type-identity/priv.gts @@ -0,0 +1,7 @@ +export default class Priv { + #secret = 1; + get val(): number { + return this.#secret; + } + +} diff --git a/tests/fixtures/type-identity/tsconfig.json b/tests/fixtures/type-identity/tsconfig.json new file mode 100644 index 0000000..f9d11a8 --- /dev/null +++ b/tests/fixtures/type-identity/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["**/*.ts", "**/*.gts"] +} diff --git a/tests/virtual-twin-type-identity.test.js b/tests/virtual-twin-type-identity.test.js new file mode 100644 index 0000000..32f092a --- /dev/null +++ b/tests/virtual-twin-type-identity.test.js @@ -0,0 +1,129 @@ +import path from 'node:path'; +import fs from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { parseForESLint } from '../src/parser/gjs-gts-parser.js'; + +/** + * A project-mode program can hold one templated file under several paths: + * the `.gts` root, a `.ts` virtual created when another file resolves + * `import './x'` extensionless through the patched fileExists, and — on + * newer typescript-eslint/TypeScript versions — a `.mts` twin injected as a + * root via the patched readDirectory. If a copy keeps its own AST it gets + * its own class symbols, so a class with a private member is not assignable + * to itself across import paths — the source of the order-dependent false + * positives in typed rules reported in #229 + * (`no-unnecessary-type-assertion`, `no-redundant-type-constituents`). + * + * syncMtsGtsSourceFiles must graft the real SourceFile onto EVERY virtual + * twin present (not just the first found), and must do so only once per + * program — re-grafting after later parses rewrites binder state under a + * live type checker. + * + * The suite runs under both `project` and `projectService`, the two ways + * typed linting obtains a program. projectService is feature-detected: its + * option spelling varies across typescript-eslint majors, and v7's + * experimental service can't hold `.gts` files in-project (the resulting + * default-project program has no virtual twins, leaving nothing to graft). + */ + +const fixtureDir = path.join(import.meta.dirname, 'fixtures', 'type-identity'); +const privPath = path.join(fixtureDir, 'priv.gts'); +const otherPath = path.join(fixtureDir, 'other.gts'); + +function parseWith(modeOptions, filePath) { + return parseForESLint(fs.readFileSync(filePath, 'utf8'), { + filePath, + ...modeOptions, + tsconfigRootDir: fixtureDir, + extraFileExtensions: ['.gts', '.gjs'], + loc: true, + range: true, + tokens: true, + comment: true, + sourceType: 'module', + }); +} + +function virtualTwinsOf(program, gtsPath) { + return [ + program.getSourceFile(gtsPath.replace(/\.gts$/, '.mts')), + program.getSourceFile(gtsPath.replace(/\.gts$/, '.ts')), + ].filter(Boolean); +} + +const MODES = [['project', { project: './tsconfig.json' }]]; + +for (const options of [{ projectService: true }, { EXPERIMENTAL_useProjectService: true }]) { + try { + const program = parseWith(options, privPath).services?.program; + if (program?.getSourceFile(privPath) && virtualTwinsOf(program, privPath).length > 0) { + MODES.push(['projectService', options]); + break; + } + } catch { + // spelling unsupported by the installed @typescript-eslint/parser + } +} + +for (const [modeName, modeOptions] of MODES) { + const parse = (filePath) => parseWith(modeOptions, filePath); + + describe(`virtual twin type identity (#229) — ${modeName}`, () => { + it('grafts the real .gts SourceFile onto every virtual twin in the program', () => { + const result = parse(privPath); + const program = result.services.program; + + const gts = program.getSourceFile(privPath); + expect(gts).toBeTruthy(); + + // consumer.ts imports './priv' extensionless, so at minimum the + // resolver-created .ts virtual exists; depending on the TS version a + // readDirectory-injected .mts twin exists as well. + const twins = virtualTwinsOf(program, privPath); + expect(twins.length).toBeGreaterThanOrEqual(1); + + for (const twin of twins) { + expect(twin.statements).toBe(gts.statements); + expect(twin.isVirtualGts).toBe(true); + } + }); + + it('a private-member class has one type identity across import paths', () => { + const result = parse(privPath); + const program = result.services.program; + const checker = program.getTypeChecker(); + + const gts = program.getSourceFile(privPath); + const classOf = (sf) => sf.statements.find((s) => s.name?.escapedText === 'Priv'); + const typeOf = (sf) => + checker.getDeclaredTypeOfSymbol(checker.getSymbolAtLocation(classOf(sf).name)); + + const gtsType = typeOf(gts); + for (const twin of virtualTwinsOf(program, privPath)) { + const twinType = typeOf(twin); + // Identical, not merely mutually assignable: statements are shared, + // so both paths reach the same class declaration node. + expect(twinType).toBe(gtsType); + expect(checker.isTypeAssignableTo(gtsType, twinType)).toBe(true); + expect(checker.isTypeAssignableTo(twinType, gtsType)).toBe(true); + } + }); + + it('does not re-graft an already-synced program on later parses', () => { + const first = parse(privPath); + const program = first.services.program; + + const [twin] = virtualTwinsOf(program, privPath); + expect(twin).toBeTruthy(); + // Bind, then plant a canary where the graft writes its marker flag. A + // re-graft on the next parse would overwrite it (and with it, live + // binder state such as symbol/locals — the #229 non-determinism vector). + program.getTypeChecker().getSymbolAtLocation(twin); + twin.isVirtualGts = 'already-synced-canary'; + + const second = parse(otherPath); + expect(second.services.program).toBe(program); + expect(twin.isVirtualGts).toBe('already-synced-canary'); + }); + }); +}