Skip to content
Merged
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
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions patches/typescript/overlay/src/compiler/tsgoChecker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion tools/ci-witness-groups.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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
],
},
{
Expand Down
170 changes: 170 additions & 0 deletions tools/triage-prototype-refresh.mjs
Original file line number Diff line number Diff line change
@@ -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 });
}
Loading