mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
Keep native declaration visits deterministic so regenerated SDK types preserve valid extension package receipts. Type-check-only workers retain their existing concurrency and all diagnostics and boundary seals remain enforced.
272 lines
10 KiB
TypeScript
272 lines
10 KiB
TypeScript
import fs from "node:fs";
|
|
import { createRequire } from "node:module";
|
|
import path from "node:path";
|
|
import { API, EmitOnly, type EmitOutputFile } from "typescript/unstable/async";
|
|
import { CompilerInputSnapshot } from "./compiler-input-snapshot.mts";
|
|
import { createDeclarationInputBoundary, resolveRepoToolBinPath } from "./local-check-runtime.mts";
|
|
import { createDeclarationFileSystem } from "./native-declaration-filesystem.mts";
|
|
import {
|
|
collectNativeTypeScriptDiagnosticsAsync,
|
|
formatNativeTypeScriptDiagnostics,
|
|
} from "./native-typescript-diagnostics.mts";
|
|
import { nativeTypeScriptToolchainFiles } from "./native-typescript-toolchain.mts";
|
|
import { resolveInstalledNativeTypeScriptCompiler } from "./native-typescript.mts";
|
|
|
|
export type NativeDeclaration = {
|
|
code: string;
|
|
map: { version: number; file: string; sources: string[]; names: string[]; mappings: string };
|
|
};
|
|
|
|
type NativeCompilationOptions = {
|
|
cwd: string;
|
|
configFile: string;
|
|
roots?: string[];
|
|
compilerOptions?: Record<string, unknown>;
|
|
diagnostics?: "declarations" | "all";
|
|
compilerRoot?: string;
|
|
assertInput?: (file: string) => string;
|
|
producedFiles?: ReadonlySet<string>;
|
|
emit?: boolean;
|
|
};
|
|
|
|
/** Compile behind one filesystem boundary; callers publish only the sealed result. */
|
|
export async function compileNativeProject({
|
|
cwd,
|
|
configFile,
|
|
roots,
|
|
compilerOptions,
|
|
diagnostics = "all",
|
|
compilerRoot,
|
|
assertInput,
|
|
producedFiles,
|
|
emit = true,
|
|
}: NativeCompilationOptions) {
|
|
// Build/lint callers require a checkout boundary. SDK revision rendering
|
|
// intentionally uses its installed compiler and linked dependency snapshots.
|
|
const boundary = assertInput ? createDeclarationInputBoundary(cwd) : undefined;
|
|
const root = boundary?.root ?? path.resolve(cwd);
|
|
const admit = (file: string) => {
|
|
const accepted = boundary?.assert(file) ?? path.resolve(root, file);
|
|
return assertInput ? assertInput(accepted) : accepted;
|
|
};
|
|
const admittedConfig = admit(configFile);
|
|
const artifacts = admit(path.join(root, ".artifacts"));
|
|
fs.mkdirSync(artifacts, { recursive: true });
|
|
const stage = fs.mkdtempSync(path.join(artifacts, "native-declarations-"));
|
|
let api: API | undefined;
|
|
let view: ReturnType<typeof createDeclarationFileSystem> | undefined;
|
|
try {
|
|
const compiler =
|
|
compilerRoot === undefined
|
|
? resolveInstalledNativeTypeScriptCompiler()
|
|
: {
|
|
executable: resolveRepoToolBinPath("tsgo", { cwd: compilerRoot }),
|
|
packageJson: createRequire(path.join(compilerRoot, "package.json")).resolve(
|
|
"typescript/package.json",
|
|
),
|
|
};
|
|
// The toolchain owner admits the normalized path while Windows launches
|
|
// retain the native executable's extended-length spelling.
|
|
const binary = compiler.executable;
|
|
const compilerPackage = admit(compiler.packageJson);
|
|
const toolchainFiles = nativeTypeScriptToolchainFiles(compilerPackage, admit);
|
|
const args = [
|
|
"bounded-native-api",
|
|
JSON.stringify({ roots, compilerOptions, diagnostics, emit }),
|
|
];
|
|
const snapshot = () =>
|
|
new CompilerInputSnapshot(root, {
|
|
toolchainFiles,
|
|
generatorInputs: [],
|
|
assertInput: assertInput ? admit : undefined,
|
|
});
|
|
const before = snapshot();
|
|
before.signature(admittedConfig, args, [], stage);
|
|
const preparationStartedAt = Date.now();
|
|
// Keep the private config beside its source so relative overrides, inherited
|
|
// paths and ${configDir} retain the compiler's own interpretation.
|
|
const config = path.join(path.dirname(admittedConfig), `${path.basename(stage)}.tsconfig.json`);
|
|
const virtualFiles = new Map([
|
|
[config, JSON.stringify({ extends: admittedConfig, compilerOptions })],
|
|
]);
|
|
view = createDeclarationFileSystem(
|
|
root,
|
|
assertInput ? admit : undefined,
|
|
virtualFiles,
|
|
before.readText,
|
|
);
|
|
const manifestFile = admit(path.join(root, "package.json"));
|
|
const manifestText = view.filesystem.readFile(manifestFile);
|
|
view.assertValid();
|
|
if (manifestText === null && assertInput) {
|
|
throw new Error(`Missing declaration package manifest: ${manifestFile}`);
|
|
}
|
|
if (manifestText !== null) {
|
|
const manifest: unknown = JSON.parse(manifestText);
|
|
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
|
|
throw new Error(`Invalid declaration package manifest: ${manifestFile}`);
|
|
}
|
|
}
|
|
api = new API({ cwd: root, tsserverPath: binary, fs: view.filesystem });
|
|
const configured = await api.parseConfigFile(config);
|
|
view.assertValid();
|
|
const typeRoots = configured.options.typeRoots?.map(admit);
|
|
const canonicalRoots = [
|
|
...new Set(
|
|
roots === undefined
|
|
? configured.fileNames.map(admit)
|
|
: [
|
|
...roots.map(admit),
|
|
...configured.fileNames.filter((file) => /\.d\.[cm]?ts$/u.test(file)).map(admit),
|
|
],
|
|
),
|
|
];
|
|
const output =
|
|
typeof compilerOptions?.outDir === "string"
|
|
? admit(path.resolve(root, compilerOptions.outDir))
|
|
: path.join(stage, "out");
|
|
virtualFiles.set(
|
|
config,
|
|
JSON.stringify({
|
|
extends: admittedConfig,
|
|
files: canonicalRoots,
|
|
include: [],
|
|
exclude: [],
|
|
compilerOptions: {
|
|
...compilerOptions,
|
|
...(typeRoots ? { typeRoots } : {}),
|
|
rootDir: compilerOptions?.rootDir ?? configured.options.rootDir ?? root,
|
|
outDir: output,
|
|
declarationDir: output,
|
|
declaration: true,
|
|
emitDeclarationOnly: emit,
|
|
noEmit: !emit,
|
|
// Validate the requested diagnostics below, then use the in-memory
|
|
// emit result for declaration errors without a second preflight.
|
|
noEmitOnError: false,
|
|
noCheck: false,
|
|
// Parallel emit can give shared inferred properties different readonly modifiers.
|
|
...(emit ? { singleThreaded: true } : {}),
|
|
},
|
|
}),
|
|
);
|
|
const nativeSnapshot = await api.createSnapshot({
|
|
openProjects: [config],
|
|
fileNotifications: { changed: [config] },
|
|
ensurePrograms: true,
|
|
});
|
|
view.assertValid();
|
|
const project = nativeSnapshot.getConfiguredProject(config);
|
|
if (!project) {
|
|
throw new Error(`Native TypeScript did not open ${config}`);
|
|
}
|
|
const errors = await collectNativeTypeScriptDiagnosticsAsync(project, {
|
|
includeSemantic: diagnostics === "all",
|
|
});
|
|
view.assertValid();
|
|
if (errors.length) {
|
|
throw new Error(formatNativeTypeScriptDiagnostics(errors));
|
|
}
|
|
const outputFiles = new Map<string, EmitOutputFile>();
|
|
if (emit) {
|
|
const result = await project.program.emitToString(EmitOnly.OnlyDts);
|
|
view.assertValid();
|
|
if (result.emitSkipped || result.diagnostics.length) {
|
|
throw new Error(
|
|
result.diagnostics.length
|
|
? formatNativeTypeScriptDiagnostics(result.diagnostics)
|
|
: "Native declaration emission was skipped",
|
|
);
|
|
}
|
|
for (const [file, contents] of result.outputFiles) {
|
|
const accepted = admit(file);
|
|
const relative = path.relative(output, accepted);
|
|
if (
|
|
relative === "" ||
|
|
relative === ".." ||
|
|
relative.startsWith(`..${path.sep}`) ||
|
|
path.isAbsolute(relative)
|
|
) {
|
|
throw new Error(`Native declaration output escapes its directory: ${file}`);
|
|
}
|
|
if (contents.sourceFileName) {
|
|
admit(contents.sourceFileName);
|
|
}
|
|
outputFiles.set(accepted, contents);
|
|
}
|
|
}
|
|
for (const file of await project.program.getSourceFileNames()) {
|
|
const accepted = admit(file);
|
|
if (assertInput && !view.inputs.has(accepted)) {
|
|
throw new Error(
|
|
`Native compiler source was not observed through its filesystem: ${accepted}`,
|
|
);
|
|
}
|
|
view.inputs.add(accepted);
|
|
}
|
|
view.assertValid();
|
|
await api.close();
|
|
api = undefined;
|
|
const consumedInputs = view.inputs;
|
|
const inputs = [...consumedInputs].toSorted();
|
|
if (canonicalRoots.some((file) => !consumedInputs.has(file))) {
|
|
throw new Error("Incomplete native compiler source membership");
|
|
}
|
|
const after = snapshot();
|
|
after.seal(admittedConfig, args, inputs, before, preparationStartedAt, stage, producedFiles);
|
|
return { inputs, outputFiles, lookups: view.getLookups() };
|
|
} catch (error) {
|
|
view?.assertValid();
|
|
throw error;
|
|
} finally {
|
|
try {
|
|
await api?.close();
|
|
} finally {
|
|
fs.rmSync(stage, { recursive: true, force: true });
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Adapt the shared compiler's in-memory declarations to tsdown source ownership. */
|
|
export async function emitNativeDeclarations(
|
|
options: Omit<NativeCompilationOptions, "roots" | "emit"> & { roots: string[] },
|
|
) {
|
|
const declarations = new Map<string, NativeDeclaration>();
|
|
if (!options.roots.length) {
|
|
return { inputs: [], declarations };
|
|
}
|
|
const compilation = await compileNativeProject({
|
|
...options,
|
|
compilerOptions: {
|
|
...options.compilerOptions,
|
|
rootDir: fs.realpathSync.native(options.cwd),
|
|
declarationMap: true,
|
|
},
|
|
});
|
|
const inputs = new Set(compilation.inputs);
|
|
for (const [file, output] of compilation.outputFiles) {
|
|
if (!/\.d\.[cm]?ts$/u.test(file)) {
|
|
continue;
|
|
}
|
|
const mapOutput = compilation.outputFiles.get(`${file}.map`);
|
|
if (!mapOutput) {
|
|
throw new Error(`Missing native declaration source map: ${file}`);
|
|
}
|
|
const map: NativeDeclaration["map"] & { sourceRoot?: string } = JSON.parse(mapOutput.text);
|
|
if (
|
|
!Array.isArray(map.sources) ||
|
|
map.sources.length !== 1 ||
|
|
typeof map.sources[0] !== "string" ||
|
|
map.sourceRoot
|
|
) {
|
|
throw new Error(`Ambiguous native declaration source owner: ${file}`);
|
|
}
|
|
const source = path.resolve(path.dirname(file), map.sources[0]);
|
|
if (!inputs.has(source) || declarations.has(source)) {
|
|
throw new Error(`Invalid native declaration source owner: ${source}`);
|
|
}
|
|
const code = output.text.replace(/^\/\/# sourceMappingURL=.*(?:\r?\n|$)/gmu, "");
|
|
declarations.set(source, { code, map: { ...map, sources: [source] } });
|
|
}
|
|
return { inputs: compilation.inputs, declarations };
|
|
}
|