Skip to content

Commit 3c38818

Browse files
test(bridge): witness deep-relation cache poisoning (issue #77)
triage-deep-relation-cache runs the 40-deep interface chain in both assignment orders against stock (clean, no TS2321/TS2322) and upstream's GetByString conditional chain against tsgo main's clean baseline — stock exhausts its heap there, so a crash must never read as clean. Fails 3/3 on the pre-backport bridge. Wired into wg5 (TOTAL 90). Co-authored-by: Johnson Chu <johnsoncodehk@users.noreply.github.com>
1 parent 7e54714 commit 3c38818

3 files changed

Lines changed: 124 additions & 2 deletions

File tree

‎AGENTS.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ TNB is a tsgo-backed TypeScript fork: upstream `microsoft/TypeScript` and `micro
3232

3333
- `npm run check:lib` / `check:enums` / `check:sourcefile-guard`
3434
- `npm run check:go-as-guards` — Go-side Type-cast guard: every `Type.As*()` chain deref and unguarded nil-family assignment in the tsgo patches/overlay is fixed or carries an inline `// asguard:exempt` reason (issues #69/#70 bug class)
35-
- Witnesses: 89 across wg0–wg6, all wired in CI (`.github/workflows/ci.yml`), single source of truth `tools/ci-witness-groups.mjs` — `node tools/ci-witness-groups.mjs all` validates dup/missing/orphan/local-only/baseline wiring and emits the matrix (`matrix` mode feeds the ci.yml prepare job).
35+
- Witnesses: 90 across wg0–wg6, all wired in CI (`.github/workflows/ci.yml`), single source of truth `tools/ci-witness-groups.mjs` — `node tools/ci-witness-groups.mjs all` validates dup/missing/orphan/local-only/baseline wiring and emits the matrix (`matrix` mode feeds the ci.yml prepare job).
3636
- Local-only — run on demand, not in the matrix (measurement, not pass/fail): completion-latency, postedit-latency, perf-edit-rpc, perf-qi-rpc, typing-cpuprof.
3737
- Locally hostable gates: `check:sim-nav` and `check:sourcefile-guard` need a volar checkout. ci.yml's recipe, which is the one to copy (`VOLAR_SHA` is the ci.yml env pin — set it in your shell, a shallow clone does not carry the tag):
3838
```sh
@@ -62,6 +62,7 @@ TNB is a tsgo-backed TypeScript fork: upstream `microsoft/TypeScript` and `micro
6262
- `triage-importclause-nil-symbol` — issue #71: type-only ImportClause is a symbol-less IsTypeDeclaration; getTypeAtLocation must yield the error type, not a Go panic
6363
- `triage-empty-tuple-basetypes` — issue #73: getBaseTypes on the literal-derived empty tuple (`const x: [] = []`, `[] as const`, `arr || []`) answers `never[]` per shape vs stock instead of nil-dereferencing in Go, a declared `readonly []` keeps `readonly never[]`, and the recomputed clone base is pinned to the same type instance as the memoized one (re-read through the engine, so the assertion can fail); tuple-target rows (`type.target`) pin getTupleBaseType's element loop and variadic branch, mutation-verified; the two non-empty tuple REFERENCE rows are panic/recursion canaries pinned as known divergences, since stock rejects that input outright
6464
- `triage-empty-file-position` — issue #72: a zero-length file through the project service reports the parser's (0, 0) — pos/end/eof/start metadata equal to stock — instead of the synthetic skeleton's -1
65+
- `triage-deep-relation-cache` — issue #77: a 40-deep interface chain relates clean in both assignment orders (no false TS2321, no poisoned-cache TS2322) vs stock, and the GetByString conditional chain is clean vs tsgo main's baseline (stock exhausts its heap there); mutation-verified against the pre-backport bridge
6566
- `triage-custom-transformers` — issue #40: emit with non-empty customTransformers must throw (tsgo can't execute JS transformers)
6667
- `triage-ambient-order` — issue #42 class: ambient-module index run-stable (Go map order must not leak onto the wire)
6768
- `triage-diag-determinism` — issues #42/#51: whole-program diagnostics byte-identical across fresh runs (strided checker assignment)

‎tools/ci-witness-groups.mjs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ import * as fs from 'node:fs';
4848
import * as path from 'node:path';
4949
import { fileURLToPath } from 'node:url';
5050

51-
const TOTAL = 89;
51+
const TOTAL = 90;
5252

5353
// Witnesses intentionally NOT in the matrix — run on demand (reasons above).
5454
const LOCAL_ONLY = [
@@ -179,6 +179,7 @@ const groups = [
179179
'triage-importclause-nil-symbol', // ~0s (issue #71)
180180
'triage-empty-tuple-basetypes', // ~2s (issue #73)
181181
'triage-empty-file-position', // ~2s (issue #72)
182+
'triage-deep-relation-cache', // ~3s (issue #77)
182183
],
183184
},
184185
{
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Issue #77: one deep structural comparison must not poison later ones. The
4+
* pinned tsgo's relater keyed recursion identities on the outer type only, so
5+
* a 40-deep interface chain (A_i.next?: A_{i+1}[]) was never cut as deeply
6+
* nested; the comparison ran into the stack-depth limit, reported TS2321, and
7+
* cached its failed sub-relations — a later, unrelated assignment inside the
8+
* same chain then failed with TS2322. Stock 6.0.3 and tsgo main (after
9+
* typescript-go#4913, carried here as the tsgo-relater-recursion-identity
10+
* stopgap) report nothing. Both assignment orders run, since the cache
11+
* poisoning is order-dependent.
12+
*
13+
* The chain cases run through stock tsc and the fork's tsc; stock must exit
14+
* clean (the fixture is still the bug's shape) and the fork's diagnostic
15+
* headers must equal stock's. The GetByString conditional-type chain
16+
* (typescript-go#2917, the same recursion-identity path) exhausts stock's
17+
* heap, so its reference is tsgo main's conformance baseline — no
18+
* .errors.txt, i.e. clean — and only the fork runs.
19+
*
20+
* Stock side: STOCK_TYPESCRIPT_PATH, else derived from STOCK_TSSERVER_PATH
21+
* (CI), else /tmp/stock-ts-p3/package/lib/typescript.js.
22+
* Exit: 0 = PASS, 1 = FAIL.
23+
*/
24+
import { spawnSync } from 'node:child_process';
25+
import * as fs from 'node:fs';
26+
import * as os from 'node:os';
27+
import * as path from 'node:path';
28+
import { fileURLToPath } from 'node:url';
29+
30+
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
31+
const forkTsc = path.join(repoRoot, 'lib', 'tsc.js');
32+
const stockTsPath = process.env.STOCK_TYPESCRIPT_PATH
33+
?? (process.env.STOCK_TSSERVER_PATH ? path.join(path.dirname(process.env.STOCK_TSSERVER_PATH), 'typescript.js') : undefined)
34+
?? '/tmp/stock-ts-p3/package/lib/typescript.js';
35+
const stockTsc = path.join(path.dirname(stockTsPath), 'tsc.js');
36+
if (!fs.existsSync(stockTsc)) {
37+
console.error(`stock tsc not found at ${stockTsc} — set STOCK_TYPESCRIPT_PATH or STOCK_TSSERVER_PATH`);
38+
process.exit(1);
39+
}
40+
41+
const DEPTH = 40;
42+
const chain = prefix => Array.from({ length: DEPTH }, (_, i) =>
43+
`interface ${prefix}${i} {\n name: string;\n next?: ${prefix}${(i + 1) % DEPTH}[];\n}\n`).join('');
44+
const CHAINS = chain('A') + chain('B');
45+
const HEAD = 'declare const a: A0;\nconst assigned: B0 = a;\n';
46+
const MID = 'declare const a20: A20;\nconst mid: B20 = a20;\n';
47+
48+
const GET_BY_STRING = `export type FlattenKeys<O> = {
49+
[K in keyof O & (string | number)]: O[K] extends Record<any, any>
50+
? K | \`\${K}.\${FlattenKeys<O[K]>}\`
51+
: K;
52+
}[O extends readonly any[]
53+
? keyof O & \`\${number}\`
54+
: keyof O & (string | number)];
55+
export type KeySeparator = '.' | '[' | ']';
56+
export type GetByString<
57+
Data,
58+
Path extends string | number = FlattenKeys<Data>,
59+
> = Path extends \`__proto__\${\`\${KeySeparator}\${string | number}\` | ''}\`
60+
? never
61+
: Path extends \`\${KeySeparator}\${infer Rest}\`
62+
? GetByString<Data, Rest>
63+
: Path extends \`\${infer Rest}\${KeySeparator}\`
64+
? GetByString<Data, Rest>
65+
: Path extends \`\${infer Key extends keyof Data & (string | number)}\${KeySeparator}\${infer Rest}\`
66+
? GetByString<Data[Key], Rest>
67+
: Path extends keyof Data & (string | number)
68+
? Data[Path]
69+
: undefined;
70+
interface Duck {
71+
taxonomy: {
72+
genus: 'anas';
73+
species: 'platyrhynchos';
74+
};
75+
}
76+
export type DuckSpecies = GetByString<Duck, 'taxonomy.species'>;
77+
export const species: DuckSpecies = 'platyrhynchos';
78+
`;
79+
80+
const cases = [
81+
{ name: 'chain-head-then-mid', source: CHAINS + HEAD + MID + 'export {};\n', stock: true },
82+
{ name: 'chain-mid-then-head', source: CHAINS + MID + HEAD + 'export {};\n', stock: true },
83+
{ name: 'get-by-string', source: GET_BY_STRING, stock: false },
84+
];
85+
86+
// Diagnostic headers only (tsgo renders shallower elaborations than stock by
87+
// design); a clean run is exit 0 with no headers, so a crash never reads clean.
88+
function run(tscJs, cwd) {
89+
const r = spawnSync(process.execPath, [tscJs, '-p', 'tsconfig.json'], { cwd, encoding: 'utf8', env: { ...process.env, GODEBUG: 'asyncpreemptoff=1' } });
90+
if (r.error) throw r.error;
91+
const out = (r.stdout ?? '').replaceAll(cwd.replaceAll('\\', '/'), '.').replaceAll('\\', '/');
92+
const headers = out.split('\n').filter(l => l.trim() && !/^\s/.test(l)).join('\n');
93+
return r.status === 0 ? headers : `exit ${r.status ?? r.signal}\n${headers}\n${r.stderr.slice(-500)}`;
94+
}
95+
96+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'tnb-deep-relation-'));
97+
let failed = 0;
98+
for (const c of cases) {
99+
const dir = path.join(root, c.name);
100+
fs.mkdirSync(dir);
101+
fs.writeFileSync(path.join(dir, 'src.ts'), c.source);
102+
fs.writeFileSync(path.join(dir, 'tsconfig.json'), JSON.stringify({ compilerOptions: { strict: true, noEmit: true, target: 'es2022', types: [] }, files: ['src.ts'] }));
103+
const stock = c.stock ? run(stockTsc, dir) : '';
104+
const fork = run(forkTsc, dir);
105+
if (stock) {
106+
console.error(`FAIL ${c.name}: stock is no longer clean — fixture is stale.\n${stock}`);
107+
failed++;
108+
} else if (fork) {
109+
console.error(`FAIL ${c.name}: fork is not clean (reference: ${c.stock ? 'stock' : 'tsgo main'}).\n${fork}`);
110+
failed++;
111+
} else {
112+
console.log(`ok ${c.name} (clean, reference: ${c.stock ? 'stock' : 'tsgo main'})`);
113+
}
114+
}
115+
fs.rmSync(root, { recursive: true, force: true });
116+
if (failed) {
117+
console.error(`FAIL: ${failed}/${cases.length} deep-relation cases diverged`);
118+
process.exit(1);
119+
}
120+
console.log(`PASS: all ${cases.length} deep-relation cases clean`);

0 commit comments

Comments
 (0)