From e39f5d9c275da2cd71af4376af60b75f6eae8533 Mon Sep 17 00:00:00 2001 From: KazariAI <166915487+KazariAI@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:11:20 +0800 Subject: [PATCH] fix(bridge): retain checker on refreshed projects --- AGENTS.md | 3 +- .../overlay/src/compiler/tsgoChecker.ts | 4 + tools/ci-witness-groups.mjs | 3 +- tools/triage-prototype-refresh.mjs | 170 ++++++++++++++++++ 4 files changed, 178 insertions(+), 2 deletions(-) create mode 100644 tools/triage-prototype-refresh.mjs diff --git a/AGENTS.md b/AGENTS.md index a516e51..9fdcc07 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,10 +31,11 @@ TNB is a tsgo-backed TypeScript fork: upstream `microsoft/TypeScript` and `micro ## Gates (run before committing behavior changes) - `npm run check:lib` / `check:enums` / `check:sourcefile-guard` -- Witnesses: 68 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). +- Witnesses: 70 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). - Local-only — run on demand, not in the matrix (reasons in the matrix header comment): framework-checks, external-edits, generation-retention, napi-fuzz, completion-latency, postedit-latency, perf-edit-rpc, perf-qi-rpc, typing-cpuprof. - Semantic witnesses (the rest of the matrix is bare stock-parity checks): - `triage-crossgen-reuse` — issue #11: cross-generation RemoteSourceFile reuse + edit invalidation; a pre-edit type handle must die, not re-resolve + - `triage-prototype-refresh` — issue #57: a replacement snapshot keeps Type prototype APIs routed to its live checker - `triage-nuxtui-exportstar` — issue #26: `./X.vue` with an on-disk `X.d.vue.ts` resolves to the declaration (@nuxt/ui dist pattern) - `triage-checker-differential` — checker-API stock differential: byte-equal canon per method@location, stale exemptions fail - `triage-type-field-audit` — every data field stock puts on a Type crosses the bridge equal or carries an inline exemption diff --git a/patches/typescript/overlay/src/compiler/tsgoChecker.ts b/patches/typescript/overlay/src/compiler/tsgoChecker.ts index f97086f..bd220a1 100644 --- a/patches/typescript/overlay/src/compiler/tsgoChecker.ts +++ b/patches/typescript/overlay/src/compiler/tsgoChecker.ts @@ -10002,6 +10002,10 @@ export function createTsgoChecker(program: any): any { const refreshed = snapshot.getProject(ctx.configFilePath); if (!refreshed) return; project = refreshed; + // Wire objects route prototype API calls through their registry's + // project, so a replacement generation must own the live checker + // before it becomes reachable through the process-global caches. + project.__tnbTypeChecker = checkerProxyRef; _projectCache.set(ctx.configFilePath, refreshed); _currentProjectRef.project = refreshed; installTsgoBackedSourceFileLoader(() => project); diff --git a/tools/ci-witness-groups.mjs b/tools/ci-witness-groups.mjs index a43a301..4b99b3a 100644 --- a/tools/ci-witness-groups.mjs +++ b/tools/ci-witness-groups.mjs @@ -40,7 +40,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; -const TOTAL = 69; +const TOTAL = 70; // Witnesses intentionally NOT in the matrix — run on demand (reasons above). const LOCAL_ONLY = [ @@ -155,6 +155,7 @@ const groups = [ 'triage-idle-drain', // ~7s 'triage-nuxtui-exportstar', // ~0s 'triage-completion-span-i55', // ~6s (npm install @types/node best-effort + tsserver session) + 'triage-prototype-refresh', // ~1s ], }, { diff --git a/tools/triage-prototype-refresh.mjs b/tools/triage-prototype-refresh.mjs new file mode 100644 index 0000000..3c9c168 --- /dev/null +++ b/tools/triage-prototype-refresh.mjs @@ -0,0 +1,170 @@ +#!/usr/bin/env node +/** + * Type prototype parity after an open-file snapshot refresh. + * + * Drives an in-process tsserver ProjectService through the issue #57 + * navigation sequence. Opening a declaration target advances the bridge + * snapshot; wire Type methods from the replacement project must still route + * to the same checker as direct TypeChecker calls. + * + * Usage: node tools/triage-prototype-refresh.mjs [path/to/typescript.js] + */ +import { createRequire } from 'node:module'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +const require = createRequire(import.meta.url); +const repoRoot = path.resolve(import.meta.dirname, '..'); +const typescriptPath = path.resolve(process.argv[2] ?? path.join(repoRoot, 'lib', 'typescript.js')); +const ts = require(typescriptPath); +const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'tnb-prototype-refresh-')); + +function write(relativePath, content) { + const fileName = path.join(fixture, relativePath); + fs.writeFileSync(fileName, content); + return fileName; +} + +write('tsconfig.json', JSON.stringify({ + compilerOptions: { strict: true }, + include: ['main.ts', 'handler.ts'], +})); +write('handler.ts', `export interface TestEvent { + value: string; +} + +export interface Handler { + (event: TestEvent): void; +} + +export declare function defineHandler(handler: Handler): void; +`); +const externalText = 'export declare function external(): void;\n'; +const externalFile = write('external.d.ts', externalText); +const mainText = `import { external } from "./external"; +import { defineHandler } from "./handler"; + +const sample = { value: "x", count: 1 }; + +defineHandler((event) => { + external(); + console.log(event.value, sample.count); +}); +`; +const mainFile = write('main.ts', mainText); + +const logger = { + hasLevel: () => false, + loggingEnabled: () => false, + write: () => {}, + writeLogFile: () => {}, + info: () => {}, + msg: () => {}, + verbose: () => {}, + startGroup: () => {}, + endGroup: () => {}, + getLevel: () => 0, +}; +const service = new ts.server.ProjectService({ + host: { + getCurrentDirectory: () => fixture, + getExecutingFilePath: () => path.join(path.dirname(typescriptPath), 'tsserver.js'), + getNodeMajorVersion: () => process.versions.node.split('.')[0], + getScriptSnapshot: fileName => fs.existsSync(fileName) + ? ts.ScriptSnapshot.fromString(fs.readFileSync(fileName, 'utf8')) + : undefined, + getDefaultLibFileName: options => ts.getDefaultLibFilePath(options), + fileExists: ts.sys.fileExists, + readFile: ts.sys.readFile, + readDirectory: ts.sys.readDirectory, + directoryExists: ts.sys.directoryExists, + getDirectories: ts.sys.getDirectories, + useCaseSensitiveFileNames: () => ts.sys.useCaseSensitiveFileNames, + getNewLine: () => '\n', + watchFile: () => ts.Noop, + watchDirectory: () => ts.Noop, + }, + logger, + cancellationToken: ts.server.nullCancellationToken, + useSingleInferredProject: false, + useInferredProjectPerProjectRoot: false, +}); + +function findNodes(sourceFile) { + let arrow; + let sample; + sourceFile.forEachChild(function visit(node) { + if (ts.isArrowFunction(node)) arrow = node; + if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === 'sample') sample = node; + node.forEachChild(visit); + }); + if (!arrow || !sample) throw new Error('fixture nodes were not found'); + return { arrow, sample }; +} + +function probe(languageService) { + const program = languageService.getProgram(); + const checker = program.getTypeChecker(); + const nodes = findNodes(program.getSourceFile(mainFile)); + const callable = checker.getContextualType(nodes.arrow); + const object = checker.getTypeAtLocation(nodes.sample.name); + return { + callType: callable.getCallSignatures().length, + callChecker: checker.getSignaturesOfType(callable, ts.SignatureKind.Call).length, + propertiesType: object.getProperties().map(symbol => symbol.name).sort(), + propertiesChecker: checker.getPropertiesOfType(object).map(symbol => symbol.name).sort(), + }; +} + +function assertParity(phase, result) { + const expectedProperties = ['count', 'value']; + const callsMatch = result.callType === 1 && result.callChecker === 1; + const propertiesMatch = JSON.stringify(result.propertiesType) === JSON.stringify(expectedProperties) + && JSON.stringify(result.propertiesChecker) === JSON.stringify(expectedProperties); + if (!callsMatch || !propertiesMatch) { + throw new Error(`${phase}: ${JSON.stringify(result)}; expected call signatures 1/1 and properties ${JSON.stringify(expectedProperties)} from both APIs`); + } +} + +function navigate(languageService, position) { + const result = languageService.getDefinitionAndBoundSpan(mainFile, position); + if (!result?.definitions?.length) { + throw new Error(`definition missing at ${position}`); + } + const program = languageService.getProgram(); + for (const definition of result.definitions) { + program.getSourceFile(definition.fileName); + } + return result; +} + +try { + service.openClientFile(mainFile, mainText, ts.ScriptKind.TS); + const configuredProjects = [...service.configuredProjects.values()]; + if (configuredProjects.length !== 1) { + throw new Error(`expected one configured project, got ${configuredProjects.length}`); + } + const [project] = configuredProjects; + const languageService = project.getLanguageService(); + const eventPosition = mainText.indexOf('event'); + const externalPosition = mainText.lastIndexOf('external'); + + navigate(languageService, eventPosition); + const before = probe(languageService); + assertParity('before refresh', before); + + const externalDefinition = navigate(languageService, externalPosition); + if (!externalDefinition?.definitions?.some(definition => definition.fileName === externalFile)) { + throw new Error(`definition target missing: ${JSON.stringify(externalDefinition?.definitions ?? [])}`); + } + service.openClientFile(externalFile, externalText, ts.ScriptKind.TS); + navigate(languageService, eventPosition); + + const after = probe(languageService); + assertParity('after refresh', after); + console.log(`check:prototype-refresh ok (${JSON.stringify({ before, after })})`); +} +finally { + fs.rmSync(fixture, { recursive: true, force: true }); +}