openclaw/scripts/lib/tsdown-declaration-writer.mts
Peter Steinberger b78612ac3a
fix(build): bound native declaration inputs to checkout (#138742)
* fix(build): reject cross-checkout native declaration inputs

Refuse ancestor and external dependency inputs before native declaration or package-boundary success records are accepted. Reuse the declared/native path boundary for compiler identity, reads and previous-snapshot keys while preserving consumed-byte, topology, ctime and publication fences. Remove implicit primary-install linking, retaining subdirectory and Windows alias support. The native engine is unchanged; this is fail-before-acceptance rather than hermetic execution. Fixes #138677.

* fix(build): align declaration owners with checkout paths

Use native-canonical namespace and output identities so Windows 8.3 paths
cannot make a declaration owner count its own emitted files as new inputs.
Keep declared cwd ownership for private child joins while snapshots and
publication share physical paths.

Bind the existing declaration-plugin cwd option before plugin construction;
its explicit entry patterns otherwise use ambient cwd and can select no
compiler roots. Preserve explicit overrides and all real receipt, byte,
ctime, topology, inventory, and publication checks.

Cover native short-entry/workspace junction cold and warm behavior, tsdown
alias writers, and explicit entries across node/workspace/AI configurations.

Refs #138677; related #138440.

* test(build): keep remaining compiler fixtures checkout-local

Give the root-lint fixture its own native compiler while retaining real lint tools, ambient types, and source aliases. Keep the shard recorder on its fixture-owned native binary and detach its launcher before replacement. Preserve all diagnostic, enumeration, and signal/join assertions.

Repairs the exact-head CI fixture failure in PR #138742 (native declaration input boundary); production admission remains unchanged.

* fix(build): validate native checkout before bootstrap

Resolve the containing checkout compiler while preserving caller-relative projects. Use the existing native Node shim so refusal and sparse skip precede shared-install or output mutation; keep execution under the artifact owner. Cover actual linked-worktree entries and replace the boundary signal fixture's notification-based readiness with its child-owned PID. Addresses review findings on #138742; preserves strict fail-before-acceptance for #138677.
2026-09-04 19:50:21 -07:00

228 lines
8.8 KiB
TypeScript

// Canonical declaration groups share one private compiler and publication lifetime.
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import {
prepareTsdownBuildExecution,
TSDOWN_DECLARATION_EXTENSIONS,
TSDOWN_UNIFIED_CACHE_ENV,
} from "../tsdown-build.mts";
import {
finalizeBuildStepCache,
portableRelativePath,
restoreBuildStepCacheOutputs,
resolveBuildStepCacheState,
resolveTsdownCompilerFiles,
type BuildCacheStep,
} from "./build-artifact-cache.mts";
import { CompilerInputSnapshot } from "./compiler-input-snapshot.mts";
import { publishStagedDeclarations } from "./declaration-stage.mts";
import { withDistArtifactOwnership } from "./dist-artifact-ownership.mts";
import { resolveTsdownDeclarationGeneratorInputs } from "./tsdown-declaration-generator-inputs.mts";
import {
createDeclarationStage,
readDeclarationInputs,
requestDeclarationInputs,
} from "./tsdown-declaration-inputs.mts";
export async function writeTsdownDeclarations(
groups: readonly string[],
label: string,
previousOutputs: (root: string) => string[],
generatorEntry: string,
) {
const root = fs.realpathSync.native(process.cwd());
const stages: string[] = [];
const createStage = () => {
const stage = createDeclarationStage(root);
stages.push(stage);
return stage;
};
const failures: unknown[] = [];
try {
// The private child retains declared cwd ownership; snapshot/output paths are physical.
await withDistArtifactOwnership(process.cwd(), async () => {
const { default: configs }: { default: typeof import("../../tsdown.config.ts").default } =
await import(pathToFileURL(path.join(root, "tsdown.config.ts")).href);
const staging = createStage();
const output = path.join(staging, "dist");
const environment = TSDOWN_UNIFIED_CACHE_ENV.map((name) =>
JSON.stringify([name, process.env[name] ?? ""]),
);
const generatorInputs = resolveTsdownDeclarationGeneratorInputs(root, generatorEntry);
const snapshot = () =>
new CompilerInputSnapshot(root, {
toolchainFiles: resolveTsdownCompilerFiles(),
generatorInputs,
isGeneratorInput: (file) => /(?:^|\/)(?:package|openclaw\.plugin)\.json$/u.test(file),
});
// All groups share the same before/after reads of configuration, topology,
// tools and overlapping sources. Only compiler membership differs.
const before = snapshot();
const liveDist = path.join(root, "dist");
const prepared = groups.map((name) => {
const config = configs.find((candidate: { name?: string }) => candidate.name === name);
if (
!config?.dts ||
typeof config.dts !== "object" ||
!Array.isArray(config.dts.entry) ||
!config.entry ||
typeof config.entry !== "object" ||
Array.isArray(config.entry)
) {
throw new Error(`Missing canonical declaration group ${name}`);
}
// Runtime worker entries are absolute; declaration partitions are checkout-relative.
const entries = Object.entries(config.entry).map(
([entry, inputs]) =>
[entry, [inputs].flat().map((input) => path.resolve(root, input))] as const,
);
const required = config.dts.entry.map((source) => {
const resolved = path.resolve(root, source);
const selected = entries.find(([, inputs]) => inputs.includes(resolved));
if (!selected) {
throw new Error(`Missing canonical declaration entry for ${source}`);
}
return `${selected[0]}.d.ts`;
});
const identity = [
...environment,
JSON.stringify({
name,
entry: Object.fromEntries(
entries.map(([entry, sources]) => [
entry,
sources.map((source) => portableRelativePath(root, source)),
]),
),
declarations: config.dts.entry,
sourcemap: config.sourcemap,
}),
];
const stage = createStage();
const groupOutput = path.join(stage, "dist");
requestDeclarationInputs(groupOutput, name, config.dts.entry);
const plan = prepareTsdownBuildExecution(
{
args: ["--config", "tsdown.config.ts", "--filter", name, "--out-dir", groupOutput],
},
{
// Every compiler owns a fresh stage; live runtime outputs stay intact.
cleanup() {},
reportShortfall(shortfall) {
console.error(shortfall.message);
},
},
);
if (!plan) {
throw new Error("Insufficient memory for declaration build");
}
const receipt = `compiler-inputs/${name}.json`;
const step: BuildCacheStep = {
label: `${label}-${name}`,
cache: {
env: TSDOWN_UNIFIED_CACHE_ENV,
inputs: generatorInputs,
// An empty canonical partition still owns its successful compiler
// receipt. Ordinary cache records never admit an empty inventory.
outputs: [{ path: "dist", extensions: TSDOWN_DECLARATION_EXTENSIONS }, receipt],
requiredOutputs: [...required.map((entry) => `dist/${entry}`), receipt],
restore: "always",
},
};
const params = {
rootDir: root,
artifactRoot: stage,
env: {
...process.env,
OPENCLAW_BUILD_PRIVATE_QA: process.env.OPENCLAW_BUILD_PRIVATE_QA === "1" ? "1" : "0",
},
inputSignature: (inputs: string[]) =>
before.signature("tsconfig.json", identity, inputs, liveDist),
};
const state =
process.env.OPENCLAW_BUILD_CACHE === "0"
? undefined
: resolveBuildStepCacheState(step, params);
if (!state) {
params.inputSignature([]);
}
// Record the lookup before compilation replaces the signature and cache record.
console.error(
`[${label}] ${name}: cache ${state?.fresh ? "hit" : "miss"} (${state?.reason ?? "disabled"})`,
);
return { name, output: groupOutput, required, identity, plan, step, params, state };
});
const required = prepared.flatMap((group) => group.required);
if (!required.length) {
throw new Error("Canonical declaration selection is empty");
}
const startedAt = Date.now();
for (const group of prepared) {
if (group.state?.fresh && !restoreBuildStepCacheOutputs(group.state, group.params)) {
throw new Error("Declaration cache changed before restoration; rerun the build");
}
}
// Keep the existing bounded, sequential executor. Hits never enter a
// compiler; misses cannot publish or refresh caches before every group joins.
const plan = {
...prepared[0]!.plan,
invocations: prepared.flatMap((group) =>
group.state?.fresh ? [] : group.plan.invocations,
),
};
await publishStagedDeclarations(
plan,
prepared.map((group) => ({ output: group.output, required: group.required })),
output,
liveDist,
required,
previousOutputs(root).map((file) => portableRelativePath(liveDist, file)),
() => {
const after = snapshot();
for (const group of prepared) {
const sealed = after.seal(
"tsconfig.json",
group.identity,
readDeclarationInputs(group.output, group.name),
before,
startedAt,
liveDist,
);
if (group.state?.fresh && sealed.signature !== group.state.signature) {
throw new Error(`Cached declaration membership changed: ${group.name}`);
}
if (group.state) {
group.state.signature = sealed.signature;
group.state.consumedInputs = sealed.inputs;
}
}
},
);
for (const group of prepared) {
if (group.state && !group.state.fresh) {
finalizeBuildStepCache(group.step, group.state, group.params);
}
}
if (prepared.every((group) => group.state?.fresh)) {
console.log(`[${label}] restored complete cached generation`);
}
});
} catch (error) {
failures.push(error);
}
for (const stage of stages) {
try {
fs.rmSync(stage, { recursive: true, force: true });
} catch (error) {
failures.push(error);
}
}
// The private entry observes this after module evaluation. Keep unjoined build
// metadata even if removing the private staging tree also failed.
if (failures.length) {
throw failures.length === 1
? failures[0]
: new AggregateError(failures, "Declaration build and staging cleanup failed");
}
}