mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-31 20:47:00 +00:00
* chore(ci): trace database worker ratchet phases * fix(ci): preserve optional database worker observer arguments * fix(ci): preserve database observer contextual types
298 lines
9.5 KiB
TypeScript
298 lines
9.5 KiB
TypeScript
import { createRequire } from "node:module";
|
|
import path from "node:path";
|
|
import type { SourceFile } from "typescript/ast";
|
|
import { createFileSystem, createFileSystemLayer, type FileSystemCallbacks } from "typescript/fs";
|
|
import {
|
|
API,
|
|
type CreateSnapshotParams,
|
|
type Diagnostic,
|
|
type Project,
|
|
type Snapshot,
|
|
} from "typescript/sync";
|
|
|
|
export type NativeTypeScriptSource = { fileName: string; text: string };
|
|
export type NativeTypeScriptParser = ReturnType<typeof createNativeTypeScriptParser>;
|
|
export type NativeTypeScriptProject = ReturnType<typeof createNativeTypeScriptProject>;
|
|
export type NativeTypeScriptObserver = (
|
|
phase: string,
|
|
facts?: Readonly<Record<string, string | number>>,
|
|
) => void;
|
|
|
|
/** Diagnostics must never replace an analysis or disposal outcome. */
|
|
export function reportNativeTypeScriptPhase(
|
|
observer: NativeTypeScriptObserver | undefined,
|
|
phase: string,
|
|
facts?: Readonly<Record<string, string | number>>,
|
|
) {
|
|
try {
|
|
observer?.(phase, facts);
|
|
} catch {
|
|
// The analysis remains authoritative when its diagnostic sink fails.
|
|
}
|
|
}
|
|
|
|
function nativePhaseReporter(getApi: () => API | undefined, observer: NativeTypeScriptObserver) {
|
|
let totals: Readonly<Record<string, number>> | undefined;
|
|
return (phase: string, sample = true) => {
|
|
const api = getApi();
|
|
if (!api) {
|
|
return;
|
|
}
|
|
let fresh = false;
|
|
if (sample) {
|
|
try {
|
|
const timing = api.getTimingInfo().totals;
|
|
totals = Object.fromEntries(
|
|
(
|
|
[
|
|
"requestCount",
|
|
"roundTripMs",
|
|
"serverTimeMs",
|
|
"bytesSent",
|
|
"bytesReceived",
|
|
"sourceFilesFetched",
|
|
"nodesFetched",
|
|
"nodesMaterialized",
|
|
] as const
|
|
).map((metric) => [metric, timing[metric]]),
|
|
);
|
|
fresh = true;
|
|
} catch {
|
|
// Timing is an extra RPC; retain the last successful snapshot if it fails.
|
|
}
|
|
}
|
|
reportNativeTypeScriptPhase(observer, phase, {
|
|
...totals,
|
|
timingSample: fresh ? "current" : totals ? "last-successful" : "unavailable",
|
|
});
|
|
};
|
|
}
|
|
|
|
type ProjectOptions = {
|
|
cwd: string;
|
|
configFileName: string;
|
|
files?: Readonly<Record<string, string>>;
|
|
fs?: FileSystemCallbacks;
|
|
};
|
|
|
|
/** Select the same installed compiler as the native API, including in relocated tooling. */
|
|
export function resolveInstalledNativeTypeScriptCompiler() {
|
|
const require = createRequire(import.meta.url);
|
|
const packageJson = require.resolve("typescript/package.json");
|
|
const getExePath: { default: () => string } = require(
|
|
path.join(path.dirname(packageJson), "lib/getExePath.js"),
|
|
);
|
|
return { executable: getExePath.default(), packageJson };
|
|
}
|
|
|
|
function compilerFileName(cwd: string, file: string) {
|
|
return path.resolve(cwd, file).split(path.sep).join("/");
|
|
}
|
|
|
|
/** Own one native compiler process and its immutable semantic snapshot. */
|
|
export function createNativeTypeScriptProject(
|
|
options: ProjectOptions & { observer?: NativeTypeScriptObserver },
|
|
) {
|
|
const cwd = path.resolve(options.cwd);
|
|
const configFileName = compilerFileName(cwd, options.configFileName);
|
|
const api = new API({
|
|
cwd,
|
|
fs: options.fs,
|
|
collectTiming: options.observer ? true : undefined,
|
|
});
|
|
const checkpoint = options.observer && nativePhaseReporter(() => api, options.observer);
|
|
let snapshot: Snapshot;
|
|
let project: Project;
|
|
try {
|
|
snapshot = api.createSnapshot({
|
|
openProjects: [configFileName],
|
|
ensurePrograms: true,
|
|
fileSystem: options.files
|
|
? createFileSystemLayer(
|
|
Object.entries(options.files).map(([file, text]) => [
|
|
compilerFileName(cwd, file),
|
|
text,
|
|
]),
|
|
)
|
|
: undefined,
|
|
});
|
|
const opened = snapshot.getConfiguredProject(configFileName);
|
|
if (!opened) {
|
|
throw new Error(`Native TypeScript did not open ${configFileName}`);
|
|
}
|
|
project = opened;
|
|
checkpoint?.("project");
|
|
} catch (error) {
|
|
api.close();
|
|
checkpoint?.("close", false);
|
|
throw error;
|
|
}
|
|
let closed = false;
|
|
const close = () => {
|
|
if (closed) {
|
|
return;
|
|
}
|
|
closed = true;
|
|
api.close();
|
|
// Error unwinding must not issue another RPC to a possibly failed compiler.
|
|
checkpoint?.("close", false);
|
|
};
|
|
return { api, snapshot, project, checkpoint, close, [Symbol.dispose]: close };
|
|
}
|
|
|
|
/** The async transport keeps its child referenced while close ends the server's input. */
|
|
export async function createNativeTypeScriptProjectAsync(options: ProjectOptions) {
|
|
const { API: AsyncAPI } = await import("typescript/async");
|
|
const cwd = path.resolve(options.cwd);
|
|
const configFileName = compilerFileName(cwd, options.configFileName);
|
|
const api = new AsyncAPI({
|
|
cwd,
|
|
fs: options.fs,
|
|
});
|
|
try {
|
|
const snapshot = await api.createSnapshot({
|
|
openProjects: [configFileName],
|
|
ensurePrograms: true,
|
|
fileSystem: options.files
|
|
? createFileSystemLayer(
|
|
Object.entries(options.files).map(([file, text]) => [
|
|
compilerFileName(cwd, file),
|
|
text,
|
|
]),
|
|
)
|
|
: undefined,
|
|
});
|
|
const project = snapshot.getConfiguredProject(configFileName);
|
|
if (!project) {
|
|
throw new Error(`Native TypeScript did not open ${configFileName}`);
|
|
}
|
|
let closePromise: Promise<void> | undefined;
|
|
const close = () => (closePromise ??= api.close());
|
|
return { api, snapshot, project, close, [Symbol.asyncDispose]: close };
|
|
} catch (error) {
|
|
await api.close();
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/** Batch syntax scans share one explicit process; source trees remain local after disposal. */
|
|
export function createNativeTypeScriptParser({
|
|
cwd = process.cwd(),
|
|
tsserverPath,
|
|
observer,
|
|
}: { cwd?: string; tsserverPath?: string; observer?: NativeTypeScriptObserver } = {}) {
|
|
const root = path.resolve(cwd);
|
|
const configFileName = compilerFileName(root, ".openclaw-native-parser.tsconfig.json");
|
|
let api: API | undefined;
|
|
const checkpoint = observer && nativePhaseReporter(() => api, observer);
|
|
let snapshot: Snapshot | undefined;
|
|
let project: Project | undefined;
|
|
let sourceNames: string[] = [];
|
|
let closed = false;
|
|
const assertOpen = () => {
|
|
if (closed) {
|
|
throw new Error("Native TypeScript parser is closed");
|
|
}
|
|
};
|
|
const parseSourceFiles = (sources: readonly NativeTypeScriptSource[]): readonly SourceFile[] => {
|
|
assertOpen();
|
|
if (sources.length === 0) {
|
|
snapshot?.dispose();
|
|
snapshot = undefined;
|
|
project = undefined;
|
|
return [];
|
|
}
|
|
const previous = snapshot;
|
|
const names = sources.map((source) => compilerFileName(root, source.fileName));
|
|
if (new Set(names).size !== names.length) {
|
|
throw new Error("Native TypeScript parser received duplicate source paths");
|
|
}
|
|
if (names.includes(configFileName)) {
|
|
throw new Error(`Native TypeScript parser source conflicts with ${configFileName}`);
|
|
}
|
|
const files = Object.fromEntries(
|
|
sources.map((source) => [compilerFileName(root, source.fileName), source.text]),
|
|
);
|
|
files[configFileName] = JSON.stringify({
|
|
compilerOptions: {
|
|
allowJs: true,
|
|
experimentalDecorators: true,
|
|
jsx: "preserve",
|
|
noLib: true,
|
|
noResolve: true,
|
|
target: "esnext",
|
|
types: [],
|
|
},
|
|
files: names,
|
|
});
|
|
api ??= new API({
|
|
cwd: root,
|
|
tsserverPath,
|
|
collectTiming: observer ? true : undefined,
|
|
});
|
|
const previousNames = new Set(sourceNames);
|
|
const currentNames = new Set(names);
|
|
const changes: CreateSnapshotParams = {
|
|
...(previous ? {} : { openProjects: [configFileName] }),
|
|
fileSystem: createFileSystem(Object.entries(files)),
|
|
// A config-file change reloads its root list; invalidateAll retains the old roots.
|
|
fileNotifications: {
|
|
changed: [configFileName, ...names.filter((name) => previousNames.has(name))],
|
|
created: names.filter((name) => !previousNames.has(name)),
|
|
deleted: sourceNames.filter((name) => !currentNames.has(name)),
|
|
},
|
|
ensurePrograms: true,
|
|
};
|
|
snapshot = previous ? previous.update(changes) : api.createSnapshot(changes);
|
|
sourceNames = names;
|
|
previous?.dispose();
|
|
project = snapshot.getConfiguredProject(configFileName);
|
|
if (!project) {
|
|
throw new Error("Native TypeScript did not open the syntax scan project");
|
|
}
|
|
const program = project.program;
|
|
checkpoint?.("project");
|
|
const parsed = names.map((fileName) => {
|
|
const sourceFile = program.getSourceFile(fileName);
|
|
if (!sourceFile) {
|
|
throw new Error(`Native TypeScript did not parse ${fileName}`);
|
|
}
|
|
return sourceFile;
|
|
});
|
|
checkpoint?.("sources");
|
|
return parsed;
|
|
};
|
|
const close = () => {
|
|
if (closed) {
|
|
return;
|
|
}
|
|
closed = true;
|
|
api?.close();
|
|
checkpoint?.("close", false);
|
|
api = undefined;
|
|
project = undefined;
|
|
snapshot = undefined;
|
|
sourceNames = [];
|
|
};
|
|
return {
|
|
checkpoint,
|
|
parseSourceFiles,
|
|
parseSourceFile(fileName: string, text: string): SourceFile {
|
|
const sourceFile = parseSourceFiles([{ fileName, text }])[0];
|
|
if (!sourceFile) {
|
|
throw new Error(`Native TypeScript did not parse ${fileName}`);
|
|
}
|
|
return sourceFile;
|
|
},
|
|
getSyntacticDiagnostics(fileName?: string): readonly Diagnostic[] {
|
|
assertOpen();
|
|
return (
|
|
project?.program.getSyntacticDiagnostics(
|
|
fileName === undefined ? undefined : compilerFileName(root, fileName),
|
|
) ?? []
|
|
);
|
|
},
|
|
close,
|
|
[Symbol.dispose]: close,
|
|
};
|
|
}
|