mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-24 06:14:01 +08:00
- Parse standalone source with an isolated native TypeScript session and preserve AST ownership across edits. - Move package declaration projection and import scanning onto statically compilable syntax passes. - Support forwarded visitor results and guarded native iteration while preserving JSDoc annotations.
310 lines
16 KiB
TypeScript
310 lines
16 KiB
TypeScript
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { expect, test } from "vitest";
|
|
import { API, type Snapshot } from "typescript/unstable/sync";
|
|
import { Ts7Api, ts7Executable } from "../../src/frontend/ts7/rpc-api.js";
|
|
import { Ts7RpcClient } from "../../src/frontend/ts7/rpc-client.js";
|
|
import type { Ts7FileSystem } from "../../src/frontend/ts7/rpc-filesystem.js";
|
|
import { spawnTs7Wire } from "../../src/frontend/ts7/rpc-process.js";
|
|
import { AstNode } from "../../src/frontend/ts7/ast-node.js";
|
|
import { SemanticChecker } from "../../src/frontend/ts7/semantic-checker.js";
|
|
import { SemanticNodeHandle, SemanticSignature, SemanticSymbol, SemanticType } from "../../src/frontend/ts7/semantic-model.js";
|
|
|
|
function tsgoPath(path: string): string {
|
|
return process.platform === "win32" ? path.replaceAll("\\", "/") : path;
|
|
}
|
|
|
|
const tempRoot = process.platform === "win32" ? tmpdir() : "/tmp";
|
|
const fallback: Ts7FileSystem = {
|
|
readFile: () => undefined,
|
|
fileExists: () => undefined,
|
|
directoryExists: () => undefined,
|
|
realpath: () => undefined,
|
|
getAccessibleEntries: () => undefined,
|
|
};
|
|
|
|
function programFacts(snapshot: Snapshot, config: string, path: string) {
|
|
const project = snapshot.getProject(config)!;
|
|
const file = project.program.getSourceFile(path)!;
|
|
const checker = project.checker;
|
|
const position = file.text.indexOf("answer");
|
|
const type = checker.getTypeAtPosition(path, position)!;
|
|
const symbol = checker.getSymbolAtPosition(path, position)!;
|
|
return {
|
|
roots: project.rootFiles,
|
|
files: project.program.getSourceFileNames(),
|
|
options: project.program.getCompilerOptions(),
|
|
text: file.text,
|
|
type: checker.typeToString(type),
|
|
symbol: symbol.name,
|
|
declaration: symbol.declarations[0]!.resolve()!.getText(file),
|
|
syntactic: project.program.getSyntacticDiagnostics(path),
|
|
semantic: project.program.getSemanticDiagnostics(path),
|
|
globals: project.program.getGlobalDiagnostics(),
|
|
config: project.program.getConfigFileParsingDiagnostics(),
|
|
defaultLibrary: project.program.isSourceFileDefaultLibrary(file),
|
|
externalLibrary: project.program.isSourceFileFromExternalLibrary(file),
|
|
};
|
|
}
|
|
|
|
test("production RPC bridge preserves pinned SDK parser/checker behavior", () => {
|
|
const dir = mkdtempSync(join(tempRoot, "scriptc-ts7-rpc-parity-"));
|
|
const config = tsgoPath(join(dir, "tsconfig.json"));
|
|
const path = tsgoPath(join(dir, "main.ts"));
|
|
writeFileSync(config, JSON.stringify({ compilerOptions: { strict: true, noEmit: true, types: [] }, files: [path] }));
|
|
writeFileSync(path, 'export const answer = 42;\nexport const invalid: number = "wrong";\n');
|
|
const nativeClient = new Ts7Api({ cwd: dir, fs: fallback });
|
|
const sdk = new API({ cwd: dir });
|
|
try {
|
|
expect(nativeClient.parseConfigFile(config)).toEqual(sdk.parseConfigFile(config));
|
|
const expected = sdk.updateSnapshot({ openProjects: [config] });
|
|
const actual = nativeClient.updateSnapshot({ openProjects: [config] });
|
|
const facts = programFacts(actual as unknown as Snapshot, config, path);
|
|
expect(facts).toEqual(programFacts(expected, config, path));
|
|
expect(facts.type).toBe("42");
|
|
expect(facts.symbol).toBe("answer");
|
|
expect(facts.semantic.map((diagnostic) => diagnostic.code)).toEqual([2322]);
|
|
const project = actual.getProject(config)!;
|
|
const source = project.program.getSourceFile(path)!;
|
|
expect(source).toBeInstanceOf(AstNode);
|
|
expect(project.program.getSourceFile(path)).toBe(source);
|
|
const first = project.checker.getTypeAtPosition(path, source.text.indexOf("answer"));
|
|
expect(project.checker).toBeInstanceOf(SemanticChecker);
|
|
expect(first).toBeInstanceOf(SemanticType);
|
|
expect(project.checker.getTypeAtPosition(path, source.text.indexOf("answer"))).toBe(first);
|
|
const symbol = project.checker.getSymbolAtPosition(path, source.text.indexOf("answer"));
|
|
expect(symbol).toBeInstanceOf(SemanticSymbol);
|
|
expect(symbol!.declarations[0]).toBeInstanceOf(SemanticNodeHandle);
|
|
expect(project.checker.getSymbolAtPosition(path, source.text.indexOf("answer"))).toBe(symbol);
|
|
expect(symbol!.declarations[0]!.resolve()).toBe(symbol!.declarations[0]!.resolve());
|
|
const declaration = symbol!.declarations[0]!.resolve()!;
|
|
expect(declaration).toBeInstanceOf(AstNode);
|
|
const variable = source.statements[0]! as import("../../src/frontend/ts7/ast-types.js").VariableStatement;
|
|
expect(variable.declarationList.declarations[0]).toBe(declaration);
|
|
expect(project.checker.getSymbolAtLocation(variable.declarationList.declarations[0]!.name)).toBe(symbol);
|
|
expect(project.checker.getTypeAtLocation(variable.declarationList.declarations[0]!.name)).toBe(first);
|
|
const expectedSource = expected.getProject(config)!.program.getSourceFile(path)!;
|
|
expect(source.statements.map((node) => [node.kind, node.pos, node.end])).toEqual(
|
|
expectedSource.statements.map((node) => [node.kind, node.pos, node.end]),
|
|
);
|
|
// Native arrays retain element identity; SDK-only properties no longer
|
|
// need a JavaScript Object.assign adapter on production source lists.
|
|
expect(Array.isArray(source.statements)).toBe(true);
|
|
expect(Object.hasOwn(source.statements, "pos")).toBe(false);
|
|
expect(Object.hasOwn(source.statements, "transformFlags")).toBe(false);
|
|
expect(source.referencedFiles).toBe(source.referencedFiles);
|
|
expect(source.imports).toBe(source.imports);
|
|
} finally {
|
|
nativeClient.close();
|
|
sdk.close();
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("snapshots share unchanged source files and release independently", () => {
|
|
const dir = mkdtempSync(join(tempRoot, "scriptc-ts7-rpc-snapshots-"));
|
|
const config = tsgoPath(join(dir, "tsconfig.json"));
|
|
const path = tsgoPath(join(dir, "main.ts"));
|
|
writeFileSync(config, JSON.stringify({ compilerOptions: { types: [] }, files: [path] }));
|
|
writeFileSync(path, "export const answer = 42;\n");
|
|
const api = new Ts7Api({ cwd: dir, fs: fallback });
|
|
try {
|
|
const first = api.updateSnapshot({ openProjects: [config] });
|
|
const firstFile = first.getProject(config)!.program.getSourceFile(path);
|
|
const second = api.updateSnapshot({ openProjects: [] });
|
|
expect(second.getProject(config)!.program.getSourceFile(path)).toBe(firstFile);
|
|
first.dispose();
|
|
first.dispose();
|
|
expect(() => first.getProjects()).toThrow("disposed");
|
|
expect(second.getProject(config)!.program.getSourceFile(path)).toBe(firstFile);
|
|
// Releasing the server baseline removes the proof that cached syntax is
|
|
// unchanged. Re-fetch even an unchanged file after the latest disposal.
|
|
second.dispose();
|
|
const third = api.updateSnapshot({ openProjects: [] });
|
|
const thirdFile = third.getProject(config)!.program.getSourceFile(path)!;
|
|
expect(thirdFile).not.toBe(firstFile);
|
|
expect(thirdFile.text).toBe(firstFile!.text);
|
|
third.dispose();
|
|
writeFileSync(path, 'export const answer = "updated";\n');
|
|
const fourth = api.updateSnapshot({ fileChanges: { changed: [path] } });
|
|
const project = fourth.getProject(config)!;
|
|
const updated = project.program.getSourceFile(path)!;
|
|
expect(updated.text).toBe('export const answer = "updated";\n');
|
|
expect(updated).not.toBe(thirdFile);
|
|
expect(project.checker.typeToString(project.checker.getTypeAtPosition(path, updated.text.indexOf("answer"))!)).toBe('"updated"');
|
|
expect(firstFile!.text).toBe("export const answer = 42;\n");
|
|
api.close();
|
|
expect(fourth.isDisposed()).toBe(true);
|
|
expect(() => api.parseConfigFile(config)).toThrow("closed");
|
|
expect(() => api.updateSnapshot({ openProjects: [] })).toThrow("closed");
|
|
expect(() => api.getTimingInfo()).toThrow("closed");
|
|
} finally {
|
|
api.close();
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("native semantic registries preserve project scope and snapshot lifetimes", () => {
|
|
const dir = mkdtempSync(join(tempRoot, "scriptc-ts7-semantic-scopes-"));
|
|
const firstConfig = tsgoPath(join(dir, "first.json"));
|
|
const secondConfig = tsgoPath(join(dir, "second.json"));
|
|
const path = tsgoPath(join(dir, "shared.ts"));
|
|
for (const config of [firstConfig, secondConfig]) writeFileSync(config, JSON.stringify({ compilerOptions: { strict: true, target: "esnext", types: [] }, files: [path] }));
|
|
const source = "export function identity<T>(value: T): T { return value; }\nexport const answer = 42;\n";
|
|
writeFileSync(path, source);
|
|
const api = new Ts7Api({ cwd: dir, fs: fallback });
|
|
const sdk = new API({ cwd: dir });
|
|
function facts(snapshot: Snapshot) {
|
|
const a = snapshot.getProject(firstConfig)!;
|
|
const b = snapshot.getProject(secondConfig)!;
|
|
const answer = source.indexOf("answer");
|
|
const symbolA = a.checker.getSymbolAtPosition(path, answer)!;
|
|
const symbolB = b.checker.getSymbolAtPosition(path, answer)!;
|
|
const typeA = a.checker.getTypeAtPosition(path, answer)!;
|
|
const typeB = b.checker.getTypeAtPosition(path, answer)!;
|
|
const fileA = a.program.getSourceFile(path)!;
|
|
const fileB = b.program.getSourceFile(path)!;
|
|
const signatureA = a.checker.getSignatureFromDeclaration(fileA.statements[0]!)!;
|
|
const signatureB = b.checker.getSignatureFromDeclaration(fileB.statements[0]!)!;
|
|
return {
|
|
shape: {
|
|
sharedSymbol: symbolA === symbolB, sharedType: typeA === typeB, sharedSignature: signatureA === signatureB,
|
|
sameDeclaration: symbolA.declarations[0]!.resolve() === symbolB.declarations[0]!.resolve(),
|
|
explicitDeclaration: symbolB.declarations[0]!.resolve(b) === (fileB.statements[1] as import("typescript/unstable/ast").VariableStatement).declarationList.declarations[0],
|
|
texts: [a.checker.typeToString(typeA), b.checker.typeToString(typeB)],
|
|
parameters: signatureA.getParameters().map((symbol) => symbol.name),
|
|
},
|
|
symbolA, typeA, signatureA, fileA,
|
|
};
|
|
}
|
|
try {
|
|
const first = api.updateSnapshot({ openProjects: [firstConfig, secondConfig] });
|
|
const actual = facts(first as unknown as Snapshot);
|
|
expect(actual.shape).toEqual(facts(sdk.updateSnapshot({ openProjects: [firstConfig, secondConfig] })).shape);
|
|
expect(actual.signatureA).toBeInstanceOf(SemanticSignature);
|
|
expect(actual.typeA).toBeInstanceOf(SemanticType);
|
|
const second = api.updateSnapshot({ openProjects: [] });
|
|
const next = facts(second as unknown as Snapshot);
|
|
expect(next.fileA).toBe(actual.fileA);
|
|
expect(next.typeA).not.toBe(actual.typeA);
|
|
expect(next.symbolA).not.toBe(actual.symbolA);
|
|
expect(next.signatureA).not.toBe(actual.signatureA);
|
|
first.dispose();
|
|
expect(() => actual.typeA.getSymbol()).toThrow("disposed");
|
|
expect(() => actual.symbolA.declarations[0]!.resolve()).toThrow("disposed");
|
|
expect(next.signatureA.getParameters()[0]!.name).toBe("value");
|
|
expect(second.getProject(firstConfig)!.checker.typeToString(next.typeA)).toBe("42");
|
|
second.dispose();
|
|
expect(() => next.signatureA.getParameters()).toThrow("disposed");
|
|
} finally {
|
|
api.close();
|
|
sdk.close();
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("virtual filesystem callbacks retain empty, hidden, and fallback files", () => {
|
|
const dir = mkdtempSync(join(tempRoot, "scriptc-ts7-rpc-fs-"));
|
|
const config = tsgoPath(join(dir, "virtual.json"));
|
|
const empty = tsgoPath(join(dir, "empty.ts"));
|
|
const hidden = tsgoPath(join(dir, "hidden.ts"));
|
|
const real = tsgoPath(join(dir, "real.ts"));
|
|
const virtual = tsgoPath(join(dir, "virtual.ts"));
|
|
writeFileSync(empty, "invalid syntax !!!");
|
|
writeFileSync(hidden, "export const hidden = 1;");
|
|
writeFileSync(real, "export const real = 2;");
|
|
const reads = new Set<string>();
|
|
const fs: Ts7FileSystem = {
|
|
...fallback,
|
|
readFile(path) {
|
|
reads.add(path);
|
|
if (path === config) return JSON.stringify({ compilerOptions: { types: [] }, files: [empty, hidden, real, virtual] });
|
|
if (path === empty) return "";
|
|
if (path === hidden) return null;
|
|
if (path === virtual) return 'export const answer = "virtual";';
|
|
return undefined;
|
|
},
|
|
fileExists: (path) => path === hidden ? false : path === empty || path === virtual || path === config ? true : undefined,
|
|
};
|
|
const api = new Ts7Api({ cwd: dir, fs });
|
|
try {
|
|
const snapshot = api.updateSnapshot({ openProjects: [config] });
|
|
const program = snapshot.getProject(config)!.program;
|
|
expect(program.getSourceFile(empty)!.text).toBe("");
|
|
expect(program.getSourceFile(hidden)).toBeUndefined();
|
|
expect(program.getSourceFile(real)!.text).toBe("export const real = 2;");
|
|
expect(program.getSourceFile(virtual)!.text).toContain('"virtual"');
|
|
expect(program.getSyntacticDiagnostics()).toEqual([]);
|
|
expect(reads.has(config)).toBe(true);
|
|
expect(reads.has(real)).toBe(true);
|
|
} finally {
|
|
api.close();
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("request and AST materialization timing keeps the existing public shape", () => {
|
|
const dir = mkdtempSync(join(tempRoot, "scriptc-ts7-rpc-timing-"));
|
|
const config = tsgoPath(join(dir, "tsconfig.json"));
|
|
const path = tsgoPath(join(dir, "main.ts"));
|
|
writeFileSync(config, JSON.stringify({ compilerOptions: { types: [] }, files: [path] }));
|
|
writeFileSync(path, "export const answer = 42;\n");
|
|
const api = new Ts7Api({ cwd: dir, fs: fallback, collectTiming: true });
|
|
const disabled = new Ts7Api({ cwd: dir, fs: fallback });
|
|
try {
|
|
const snapshot = api.updateSnapshot({ openProjects: [config] });
|
|
programFacts(snapshot, config, path);
|
|
const timing = api.getTimingInfo();
|
|
expect(timing.enabled).toBe(true);
|
|
expect(timing.totals.requestCount).toBeGreaterThan(10);
|
|
expect(timing.totals.bytesSent).toBeGreaterThan(0);
|
|
expect(timing.totals.bytesReceived).toBeGreaterThan(0);
|
|
expect(timing.totals.sourceFilesFetched).toBe(1);
|
|
expect(timing.totals.nodesMaterialized).toBeGreaterThan(0);
|
|
expect(timing.totals.nodesFetched).toBeGreaterThanOrEqual(timing.totals.nodesMaterialized);
|
|
expect(timing.recentRequests).toHaveLength(5);
|
|
expect(api.getTimingInfo().totals.requestCount).toBe(timing.totals.requestCount);
|
|
expect(disabled.getTimingInfo().enabled).toBe(false);
|
|
expect(disabled.getTimingInfo().totals.requestCount).toBe(0);
|
|
} finally {
|
|
api.close();
|
|
disabled.close();
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("real server refusals leave the production process channel reusable", () => {
|
|
const client = new Ts7RpcClient(spawnTs7Wire(ts7Executable(), ["--api", "--cwd", process.cwd()]));
|
|
try {
|
|
expect(() => client.requestText("scriptcUnknownMethod", "null")).toThrow();
|
|
expect(client.requestText("echo", "after error")).toBe("after error");
|
|
expect(JSON.parse(client.requestText("initialize", "null"))).toHaveProperty("currentDirectory");
|
|
} finally {
|
|
client.close();
|
|
}
|
|
});
|
|
|
|
test("failed process startup has no uncaught error or leaked exit hook", async () => {
|
|
const before = process.listenerCount("exit");
|
|
expect(() => spawnTs7Wire(join(tempRoot, "scriptc-ts7-does-not-exist", "tsgo"), [])).toThrow("Unable to start TypeScript server");
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
expect(process.listenerCount("exit")).toBe(before);
|
|
});
|
|
|
|
test("live process transports share one removable exit hook", () => {
|
|
const before = process.listenerCount("exit");
|
|
const first = spawnTs7Wire(ts7Executable(), ["--api", "--cwd", process.cwd()]);
|
|
const second = spawnTs7Wire(ts7Executable(), ["--api", "--cwd", process.cwd()]);
|
|
try {
|
|
expect(process.listenerCount("exit")).toBe(before + 1);
|
|
first.close();
|
|
expect(process.listenerCount("exit")).toBe(before + 1);
|
|
second.close();
|
|
expect(process.listenerCount("exit")).toBe(before);
|
|
} finally {
|
|
first.close();
|
|
second.close();
|
|
}
|
|
});
|