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'); + }); + }); +}