Something went wrong. Try again.
Something went wrong. Try again.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798/** * P6-8: finds script changes that make a test easier to pass. A `.aite` diff that removes a `verify`, changes what it expects (or loosens how it matches), * or gives it more time is reported, unless the merge request carries the label below. A renamed step is not a weakening: verifies are matched by what they * check, not by the name of the step they sit in. */import { parseDuration, parseScript, type Command, type Script } from "../runner/parser";
export const ALLOW_LABEL = "allow-weaker-verify";const DEFAULT_TIMEOUT_MS = 10_000;
export interface GuardFinding { kind: "removed" | "changed" | "slower" | "unreadable"; step: string; line: number; message: string;}
type Check = Extract<Command, { verb: "verify" | "verifyNot" }> & { step: string; scriptTimeoutMs?: number };
const OWN = new Set(["line", "timeoutMs", "within", "as", "on", "verb", "kind", "target"]);const stable = (v: unknown): string => JSON.stringify(v, (_k, x) => (x && typeof x === "object" && !Array.isArray(x) ? Object.fromEntries(Object.entries(x as object).sort(([a], [b]) => (a < b ? -1 : 1))) : x));
function checksOf(script: Script): Check[] { const out: Check[] = []; for (const s of script.steps) for (const c of s.commands) if (c.verb === "verify" || c.verb === "verifyNot") out.push({ ...c, step: s.name, ...(script.timeoutMs !== undefined ? { scriptTimeoutMs: script.timeoutMs } : {}) } as Check); return out;}const identity = (c: Check) => stable({ verb: c.verb, kind: c.kind, target: (c as { target?: unknown }).target ?? null });const expectation = (c: Check) => stable(Object.fromEntries(Object.entries(c).filter(([k]) => !OWN.has(k) && k !== "step" && k !== "scriptTimeoutMs")));const shape = (c: Check) => `${c.verb}/${c.kind}`;const budget = (c: Check): number => (c.within !== undefined ? parseDuration(c.within) : undefined) ?? c.timeoutMs ?? c.scriptTimeoutMs ?? DEFAULT_TIMEOUT_MS;const describe = (c: Check): string => `${c.verb} ${c.kind} ${expectation(c)}`;
export function compareScripts(base: string | undefined, head: string | undefined): GuardFinding[] { if (base === undefined) return []; const before = parseScript(base); if (!before.ok) return []; const old = checksOf(before.script); let now: Check[] = []; if (head !== undefined) { const after = parseScript(head); if (!after.ok) return [{ kind: "unreadable", step: "", line: after.errors[0]?.line ?? 1, message: "the script does not parse, so its verifies cannot be compared with the old ones" }]; now = checksOf(after.script); } const findings: GuardFinding[] = []; const used = new Set<number>(); const pair = new Map<number, number>(); const take = (want: (o: Check, n: Check) => boolean) => { old.forEach((o, i) => { if (pair.has(i)) return; const j = now.findIndex((n, k) => !used.has(k) && want(o, n)); if (j >= 0) { used.add(j); pair.set(i, j); } }); }; take((o, n) => identity(o) === identity(n) && expectation(o) === expectation(n)); const exact = new Set(pair.keys()); take((o, n) => identity(o) === identity(n)); take((o, n) => shape(o) === shape(n)); old.forEach((o, i) => { const j = pair.get(i); if (j === undefined) { findings.push({ kind: "removed", step: o.step, line: o.line, message: `a verify was removed (it was line ${o.line}, step "${o.step}": ${describe(o)})` }); return; } const n = now[j]!; if (!exact.has(i)) findings.push({ kind: "changed", step: n.step, line: n.line, message: `a verify now expects something else (was: ${describe(o)}; now: ${describe(n)})` }); if (budget(n) > budget(o)) findings.push({ kind: "slower", step: n.step, line: n.line, message: `a verify was given more time (${budget(o)} ms before, ${budget(n)} ms now)` }); }); return findings;}
export interface FileChange { path: string; /** The old path when the file was renamed. */ oldPath?: string; base?: string; head?: string;}
export interface GuardResult { ok: boolean; overridden: boolean; findings: Array<GuardFinding & { file: string }>; output: string;}
export function guardChanges(input: { changes: FileChange[]; labels: string[] }): GuardResult { const files = input.changes.filter((c) => c.path.endsWith(".aite") || c.oldPath?.endsWith(".aite")); const findings = files.flatMap((c) => compareScripts(c.base, c.head).map((f) => ({ ...f, file: c.path }))); const overridden = findings.length > 0 && input.labels.includes(ALLOW_LABEL); const lines = findings.map((f) => `${f.file}:${f.line}: ${f.message}`); let output: string; if (findings.length === 0) output = `verify guard: no verify was removed or weakened in ${files.length} changed script(s).`; else if (overridden) output = `${lines.join("\n")}\nverify guard: ${findings.length} weaker verify(s), allowed by the label "${ALLOW_LABEL}".`; else output = `${lines.join("\n")}\nverify guard: ${findings.length} verify(s) removed or weakened. If that is intended, a person adds the label "${ALLOW_LABEL}" to the merge request.`; return { ok: findings.length === 0 || overridden, overridden, findings, output };}