mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-02 00:25:34 +08:00
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:
@@ -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(); };
|
||||
}
|
||||
@@ -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(); }
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user