Files
open-design/e2e/tests/server-identifier-scope.test.ts
Marc ChanandGitHub 417b81c752 [OPEND-4240] chore(tooling): migrate workspace to TypeScript 7 (#8957)
* chore(tooling): migrate workspace to TypeScript 7

* fix(docker): stage plugin manifests before native web type checking

* perf(guard): batch native parsing with bounded AST retention

* fix(tooling): port main's AST tests to native TypeScript
2026-10-22 08:40:45 +00:00

217 lines
9.1 KiB
TypeScript

import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import * as ts from '@typescript/native/unstable/ast';
import { API } from '@typescript/native/unstable/sync';
import { describe, expect, it } from 'vitest';
/**
* `apps/daemon/src/server.ts` opts out of type checking with `@ts-nocheck`, so
* the compiler never reports an identifier that is referenced where its
* declaration is not in scope. Nothing else in the toolchain does either: the
* file is bundled, not linted for scope, and a bad reference only surfaces as a
* runtime `ReferenceError` on whichever request path happens to evaluate it.
*
* That is how `appVersionForCapture` shipped in 0.16.2-beta.148. The name is a
* local of `createFinalizedMessageTelemetryReporter`; #6221 called it ~7,500
* lines away inside `startChatRun`'s AMR workspace-scope telemetry, on the spawn
* path, so every AMR run died with
* `spawn failed: appVersionForCapture is not defined`.
*
* This suite re-checks the one thing `@ts-nocheck` suppresses and nothing else:
* every identifier that survives to runtime resolves to a real binding. It runs
* the TypeScript checker over `server.ts` with the pragma stripped, keeps only
* "Cannot find name" diagnostics, and drops the ones in type positions (erased
* before execution, so they cannot throw — those belong to the separate project
* of removing `@ts-nocheck` from this file).
*
* It is deliberately not a typecheck of `server.ts`. Only unresolvable
* value-position identifiers fail it.
*
* ## Why this lives in `e2e/tests/` and not `apps/daemon/tests/`
*
* A guard is worth exactly the lane that runs it. The daemon lane now executes
* the complete package suite in four shards, so a daemon-hosted regression
* would also protect the merge gate. This check remains here as a lightweight,
* independent structural guard because it does not boot a daemon and runs in
* the broader E2E Vitest lane.
*
* `e2e/tests/` closes that gap, and the closure is structural rather than
* incidental:
*
* - Any change under `apps/daemon/src/` matches the `certain-daemon-core` rule
* in `.github/config/scopes.json`, whose effects include `ui_p0_validation_required`;
* `run_e2e_vitest` is `isFull || web_tests_required ||
* ui_p0_validation_required`. So a `server.ts`-only change arms the `E2E
* Vitest` lane even at the merge queue's `certain` threshold — the strictest
* context there is. An unresolved file list escalates fail-closed to full,
* which arms it too.
* - Direct `scopes.py plan` tests pin that routing, while the workflow topology
* test pins the `e2e_vitest` hash-run identity and package command. Those are
* planner contract tests, not an independent proof that authorizes the plan.
*
* The placement also follows the root `AGENTS.md` boundary rule — cross-app and
* repository-resource consistency checks belong here, not inside an app package
* — for the same reason `e2e/tests/critique-coverage.test.ts` does: the check
* reads another app's tree as a repository resource. Nothing here boots a
* daemon or needs a fixture; `server.ts` is only ever read as text.
*/
const CANNOT_FIND_NAME = 2304;
const REPO_ROOT = fileURLToPath(new URL('../../', import.meta.url));
const SERVER_TS = path.join(REPO_ROOT, 'apps/daemon/src/server.ts');
/**
* Names TypeScript cannot resolve here only because the scope check runs without
* ambient type packages, not because the binding is missing at runtime. Keep
* this list to genuine host globals; anything a module has to declare or import
* does not belong here.
*/
const AMBIENT_RUNTIME_GLOBALS = new Set([
'AbortSignal',
'Buffer',
'NodeJS',
'TextDecoder',
'TextEncoder',
'URL',
'URLSearchParams',
'__dirname',
'__filename',
'clearInterval',
'clearTimeout',
'console',
'fetch',
'global',
'globalThis',
'module',
'process',
'queueMicrotask',
'require',
'setInterval',
'setTimeout',
'structuredClone',
]);
interface UnresolvedReference {
readonly name: string;
readonly line: number;
}
/** Strip the blanket opt-out so the checker actually reports on this file. */
function withoutTsNocheck(source: string): string {
return source.replace(/^\s*\/\/\s*@ts-nocheck.*$/m, '// (@ts-nocheck stripped by the scope guard)');
}
/**
* True when the identifier lives inside a type annotation, and so is erased
* before the code runs. A value-position `typeof x` is a `TypeOfExpression` and
* is NOT covered here on purpose: `typeof` does not throw on an undeclared name,
* which is exactly how an out-of-scope reference can hide as a silent
* `undefined` rather than a crash.
*/
function isErasedTypePosition(node: ts.Node): boolean {
for (let current: ts.Node | undefined = node; current; current = current.parent) {
if (ts.isTypeNode(current) || ts.isTypeParameterDeclaration(current)) return true;
if (ts.isImportTypeNode(current)) return true;
if (ts.isStatement(current) || ts.isSourceFile(current)) return false;
}
return false;
}
function identifierAt(sourceFile: ts.SourceFile, pos: number): ts.Identifier | null {
function visit(node: ts.Node): ts.Identifier | null {
if (pos < node.getStart(sourceFile) || pos >= node.getEnd()) return null;
const found = node.forEachChild(visit);
if (found) return found;
return ts.isIdentifier(node) ? node : null;
}
return visit(sourceFile);
}
/**
* Report every identifier in `source` that reaches runtime without a binding.
*
* `noResolve` keeps this to one file: imports still declare their names, so an
* unresolved module never masquerades as an unresolved identifier, and the whole
* dependency graph stays out of the check.
*/
function unresolvedRuntimeReferences(source: string, fileName: string): UnresolvedReference[] {
const config = path.join(path.dirname(fileName), 'tsconfig.native-scope.json');
const configText = JSON.stringify({
compilerOptions: { allowJs: false, module: 'ESNext', moduleResolution: 'Bundler', noEmit: true, noResolve: true, skipLibCheck: true, target: 'ES2022' },
files: [fileName],
});
const api = new API({ fs: {
readFile: (name) => path.resolve(name) === path.resolve(fileName) ? source : path.resolve(name) === config ? configText : undefined,
fileExists: (name) => name === fileName || path.resolve(name) === config ? true : undefined,
} });
try {
using snapshot = api.updateSnapshot({ openProjects: [config] });
const program = snapshot.getProject(config)?.program;
const sourceFile = program?.getSourceFile(fileName);
if (!program || !sourceFile) throw new Error(`scope guard could not load ${fileName}`);
const found: UnresolvedReference[] = [];
for (const diagnostic of program.getSemanticDiagnostics(fileName)) {
if (diagnostic.code !== CANNOT_FIND_NAME) continue;
const identifier = identifierAt(sourceFile, diagnostic.pos);
if (!identifier) continue;
if (AMBIENT_RUNTIME_GLOBALS.has(identifier.text)) continue;
if (isErasedTypePosition(identifier)) continue;
found.push({
name: identifier.text,
line: sourceFile.getLineAndCharacterOfPosition(diagnostic.pos).line + 1,
});
}
return found;
} finally {
api.close();
}
}
describe('server.ts identifier scope', () => {
it('reports a helper called from outside the function that declares it', () => {
// The shape of the shipped bug, in miniature: a local of one function called
// from another. If this stops failing, the guard below has gone vacuous.
const reproduction = `export function makeReporter(getAppVersion: () => string) {
const appVersionForCapture = () => getAppVersion();
return () => ({ appVersion: appVersionForCapture() });
}
export function startRun(capture: (payload: unknown) => void) {
capture({ appVersion: appVersionForCapture() });
}
`;
expect(unresolvedRuntimeReferences(reproduction, path.join(path.dirname(SERVER_TS), 'scope-guard-fixture.ts')))
.toEqual([{ name: 'appVersionForCapture', line: 6 }]);
});
it('leaves an identifier that a typeof guard hides still reportable', () => {
// `typeof missing === 'string'` never throws, so this class of out-of-scope
// reference degrades to a silent wrong value instead of a crash. It has to
// stay visible to the guard — that is how the `effectiveSkillId` sibling of
// the beta.148 bug went unnoticed through a whole release.
const reproduction = `export function startRun(fallback: string) {
return typeof missingSkillId === 'string' && missingSkillId ? missingSkillId : fallback;
}
`;
expect(
unresolvedRuntimeReferences(reproduction, path.join(path.dirname(SERVER_TS), 'scope-guard-fixture.ts'))
.map((reference) => reference.name),
).toEqual(['missingSkillId', 'missingSkillId', 'missingSkillId']);
});
it('has no identifier that reaches runtime unbound', () => {
const source = fs.readFileSync(SERVER_TS, 'utf8');
expect(source).toMatch(/@ts-nocheck/);
const unresolved = unresolvedRuntimeReferences(withoutTsNocheck(source), SERVER_TS);
expect(
unresolved.map((reference) => `${reference.name} (line ${reference.line})`),
).toEqual([]);
}, 120_000);
});