diff --git a/scripts/lib/update-compat-chunks.mts b/scripts/lib/update-compat-chunks.mts index 7a7605af2fe8..e53a33751429 100644 --- a/scripts/lib/update-compat-chunks.mts +++ b/scripts/lib/update-compat-chunks.mts @@ -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()}`); diff --git a/scripts/lib/update-compat-source-imports.mts b/scripts/lib/update-compat-source-imports.mts index e17af1afd7c9..92f6c89765d0 100644 --- a/scripts/lib/update-compat-source-imports.mts +++ b/scripts/lib/update-compat-source-imports.mts @@ -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" || diff --git a/test/scripts/runtime-postbuild.test.ts b/test/scripts/runtime-postbuild.test.ts index fc2ac4916ea0..74635ac31123 100644 --- a/test/scripts/runtime-postbuild.test.ts +++ b/test/scripts/runtime-postbuild.test.ts @@ -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))