fix(update): record the published Node bootstrap contract (#156873)

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
Vincent Koc 2026-09-24 08:52:45 +08:00 • committed by GitHub
parent 2a5cda2715
commit 0580bd9045
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 178 additions and 30 deletions

View file

@ -12,7 +12,7 @@ import {
parseModule,
type UpdateCompatibilityOrigin,
} from "./update-compat-module-graph.mts";
import { isUpdateSourceScriptImport } from "./update-compat-source-imports.mts";
import { isUpdatePackageAssetImport } from "./update-compat-source-imports.mts";
export { isUpdateCompatibilityChunk } from "./update-compat-contract.mjs";
export const UPDATE_COMPATIBILITY_INVENTORY_FILE = "update-compat-inventory.json";
@ -198,7 +198,7 @@ export function recordUpdateCompatibilityRelease(params: {
if (owner && POST_SWAP_OWNER.test(owner) && owner !== "src/cli/update-cli/wizard.ts") {
const specifier = node.arguments[0];
if (!specifier || !ts.isStringLiteralLike(specifier)) {
if (isUpdateSourceScriptImport(owner, node)) {
if (isUpdatePackageAssetImport(owner, node)) {
return;
}
throw new Error(`Nonliteral post-swap import in ${file}: ${node.getText()}`);

View file

@ -1,11 +1,88 @@
import type ts from "typescript";
import { getTypeScript } from "./ts-guard-utils.mts";
/** Source-update completion loads the installed checkout's scripts, not retained dist chunks. */
export function isUpdateSourceScriptImport(owner: string, node: ts.CallExpression): boolean {
if (owner !== "src/cli/update-cli/update-command-runtime.ts") {
function precedingConstInitializer(node: ts.Node, name: string): ts.Expression | undefined {
const ts = getTypeScript();
let statement = node;
while (statement.parent && !ts.isBlock(statement.parent)) {
statement = statement.parent;
}
if (!statement.parent || !ts.isBlock(statement.parent)) {
return undefined;
}
for (const preceding of statement.parent.statements) {
if (preceding === statement) {
break;
}
if (!ts.isVariableStatement(preceding)) {
continue;
}
for (const declaration of preceding.declarationList.declarations) {
if (ts.isIdentifier(declaration.name) && declaration.name.text === name) {
return preceding.declarationList.flags & ts.NodeFlags.Const
? declaration.initializer
: undefined;
}
}
}
return undefined;
}
function isOwnPackageRoot(node: ts.Node, name: string): boolean {
const ts = getTypeScript();
const awaited = node.parent;
const declaration = awaited?.parent;
const declarations = declaration?.parent;
const statement = declarations?.parent;
// The shipped bootstrap is a direct const initializer. Crossing loops or
// functions could mistake an outer driverRoot for a shadowing lexical binding.
if (
!awaited ||
!ts.isAwaitExpression(awaited) ||
!declaration ||
!ts.isVariableDeclaration(declaration) ||
declaration.initializer !== awaited ||
!declarations ||
!ts.isVariableDeclarationList(declarations) ||
!(declarations.flags & ts.NodeFlags.Const) ||
declarations.declarations.length !== 1 ||
!statement ||
!ts.isVariableStatement(statement) ||
!ts.isBlock(statement.parent)
) {
return false;
}
const initializer = precedingConstInitializer(node, name);
if (
!initializer ||
!ts.isCallExpression(initializer) ||
initializer.questionDotToken ||
initializer.expression.getText() !== "resolveOpenClawPackageRootSync" ||
initializer.arguments.length !== 1
) {
return false;
}
const options = initializer.arguments[0];
const property =
options && ts.isObjectLiteralExpression(options) && options.properties.length === 1
? options.properties[0]
: undefined;
return Boolean(
property &&
ts.isPropertyAssignment(property) &&
ts.isIdentifier(property.name) &&
property.name.text === "moduleUrl" &&
ts.isPropertyAccessExpression(property.initializer) &&
!property.initializer.questionDotToken &&
property.initializer.name.text === "url" &&
ts.isMetaProperty(property.initializer.expression) &&
property.initializer.expression.keywordToken === ts.SyntaxKind.ImportKeyword &&
property.initializer.expression.name.text === "meta",
);
}
/** Recognize shipped package assets outside the hashed dist compatibility graph. */
export function isUpdatePackageAssetImport(owner: string, node: ts.CallExpression): boolean {
const ts = getTypeScript();
const specifier = node.arguments[0];
if (!specifier || !ts.isPropertyAccessExpression(specifier) || specifier.name.text !== "href") {
@ -21,37 +98,35 @@ export function isUpdateSourceScriptImport(owner: string, node: ts.CallExpressio
}
let target = url.arguments[0];
if (target && ts.isIdentifier(target)) {
const name = target.text;
let statement: ts.Node = node;
while (statement.parent && !ts.isBlock(statement.parent)) {
statement = statement.parent;
}
if (!statement.parent || !ts.isBlock(statement.parent)) {
return false;
}
target = undefined;
for (const preceding of statement.parent.statements) {
if (preceding === statement) {
break;
}
if (
!ts.isVariableStatement(preceding) ||
!(preceding.declarationList.flags & ts.NodeFlags.Const)
) {
continue;
}
for (const declaration of preceding.declarationList.declarations) {
if (ts.isIdentifier(declaration.name) && declaration.name.text === name) {
target = declaration.initializer;
}
}
}
target = precedingConstInitializer(node, target.text);
}
if (!target || !ts.isCallExpression(target) || target.expression.getText() !== "path.join") {
return false;
}
const [root, ...segments] = target.arguments;
if (owner === "src/cli/update-cli/update-command-node-runtime-resolution.ts") {
const entry = segments[0];
// The published Node bootstrap resolves installer assets from its own package root.
// It is not a dist bridge; unknown roots or paths must still fail recording.
return Boolean(
node.arguments.length === 1 &&
!specifier.questionDotToken &&
!url.questionDotToken &&
!target.questionDotToken &&
url.arguments[0] &&
ts.isCallExpression(url.arguments[0]) &&
root &&
ts.isIdentifier(root) &&
root.text === "driverRoot" &&
segments.length === 1 &&
entry &&
ts.isStringLiteral(entry) &&
entry.text === "node-runtime-recovery.mjs" &&
isOwnPackageRoot(node, root.text),
);
}
if (
owner !== "src/cli/update-cli/update-command-runtime.ts" ||
!root ||
!ts.isIdentifier(root) ||
root.text !== "root" ||

View file

@ -1341,6 +1341,79 @@ describe("previous release update compatibility", () => {
},
);
it.each([
{ name: "published bootstrap" },
{ name: "different owner", owner: "src/cli/update-cli/update-command-runtime.ts" },
{
name: "mutable root",
binding: "let driverRoot = resolveOpenClawPackageRootSync({ moduleUrl: import.meta.url });",
},
{ name: "unknown root", binding: "" },
{ name: "target root", binding: "const driverRoot = root;" },
{
name: "cwd root",
binding: "const driverRoot = resolveOpenClawPackageRootSync({ cwd: process.cwd() });",
},
{
name: "different module",
binding: "const driverRoot = resolveOpenClawPackageRootSync({ moduleUrl: targetUrl });",
},
{
name: "extra root options",
binding:
"const driverRoot = resolveOpenClawPackageRootSync({ moduleUrl: import.meta.url, cwd: root });",
},
{ name: "extra path", target: 'path.join(driverRoot, "node-runtime-recovery.mjs", "extra")' },
{ name: "dist path", target: 'path.join(driverRoot, "dist", "node-runtime-recovery.mjs")' },
{ name: "traversal", target: 'path.join(driverRoot, "../node-runtime-recovery.mjs")' },
{ name: "dynamic path", target: "path.join(driverRoot, entry)" },
{
name: "different URL form",
url: 'new URL("node-runtime-recovery.mjs", import.meta.url).href',
},
{ name: "import options", suffix: ", { with: options }" },
{ name: "for-of shadow", prefix: "for (const driverRoot of roots) ", declaration: "" },
{
name: "for initializer shadow",
prefix: "for (const driverRoot = root; driverRoot;) ",
declaration: "",
},
{ name: "arrow shadow", prefix: "const load = async (driverRoot) => ", declaration: "" },
])(
"records dist edges while qualifying the package bootstrap ($name)",
({
name,
owner = "src/cli/update-cli/update-command-node-runtime-resolution.ts",
binding = "const driverRoot = resolveOpenClawPackageRootSync({ moduleUrl: import.meta.url });",
target = 'path.join(driverRoot, "node-runtime-recovery.mjs")',
url = `pathToFileURL(${target}).href`,
suffix = "",
prefix = "",
declaration = "const { findUsableNodeRuntime } = ",
}) => {
const expression = `await (async () => {
${binding}
if (!driverRoot) return;
${prefix}${declaration}await import(${url}${suffix});
return (await import("./surface-abcdefgh.js")).x;
})()`;
const record = () =>
recordImportedFixture(
expression,
{ "surface-abcdefgh.js": "//#region src/infra/value.ts\nexport const x = 1;\n" },
undefined,
owner,
);
if (name !== "published bootstrap") {
expect(record).toThrow("Nonliteral post-swap import");
return;
}
expect(record().inventory.releases[0]?.chunks.map((chunk) => chunk.path)).toEqual([
"surface-abcdefgh.js",
]);
},
);
it.each(
previousReleaseInventory.releases
.filter(({ version }) => ["2026.9.1", "2026.9.2", "2026.9.3", "2026.9.4"].includes(version))