mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 17:53:39 +00:00
* test(media): deslop s155 tests * test(googlechat): deslop s156 tests * test(scripts): deslop s140 tests * test(reef): deslop s158 tests * test(infra): deslop s153 tests * test(microsoft-foundry): deslop s163 tests * test(ai): deslop s164 tests * test(sessions): deslop s160 tests * test(browser): deslop s165 tests * test(config): deslop s162 tests * test: preserve redaction and current release identity coverage * test: retain helpers used by current main regressions * test(googlechat): use current config type owner * test(googlechat): remove unused config type import
259 lines
8 KiB
TypeScript
259 lines
8 KiB
TypeScript
import { execFileSync } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
|
|
// Owns file-backed baseline loading, comparison, and deterministic failure/shrink guidance.
|
|
// The chained-assertion ledger stays in type-assertion-guard-scope.mjs: it is live scope policy
|
|
// loaded by plain-JS oxlint, not a baseline, and folding it here would couple oxlint to git/fs.
|
|
|
|
export type RatchetCountDelta = { allowed: number; current: number; entry: string };
|
|
|
|
const GIT_MAX_BUFFER = 256 * 1024 * 1024;
|
|
const compareEntries = (left: string, right: string) => (left < right ? -1 : left > right ? 1 : 0);
|
|
|
|
export function parseRatchetArgs(argv: string[]) {
|
|
const args: { base?: string; prune: boolean; staged: boolean } = { prune: false, staged: false };
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const arg = argv[index];
|
|
if (arg === "--prune") {
|
|
args.prune = true;
|
|
continue;
|
|
}
|
|
if (arg === "--staged") {
|
|
args.staged = true;
|
|
continue;
|
|
}
|
|
if (arg === "--base" && argv[index + 1]) {
|
|
args.base = argv[index + 1];
|
|
index += 1;
|
|
continue;
|
|
}
|
|
throw new Error("Unknown or incomplete argument: " + arg);
|
|
}
|
|
return args;
|
|
}
|
|
|
|
function readGitText(root: string, args: string[]) {
|
|
return execFileSync("git", args, {
|
|
cwd: root,
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "ignore"],
|
|
});
|
|
}
|
|
|
|
function resolvesCommit(root: string, ref: string) {
|
|
try {
|
|
execFileSync("git", ["rev-parse", "--verify", ref + "^{commit}"], {
|
|
cwd: root,
|
|
stdio: "ignore",
|
|
});
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function resolveRatchetBase(root: string, options: { base?: string; staged: boolean }) {
|
|
const resolved =
|
|
options.base ??
|
|
(options.staged ? ["HEAD"] : ["origin/main", "HEAD"]).find((ref) => resolvesCommit(root, ref));
|
|
if (!resolved || options.staged) {
|
|
return resolved ?? null;
|
|
}
|
|
|
|
// Branches own their grandfathered debt from the fork. Comparing against a
|
|
// moving base tip turns unrelated cleanup there into a local expansion.
|
|
try {
|
|
return readGitText(root, ["merge-base", "HEAD", resolved]).trim();
|
|
} catch {
|
|
return resolved;
|
|
}
|
|
}
|
|
|
|
export function loadRatchetSnapshot<T>(
|
|
root: string,
|
|
baselinePath: string,
|
|
staged: boolean,
|
|
parse: (source: string) => T,
|
|
) {
|
|
const source = staged
|
|
? readGitText(root, ["show", ":" + baselinePath])
|
|
: fs.readFileSync(path.join(root, baselinePath), "utf8");
|
|
return parse(source);
|
|
}
|
|
|
|
export function loadRatchetReference<T>(
|
|
root: string,
|
|
ref: string,
|
|
baselinePath: string,
|
|
parse: (source: string) => T,
|
|
) {
|
|
const entry = readGitText(root, ["ls-tree", "--name-only", ref, "--", baselinePath]).trim();
|
|
return entry === baselinePath
|
|
? parse(readGitText(root, ["show", ref + ":" + baselinePath]))
|
|
: null;
|
|
}
|
|
|
|
export function loadRatchetSources(root: string, filePaths: string[], ref = "") {
|
|
if (filePaths.length === 0) {
|
|
return new Map<string, string>();
|
|
}
|
|
const output = execFileSync("git", ["cat-file", "--batch", "-z"], {
|
|
cwd: root,
|
|
input: filePaths.map((filePath) => ref + ":" + filePath).join("\0") + "\0",
|
|
maxBuffer: GIT_MAX_BUFFER,
|
|
});
|
|
const sources = new Map<string, string>();
|
|
let offset = 0;
|
|
// `-z` frames requests only; each response still has a newline header and payload terminator.
|
|
for (const filePath of filePaths) {
|
|
const headerEnd = output.indexOf(10, offset);
|
|
if (headerEnd < 0) {
|
|
throw new Error("Invalid git cat-file response for " + filePath);
|
|
}
|
|
// Missing responses echo the requested path, whose spaces/newlines can spoof a size.
|
|
// Only a complete object header may frame source bytes.
|
|
const header = output.subarray(offset, headerEnd).toString("utf8");
|
|
const size = Number(/^[0-9a-f]+ (?:blob|tree|commit|tag) (\d+)$/u.exec(header)?.[1]);
|
|
if (!Number.isSafeInteger(size)) {
|
|
throw new Error("Could not read " + (ref || "staged") + " source " + filePath);
|
|
}
|
|
const sourceStart = headerEnd + 1;
|
|
const sourceEnd = sourceStart + size;
|
|
if (output[sourceEnd] !== 10) {
|
|
throw new Error("Invalid git cat-file framing for " + filePath);
|
|
}
|
|
sources.set(filePath, output.subarray(sourceStart, sourceEnd).toString("utf8"));
|
|
offset = sourceEnd + 1;
|
|
}
|
|
return sources;
|
|
}
|
|
|
|
export function listRatchetRenames(
|
|
root: string,
|
|
baseRef: string,
|
|
staged: boolean,
|
|
sourceRoots: string[],
|
|
) {
|
|
const args = ["diff", "--name-status", "-z", "--find-renames"];
|
|
if (staged) {
|
|
args.push("--cached");
|
|
}
|
|
args.push(baseRef, "--", ...sourceRoots);
|
|
const fields = execFileSync("git", args, { cwd: root, maxBuffer: GIT_MAX_BUFFER })
|
|
.toString("utf8")
|
|
.split("\0");
|
|
const renames: { from: string; to: string }[] = [];
|
|
for (let index = 0; index < fields.length;) {
|
|
const status = fields[index++];
|
|
if (!status) {
|
|
break;
|
|
}
|
|
const from = fields[index++];
|
|
if (!status.startsWith("R") && !status.startsWith("C")) {
|
|
continue;
|
|
}
|
|
const to = fields[index++];
|
|
if (status.startsWith("R") && from && to) {
|
|
renames.push({ from, to });
|
|
}
|
|
}
|
|
return renames;
|
|
}
|
|
|
|
export function parseRatchetPaths(source: string) {
|
|
return new Set(
|
|
source
|
|
.split(/\r?\n/u)
|
|
.map((line) => line.trim())
|
|
.filter((line) => line && !line.startsWith("#")),
|
|
);
|
|
}
|
|
|
|
export function parseRatchetCounts(source: string, baselinePath: string) {
|
|
const counts = new Map<string, number>();
|
|
for (const rawLine of source.split(/\r?\n/u)) {
|
|
if (rawLine === "" || rawLine.startsWith("#")) {
|
|
continue;
|
|
}
|
|
const separator = rawLine.lastIndexOf("\t");
|
|
const entry = rawLine.slice(0, separator);
|
|
const count = Number(rawLine.slice(separator + 1));
|
|
if (separator <= 0 || !Number.isSafeInteger(count) || count <= 0 || counts.has(entry)) {
|
|
throw new Error(`Invalid ${baselinePath} entry: ${rawLine}`);
|
|
}
|
|
counts.set(entry, count);
|
|
}
|
|
return counts;
|
|
}
|
|
|
|
export function parseRatchetScalar(source: string, baselinePath: string) {
|
|
const values = source
|
|
.split(/\r?\n/u)
|
|
.map((line) => line.trim())
|
|
.filter((line) => line && !line.startsWith("#"));
|
|
const value = values[0];
|
|
if (values.length !== 1 || value === undefined || !/^\d+$/u.test(value)) {
|
|
throw new Error(`${baselinePath} must contain exactly one non-negative integer`);
|
|
}
|
|
return Number(value);
|
|
}
|
|
|
|
export function compareRatchetSets(
|
|
current: Iterable<string>,
|
|
allowed: ReadonlySet<string>,
|
|
compare: (left: string, right: string) => number = compareEntries,
|
|
) {
|
|
const currentSet = new Set(current);
|
|
return {
|
|
added: [...currentSet].filter((entry) => !allowed.has(entry)).toSorted(compare),
|
|
removed: [...allowed].filter((entry) => !currentSet.has(entry)).toSorted(compare),
|
|
};
|
|
}
|
|
|
|
export function compareRatchetCounts(
|
|
current: ReadonlyMap<string, number>,
|
|
allowed: ReadonlyMap<string, number>,
|
|
) {
|
|
return {
|
|
increased: collectRatchetDeltas(current, allowed, true),
|
|
decreased: collectRatchetDeltas(allowed, current, false),
|
|
};
|
|
}
|
|
|
|
function collectRatchetDeltas(
|
|
entries: ReadonlyMap<string, number>,
|
|
counterpart: ReadonlyMap<string, number>,
|
|
increased: boolean,
|
|
) {
|
|
return [...entries]
|
|
.flatMap(([entry, count]): RatchetCountDelta[] => {
|
|
const other = counterpart.get(entry) ?? 0;
|
|
return count > other
|
|
? [{ allowed: increased ? other : count, current: increased ? count : other, entry }]
|
|
: [];
|
|
})
|
|
.toSorted((left, right) => compareEntries(left.entry, right.entry));
|
|
}
|
|
|
|
function formatRatchetMessage(title: string, entries: readonly string[]) {
|
|
return [title, ...entries.map((entry) => " " + entry)].join("\n");
|
|
}
|
|
|
|
export function reportRatchetFailures(
|
|
groups: readonly { entries: readonly string[]; title: string }[],
|
|
guidance?: string,
|
|
) {
|
|
const active = groups.filter((group) => group.entries.length > 0);
|
|
for (const group of active) {
|
|
console.error(formatRatchetMessage(group.title, group.entries));
|
|
}
|
|
if (active.length > 0 && guidance) {
|
|
console.error(guidance);
|
|
}
|
|
return active.length > 0;
|
|
}
|
|
|
|
export function reportRatchetSuccess(message: string) {
|
|
console.log(message);
|
|
}
|