Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 29 additions & 8 deletions src/parser/ts-patch.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions tests/fixtures/type-identity/consumer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import Priv from './priv';

export function take(p: Priv): Priv {
return p;
}
6 changes: 6 additions & 0 deletions tests/fixtures/type-identity/other.gts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import Priv from './priv';

export default class Other {
prop: Priv | null = null;
<template>{{this.prop}}</template>
}
7 changes: 7 additions & 0 deletions tests/fixtures/type-identity/priv.gts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export default class Priv {
#secret = 1;
get val(): number {
return this.#secret;
}
<template>{{this.val}}</template>
}
11 changes: 11 additions & 0 deletions tests/fixtures/type-identity/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["**/*.ts", "**/*.gts"]
}
129 changes: 129 additions & 0 deletions tests/virtual-twin-type-identity.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
}
Loading