feat(compiler): statically compile the TypeScript session lifecycle

- Replace SDK snapshots, projects, and programs with the owned native client.
- Preserve source identity across snapshots with native paths, caches, metadata, and diagnostics.
- Collect request timing and close snapshot state reliably without SDK helpers.
This commit is contained in:
Chris Tate
2026-09-28 07:45:35 -05:00
committed by GitHub
parent 3d9a5a59da
commit 22e8565d54
16 changed files with 1237 additions and 276 deletions
@@ -1088,7 +1088,7 @@ function completeFuncValueArgs(
* other unmappable type (the caller's badType reports those). */
function fenceGenericSignatureResult(lowerer: Lowerer, blame: ts.Node, t: ts.Type): void {
const parts = t.isUnionType() ? ts.constituentTypes(t) : [t];
if (!parts.some((p) => lowerer.checker.getCallSignatures(p).some((s) => (s.typeParameters?.length ?? 0) > 0))) {
if (!parts.some((p) => lowerer.checker.getCallSignatures(p).some((s) => s.getTypeParameters().length > 0))) {
return;
}
lowerer.unsupported(
@@ -4541,7 +4541,7 @@ export class Lowerer {
const parts = widened.isUnionType() ? ts.constituentTypes(widened) : [widened];
if (
parts.some((p) =>
this.checker.getCallSignatures(p).some((s) => (s.typeParameters?.length ?? 0) > 0),
this.checker.getCallSignatures(p).some((s) => s.getTypeParameters().length > 0),
)
) {
this.pushDiag(genericSignatureTypeDiag(this.checker.typeToString(type), locOf(node)));
@@ -9138,7 +9138,7 @@ export class Lowerer {
if (d && ts.isVariableDeclaration(d) && d.initializer === undefined) {
const t = this.checker.getTypeOfSymbol(symbol);
const parts = t.isUnionType() ? ts.constituentTypes(t) : [t];
if (parts.some((p) => this.checker.getCallSignatures(p).some((s) => (s.typeParameters?.length ?? 0) > 0))) {
if (parts.some((p) => this.checker.getCallSignatures(p).some((s) => s.getTypeParameters().length > 0))) {
this.unsupported(
"SC1030",
node,
@@ -1,71 +0,0 @@
/** Transitional SDK boundary. The AST itself is the same concrete model
* used by the native client. The SDK still owns program metadata and
* snapshots. ASTs, checker queries, and semantic identities are owned here;
* the casts retain the frontend's existing type-only interface surface. */
import { Program } from "typescript/unstable/sync";
import type { Checker, DocumentIdentifier, Project, Snapshot } from "typescript/unstable/sync";
import { resolveFileName } from "typescript/unstable/proto";
import type { Path, SourceFile } from "typescript/unstable/ast";
import { AstFile, AstNode } from "./ast-node.js";
import { SemanticChecker } from "./semantic-checker.js";
import { SemanticSnapshot, type SemanticTransport } from "./semantic-model.js";
type SdkClient = ConstructorParameters<typeof Checker>[2];
type Cache = ConstructorParameters<typeof Program>[3];
type CanonicalPath = (fileName: string) => Path;
function decode(bytes: Uint8Array, timing?: ReturnType<SdkClient["getTimingCollector"]>): AstFile {
return new AstFile(bytes, (nodes, pos, end) => {
// The SDK's NodeArray interface adds properties to an ordinary array.
// Keep this JavaScript surface adaptation outside the native AST model.
Object.assign(nodes, { pos, end, transformFlags: 0 });
}, timing === undefined ? undefined : () => { timing.recordMaterialization(); });
}
class NativeAstProgram extends Program {
constructor(
private readonly astSnapshot: number,
private readonly astProject: Project,
private readonly astClient: SdkClient,
private readonly astCache: Cache,
private readonly astPath: CanonicalPath,
) { super(astSnapshot, astProject, astClient, astCache, astPath); }
override getSourceFile(file: DocumentIdentifier): SourceFile | undefined {
const path = this.astPath(resolveFileName(file));
const retained = this.astCache.getRetained(path, this.astSnapshot, this.astProject.id);
if (retained !== undefined) return retained;
const bytes = this.astClient.apiRequestBinary("getSourceFile", { snapshot: this.astSnapshot, project: this.astProject.id, file });
if (bytes === undefined) return undefined;
const ast = decode(bytes, this.astClient.getTimingCollector());
this.astClient.getTimingCollector()?.recordSourceFileFetched(Math.max(0, ast.wire.nodeCount - 2));
// The frontend keeps the upstream discriminated interfaces until its
// type layer moves to nominal native refinements. This is the one
// structural boundary; all references point at the same AstNode objects.
return this.astCache.set(path, ast.root as unknown as SourceFile, ast.wire.parseOptionsKey, ast.wire.contentHash, this.astSnapshot, this.astProject.id);
}
}
/** Install every project before publishing a snapshot: shared symbols may
* name another project's canonical context on their very first response. */
export function installNativeAst(snapshot: Snapshot, client: SdkClient, cache: Cache, canonical: CanonicalPath, transport: SemanticTransport): () => void {
const semantic = new SemanticSnapshot(snapshot.id, transport);
const projects = snapshot.getProjects();
for (const project of projects) {
const target = project as unknown as { program: Program; checker: Checker };
target.program = new NativeAstProgram(snapshot.id, project, client, cache, canonical);
semantic.addProject(project.id, (path) => {
const file = project.program.getSourceFile(path);
if (file === undefined) return undefined;
if (!(file instanceof AstNode)) throw new Error("TypeScript semantic query requires a scriptc AST");
return file;
});
}
for (const project of projects) {
// The frontend's structural Type/Node interfaces are a type-only bridge.
// No native object is copied, adapted, or inserted in an SDK registry.
const target = project as unknown as { checker: Checker };
target.checker = new SemanticChecker(semantic.getProject(project.id)) as unknown as Checker;
}
return () => { semantic.dispose(); };
}
+21 -133
View File
@@ -1,91 +1,29 @@
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import { Snapshot, type TimingInfo } from "typescript/unstable/sync";
import type { ConfigResponse, InitializeResponse, UpdateSnapshotResponse } from "typescript/unstable/proto";
import type { Snapshot, TimingInfo } from "typescript/unstable/sync";
import type { ConfigResponse } from "typescript/unstable/proto";
import { AstNode } from "./ast-node.js";
import { Ts7RpcClient } from "./rpc-client.js";
import { registerTs7FileSystem, TS7_FILE_SYSTEM_CALLBACKS, type Ts7FileSystem } from "./rpc-filesystem.js";
import { spawnTs7Wire } from "./rpc-process.js";
import { installNativeAst } from "./ast-sdk.js";
// The pinned SDK still owns snapshots and program metadata.
// These hidden helpers have no public package exports. Derive their types
// from Snapshot's constructor rather than duplicating the SDK's contracts.
// This bridge can go away when those object models also compile statically;
// the protocol and filesystem callback modules have no runtime SDK import.
type SdkClient = ConstructorParameters<typeof Snapshot>[1];
type SdkCache = ConstructorParameters<typeof Snapshot>[2];
type CanonicalPath = ConstructorParameters<typeof Snapshot>[3];
type TimingCollector = NonNullable<ReturnType<SdkClient["getTimingCollector"]>>;
type ServerTiming = { enabled: boolean; totals: { requestCount: number; totalProcessingTimeMs: number }; recentRequests: { method: string; processingTimeMs: number; timestamp: number }[] };
import { Ts7Session, type Ts7Update } from "./session.js";
const require = createRequire(import.meta.url);
const packageRoot = dirname(require.resolve("typescript/package.json"));
const { SourceFileCache } = require(join(packageRoot, "dist/api/sourceFileCache.js")) as { SourceFileCache: new () => SdkCache };
const { createGetCanonicalFileName, toPath } = require(join(packageRoot, "dist/api/path.js")) as {
createGetCanonicalFileName: (caseSensitive: boolean) => (path: string) => string;
toPath: (file: string, cwd: string, canonical: (path: string) => string) => ReturnType<CanonicalPath>;
};
const { TimingCollector, combineTimingInfo, disabledTimingInfo } = require(join(packageRoot, "dist/api/timing.js")) as {
TimingCollector: new () => TimingCollector;
combineTimingInfo: (client: TimingInfo, server: ServerTiming) => TimingInfo;
disabledTimingInfo: () => TimingInfo;
};
const { default: getExePath } = require(join(packageRoot, "lib/getExePath.js")) as { default: () => string };
/** Resolve from the pinned TypeScript package, including its platform
* package and Windows long-path handling. Never pick an unrelated PATH tsc. */
export function ts7Executable(): string {
return getExePath();
/** Platform-package discovery and process creation remain at the host
* boundary. The session itself uses no SDK implementation or Node process. */
export function ts7Executable(): string { return getExePath(); }
function listMetadata(nodes: AstNode[], pos: number, end: number): void {
// Legacy frontend NodeArray interfaces attach properties to JS arrays.
// Native clients consume AstNode arrays directly and omit this adapter.
Object.assign(nodes, { pos, end, transformFlags: 0 });
}
class SnapshotClient {
private readonly timing: TimingCollector | undefined;
constructor(readonly rpc: Ts7RpcClient, collectTiming: boolean) {
this.timing = collectTiming ? new TimingCollector() : undefined;
}
apiRequest<T>(method: string, params: unknown): T {
const result = this.apiRequestBinary(method, params);
return (result === undefined ? undefined : JSON.parse(Buffer.from(result).toString("utf8"))) as T;
}
apiRequestBinary(method: string, params: unknown): Uint8Array | undefined {
const result = this.requestEncoded(method, JSON.stringify(params));
return result.length === 0 ? undefined : result;
}
requestEncoded(method: string, json: string): Uint8Array {
const payload = Buffer.from(json, "utf8");
const start = performance.now();
const result = this.rpc.requestBytes(method, payload);
this.timing?.record({ method, roundTripMs: performance.now() - start, bytesSent: payload.length, bytesReceived: result.length });
return result;
}
getTimingCollector(): TimingCollector | undefined {
return this.timing;
}
getTimingInfo(): TimingInfo {
if (!this.timing) return disabledTimingInfo();
const local = this.timing.getInfo();
const server = JSON.parse(this.rpc.requestText("getServerTiming", "")) as ServerTiming;
return combineTimingInfo(local, server);
}
}
/** The API operations scriptc uses, backed by its own synchronous client.
* Keep upstream Snapshot/Project objects for lifecycle and metadata. ASTs
* and checker registries use concrete native models. No upstream API
* or Client instance is constructed, and all requests use Ts7RpcClient. */
export class Ts7Api {
private readonly client: SnapshotClient;
private readonly cache = new SourceFileCache();
private readonly snapshots = new Set<Snapshot>();
private latest: Snapshot | undefined;
private canonicalPath: CanonicalPath | undefined;
private closed = false;
private readonly session: Ts7Session;
constructor(options: { cwd: string; fs: Ts7FileSystem; collectTiming?: boolean }) {
const timing = options.collectTiming ?? false;
@@ -93,65 +31,15 @@ export class Ts7Api {
if (timing) args.push("--timing");
const rpc = new Ts7RpcClient(spawnTs7Wire(ts7Executable(), args));
registerTs7FileSystem(rpc, options.fs);
this.client = new SnapshotClient(rpc, timing);
this.session = new Ts7Session(rpc, timing, listMetadata);
}
private initialize(): CanonicalPath {
if (this.closed) throw new Error("TypeScript API is closed");
if (this.canonicalPath === undefined) {
const data = this.client.apiRequest<InitializeResponse>("initialize", null);
const canonical = createGetCanonicalFileName(data.useCaseSensitiveFileNames);
this.canonicalPath = (file) => toPath(file, data.currentDirectory, canonical);
}
return this.canonicalPath;
}
parseConfigFile(file: string): ConfigResponse {
this.initialize();
return this.client.apiRequest<ConfigResponse>("parseConfigFile", { file });
}
updateSnapshot(params: { openProjects: string[] }): Snapshot {
const canonical = this.initialize();
const data = this.client.apiRequest<UpdateSnapshotResponse>("updateSnapshot", params);
if (this.latest) {
this.cache.retainForSnapshot(data.snapshot, this.latest.id, data.changes);
if (this.latest.isDisposed()) this.cache.releaseSnapshot(this.latest.id);
}
// The upstream class's private fields make its Client nominal. Only
// these three public operations are used by Snapshot and its children.
const snapshot = new Snapshot(data, this.client as unknown as SdkClient, this.cache, canonical, () => {
releaseSemantic();
this.snapshots.delete(snapshot);
if (snapshot !== this.latest) this.cache.releaseSnapshot(snapshot.id);
});
const releaseSemantic = installNativeAst(snapshot, this.client as unknown as SdkClient, this.cache, canonical, {
text: (method, payload) => {
const bytes = this.client.requestEncoded(method, payload);
return bytes.length === 0 ? "null" : Buffer.from(bytes).toString("utf8");
},
binary: (method, payload) => this.client.requestEncoded(method, payload),
});
this.latest = snapshot;
this.snapshots.add(snapshot);
return snapshot;
}
getTimingInfo(): TimingInfo {
if (this.closed) throw new Error("TypeScript API is closed");
return this.client.getTimingInfo();
}
close(): void {
if (this.closed) return;
this.closed = true;
try {
for (const snapshot of this.snapshots) snapshot.dispose();
} finally {
this.snapshots.clear();
this.latest = undefined;
this.cache.clear();
this.client.rpc.close();
}
parseConfigFile(file: string): ConfigResponse { return this.session.parseConfigFile(file); }
updateSnapshot(params: Ts7Update): Snapshot {
// Existing frontend discriminated interfaces remain a type-only bridge;
// every object behind them now belongs to the statically compiled client.
return this.session.updateSnapshot(params) as unknown as Snapshot;
}
getTimingInfo(): TimingInfo { return this.session.getTimingInfo(); }
close(): void { this.session.close(); }
}
@@ -0,0 +1,91 @@
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import { expect, test } from "vitest";
import type { AstNode } from "./ast-node.js";
import { Ts7SourceCache, type Ts7SnapshotChanges } from "./session-cache.js";
const require = createRequire(import.meta.url);
const { SourceFileCache } = require(join(dirname(require.resolve("typescript/package.json")), "dist/api/sourceFileCache.js")) as {
SourceFileCache: new () => {
getRetained(path: string, snapshot: number, project: string): AstNode | undefined;
set(path: string, file: AstNode, options: string, hash: string, snapshot: number, project: string): AstNode;
retainForSnapshot(snapshot: number, previous: number, changes?: Ts7SnapshotChanges): void;
releaseSnapshot(snapshot: number): void;
clear(): void;
size: number;
};
};
// Cache tests need identity and the two wire keys, not a parser response.
// The live native session test supplies real immutable AstFile instances.
function source(hash: string, options = "0"): AstNode {
return { file: { wire: { contentHash: hash, parseOptionsKey: options } } } as AstNode;
}
test("the cache separates content, parser options, paths and retention owners", () => {
const cache = new Ts7SourceCache();
const original = source("one");
expect(cache.set("/file.ts", original, 1, "/a")).toBe(original);
expect(cache.set("/file.ts", source("one"), 1, "/b")).toBe(original);
const changed = source("two");
expect(cache.set("/file.ts", changed, 2, "/a")).toBe(changed);
const options = source("two", "different");
expect(cache.set("/file.ts", options, 2, "/b")).toBe(options);
const other = source("one");
expect(cache.set("/other.ts", other, 2, "/a")).toBe(other);
expect(cache.get("/file.ts", 1, "/b")).toBe(original);
expect(cache.get("/file.ts", 2, "/b")).toBe(options);
cache.release(1);
expect(cache.get("/file.ts", 1, "/b")).toBeUndefined();
expect(cache.get("/file.ts", 2, "/a")).toBe(changed);
expect(cache.size).toBe(2);
cache.release(2);
cache.release(2);
expect(cache.size).toBe(0);
});
test("project-specific edits and removals preserve other projects and old snapshots", () => {
const cache = new Ts7SourceCache();
for (const project of ["/a", "/b", "/removed"]) for (const path of ["/changed", "/deleted", "/stable"]) cache.set(path, source(path), 1, project);
cache.retain(2, 1, { changedProjects: { "/a": { changedFiles: ["/changed"], deletedFiles: ["/deleted"] } }, removedProjects: ["/removed"] });
expect(cache.get("/changed", 2, "/a")).toBeUndefined();
expect(cache.get("/deleted", 2, "/a")).toBeUndefined();
expect(cache.get("/stable", 2, "/a")).toBe(cache.get("/stable", 1, "/a"));
expect(cache.get("/changed", 2, "/b")).toBe(cache.get("/changed", 1, "/b"));
expect(cache.get("/stable", 2, "/removed")).toBeUndefined();
expect(cache.get("/stable", 1, "/removed")).toBeDefined();
cache.release(1);
expect(cache.get("/changed", 2, "/b")).toBeDefined();
cache.clear();
expect(cache.size).toBe(0);
expect(cache.get("/stable", 2, "/a")).toBeUndefined();
});
test("retention and eviction agree with the pinned SDK through interleaved snapshots", () => {
const cache = new Ts7SourceCache();
const sdk = new SourceFileCache();
const projects = ["/one.json", "/two.json", "/three.json"];
const paths = ["/a.ts", "/b.ts", "/c.ts"];
let seed = 117;
const random = () => { seed = (seed * 16807) % 2147483647; return seed; };
for (let snapshot = 1; snapshot <= 15; snapshot++) {
const changes: Ts7SnapshotChanges = { changedProjects: { "/one.json": { changedFiles: [paths[random() % 3]!] } }, removedProjects: snapshot % 4 === 0 ? ["/three.json"] : [] };
cache.retain(snapshot, snapshot - 1, changes);
sdk.retainForSnapshot(snapshot, snapshot - 1, changes);
for (const project of projects) for (const path of paths) {
if (cache.get(path, snapshot, project) !== undefined) continue;
const file = source(String(random() % 4), String(random() % 2));
expect(cache.set(path, file, snapshot, project)).toBe(sdk.set(path, file, file.file.wire.parseOptionsKey, file.file.wire.contentHash, snapshot, project));
}
if (snapshot > 2) {
const release = random() % (snapshot - 1) + 1;
cache.release(release);
sdk.releaseSnapshot(release);
}
for (let id = 1; id <= snapshot; id++) for (const project of projects) for (const path of paths) expect(cache.get(path, id, project)).toBe(sdk.getRetained(path, id, project));
expect(cache.size).toBe(sdk.size);
}
for (let id = 1; id <= 15; id++) { cache.release(id); sdk.releaseSnapshot(id); }
expect(cache.size).toBe(sdk.size);
expect(cache.size).toBe(0);
});
@@ -0,0 +1,103 @@
import { AstNode } from "./ast-node.js";
import type { Ts7SnapshotChangeData as Ts7SnapshotChanges } from "./session-schema.generated.js";
export type { Ts7SnapshotChangeData as Ts7SnapshotChanges } from "./session-schema.generated.js";
interface CachedSource {
file: AstNode;
parseOptions: string;
hash: string;
references: Set<string>;
}
function reference(snapshot: number, project: string): string { return `${snapshot}:${project}`; }
class ProjectPaths { readonly paths = new Set<string>(); }
class SnapshotPaths { readonly projects = new Map<string, ProjectPaths>(); }
/** AST identity is shared only when path, parser options and content all
* agree. A server snapshot/project pair owns each reference; project-local
* invalidation must not evict a version retained by another open snapshot. */
export class Ts7SourceCache {
private readonly files = new Map<string, CachedSource[]>();
private readonly paths = new Map<number, SnapshotPaths>();
get(path: string, snapshot: number, project: string): AstNode | undefined {
const ref = reference(snapshot, project);
return this.files.get(path)?.find((entry) => entry.references.has(ref))?.file;
}
set(path: string, file: AstNode, snapshot: number, project: string): AstNode {
let entries = this.files.get(path);
if (entries === undefined) {
entries = [];
this.files.set(path, entries);
}
const ref = reference(snapshot, project);
const parseOptions = file.file.wire.parseOptionsKey;
const hash = file.file.wire.contentHash;
const existing = entries.find((entry) => entry.parseOptions === parseOptions && entry.hash === hash);
if (existing !== undefined) {
existing.references.add(ref);
this.track(snapshot, project, path);
return existing.file;
}
entries.push({ file, parseOptions, hash, references: new Set<string>([ref]) });
this.track(snapshot, project, path);
return file;
}
retain(snapshot: number, previous: number, changes?: Ts7SnapshotChanges): void {
const projects = this.paths.get(previous);
if (projects === undefined) return;
for (const [project, retained] of projects.projects) {
if (changes?.removedProjects?.includes(project)) continue;
const changed = changes?.changedProjects?.[project];
const previousRef = reference(previous, project);
const nextRef = reference(snapshot, project);
for (const path of retained.paths) {
if (changed?.changedFiles?.includes(path) || changed?.deletedFiles?.includes(path)) continue;
for (const entry of this.files.get(path) ?? []) {
if (!entry.references.has(previousRef)) continue;
entry.references.add(nextRef);
this.track(snapshot, project, path);
}
}
}
}
release(snapshot: number): void {
const projects = this.paths.get(snapshot);
if (projects === undefined) return;
for (const [project, retained] of projects.projects) {
const ref = reference(snapshot, project);
for (const path of retained.paths) {
const entries = this.files.get(path);
if (entries === undefined) continue;
for (let index = entries.length - 1; index >= 0; index--) {
const entry = entries[index]!;
entry.references.delete(ref);
if (entry.references.size === 0) entries.splice(index, 1);
}
if (entries.length === 0) this.files.delete(path);
}
}
this.paths.delete(snapshot);
}
private track(snapshot: number, project: string, path: string): void {
let projects = this.paths.get(snapshot);
if (projects === undefined) {
projects = new SnapshotPaths();
this.paths.set(snapshot, projects);
}
let paths = projects.projects.get(project);
if (paths === undefined) {
paths = new ProjectPaths();
projects.projects.set(project, paths);
}
paths.paths.add(path);
}
clear(): void { this.files.clear(); this.paths.clear(); }
get size(): number { return this.files.size; }
}
@@ -0,0 +1,35 @@
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import { expect, test } from "vitest";
import { resolveFileName } from "typescript/unstable/proto";
import { Ts7Paths, ts7DocumentFile } from "./session-path.js";
const require = createRequire(import.meta.url);
const sdk = require(join(dirname(require.resolve("typescript/package.json")), "dist/api/path.js")) as {
toPath: (file: string, cwd: string, canonical: (path: string) => string) => string;
};
test("native path identities match the pinned SDK across host platforms and virtual roots", () => {
const roots = ["", "/", "/src/", "C:", "C:/", "C:\\src\\", "//server/", "//server/share/", "file:///", "file:///C:/", "file:///C%3a/", "file://localhost/D:/", "bundled:///", "https://server/", "^/untitled/ts-nul-authority/"];
const tails = ["", ".", "..", "../..", "file.ts", "file.ts/", "./file.ts", "a/../file.ts", "a//b/./../file.ts", "../../file.ts", "a/../../file.ts", "a/../..", "a/..", "a/.", "a//", "é/İ/Σ.ts"];
for (const cwd of ["/work", "C:/Work", "\\\\server\\share\\work"]) for (const sensitive of [true, false]) {
const native = new Ts7Paths(cwd, sensitive);
const canonical = (s: string) => sensitive ? s : s.toLowerCase();
for (const root of roots) for (const tail of tails) {
const file = root + tail;
expect(native.canonical(file), JSON.stringify({file,cwd,sensitive})).toBe(sdk.toPath(file,cwd,canonical));
}
}
for (const cwd of ["", "relative", "bundled:///"]) expect(() => new Ts7Paths(cwd, true)).toThrow("absolute disk path");
});
test("document identifiers preserve local, UNC, bundled and virtual URI semantics", () => {
for (const uri of ["file:///tmp/a%20b.ts", "file:///C%3A/Dir/File.ts", "file://localhost/C:/file.ts", "file://server/share/a%20b.ts", "bundled:///lib.esnext.d.ts", "untitled:Untitled-1", "vscode-vfs://github/org/repo/a%20b.ts", "scheme:/absolute", "file:///é.ts"]) {
expect(ts7DocumentFile({uri}), uri).toBe(resolveFileName({uri}));
}
expect(ts7DocumentFile("raw\\file.ts")).toBe("raw\\file.ts");
for (const uri of ["missing-colon", "scheme://server", "file://[bad", "file:///bad%XX"]) {
expect(() => ts7DocumentFile({uri}), uri).toThrow();
expect(() => resolveFileName({uri}), uri).toThrow();
}
});
@@ -0,0 +1,164 @@
/** TypeScript's protocol uses platform-independent path keys, including
* bundled-library URLs and Windows paths on non-Windows clients. Do not
* canonicalize these with the client's host-specific node:path rules. */
// Normalization follows typescript@7.0.2's path helpers, Copyright Microsoft
// Corporation, licensed under Apache-2.0. Preserve the pinned key spelling.
function volume(code: number): boolean {
return code >= 65 && code <= 90 || code >= 97 && code <= 122;
}
function encodedRootLength(path: string): number {
if (path.length === 0) return 0;
const first = path.charCodeAt(0);
if (first === 47) {
if (path.charCodeAt(1) !== 47) return 1;
const slash = path.indexOf("/", 2);
return slash < 0 ? path.length : slash + 1;
}
if (volume(first) && path.charCodeAt(1) === 58) {
if (path.charCodeAt(2) === 47) return 3;
if (path.length === 2) return 2;
}
const schemeEnd = path.indexOf("://");
if (schemeEnd < 0) return 0;
const authorityStart = schemeEnd + 3;
const authorityEnd = path.indexOf("/", authorityStart);
if (authorityEnd < 0) return ~path.length;
const authority = path.slice(authorityStart, authorityEnd);
if (path.slice(0, schemeEnd) === "file" && (authority === "" || authority === "localhost") && volume(path.charCodeAt(authorityEnd + 1))) {
const start = authorityEnd + 2;
const end = path.charCodeAt(start) === 58 ? start + 1
: path.slice(start, start + 3).toLowerCase() === "%3a" ? start + 3 : -1;
if (end >= 0) {
if (path.charCodeAt(end) === 47) return ~(end + 1);
if (end === path.length) return ~end;
}
}
return ~(authorityEnd + 1);
}
function slashes(path: string): string { return path.split("\\").join("/"); }
const relativeSegments = /\/\/|(?:^|\/)\.\.?(?:$|\/)/;
function simplePath(path: string): string | undefined {
if (!relativeSegments.test(path)) return path;
let simplified = path.replace(/\/\.\//g, "/");
if (simplified.startsWith("./")) simplified = simplified.slice(2);
if (simplified !== path && !relativeSegments.test(simplified)) return simplified;
return undefined;
}
function withoutTrailing(path: string): string {
return path.endsWith("/") ? path.slice(0, -1) : path;
}
function normalizeAbsolute(path: string, rootLength: number): string {
const simple = simplePath(path);
if (simple !== undefined) return simple.length > rootLength ? withoutTrailing(simple) : simple;
const root = path.slice(0, rootLength);
let normalized = "";
let initialized = false;
let index = rootLength;
let normalizedUpTo = index;
let seenNonDotDot = rootLength !== 0;
while (index < path.length) {
let segmentStart = index;
let code = path.charCodeAt(index);
while (code === 47 && index + 1 < path.length) {
code = path.charCodeAt(++index);
}
if (index > segmentStart) {
if (!initialized) { normalized = path.slice(0, segmentStart - 1); initialized = true; }
segmentStart = index;
}
let end = path.indexOf("/", index + 1);
if (end < 0) end = path.length;
const length = end - segmentStart;
if (length === 1 && code === 46) {
if (!initialized) { normalized = path.slice(0, normalizedUpTo); initialized = true; }
}
else if (length === 2 && code === 46 && path.charCodeAt(index + 1) === 46) {
if (!seenNonDotDot) {
if (initialized) normalized += normalized.length === rootLength ? ".." : "/..";
else normalizedUpTo = index + 2;
} else if (!initialized) {
initialized = true;
normalized = normalizedUpTo >= 2
? path.slice(0, Math.max(rootLength, path.lastIndexOf("/", normalizedUpTo - 2)))
: path.slice(0, normalizedUpTo);
} else {
const slash = normalized.lastIndexOf("/");
normalized = slash < 0 ? root : normalized.slice(0, Math.max(rootLength, slash));
if (normalized.length === rootLength) seenNonDotDot = rootLength !== 0;
}
} else if (initialized) {
if (normalized.length !== rootLength) normalized += "/";
seenNonDotDot = true;
normalized += path.slice(segmentStart, end);
} else {
seenNonDotDot = true;
normalizedUpTo = end;
}
index = end + 1;
}
return initialized ? normalized : path.length > rootLength ? withoutTrailing(path) : path;
}
/** Canonical identity follows the server's case-sensitivity, independently
* of the platform hosting the client. Rooted disk paths retain a trailing
* slash, while paths resolved against a working directory do not. */
export class Ts7Paths {
constructor(readonly currentDirectory: string, readonly caseSensitive: boolean) {
if (encodedRootLength(slashes(currentDirectory)) <= 0) throw new Error("TypeScript working directory must be an absolute disk path");
}
canonical(file: string): string {
let path = slashes(file);
let encoded = encodedRootLength(path);
const disk = encoded > 0;
if (encoded === 0 && this.currentDirectory.length > 0) {
const cwd = slashes(this.currentDirectory);
path = cwd + (cwd.endsWith("/") || path.length === 0 ? "" : "/") + path;
encoded = encodedRootLength(path);
}
const rootLength = encoded < 0 ? ~encoded : encoded;
const simple = disk ? simplePath(path) : undefined;
let result = simple ?? normalizeAbsolute(path, rootLength);
if (simple === undefined && disk && path.endsWith("/") && result !== "" && !result.endsWith("/")) result += "/";
return this.caseSensitive ? result : result.toLowerCase();
}
}
/** Preserve TypeScript's URI spelling, including escaped virtual names.
* Local file URLs decode their path; UNC URLs preserve escaped path bytes
* just as the pinned SDK does. */
export function ts7DocumentFile(document: string | { uri: string }): string {
if (typeof document === "string") return document;
const uri = document.uri;
if (uri.startsWith("bundled:///")) return uri;
if (uri.startsWith("file://")) {
let parsed: URL;
try { parsed = new URL(uri); }
catch { throw new Error("invalid file URI: " + uri); }
if (parsed.host !== "") return "//" + parsed.host + parsed.pathname;
const path = decodeURIComponent(parsed.pathname);
if (path.length >= 3 && path.charCodeAt(0) === 47 && volume(path.charCodeAt(1)) && path.charCodeAt(2) === 58) {
return path.slice(1, 3).toLowerCase() + path.slice(3);
}
return path;
}
const colon = uri.indexOf(":");
if (colon < 0) throw new Error("invalid URI: " + uri);
const scheme = uri.slice(0, colon);
let path = uri.slice(colon + 1);
let authority = "ts-nul-authority";
if (path.startsWith("//")) {
const rest = path.slice(2);
const slash = rest.indexOf("/");
if (slash < 0) throw new Error("invalid URI: " + uri);
authority = rest.slice(0, slash);
path = rest.slice(slash + 1);
}
return "^/" + scheme + "/" + authority + "/" + path;
}
@@ -0,0 +1,179 @@
// Generated from typescript@7.0.2 by scripts/generate-ts7-ast-schema.mjs.
// TypeScript is Copyright Microsoft Corporation, licensed under Apache-2.0.
// Regenerate when changing the TypeScript pin; do not edit by hand.
export interface Ts7CompilerOptionsData {
allowJs?: boolean;
allowArbitraryExtensions?: boolean;
allowImportingTsExtensions?: boolean;
allowNonTsExtensions?: boolean;
allowUmdGlobalAccess?: boolean;
allowUnreachableCode?: boolean;
allowUnusedLabels?: boolean;
assumeChangesOnlyAffectDirectDependencies?: boolean;
checkJs?: boolean;
customConditions?: string[];
composite?: boolean;
emitDeclarationOnly?: boolean;
emitBOM?: boolean;
emitDecoratorMetadata?: boolean;
declaration?: boolean;
declarationDir?: string;
declarationMap?: boolean;
deduplicatePackages?: boolean;
disableSizeLimit?: boolean;
disableSourceOfProjectReferenceRedirect?: boolean;
disableSolutionSearching?: boolean;
disableReferencedProjectLoad?: boolean;
erasableSyntaxOnly?: boolean;
exactOptionalPropertyTypes?: boolean;
experimentalDecorators?: boolean;
forceConsistentCasingInFileNames?: boolean;
isolatedModules?: boolean;
isolatedDeclarations?: boolean;
ignoreConfig?: boolean;
ignoreDeprecations?: string;
importHelpers?: boolean;
inlineSourceMap?: boolean;
inlineSources?: boolean;
init?: boolean;
incremental?: boolean;
jsx?: number;
jsxFactory?: string;
jsxFragmentFactory?: string;
jsxImportSource?: string;
lib?: string[];
libReplacement?: boolean;
locale?: string;
mapRoot?: string;
module?: number;
moduleResolution?: number;
moduleSuffixes?: string[];
moduleDetection?: number;
newLine?: number;
noEmit?: boolean;
noCheck?: boolean;
noErrorTruncation?: boolean;
noFallthroughCasesInSwitch?: boolean;
noImplicitAny?: boolean;
noImplicitThis?: boolean;
noImplicitReturns?: boolean;
noEmitHelpers?: boolean;
noLib?: boolean;
noPropertyAccessFromIndexSignature?: boolean;
noUncheckedIndexedAccess?: boolean;
noEmitOnError?: boolean;
noUnusedLocals?: boolean;
noUnusedParameters?: boolean;
noResolve?: boolean;
noImplicitOverride?: boolean;
noUncheckedSideEffectImports?: boolean;
outDir?: string;
paths?: Record<string, string[]>;
preserveConstEnums?: boolean;
preserveSymlinks?: boolean;
project?: string;
resolveJsonModule?: boolean;
resolvePackageJsonExports?: boolean;
resolvePackageJsonImports?: boolean;
removeComments?: boolean;
rewriteRelativeImportExtensions?: boolean;
reactNamespace?: string;
rootDir?: string;
rootDirs?: string[];
skipLibCheck?: boolean;
stableTypeOrdering?: boolean;
strict?: boolean;
strictBindCallApply?: boolean;
strictBuiltinIteratorReturn?: boolean;
strictFunctionTypes?: boolean;
strictNullChecks?: boolean;
strictPropertyInitialization?: boolean;
stripInternal?: boolean;
skipDefaultLibCheck?: boolean;
sourceMap?: boolean;
sourceRoot?: string;
suppressOutputPathCheck?: boolean;
target?: number;
traceResolution?: boolean;
tsBuildInfoFile?: string;
typeRoots?: string[];
types?: string[];
useDefineForClassFields?: boolean;
useUnknownInCatchVariables?: boolean;
verbatimModuleSyntax?: boolean;
maxNodeModuleJsDepth?: number;
}
export interface Ts7InitializeData {
/** Whether the host file system is case-sensitive */
useCaseSensitiveFileNames: boolean;
/** The server's current working directory */
currentDirectory: string;
}
export interface Ts7ConfigData {
options: Record<string, unknown>;
fileNames: string[];
}
export interface Ts7SnapshotData {
/** Handle for the newly created snapshot */
snapshot: number;
/** List of projects in the snapshot */
projects: Ts7ProjectData[];
/** Changes from the previous snapshot (absent for the first snapshot) */
changes?: Ts7SnapshotChangeData;
}
export interface Ts7ProjectData {
id: string;
configFileName: string;
compilerOptions: Ts7CompilerOptionsData;
rootFiles: string[];
}
export interface Ts7SourceMetadata {
isDefaultLibrary: boolean;
isFromExternalLibrary: boolean;
packageJsonType: string;
packageJsonDirectory: string;
impliedNodeFormat: number;
}
export interface Ts7SnapshotChangeData {
/** Project handles mapped to their file changes. Projects not listed are unchanged. */
changedProjects?: Record<string, Ts7ProjectChangeData>;
/** Project handles that were removed from the snapshot */
removedProjects?: string[];
}
export interface Ts7ProjectChangeData {
/** Source file paths whose content changed */
changedFiles?: string[];
/** Source file paths removed from the project's program */
deletedFiles?: string[];
}
export interface Ts7DiagnosticData {
/** File name of the source file this diagnostic belongs to, if any */
readonly fileName?: string | undefined;
/** Start position of the diagnostic */
readonly pos: number;
/** End position of the diagnostic */
readonly end: number;
/** Diagnostic error code */
readonly code: number;
/** Diagnostic category (error, warning, suggestion, message) */
readonly category: number;
/** Localized diagnostic message text */
readonly text: string;
/** Whether this diagnostic highlights unnecessary code */
readonly reportsUnnecessary?: boolean | undefined;
/** Whether this diagnostic highlights deprecated code */
readonly reportsDeprecated?: boolean | undefined;
/** Chained diagnostic messages */
readonly messageChain?: readonly Ts7DiagnosticData[] | undefined;
/** Related diagnostic information */
readonly relatedInformation?: readonly Ts7DiagnosticData[] | undefined;
}
@@ -0,0 +1,57 @@
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import { expect, test, vi } from "vitest";
import { Ts7Timing, type Ts7TimingInfo, type Ts7ServerTiming } from "./session-timing.js";
const require = createRequire(import.meta.url);
const sdk = require(join(dirname(require.resolve("typescript/package.json")), "dist/api/timing.js")) as {
TimingCollector: new () => {
record(sample: {method: string; roundTripMs: number; bytesSent: number; bytesReceived: number}): void;
recordMaterialization(): void;
recordSourceFileFetched(nodes: number): void;
reset(): void;
getInfo(): Ts7TimingInfo;
};
combineTimingInfo(client: Ts7TimingInfo, server: Ts7ServerTiming): Ts7TimingInfo;
disabledTimingInfo(): Ts7TimingInfo;
};
test("timing totals and bounded history match the pinned SDK, including server pairing", () => {
const oracle = new sdk.TimingCollector();
const native = new Ts7Timing(true);
const clock = vi.spyOn(Date, "now");
try {
for (let index = 0; index < 14; index++) {
clock.mockReturnValue(100 + index);
const sample = {method: `request${index}`, roundTripMs: index + 1, bytesSent: 2 * index, bytesReceived: 4 * index};
oracle.record(sample);
native.record(sample.method, sample.roundTripMs, sample.bytesSent, sample.bytesReceived, Date.now());
oracle.recordMaterialization(); native.materialized();
oracle.recordSourceFileFetched(index); native.fetched(index);
expect(native.info()).toEqual(oracle.getInfo());
}
const server: Ts7ServerTiming = { enabled: true, totals: {requestCount: 20, totalProcessingTimeMs: 250}, recentRequests: [
{method: "mismatch", processingTimeMs: 40, timestamp: 500},
{method: "request12", processingTimeMs: 5, timestamp: 600},
{method: "request13", processingTimeMs: 25, timestamp: 700},
] };
expect(native.info(server)).toEqual(sdk.combineTimingInfo(oracle.getInfo(), server));
expect(native.info(server).totals.transportOverheadMs).toBe(0);
oracle.reset(); native.reset();
expect(native.info()).toEqual(oracle.getInfo());
} finally { clock.mockRestore(); }
});
test("disabled timing does no accumulation and report mutation cannot corrupt live state", () => {
const disabled = new Ts7Timing(false);
disabled.record("ignored", 10, 20, 30, 40);
disabled.fetched(8); disabled.materialized();
expect(disabled.info()).toEqual(sdk.disabledTimingInfo());
const native = new Ts7Timing(true);
native.record("one", 1, 2, 3, 4);
const report = native.info();
report.totals.requestCount = 100;
report.recentRequests[0]!.method = "changed";
expect(native.info().totals.requestCount).toBe(1);
expect(native.info().recentRequests[0]!.method).toBe("one");
});
@@ -0,0 +1,93 @@
export interface Ts7RequestTiming {
method: string;
roundTripMs: number;
bytesSent: number;
bytesReceived: number;
timestamp: number;
serverTimeMs?: number;
transportOverheadMs?: number;
}
export interface Ts7TimingTotals {
requestCount: number;
roundTripMs: number;
bytesSent: number;
bytesReceived: number;
serverTimeMs: number;
transportOverheadMs: number;
nodesMaterialized: number;
sourceFilesFetched: number;
nodesFetched: number;
}
export interface Ts7TimingInfo {
enabled: boolean;
totals: Ts7TimingTotals;
recentRequests: Ts7RequestTiming[];
}
export interface Ts7ServerTiming {
enabled: boolean;
totals: { requestCount: number; totalProcessingTimeMs: number };
recentRequests: { method: string; processingTimeMs: number; timestamp: number }[];
}
function emptyTotals(): Ts7TimingTotals {
return { requestCount: 0, roundTripMs: 0, bytesSent: 0, bytesReceived: 0, serverTimeMs: 0, transportOverheadMs: 0, nodesMaterialized: 0, sourceFilesFetched: 0, nodesFetched: 0 };
}
function copyRequest(sample: Ts7RequestTiming): Ts7RequestTiming {
return { method: sample.method, roundTripMs: sample.roundTripMs, bytesSent: sample.bytesSent, bytesReceived: sample.bytesReceived, timestamp: sample.timestamp };
}
/** Small bounded request history. Returned measurements own their records,
* so later requests or a caller modifying a report cannot change totals. */
export class Ts7Timing {
private totals = emptyTotals();
private readonly recent: Ts7RequestTiming[] = [];
constructor(readonly enabled: boolean) {}
record(method: string, roundTripMs: number, bytesSent: number, bytesReceived: number, timestamp: number): void {
if (!this.enabled) return;
const totals = this.totals;
totals.requestCount++;
totals.roundTripMs += roundTripMs;
totals.bytesSent += bytesSent;
totals.bytesReceived += bytesReceived;
if (this.recent.length === 5) this.recent.shift();
this.recent.push({ method, roundTripMs, bytesSent, bytesReceived, timestamp });
}
materialized(): void {
if (!this.enabled) return;
const totals = this.totals;
totals.nodesMaterialized++;
}
fetched(nodes: number): void {
if (!this.enabled) return;
const totals = this.totals;
totals.sourceFilesFetched++;
totals.nodesFetched += nodes;
}
info(server?: Ts7ServerTiming): Ts7TimingInfo {
const totals = { ...this.totals };
const recentRequests = this.recent.map((sample) => copyRequest(sample));
if (this.enabled && server !== undefined) {
totals.serverTimeMs = server.totals.totalProcessingTimeMs;
totals.transportOverheadMs = Math.max(0, totals.roundTripMs - totals.serverTimeMs);
const count = Math.min(recentRequests.length, server.recentRequests.length);
for (let index = 1; index <= count; index++) {
const client = recentRequests[recentRequests.length - index]!;
const sample = server.recentRequests[server.recentRequests.length - index]!;
if (client.method !== sample.method) continue;
client.serverTimeMs = sample.processingTimeMs;
client.transportOverheadMs = Math.max(0, client.roundTripMs - sample.processingTimeMs);
}
}
return { enabled: this.enabled, totals, recentRequests };
}
reset(): void { this.totals = emptyTotals(); this.recent.length = 0; }
}
@@ -0,0 +1,164 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vitest";
import { API } from "typescript/unstable/sync";
import { Ts7RpcClient } from "./rpc-client.js";
import { registerTs7FileSystem, TS7_FILE_SYSTEM_CALLBACKS, type Ts7FileSystem } from "./rpc-filesystem.js";
import { spawnTs7Wire } from "./rpc-process.js";
import { ts7Executable } from "./rpc-api.js";
import { Ts7Session, Ts7SessionProject, Ts7SessionSnapshot } from "./session.js";
import { AstNode } from "./ast-node.js";
const tempRoot = process.platform === "win32" ? tmpdir() : "/tmp";
const protocolPath = (path: string) => path.replaceAll("\\", "/");
const fallback: Ts7FileSystem = { readFile: () => undefined, fileExists: () => undefined, directoryExists: () => undefined, realpath: () => undefined, getAccessibleEntries: () => undefined };
function transport(cwd: string, fs: Ts7FileSystem = fallback, timing = false) {
const rpc = new Ts7RpcClient(spawnTs7Wire(ts7Executable(), ["--api", "--cwd", cwd, `--callbacks=${TS7_FILE_SYSTEM_CALLBACKS}`, ...(timing ? ["--timing"] : [])]));
registerTs7FileSystem(rpc, fs);
return rpc;
}
function connect(cwd: string, fs: Ts7FileSystem = fallback, timing = false) { return new Ts7Session(transport(cwd, fs, timing), timing); }
test("owned snapshots preserve SDK options, diagnostics, metadata and default-project lookup", () => {
const dir = mkdtempSync(join(tempRoot, "scriptc-session-parity-"));
const file = protocolPath(join(dir, "main.ts"));
const config = protocolPath(join(dir, "tsconfig.json"));
writeFileSync(file, 'export const answer: number = "wrong";\n');
writeFileSync(config, JSON.stringify({ compilerOptions: { strict: true, target: "esnext", types: [], declaration: true, sourceMap: true, paths: { "named/*": ["./*", "../*"] } }, files: [file] }));
const session = connect(dir, fallback, true);
const sdk = new API({ cwd: dir });
try {
expect(session.parseConfigFile(config)).toEqual(sdk.parseConfigFile(config));
const snapshot = session.updateSnapshot({ openProjects: [config], openFiles: [file] });
const oracle = sdk.updateSnapshot({ openProjects: [config], openFiles: [file] });
expect(snapshot).toBeInstanceOf(Ts7SessionSnapshot);
const project = snapshot.getProject(config)!;
const expected = oracle.getProject(config)!;
expect(project).toBeInstanceOf(Ts7SessionProject);
expect(project.program.getCompilerOptions()).toEqual(expected.program.getCompilerOptions());
expect(project.rootFiles).toEqual(expected.rootFiles);
expect(snapshot.getDefaultProjectForFile(file)).toBe(project);
expect(snapshot.getProject(protocolPath(join(dir, "./tsconfig.json")))).toBe(project);
expect(project.program.getSourceFileNames()).toEqual(expected.program.getSourceFileNames());
const source = project.program.getSourceFile(file)!;
expect(source).toBeInstanceOf(AstNode);
expect(source.text).toBe(expected.program.getSourceFile(file)!.text);
const metadata = project.program.getSourceFileMetadata(file)!;
expect(metadata).toEqual(expected.program.getSourceFileMetadata(file));
expect(project.program.getSourceFileMetadata(file)).toBe(metadata);
const missing = protocolPath(join(dir, "missing.ts"));
expect(project.program.getSourceFile(missing)).toBeUndefined();
expect(project.program.getSourceFileMetadata(missing)).toBeUndefined();
const before = session.getTimingInfo().totals.requestCount;
expect(project.program.getSourceFileMetadata(missing)).toBeUndefined();
expect(session.getTimingInfo().totals.requestCount).toBe(before);
for (const name of ["getSyntacticDiagnostics", "getBindDiagnostics", "getSemanticDiagnostics", "getSuggestionDiagnostics", "getDeclarationDiagnostics"] as const) {
expect(project.program[name](file), name).toEqual(expected.program[name](file));
expect(project.program[name](), name).toEqual(expected.program[name]());
}
for (const name of ["getProgramDiagnostics", "getGlobalDiagnostics", "getConfigFileParsingDiagnostics"] as const) expect(project.program[name](), name).toEqual(expected.program[name]());
const library = project.program.getSourceFileNames().find((name) => name.endsWith("lib.esnext.d.ts"))!;
expect(library).toBeDefined();
expect(project.program.isSourceFileDefaultLibrary(project.program.getSourceFile(library)!)).toBe(true);
expect(project.program.isSourceFileFromExternalLibrary(source)).toBe(false);
snapshot.dispose();
expect(snapshot.isDisposed()).toBe(true);
expect(() => snapshot.getProjects()).toThrow("disposed");
expect(() => project.program.getSourceFileNames()).toThrow("disposed");
} finally { session.close(); sdk.close(); rmSync(dir, { recursive: true, force: true }); }
});
test("virtual updates keep old ASTs, share unchanged versions, and release the latest cache lazily", () => {
const dir = mkdtempSync(join(tempRoot, "scriptc-session-updates-"));
const a = protocolPath(join(dir, "a.json")), b = protocolPath(join(dir, "b.json"));
const changed = protocolPath(join(dir, "changed.ts")), stable = protocolPath(join(dir, "stable.ts"));
let content = "export const value = 1;";
let option = "esnext";
const overlay: Ts7FileSystem = { ...fallback, readFile: (file) => {
if (file === a || file === b) return JSON.stringify({compilerOptions: {strict: true, target: option, types: []}, files: [changed, stable]});
if (file === changed) return content;
if (file === stable) return "export const stable = true;";
return undefined;
}, fileExists: (file) => [a,b,changed,stable].includes(file) ? true : undefined };
const session = connect(dir, overlay, true);
const sdk = new API({cwd: dir, fs: overlay});
try {
const first = session.updateSnapshot({openProjects: [a,b]});
const oracleFirst = sdk.updateSnapshot({openProjects: [a,b]});
const oracleStable = oracleFirst.getProject(a)!.program.getSourceFile(stable)!;
const old = first.getProject(a)!.program.getSourceFile(changed)!;
const retained = first.getProject(a)!.program.getSourceFile(stable)!;
expect(first.getProject(b)!.program.getSourceFile(changed)).toBe(old);
content = 'export const value: number = "wrong";';
const second = session.updateSnapshot({fileChanges: {changed: [changed]}});
const oracleSecond = sdk.updateSnapshot({fileChanges: {changed: [changed]}});
const next = second.getProject(a)!.program.getSourceFile(changed)!;
expect(next).not.toBe(old);
expect(old.text).toContain("= 1");
expect(next.text).toBe(content);
expect(second.getProject(a)!.program.getSemanticDiagnostics(changed).map((d) => d.code)).toEqual([2322]);
expect(first.getProject(a)!.program.getSemanticDiagnostics(changed)).toEqual([]);
expect(second.getProject(a)!.program.getSourceFile(stable)).toBe(retained);
first.dispose();
second.dispose();
oracleFirst.dispose(); oracleSecond.dispose();
const before = session.getTimingInfo().totals.sourceFilesFetched;
const third = session.updateSnapshot();
const oracleThird = sdk.updateSnapshot();
expect(third.getProject(a)!.program.getSourceFile(stable)).toBe(retained);
expect(session.getTimingInfo().totals.sourceFilesFetched).toBe(before);
option = "es2020";
const fourth = session.updateSnapshot({fileChanges: {changed: [a,b]}});
const oracleFourth = sdk.updateSnapshot({fileChanges: {changed: [a,b]}});
const reparsed = fourth.getProject(a)!.program.getSourceFile(stable)!;
expect(reparsed === retained).toBe(oracleFourth.getProject(a)!.program.getSourceFile(stable) === oracleStable);
expect(fourth.getProject(a)!.compilerOptions.target).toBe(oracleFourth.getProject(a)!.compilerOptions.target);
expect(fourth.getProject(a)!.compilerOptions.target).not.toBe(third.getProject(a)!.compilerOptions.target);
expect(oracleThird.getProject(a)!.program.getSourceFile(stable)).toBe(oracleStable);
const closed = session.updateSnapshot({closeProjects: [b]});
expect(closed.getProject(b)).toBeUndefined();
expect(closed.getProject(a)).toBeDefined();
session.close();
expect(third.isDisposed() && fourth.isDisposed() && closed.isDisposed()).toBe(true);
expect(session.cache.size).toBe(0);
expect(() => session.updateSnapshot()).toThrow("closed");
expect(() => session.parseConfigFile(a)).toThrow("closed");
expect(() => session.getTimingInfo()).toThrow("closed");
} finally { session.close(); sdk.close(); rmSync(dir, { recursive: true, force: true }); }
});
test("transport failure during close seals every snapshot and clears retained ASTs", () => {
const dir = mkdtempSync(join(tempRoot, "scriptc-session-close-"));
const file = protocolPath(join(dir, "main.ts"));
const config = protocolPath(join(dir, "tsconfig.json"));
writeFileSync(file, "export const answer = 42;");
writeFileSync(config, JSON.stringify({compilerOptions: {strict: true, types: []}, files: [file]}));
const rpc = transport(dir);
const session = new Ts7Session(rpc);
try {
const first = session.updateSnapshot({openProjects: [config]});
const project = first.getProject(config)!;
const source = project.program.getSourceFile(file)!;
const type = project.checker.getTypeAtPosition(file, source.text!.indexOf("answer"))!;
const second = session.updateSnapshot();
const secondProject = second.getProject(config)!;
expect(secondProject.program.getSourceFile(file)).toBe(source);
const third = session.updateSnapshot();
expect(session.cache.size).toBe(1);
rpc.close();
expect(() => session.close()).toThrow("closed");
for (const snapshot of [first, second, third]) {
expect(snapshot.isDisposed()).toBe(true);
expect(() => snapshot.getProjects()).toThrow("closed");
expect(() => snapshot.dispose()).not.toThrow();
}
expect(() => secondProject.checker.getNumberType()).toThrow("disposed");
expect(() => secondProject.program.getSourceFile(file)).toThrow("closed");
expect(() => type.getRegularType()).toThrow("disposed");
expect(session.cache.size).toBe(0);
expect(() => session.close()).not.toThrow();
} finally { session.close(); rmSync(dir, {recursive: true, force: true}); }
});
@@ -0,0 +1,240 @@
import { AstFile, AstNode } from "./ast-node.js";
import { Ts7RpcClient } from "./rpc-client.js";
import { SemanticChecker } from "./semantic-checker.js";
import { SemanticSnapshot, type SemanticDocument } from "./semantic-model.js";
import { parseSemanticJson } from "./semantic-json.js";
import { Ts7SourceCache } from "./session-cache.js";
import { Ts7Paths, ts7DocumentFile } from "./session-path.js";
import { Ts7Timing, type Ts7ServerTiming, type Ts7TimingInfo } from "./session-timing.js";
import type { Ts7CompilerOptionsData, Ts7ConfigData, Ts7DiagnosticData, Ts7InitializeData, Ts7ProjectData, Ts7SnapshotData, Ts7SourceMetadata } from "./session-schema.generated.js";
export interface Ts7FileChanges {
changed?: SemanticDocument[];
created?: SemanticDocument[];
deleted?: SemanticDocument[];
invalidateAll?: boolean;
}
export interface Ts7Update {
openProjects?: SemanticDocument[];
closeProjects?: SemanticDocument[];
openFiles?: SemanticDocument[];
closeFiles?: SemanticDocument[];
fileChanges?: Ts7FileChanges;
}
interface ProgramQuery {
snapshot?: number;
project?: string;
file?: SemanticDocument;
}
/** Platform-independent session over an already connected transport. The
* host chooses how to start tsgo; no JavaScript SDK constructs the snapshot,
* program, AST, or semantic objects exposed by this client. */
export class Ts7Session {
readonly cache = new Ts7SourceCache();
readonly timing: Ts7Timing;
private readonly snapshots = new Set<Ts7SessionSnapshot>();
private latest: Ts7SessionSnapshot | undefined;
private paths: Ts7Paths | undefined;
private closed = false;
constructor(
private readonly rpc: Ts7RpcClient,
collectTiming = false,
readonly listMetadata?: (nodes: AstNode[], pos: number, end: number) => void,
) { this.timing = new Ts7Timing(collectTiming); }
ensureOpen(): void { if (this.closed) throw new Error("TypeScript API is closed"); }
binary(method: string, json: string): Uint8Array {
this.ensureOpen();
const payload = Buffer.from(json, "utf8");
const start = this.timing.enabled ? performance.now() : 0;
const bytes = this.rpc.requestBytes(method, payload);
if (this.timing.enabled) this.timing.record(method, performance.now() - start, payload.length, bytes.length, Date.now());
return bytes;
}
text(method: string, json: string): string {
const bytes = this.binary(method, json);
return bytes.length === 0 ? "null" : Buffer.from(bytes).toString("utf8");
}
request<T>(method: string, query: ProgramQuery): T {
return parseSemanticJson<T>(this.text(method, JSON.stringify(query)));
}
initialize(): Ts7Paths {
this.ensureOpen();
if (this.paths === undefined) {
const data = parseSemanticJson<Ts7InitializeData>(this.text("initialize", "null"));
this.paths = new Ts7Paths(data.currentDirectory, data.useCaseSensitiveFileNames);
}
return this.paths;
}
parseConfigFile(file: SemanticDocument): Ts7ConfigData {
this.initialize();
return this.request<Ts7ConfigData>("parseConfigFile", { file });
}
updateSnapshot(params: Ts7Update = {}): Ts7SessionSnapshot {
const paths = this.initialize();
const data = parseSemanticJson<Ts7SnapshotData>(this.text("updateSnapshot", JSON.stringify(params)));
const snapshot = new Ts7SessionSnapshot(data, this, paths);
const previous = this.latest;
if (previous !== undefined) {
this.cache.retain(snapshot.id, previous.id, data.changes);
if (previous.isDisposed()) this.cache.release(previous.id);
}
this.latest = snapshot;
this.snapshots.add(snapshot);
return snapshot;
}
release(snapshot: Ts7SessionSnapshot): void {
this.snapshots.delete(snapshot);
if (snapshot !== this.latest) this.cache.release(snapshot.id);
this.binary("release", JSON.stringify({ snapshot: snapshot.id }));
}
getTimingInfo(): Ts7TimingInfo {
this.ensureOpen();
if (!this.timing.enabled) return this.timing.info();
// Meta requests are intentionally absent from the client timing ring.
const server = parseSemanticJson<Ts7ServerTiming>(this.rpc.requestText("getServerTiming", ""));
return this.timing.info(server);
}
close(): void {
if (this.closed) return;
try {
for (const snapshot of this.snapshots) snapshot.dispose();
} finally {
// Seal all object graphs even if releasing one snapshot fails because
// the process has exited. Nothing retained may consult recycled ids.
this.closed = true;
for (const snapshot of this.snapshots) snapshot.invalidate();
this.snapshots.clear();
this.latest = undefined;
this.cache.clear();
this.rpc.close();
}
}
}
export class Ts7SessionSnapshot {
readonly id: number;
readonly semantic: SemanticSnapshot;
private readonly projects = new Map<string, Ts7SessionProject>();
private disposed = false;
constructor(data: Ts7SnapshotData, private readonly session: Ts7Session, readonly paths: Ts7Paths) {
this.id = data.snapshot;
this.semantic = new SemanticSnapshot(this.id, {
text: (method, payload) => this.session.text(method, payload),
binary: (method, payload) => this.session.binary(method, payload),
});
for (const item of data.projects) {
const project = new Ts7SessionProject(item, this, session);
this.projects.set(paths.canonical(item.configFileName), project);
}
}
ensureActive(): void {
this.session.ensureOpen();
if (this.disposed) throw new Error("TypeScript snapshot is disposed");
}
getProjects(): Ts7SessionProject[] { this.ensureActive(); return [...this.projects.values()]; }
getProject(config: string): Ts7SessionProject | undefined { this.ensureActive(); return this.projects.get(this.paths.canonical(config)); }
getDefaultProjectForFile(file: SemanticDocument): Ts7SessionProject | undefined {
this.ensureActive();
const data = this.session.request<Ts7ProjectData | null>("getDefaultProjectForFile", { snapshot: this.id, file });
return data === null ? undefined : this.getProject(data.configFileName);
}
isDisposed(): boolean { return this.disposed; }
invalidate(): void {
if (this.disposed) return;
this.disposed = true;
this.semantic.dispose();
for (const project of this.projects.values()) project.dispose();
this.projects.clear();
}
dispose(): void {
if (this.disposed) return;
this.invalidate();
this.session.release(this);
}
}
export class Ts7SessionProject {
readonly id: string;
readonly configFileName: string;
readonly compilerOptions: Ts7CompilerOptionsData;
readonly rootFiles: string[];
readonly program: Ts7SessionProgram;
readonly checker: SemanticChecker;
constructor(data: Ts7ProjectData, snapshot: Ts7SessionSnapshot, session: Ts7Session) {
this.id = data.id;
this.configFileName = data.configFileName;
this.compilerOptions = data.compilerOptions;
this.rootFiles = data.rootFiles;
this.program = new Ts7SessionProgram(this, snapshot, session);
const context = snapshot.semantic.addProject(this.id, (path) => this.program.getSourceFile(path));
this.checker = new SemanticChecker(context);
}
dispose(): void { this.checker.dispose(); this.program.dispose(); }
}
export class Ts7SessionProgram {
private readonly metadata = new Map<string, Ts7SourceMetadata | undefined>();
private disposed = false;
constructor(private readonly project: Ts7SessionProject, private readonly snapshot: Ts7SessionSnapshot, private readonly session: Ts7Session) {}
private ensureActive(): void {
this.snapshot.ensureActive();
if (this.disposed) throw new Error("TypeScript program is disposed");
}
private query(file?: SemanticDocument): ProgramQuery {
this.ensureActive();
return { snapshot: this.snapshot.id, project: this.project.id, ...(file === undefined ? {} : { file }) };
}
getCompilerOptions(): Ts7CompilerOptionsData { this.ensureActive(); return this.project.compilerOptions; }
getSourceFile(file: SemanticDocument): AstNode | undefined {
this.ensureActive();
const path = this.snapshot.paths.canonical(ts7DocumentFile(file));
const retained = this.session.cache.get(path, this.snapshot.id, this.project.id);
if (retained !== undefined) return retained;
const bytes = this.session.binary("getSourceFile", JSON.stringify(this.query(file)));
if (bytes.length === 0) return undefined;
const ast = new AstFile(bytes, this.session.listMetadata, () => { this.session.timing.materialized(); });
this.session.timing.fetched(Math.max(0, ast.wire.nodeCount - 2));
return this.session.cache.set(path, ast.root, this.snapshot.id, this.project.id);
}
getSourceFileNames(): string[] { return this.session.request<string[] | null>("getSourceFileNames", this.query()) ?? []; }
getSourceFileMetadata(file: string): Ts7SourceMetadata | undefined { return this.getSourceFileMetadataByPath(this.snapshot.paths.canonical(file)); }
getSourceFileMetadataByPath(path: string): Ts7SourceMetadata | undefined {
this.ensureActive();
if (this.metadata.has(path)) return this.metadata.get(path);
const data = this.session.request<Ts7SourceMetadata | null>("getSourceFileMetadata", this.query(path)) ?? undefined;
this.metadata.set(path, data);
return data;
}
isSourceFileFromExternalLibrary(file: AstNode): boolean { return this.getSourceFileMetadataByPath(file.path)?.isFromExternalLibrary ?? false; }
isSourceFileDefaultLibrary(file: AstNode): boolean { return this.getSourceFileMetadataByPath(file.path)?.isDefaultLibrary ?? false; }
private diagnostics(method: string, file?: SemanticDocument): Ts7DiagnosticData[] { return this.session.request<Ts7DiagnosticData[] | null>(method, this.query(file)) ?? []; }
getSyntacticDiagnostics(file?: SemanticDocument): Ts7DiagnosticData[] { return this.diagnostics("getSyntacticDiagnostics", file); }
getBindDiagnostics(file?: SemanticDocument): Ts7DiagnosticData[] { return this.diagnostics("getBindDiagnostics", file); }
getSemanticDiagnostics(file?: SemanticDocument): Ts7DiagnosticData[] { return this.diagnostics("getSemanticDiagnostics", file); }
getSuggestionDiagnostics(file?: SemanticDocument): Ts7DiagnosticData[] { return this.diagnostics("getSuggestionDiagnostics", file); }
getDeclarationDiagnostics(file?: SemanticDocument): Ts7DiagnosticData[] { return this.diagnostics("getDeclarationDiagnostics", file); }
getProgramDiagnostics(): Ts7DiagnosticData[] { return this.diagnostics("getProgramDiagnostics"); }
getGlobalDiagnostics(): Ts7DiagnosticData[] { return this.diagnostics("getGlobalDiagnostics"); }
getConfigFileParsingDiagnostics(): Ts7DiagnosticData[] { return this.diagnostics("getConfigFileParsingDiagnostics"); }
dispose(): void { this.disposed = true; this.metadata.clear(); }
}
+36
View File
@@ -84,6 +84,42 @@ if (process.argv.includes("--check")) {
writeFileSync(semanticTarget, semanticOutput);
}
// Session metadata travels as ordinary JSON records. Generate its complete
// pinned shape too: parsing options or diagnostics must not pull SDK values
// into a statically compiled client through their type declarations.
const sessionTypes = new Map([
["CompilerOptions", "Ts7CompilerOptionsData"],
["InitializeResponse", "Ts7InitializeData"],
["ConfigResponse", "Ts7ConfigData"],
["UpdateSnapshotResponse", "Ts7SnapshotData"],
["ProjectResponse", "Ts7ProjectData"],
["SourceFileMetadata", "Ts7SourceMetadata"],
["SnapshotChanges", "Ts7SnapshotChangeData"],
["ProjectFileChanges", "Ts7ProjectChangeData"],
["Diagnostic", "Ts7DiagnosticData"],
]);
const sessionDeclarations = protoDeclarations + "\n" +
readFileSync(join(packageRoot, "dist/api/compilerOptions.d.ts"), "utf8") + "\n" +
readFileSync(join(packageRoot, "dist/api/sync/types.d.ts"), "utf8");
const sessionLines = semanticLines.slice(0, 3);
for (const [name] of sessionTypes) {
const declaration = sessionDeclarations.match(new RegExp(`export interface ${name} \\{[\\s\\S]*?\\n\\}`));
if (!declaration) throw new Error(`Missing TypeScript session response: ${name}`);
const text = declaration[0].replace(/\/\*[\s\S]*?\*\/|[A-Za-z_][A-Za-z0-9_]*/g, (word) => {
if (word === "Path") return "string";
if (["JsxEmit", "ModuleDetectionKind", "ModuleKind", "ModuleResolutionKind", "NewLineKind", "ScriptTarget", "DiagnosticCategory"].includes(word)) return "number";
return sessionTypes.get(word) ?? word;
});
sessionLines.push("", text);
}
const sessionOutput = sessionLines.join("\n") + "\n";
const sessionTarget = join(root, "packages/compiler/src/frontend/ts7/session-schema.generated.ts");
if (process.argv.includes("--check")) {
if (readFileSync(sessionTarget, "utf8") !== sessionOutput) throw new Error("TypeScript session schema is stale; run node scripts/generate-ts7-ast-schema.mjs");
} else {
writeFileSync(sessionTarget, sessionOutput);
}
// Child names alone cannot distinguish arrays from nodes (attributes and
// children can be either). Read that distinction from the pinned client's
// declarations, and require every wire property to have a declared getter.
+49 -67
View File
@@ -6,8 +6,8 @@ import { Ts7Wire } from "../../../packages/compiler/src/frontend/ts7/rpc-wire.js
import { AstFile, AstNode } from "../../../packages/compiler/src/frontend/ts7/ast-node.js";
import { AstKind, KIND_NODE_LIST, astChildNames } from "../../../packages/compiler/src/frontend/ts7/ast-schema.generated.js";
import { decodeAstString } from "../../../packages/compiler/src/frontend/ts7/ast-bytes.js";
import { SemanticSnapshot } from "../../../packages/compiler/src/frontend/ts7/semantic-model.js";
import { SemanticChecker } from "../../../packages/compiler/src/frontend/ts7/semantic-checker.js";
import { Ts7Session } from "../../../packages/compiler/src/frontend/ts7/session.js";
import { Ts7Paths, ts7DocumentFile } from "../../../packages/compiler/src/frontend/ts7/session-path.js";
import { parseSemanticJson } from "../../../packages/compiler/src/frontend/ts7/semantic-json.js";
import { checkSemanticModel, semanticSource } from "./ts7-semantic-cases.js";
@@ -19,30 +19,6 @@ const client = new Ts7RpcClient(new Ts7Wire({
close: () => {},
}));
interface Initialization {
currentDirectory: string;
useCaseSensitiveFileNames: boolean;
}
interface Config {
fileNames: string[];
}
interface Snapshot {
snapshot: number;
projects: { id: string; configFileName: string; rootFiles: string[] }[];
}
interface TypeInfo {
id: number;
flags: number;
}
interface SymbolInfo {
id: number;
name: string;
declarations?: string[];
}
interface Diagnostic {
code: number;
}
function check(condition: boolean, message: string): void {
if (!condition) throw new Error(message);
}
@@ -184,6 +160,13 @@ function checkFailures(): void {
checkFailures();
// Canonical protocol paths do not depend on the executable's host OS.
const windowsPaths = new Ts7Paths("C:/Work", false);
check(windowsPaths.canonical("..\\Lib\\File.ts") === "c:/lib/file.ts", "Windows path identity");
check(new Ts7Paths("/work", true).canonical("bundled:///lib.esnext.d.ts") === "bundled:///lib.esnext.d.ts", "bundled path identity");
check(ts7DocumentFile({uri: "file:///C%3A/dir/a%20b.ts"}) === "c:/dir/a b.ts", "file URI decoding");
check(ts7DocumentFile({uri: "untitled:Untitled-1"}) === "^/untitled/ts-nul-authority/Untitled-1", "virtual URI decoding");
const directory = process.argv[2]!;
const report = process.argv[3]!;
function protocolPath(path: string): string {
@@ -219,10 +202,11 @@ registerTs7FileSystem(client, {
getAccessibleEntries: () => undefined,
});
const session = new Ts7Session(client, true);
try {
const initialization = JSON.parse(client.requestText("initialize", "null")) as Initialization;
const initialization = session.initialize();
check(protocolPath(initialization.currentDirectory) === protocolPath(directory), "server working directory");
const config = JSON.parse(client.requestText("parseConfigFile", JSON.stringify({ file: configPath }))) as Config;
const config = session.parseConfigFile(configPath);
check(config.fileNames.includes(file), "virtual config roots");
// Exercise all binary length encodings, including reads larger than the
@@ -236,16 +220,22 @@ try {
}
check(client.requestText("echo", "\uFEFFhéllo\0🌍") === "\uFEFFhéllo\0🌍", "UTF8 echo");
const snapshot = JSON.parse(client.requestText("updateSnapshot", JSON.stringify({ openProjects: [configPath] }))) as Snapshot;
const project = snapshot.projects[0]!;
const snapshot = session.updateSnapshot({ openProjects: [configPath] });
const project = snapshot.getProject(configPath)!;
check(snapshot.getProjects()[0] === project, "project registry identity");
check(project.configFileName === configPath, "project identity");
const request = { snapshot: snapshot.snapshot, project: project.id, file };
const names = JSON.parse(client.requestText("getSourceFileNames", JSON.stringify(request))) as string[];
const names = project.program.getSourceFileNames();
check(names.includes(file) && names.includes(empty) && names.includes(disk), "virtual, empty, and disk files");
check(!names.includes(hidden), "hidden file remains absent");
const ast = client.requestBytes("getSourceFile", Buffer.from(JSON.stringify(request)));
check(ast.length > content.length, "binary AST response");
const tree = new AstFile(ast);
const root = project.program.getSourceFile(file)!;
const tree = root.file;
check(project.program.getSourceFile(file) === root, "cached source identity");
check(project.program.getCompilerOptions().strict === true, "parsed compiler options");
check(session.parseConfigFile(configPath).options["strict"] === true, "raw config option decoding");
const uri = "file:///" + file.replace(/^\//, "");
check(project.program.getSourceFile({uri}) === root, "document URI cache identity");
check(project.program.getSourceFileMetadata(file)!.isDefaultLibrary === false, "source metadata");
check(!project.program.isSourceFileFromExternalLibrary(root) && !project.program.isSourceFileDefaultLibrary(root), "source library flags");
checkAst(tree, content);
const statements = tree.root.statements!;
check(statements[1]!.declarationList!.declarations![0]!.initializer!.text === "\uFEFFhéllo 🌍", "string BOM retained");
@@ -255,34 +245,21 @@ try {
check(statements[3]!.declarationList!.declarations![0]!.initializer!.elements!.length === 40, "long native node list");
const template = statements[4]!.declarationList!.declarations![0]!.initializer!;
check(template.head!.text === "head\n" && template.head!.rawText === "head\\n", "cooked and raw template text");
const semantic = JSON.parse(client.requestText("getSemanticDiagnostics", JSON.stringify(request))) as Diagnostic[];
check(semantic.length === 0, "valid program diagnostics");
const type = JSON.parse(client.requestText("getTypeAtPosition", JSON.stringify({ ...request, position: content.indexOf("answer") }))) as TypeInfo;
const symbol = JSON.parse(client.requestText("getSymbolAtPosition", JSON.stringify({ ...request, position: content.indexOf("answer") }))) as SymbolInfo;
check(symbol.name === "answer", "checker symbol");
const typeText = JSON.parse(client.requestText("typeToString", JSON.stringify({ snapshot: snapshot.snapshot, project: project.id, type: type.id }))) as string;
check(typeText === "42", "checker literal type");
const declaration = tree.root.statements![0]!.declarationList!.declarations![0]!;
check(project.program.getSemanticDiagnostics(file).length === 0, "valid program diagnostics");
check(project.program.getSyntacticDiagnostics(file).length === 0 && project.program.getBindDiagnostics(file).length === 0, "parse and bind diagnostics");
const declaration = root.statements![0]!.declarationList!.declarations![0]!;
const identifier = declaration.name!;
const nodeType = JSON.parse(client.requestText("getTypeAtLocation", JSON.stringify({ snapshot: snapshot.snapshot, project: project.id, location: identifier.id }))) as TypeInfo;
const nodeSymbol = JSON.parse(client.requestText("getSymbolAtLocation", JSON.stringify({ snapshot: snapshot.snapshot, project: project.id, location: identifier.id }))) as SymbolInfo;
check(nodeType.id === type.id && nodeSymbol.id === symbol.id, "native AST checker query");
check(tree.resolve(nodeSymbol.declarations![0]!) === declaration, "checker declaration identity");
const semanticSnapshot = new SemanticSnapshot(snapshot.snapshot, {
text: (method, payload) => client.requestText(method, payload),
binary: (method, payload) => client.requestBytes(method, Buffer.from(payload)),
});
const context = semanticSnapshot.addProject(project.id, (path) => path === file || path === tree.root.path ? tree.root : undefined);
const checker = new SemanticChecker(context);
const checker = project.checker;
const ownedType = checker.getTypeAtLocation(identifier)!;
const ownedSymbol = checker.getSymbolAtLocation(identifier)!;
check(ownedType.id === type.id && ownedSymbol.id === symbol.id, "semantic model handles");
const typeText = checker.typeToString(ownedType);
check(checker.getTypeAtPosition(file, content.indexOf("answer")) === ownedType, "semantic type identity");
check(checker.getSymbolAtPosition(file, content.indexOf("answer")) === ownedSymbol, "semantic symbol identity");
check(ownedSymbol.declarations[0]!.resolve() === declaration, "semantic declaration identity");
check(checker.typeToString(ownedType) === "42" && ownedType.isNumberLiteralType() && ownedType.value === 42, "semantic literal metadata");
checkSemanticModel(semanticSnapshot, checker, tree);
check(typeText === "42" && ownedType.isNumberLiteralType() && ownedType.value === 42, "semantic literal metadata");
// The semantic exercise disposes its graph; the session still owns the
// server snapshot and its program until explicit snapshot disposal.
checkSemanticModel(snapshot.semantic, checker, tree);
// A server-side refusal completes its request. It must not poison the
// channel: the frontend's checker panic fence relies on this recovery.
@@ -293,23 +270,28 @@ try {
check(client.requestText("echo", "after error") === "after error", "server error recovery");
content = 'export const answer: number = "incorrect";\n';
const updated = JSON.parse(client.requestText("updateSnapshot", JSON.stringify({ fileChanges: { changed: [file] } }))) as Snapshot;
const updatedProject = updated.projects[0]!;
const diagnostics = JSON.parse(client.requestText("getSemanticDiagnostics", JSON.stringify({ snapshot: updated.snapshot, project: updatedProject.id, file }))) as Diagnostic[];
const updated = session.updateSnapshot({ fileChanges: { changed: [file] } });
const updatedProject = updated.getProject(configPath)!;
const diagnostics = updatedProject.program.getSemanticDiagnostics(file);
check(diagnostics.some((diagnostic) => diagnostic.code === 2322), "updated snapshot diagnostics");
check(updatedProject.program.getSourceFile(file) !== root, "changed source version");
// The original immutable snapshot must remain available after the update.
const oldDiagnostics = JSON.parse(client.requestText("getSemanticDiagnostics", JSON.stringify(request))) as Diagnostic[];
check(oldDiagnostics.length === 0, "old snapshot retained");
client.requestText("release", JSON.stringify({ snapshot: snapshot.snapshot }));
client.requestText("release", JSON.stringify({ snapshot: updated.snapshot }));
check(project.program.getSemanticDiagnostics(file).length === 0 && project.program.getSourceFile(file) === root, "old snapshot retained");
const unchanged = updatedProject.program.getSourceFile(empty)!;
snapshot.dispose();
updated.dispose();
const retained = session.updateSnapshot();
check(retained.getProject(configPath)!.program.getSourceFile(empty) === unchanged, "disposed latest cache retained for successor");
retained.dispose();
check(session.getTimingInfo().totals.sourceFilesFetched > 0, "native session source timing");
const timing = client.timing();
check(timing.requests > 20 && timing.callbacks > 0 && reads > 0, "requests and filesystem callbacks executed");
writeFileSync(report, JSON.stringify({
typeText, symbol: symbol.name, diagnostics: diagnostics.map((diagnostic) => diagnostic.code),
typeText, symbol: ownedSymbol.name, diagnostics: diagnostics.map((diagnostic) => diagnostic.code),
surrogateBoundary: checkSurrogateBoundary(),
semanticSurrogateBoundary: checkSemanticSurrogateBoundary(), semanticModel: true,
semanticSurrogateBoundary: checkSemanticSurrogateBoundary(), semanticModel: true, sessionLifecycle: true,
echo: true, binaryAst: true, astIdentity: true, virtualFiles: true, retainedSnapshot: true, serverErrorRecovery: true, protocolFailures: true,
}));
} finally {
client.close();
session.close();
}
@@ -31,7 +31,7 @@ function result(child: ChildProcess): Promise<{ status: number | null; signal: s
}
async function runClient(command: string, args: string[], directory: string, report: string): Promise<string> {
const server = spawn(ts7Executable(), ["--api", "--cwd", directory, `--callbacks=${TS7_FILE_SYSTEM_CALLBACKS}`], {
const server = spawn(ts7Executable(), ["--api", "--timing", "--cwd", directory, `--callbacks=${TS7_FILE_SYSTEM_CALLBACKS}`], {
stdio: ["pipe", "pipe", "pipe"],
});
let serverStderr = "";
@@ -102,7 +102,7 @@ for (const backend of ["c", "llvm"] as const) {
expect(nativeSemanticSurrogates).toBe("refused");
expect(nativeFacts).toEqual(oracleFacts);
expect(nativeFacts).toEqual({
typeText: "42", symbol: "answer", diagnostics: [2322], echo: true, binaryAst: true, astIdentity: true, semanticModel: true,
typeText: "42", symbol: "answer", diagnostics: [2322], echo: true, binaryAst: true, astIdentity: true, semanticModel: true, sessionLifecycle: true,
virtualFiles: true, retainedSnapshot: true, serverErrorRecovery: true, protocolFailures: true,
});
} finally {