mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-10 17:16:55 +00:00
* refactor(scripts): migrate JavaScript tools to TypeScript * fix(ci): keep changed-scope preflight zero-install * fix(ci): preserve zero-install script owners * fix(ci): complete script migration follow-through * fix(release): keep stable closeout zero-install * fix(scripts): preserve standalone execution boundaries * fix(scripts): repair standalone loader boundaries * fix(scripts): normalize gateway observation ids * fix(scripts): keep Docker packager standalone * test(scripts): preserve rebase cleanup helpers * test(sessions): use tracked temp directory
193 lines
6.8 KiB
TypeScript
193 lines
6.8 KiB
TypeScript
#!/usr/bin/env node
|
|
import { spawnSync } from "node:child_process";
|
|
// Enforces release-train metadata on core gateway methods.
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
|
import ts from "typescript";
|
|
import { resolveRepoRoot } from "./lib/repo-root.mjs";
|
|
|
|
const repoRoot = resolveRepoRoot(import.meta.url);
|
|
const descriptorPath = "src/gateway/methods/core-descriptors.ts";
|
|
|
|
type MethodSpec = { line: number; name: string; since: string | undefined };
|
|
|
|
function runGit(args: string[]): string {
|
|
const result = spawnSync("git", args, { cwd: repoRoot, encoding: "utf8" });
|
|
if (result.status !== 0) {
|
|
const detail = result.stderr.trim() || result.stdout.trim() || `git exited ${result.status}`;
|
|
throw new Error(
|
|
`${detail}\nRun git fetch origin main so origin/main and its merge-base are available.`,
|
|
);
|
|
}
|
|
return result.stdout.trim();
|
|
}
|
|
|
|
function tryRunGit(args: string[]): string | undefined {
|
|
const result = spawnSync("git", args, { cwd: repoRoot, encoding: "utf8" });
|
|
return result.status === 0 ? result.stdout.trim() : undefined;
|
|
}
|
|
|
|
function resolveBaseCommit(): string {
|
|
const explicitBase = process.env.PROTOCOL_SINCE_BASE_SHA?.trim();
|
|
if (explicitBase) {
|
|
if (!/^[0-9a-f]{40}$/u.test(explicitBase)) {
|
|
throw new Error("PROTOCOL_SINCE_BASE_SHA must be a full commit SHA.");
|
|
}
|
|
if (tryRunGit(["cat-file", "-e", `${explicitBase}:${descriptorPath}`]) === undefined) {
|
|
throw new Error(`PROTOCOL_SINCE_BASE_SHA ${explicitBase} is unavailable in this checkout.`);
|
|
}
|
|
return explicitBase;
|
|
}
|
|
const mainMergeBase = tryRunGit(["merge-base", "HEAD", "origin/main"]);
|
|
if (mainMergeBase) {
|
|
return mainMergeBase;
|
|
}
|
|
// Pull-request CI checks out a synthetic merge commit without creating origin/main.
|
|
// Its first parent is the exact base used to build the merge result.
|
|
const parents = (tryRunGit(["show", "-s", "--format=%P", "HEAD"]) ?? "").split(/\s+/u);
|
|
if (parents.length === 2 && parents[0]) {
|
|
return parents[0];
|
|
}
|
|
throw new Error(
|
|
"Could not resolve PROTOCOL_SINCE_BASE_SHA, origin/main, or a two-parent pull-request merge checkout.",
|
|
);
|
|
}
|
|
|
|
function unwrapExpression(expression: ts.Expression): ts.Expression {
|
|
let current = expression;
|
|
while (
|
|
ts.isAsExpression(current) ||
|
|
ts.isSatisfiesExpression(current) ||
|
|
ts.isParenthesizedExpression(current)
|
|
) {
|
|
current = current.expression;
|
|
}
|
|
return current;
|
|
}
|
|
|
|
function stringProperty(object: ts.ObjectLiteralExpression, key: string): string | undefined {
|
|
for (const property of object.properties) {
|
|
if (!ts.isPropertyAssignment(property)) {
|
|
continue;
|
|
}
|
|
const propertyName = property.name;
|
|
const name =
|
|
ts.isIdentifier(propertyName) || ts.isStringLiteral(propertyName)
|
|
? propertyName.text
|
|
: undefined;
|
|
if (name === key && ts.isStringLiteralLike(property.initializer)) {
|
|
return property.initializer.text;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function collectMethodSpec(
|
|
element: ts.Expression,
|
|
sourceFile: ts.SourceFile,
|
|
fileName: string,
|
|
): MethodSpec {
|
|
const line = sourceFile.getLineAndCharacterOfPosition(element.getStart(sourceFile)).line + 1;
|
|
if (ts.isObjectLiteralExpression(element)) {
|
|
const name = stringProperty(element, "name");
|
|
if (!name) {
|
|
throw new Error(
|
|
`${fileName}:${line} core method spec names must be string literals so additions can be compared with origin/main.`,
|
|
);
|
|
}
|
|
return { name, since: stringProperty(element, "since"), line };
|
|
}
|
|
if (ts.isArrayLiteralExpression(element)) {
|
|
const name = element.elements[0];
|
|
const since = element.elements[3];
|
|
if (!name || !since || !ts.isStringLiteralLike(name) || !ts.isStringLiteralLike(since)) {
|
|
throw new Error(
|
|
`${fileName}:${line} core method spec rows must use string literal names and vintage metadata.`,
|
|
);
|
|
}
|
|
return { name: name.text, since: since.text, line };
|
|
}
|
|
throw new Error(
|
|
`${fileName}:${line} core method specs must be inline object literals or labeled rows so vintage metadata can be enforced.`,
|
|
);
|
|
}
|
|
|
|
function collectMethodSpecs(sourceText: string, fileName: string): MethodSpec[] {
|
|
const sourceFile = ts.createSourceFile(fileName, sourceText, ts.ScriptTarget.Latest, true);
|
|
let specs: MethodSpec[] | undefined;
|
|
|
|
function visit(node: ts.Node): void {
|
|
if (
|
|
ts.isVariableDeclaration(node) &&
|
|
ts.isIdentifier(node.name) &&
|
|
node.name.text === "CORE_GATEWAY_METHOD_SPECS" &&
|
|
node.initializer
|
|
) {
|
|
const initializer = unwrapExpression(node.initializer);
|
|
if (ts.isArrayLiteralExpression(initializer)) {
|
|
specs = initializer.elements.map((element) =>
|
|
collectMethodSpec(element, sourceFile, fileName),
|
|
);
|
|
}
|
|
}
|
|
ts.forEachChild(node, visit);
|
|
}
|
|
|
|
visit(sourceFile);
|
|
if (!specs) {
|
|
throw new Error(`Could not find CORE_GATEWAY_METHOD_SPECS in ${fileName}.`);
|
|
}
|
|
return specs;
|
|
}
|
|
|
|
function currentTrain(): string {
|
|
const packageJson: unknown = JSON.parse(
|
|
fs.readFileSync(path.join(repoRoot, "package.json"), "utf8"),
|
|
);
|
|
if (!isRecord(packageJson)) {
|
|
throw new Error("Root package.json must contain an object.");
|
|
}
|
|
const version = typeof packageJson.version === "string" ? packageJson.version : "";
|
|
const match = /^(\d{4})\.(\d{1,2})(?:\.|$)/.exec(version);
|
|
if (!match) {
|
|
throw new Error(`Root package version ${JSON.stringify(packageJson.version)} is not calver.`);
|
|
}
|
|
return `${match[1]!}.${match[2]!}`;
|
|
}
|
|
|
|
try {
|
|
const train = currentTrain();
|
|
const mergeBase = resolveBaseCommit();
|
|
const currentSource = fs.readFileSync(path.join(repoRoot, descriptorPath), "utf8");
|
|
const currentSpecs = collectMethodSpecs(currentSource, descriptorPath);
|
|
const baseSource = runGit(["show", `${mergeBase}:${descriptorPath}`]);
|
|
const baseNames = new Set(
|
|
collectMethodSpecs(baseSource, `${descriptorPath}@${mergeBase}`).map((s) => s.name),
|
|
);
|
|
const added = currentSpecs.filter((spec) => !baseNames.has(spec.name));
|
|
const violations = added.filter((spec) => spec.since !== train);
|
|
|
|
if (violations.length > 0) {
|
|
console.error(`Protocol since guard failed for current train ${train}:`);
|
|
for (const spec of violations) {
|
|
const problem = spec.since
|
|
? `has since ${JSON.stringify(spec.since)}`
|
|
: "is missing since metadata";
|
|
console.error(
|
|
`- ${descriptorPath}:${spec.line} ${spec.name} ${problem}; add since: ${JSON.stringify(train)}.`,
|
|
);
|
|
}
|
|
process.exitCode = 1;
|
|
} else {
|
|
console.log(
|
|
`protocol since guard passed: ${added.length} new core method${added.length === 1 ? "" : "s"} use train ${train}`,
|
|
);
|
|
}
|
|
} catch (error) {
|
|
console.error(
|
|
`Protocol since guard failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
process.exitCode = 1;
|
|
}
|