forgedock 1.25.1 → 1.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/engine/outcome-codes.mjs +12 -0
- package/bin/engine-cli.mjs +9 -2
- package/bin/lean/conformance.e2e.test.mjs +232 -0
- package/bin/lean/conformance.mjs +238 -0
- package/bin/lean/conformance.test.mjs +163 -0
- package/bin/lean/effects.mjs +23 -0
- package/bin/lean/lean.mjs +221 -51
- package/bin/lean/lean.test.mjs +452 -107
- package/bin/lean/prompts.mjs +60 -57
- package/bin/lean/render.mjs +51 -6
- package/docs/CONFIG.md +2 -1
- package/docs/OUTCOME-CODES.md +11 -0
- package/package.json +1 -1
- package/scripts/ci-local.test.sh +4 -1
|
@@ -63,6 +63,18 @@ export const OUTCOME_CODES = Object.freeze({
|
|
|
63
63
|
BUILDER_NO_COMMIT: "builder.no_commit",
|
|
64
64
|
REVIEW_FAILED: "review.failed",
|
|
65
65
|
REVIEW_NO_VERDICT: "review.no_verdict",
|
|
66
|
+
// Core work-on conformance gate (bin/lean/conformance.mjs).
|
|
67
|
+
CONTRACT_INVALID: "contract.invalid",
|
|
68
|
+
REVIEW_INCOMPLETE: "review.incomplete",
|
|
69
|
+
REVIEW_INCONSISTENT: "review.inconsistent",
|
|
70
|
+
CONFORMANCE_OMISSION: "conformance.omission",
|
|
71
|
+
CONFORMANCE_UNIMPLEMENTED: "conformance.unimplemented",
|
|
72
|
+
CONFORMANCE_SCOPE_CREEP: "conformance.scope_creep",
|
|
73
|
+
CONFORMANCE_UNTRACKED_DEFERRAL: "conformance.untracked_deferral",
|
|
74
|
+
CONFORMANCE_UNJUSTIFIED_DEFERRAL: "conformance.unjustified_deferral",
|
|
75
|
+
CONFORMANCE_PRESERVE_VIOLATED: "conformance.preserve_violated",
|
|
76
|
+
CONFORMANCE_REGRESSION: "conformance.regression",
|
|
77
|
+
FOLLOWUP_UNAVAILABLE: "followup.unavailable",
|
|
66
78
|
MERGE_HEAD_MISMATCH: "merge.head_mismatch",
|
|
67
79
|
// Non-failure terminals, so a RUN_TERMINAL for a healthy run is not counted as a gap.
|
|
68
80
|
COMPLETED: "completed",
|
package/bin/engine-cli.mjs
CHANGED
|
@@ -94,6 +94,13 @@ export function readEnginePath(cwd = process.cwd()) {
|
|
|
94
94
|
return ENGINE_PATHS.includes(v) ? v : undefined;
|
|
95
95
|
} catch { return undefined; }
|
|
96
96
|
}
|
|
97
|
+
/** forge.yaml `engine.conformance` (shadow|enforce) for the core work-on gate; undefined when absent or invalid (the core defaults to shadow). */
|
|
98
|
+
export function readConformanceMode(cwd = process.cwd()) {
|
|
99
|
+
try {
|
|
100
|
+
const v = String(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.engine?.conformance ?? "").trim();
|
|
101
|
+
return ["shadow", "enforce"].includes(v) ? v : undefined;
|
|
102
|
+
} catch { return undefined; }
|
|
103
|
+
}
|
|
97
104
|
function readLeanSetting(key, cwd = process.cwd()) {
|
|
98
105
|
try {
|
|
99
106
|
const v = String(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.engine?.[`lean_${key}`] ?? "").trim();
|
|
@@ -528,7 +535,7 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
528
535
|
try {
|
|
529
536
|
lres = await runCore({ issue, repo: leanRepo, ...(lane ? { lane } : {}), laneOverride, branches: readBranches(), cwd: process.cwd(), dir, io: leanIo, path: enginePath, onProgress,
|
|
530
537
|
emitTelemetry: deps.emitTelemetry ?? makeEmitTelemetry({ repo: leanRepo }),
|
|
531
|
-
admission: readAdmission(), ...readCoreLimits(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}) });
|
|
538
|
+
admission: readAdmission(), ...readCoreLimits(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(readConformanceMode() ? { conformance: readConformanceMode() } : {}), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}) });
|
|
532
539
|
} catch (e) { report({ terminalReason: "engine-error", detail: String(e?.message ?? e).slice(0, 500) }); throw e; }
|
|
533
540
|
report(lres);
|
|
534
541
|
if (lres.code === "host.busy") { console.log(`issue #${issue} → deferred (${lres.detail})`); return lres; }
|
|
@@ -780,7 +787,7 @@ export async function orchestrateFromCli(argv, deps = {}) {
|
|
|
780
787
|
const { runCore } = deps.leanModule;
|
|
781
788
|
const emitTelemetry = deps.emitTelemetry ?? makeEmitTelemetry({ repo });
|
|
782
789
|
lanes = { adopt: null, runOne: (n, ln) => runCore({ issue: n, repo, lane: ln, laneOverride, branches, cwd, dir, io: leanIo, agentId: `orch_${process.pid}_${n}`, emitTelemetry,
|
|
783
|
-
admission: readAdmission(cwd), ...readCoreLimits(cwd), ...(readMergeWait(cwd) ? { mergeWait: readMergeWait(cwd) } : {}), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model", cwd) ? { reviewerModel: readLeanSetting("reviewer_model", cwd) } : {}) }) };
|
|
790
|
+
admission: readAdmission(cwd), ...readCoreLimits(cwd), ...(readMergeWait(cwd) ? { mergeWait: readMergeWait(cwd) } : {}), ...(readConformanceMode(cwd) ? { conformance: readConformanceMode(cwd) } : {}), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model", cwd) ? { reviewerModel: readLeanSetting("reviewer_model", cwd) } : {}) }) };
|
|
784
791
|
} else {
|
|
785
792
|
lanes = batchMod.detachedLanes({ batch, cwd, script: deps.laneScript ?? fileURLToPath(new URL("./forgedock.mjs", import.meta.url)), bin: deps.laneBin, pollMs: deps.pollMs,
|
|
786
793
|
laneArgs: (n, ln) => ["--repo", repo, ...(ln ? ["--lane", ln] : []), ...(laneOverride ? ["--lane-override"] : []), ...(model ? ["--model", model] : [])] });
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* End-to-end conformance gate on a disposable REAL git repository: a bare origin with main + staging, a clone,
|
|
5
|
+
* lane worktrees, real commits, diffs, test runs and --no-ff merges into staging. GitHub is in-memory. The builder
|
|
6
|
+
* makes real code edits; the reviewer is an oracle that derives every row from the real worktree (or a generic PASS).
|
|
7
|
+
* Fixture: a small service whose routes leak a spend reservation on 429; issue #101 asks for three routes fixed or
|
|
8
|
+
* tracked, the 429 re-thrown unchanged, a regression test, and billing pricing unchanged.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it } from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
15
|
+
import { tmpDir } from "../tests/_tmp.mjs";
|
|
16
|
+
import { runCore } from "./lean.mjs";
|
|
17
|
+
|
|
18
|
+
const GIT_ENV = { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@example.invalid", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@example.invalid", GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1" };
|
|
19
|
+
const git = (args, cwd) => { try { return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env: GIT_ENV }); } catch (e) { throw Object.assign(new Error(`git ${args.join(" ")}: ${e.stderr || e.message}`), { stderr: String(e.stderr ?? "") }); } };
|
|
20
|
+
const HAVE_GIT = spawnSync("git", ["--version"]).status === 0;
|
|
21
|
+
const writeAll = (root, files) => { for (const [p, c] of Object.entries(files)) { fs.mkdirSync(path.dirname(path.join(root, p)), { recursive: true }); fs.writeFileSync(path.join(root, p), c); } };
|
|
22
|
+
|
|
23
|
+
// ---- fixture ----
|
|
24
|
+
const Q = {
|
|
25
|
+
R1: "The scrape route must release the spend reservation when the rate limiter rejects the request.",
|
|
26
|
+
R2: "The search route must release the spend reservation when the rate limiter rejects the request.",
|
|
27
|
+
R3: "The export route has the same leak and must be fixed or tracked in a follow-up issue.",
|
|
28
|
+
R4: "The original 429 error must be re-thrown unchanged (status 429 and retryAfter preserved).",
|
|
29
|
+
AC1: "A regression test shows outstanding reservations return to 0 after a 429 on each fixed route.",
|
|
30
|
+
AC2: "Existing route tests still pass.",
|
|
31
|
+
AC3: "Billing pricing is unchanged.",
|
|
32
|
+
};
|
|
33
|
+
const ISSUE = { number: 101, title: "429 from the rate limiter leaks the spend reservation in scrape, search and export",
|
|
34
|
+
body: `## Problem\nWhen the per-user rate limiter rejects a request with 429, the scrape, search and export routes have already reserved spend and never release it.\n\n## Requirements\n1. ${Q.R1}\n2. ${Q.R2}\n3. ${Q.R3}\n4. ${Q.R4}\n\n## Acceptance Criteria\n- [ ] ${Q.AC1}\n- [ ] ${Q.AC2}\n- [ ] ${Q.AC3}` };
|
|
35
|
+
const FN = { scrape: "scrape", search: "search", export: "exportData" };
|
|
36
|
+
const route = (r, fixed) => `import { reserve, ${fixed ? "release, " : ""}settle } from "../quota.js";\nimport { check } from "../ratelimit.js";\n\nexport async function ${FN[r]}(req, limits) {\n const id = reserve(req.user, 1);\n${fixed ? " try {\n check(req.user, limits);\n } catch (e) {\n release(id);\n throw e;\n }" : " check(req.user, limits);"}\n settle(id);\n return { ok: true, ${r}: req.q };\n}\n`;
|
|
37
|
+
const BILLING = "export function price(units) { return units * 0.01; }\n";
|
|
38
|
+
const BASE = {
|
|
39
|
+
"package.json": `{ "name": "fixture-svc", "type": "module", "private": true }\n`,
|
|
40
|
+
"src/quota.js": "const r = new Map();\nlet n = 1;\nexport function reserve(user, amount) { const id = n++; r.set(id, { user, amount }); return id; }\nexport function release(id) { return r.delete(id); }\nexport function settle(id) { return r.delete(id); }\nexport function outstanding() { return r.size; }\nexport function _reset() { r.clear(); n = 1; }\n",
|
|
41
|
+
"src/ratelimit.js": "export class RateLimited extends Error { constructor(s) { super(\"rate limited\"); this.status = 429; this.retryAfter = s; } }\nexport function check(user, limits) { if ((limits[user] ?? 1) <= 0) throw new RateLimited(30); limits[user] = (limits[user] ?? 1) - 1; }\n",
|
|
42
|
+
"src/billing.js": BILLING,
|
|
43
|
+
"src/routes/scrape.js": route("scrape", false), "src/routes/search.js": route("search", false), "src/routes/export.js": route("export", false),
|
|
44
|
+
"test/routes.test.js": `import test from "node:test";\nimport assert from "node:assert/strict";\nimport { scrape } from "../src/routes/scrape.js";\nimport { price } from "../src/billing.js";\ntest("scrape answers under the limit", async () => assert.deepEqual(await scrape({ user: "u", q: "a" }, { u: 3 }), { ok: true, scrape: "a" }));\ntest("price", () => assert.equal(price(150), 1.5));\n`,
|
|
45
|
+
};
|
|
46
|
+
const leakTest = (rs) => `import test from "node:test";\nimport assert from "node:assert/strict";\nimport { outstanding, _reset } from "../src/quota.js";\n${rs.map((r) => `import { ${FN[r]} } from "../src/routes/${r}.js";`).join("\n")}\nfor (const [n, fn] of [${rs.map((r) => `["${r}", ${FN[r]}]`).join(", ")}]) test(\`\${n}: 429 releases and re-throws\`, async () => { _reset(); await assert.rejects(fn({ user: "u", q: "x" }, { u: 0 }), (e) => e.status === 429 && e.retryAfter === 30); assert.equal(outstanding(), 0); });\n`;
|
|
47
|
+
const V1 = "node --test test/routes.test.js test/leak.test.js"; // explicit files: Node 26 rejects directory args to --test
|
|
48
|
+
|
|
49
|
+
const D = { R1: { id: "D1", path: "src/routes/scrape.js", change: "release on 429", requirements: ["R1", "R4"] }, R2: { id: "D2", path: "src/routes/search.js", change: "release on 429", requirements: ["R2", "R4"] },
|
|
50
|
+
R3: { id: "D3", path: "src/routes/export.js", change: "release on 429", requirements: ["R3", "R4"] }, R5: { id: "D4", path: "test/leak.test.js", change: "regression test", requirements: ["R5"] } };
|
|
51
|
+
const req = (id, disposition = "deliver", extra = {}) => ({ id, quote: Q[id === "R5" ? "AC1" : id], disposition, ...extra });
|
|
52
|
+
const contract = (reqs) => ({ requirements: reqs, deliverables: reqs.filter((r) => r.disposition === "deliver" && D[r.id]).map((r) => D[r.id]),
|
|
53
|
+
preserve: [{ id: "P1", what: `${Q.AC3} (src/billing.js price())` }, { id: "P2", what: `${Q.AC2} (test/routes.test.js)` }], validate: [{ id: "V1", command: V1 }] });
|
|
54
|
+
const FULL = () => contract([req("R1"), req("R2"), req("R3"), req("R4"), req("R5")]);
|
|
55
|
+
const OMIT = () => contract([req("R1"), req("R2"), req("R4"), req("R5")]);
|
|
56
|
+
const DEFER = () => contract([req("R1"), req("R2"), req("R3", "defer", { why: "export.js ships on the data-export module's own release train" }), req("R4"), req("R5")]);
|
|
57
|
+
|
|
58
|
+
// ---- world ----
|
|
59
|
+
function makeWorld(dir, script) {
|
|
60
|
+
const origin = path.join(dir, "origin.git"), repo = path.join(dir, "repo"), merger = path.join(dir, "merger");
|
|
61
|
+
git(["init", "-q", "--bare", "-b", "main", origin], dir);
|
|
62
|
+
git(["clone", "-q", origin, repo], dir);
|
|
63
|
+
writeAll(repo, BASE);
|
|
64
|
+
git(["add", "-A"], repo); git(["commit", "-q", "-m", "base"], repo);
|
|
65
|
+
git(["push", "-q", "origin", "main"], repo); git(["push", "-q", "origin", "main:staging"], repo); git(["fetch", "-q", "origin"], repo);
|
|
66
|
+
git(["clone", "-q", origin, merger], dir);
|
|
67
|
+
const gh = { issues: { 101: { ...ISSUE, state: "OPEN", reason: null, labels: new Set(), comments: [] } }, next: 200, prs: [] };
|
|
68
|
+
const counts = { builder: 0, reviewer: 0 };
|
|
69
|
+
const tools = (wt) => ({
|
|
70
|
+
write: (files) => writeAll(wt, files),
|
|
71
|
+
lean: (name, v) => writeAll(wt, { [`.forge-lean/${name}`]: typeof v === "string" ? v : JSON.stringify(v) }),
|
|
72
|
+
commit: (m) => { git(["add", "-A"], wt); if (git(["status", "--porcelain"], wt).trim()) git(["commit", "-q", "-m", m], wt); },
|
|
73
|
+
restore: (p) => git(["checkout", "origin/staging", "--", p], wt),
|
|
74
|
+
run: (cmd) => spawnSync("sh", ["-c", cmd], { cwd: wt, encoding: "utf8" }).status,
|
|
75
|
+
changed: () => git(["diff", "--name-only", "origin/staging...HEAD"], wt).trim().split("\n").filter(Boolean),
|
|
76
|
+
});
|
|
77
|
+
const io = {
|
|
78
|
+
gh: async (a) => {
|
|
79
|
+
const k = a.slice(0, 2).join(" "), n = Number(a[2]), val = (f) => a[a.indexOf(f) + 1];
|
|
80
|
+
if (a[0] === "api") return "";
|
|
81
|
+
if (k === "repo view") return "main\n";
|
|
82
|
+
if (k === "issue view" && a.includes("milestone")) return JSON.stringify({ milestone: null });
|
|
83
|
+
if (k === "issue view") { const i = gh.issues[n]; return JSON.stringify({ number: i.number, title: i.title, body: i.body, state: i.state, comments: [] }); }
|
|
84
|
+
if (k === "issue comment") { gh.issues[n].comments.push(val("--body")); return `https://github.com/o/r/issues/${n}#c${gh.issues[n].comments.length}\n`; }
|
|
85
|
+
if (k === "issue edit") { for (let i = 0; i < a.length; i++) { if (a[i] === "--add-label") a[i + 1].split(",").forEach((l) => gh.issues[n].labels.add(l)); if (a[i] === "--remove-label") a[i + 1].split(",").forEach((l) => gh.issues[n].labels.delete(l)); } return ""; }
|
|
86
|
+
if (k === "issue close") { Object.assign(gh.issues[n], { state: "CLOSED", reason: val("--reason") }); return ""; }
|
|
87
|
+
if (k === "pr list") return "[]";
|
|
88
|
+
if (k === "pr create") { const p = { number: 300 + gh.prs.length, base: val("--base"), head: val("--head"), body: val("--body"), state: "OPEN" }; gh.prs.push(p); return `https://github.com/o/r/pull/${p.number}\n`; }
|
|
89
|
+
if (k === "pr view") return JSON.stringify({ mergeable: "MERGEABLE", mergeStateStatus: "CLEAN" });
|
|
90
|
+
if (k === "pr merge") {
|
|
91
|
+
const p = gh.prs.find((x) => x.number === n);
|
|
92
|
+
git(["fetch", "-q", "origin"], merger);
|
|
93
|
+
assert.ok(git(["rev-parse", `origin/${p.head}`], merger).startsWith(val("--match-head-commit").slice(0, 12)), "merges only the reviewed head");
|
|
94
|
+
git(["checkout", "-q", "-B", p.base, `origin/${p.base}`], merger);
|
|
95
|
+
git(["merge", "-q", "--no-ff", "-m", `Merge PR #${n}`, `origin/${p.head}`], merger);
|
|
96
|
+
git(["push", "-q", "origin", p.base], merger);
|
|
97
|
+
p.state = "MERGED"; return "";
|
|
98
|
+
}
|
|
99
|
+
throw new Error(`unexpected gh ${a.join(" ")}`);
|
|
100
|
+
},
|
|
101
|
+
git: async (args, { cwd } = {}) => git(args, cwd ?? repo),
|
|
102
|
+
runner: async (c) => {
|
|
103
|
+
const n = ++counts[c.role];
|
|
104
|
+
const text = c.role === "builder" ? (await script.builder(n, c, tools(c.cwd)), "done") : script.reviewer(n, c);
|
|
105
|
+
return { text, sessionId: "sess-1", usage: { input_tokens: 1, output_tokens: 1 }, costUsd: 0, durationMs: 1, turns: 1, model: "m" };
|
|
106
|
+
},
|
|
107
|
+
transitionLabel: async (n, st) => { const i = gh.issues[n]; for (const l of [...i.labels]) if (l.startsWith("workflow:")) i.labels.delete(l); i.labels.add(`workflow:${st}`); },
|
|
108
|
+
requiredChecks: async () => [],
|
|
109
|
+
fileFollowups: async ({ parent, items }) => { // stands in for scripts/file-deferred-followups.sh: ONE batched issue per parent
|
|
110
|
+
let f = Object.values(gh.issues).find((i) => i.parent === parent);
|
|
111
|
+
if (!f) { f = { number: gh.next++, parent, title: `Follow-up from #${parent}`, body: "", state: "OPEN", labels: new Set(), comments: [], items: [] }; gh.issues[f.number] = f; }
|
|
112
|
+
for (const it of items) if (!f.items.includes(it.key)) f.items.push(it.key);
|
|
113
|
+
return Object.fromEntries(items.map((i) => [i.key, f.number]));
|
|
114
|
+
},
|
|
115
|
+
fs: { read: async (p) => fs.readFileSync(p, "utf8"), stat: async (p) => fs.statSync(p), append: async (p, t) => fs.appendFileSync(p, t), write: async (p, t) => fs.writeFileSync(p, t),
|
|
116
|
+
exists: async (p) => fs.existsSync(p), rmdir: async (p) => fs.rmSync(p, { recursive: true, force: true }) },
|
|
117
|
+
};
|
|
118
|
+
const observe = (ret) => {
|
|
119
|
+
git(["fetch", "-q", "origin"], merger);
|
|
120
|
+
const staged = (p) => git(["show", `origin/staging:${p}`], merger);
|
|
121
|
+
const i = gh.issues[101], pr = gh.prs[0];
|
|
122
|
+
return { ret, issue: i, pr, counts, followups: Object.values(gh.issues).filter((x) => x.number !== 101),
|
|
123
|
+
markers: i.comments.map((c) => /<!-- (FORGE:[A-Z_]+)/.exec(c)?.[1]),
|
|
124
|
+
staging: { search: staged("src/routes/search.js").includes("release(id)"), export: staged("src/routes/export.js").includes("release(id)"), billingChanged: staged("src/billing.js") !== BILLING } };
|
|
125
|
+
};
|
|
126
|
+
return { io, repo, observe };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---- builder and reviewer scripts ----
|
|
130
|
+
const fix = (t, rs) => t.write(Object.fromEntries([...rs.map((r) => [`src/routes/${r}.js`, route(r, true)]), ["test/leak.test.js", leakTest(rs)]]));
|
|
131
|
+
const claimAll = (k) => k.requirements.map((r) => ({ id: r.id, status: r.disposition === "deliver" ? "implemented" : "deferred", evidence: D[r.id] ? `${D[r.id].path}:7` : "" }));
|
|
132
|
+
function builderScript(cs) {
|
|
133
|
+
let res = null;
|
|
134
|
+
const finish = (t) => { res.validation = [{ id: "V1", command: V1, exit: t.run(V1) }]; t.lean("implementation.md", `### Approach\nrelease in a catch\n\n### Changes\n${t.changed().map((f) => `- ${f}`).join("\n")}\n\n### Acceptance Criteria Status\n- see result\n\n### Verification\n- ${V1}\n\n### Notes\nnone`); t.lean("result.json", res); };
|
|
135
|
+
return async (n, c, t) => {
|
|
136
|
+
if (n === 1) { t.lean("investigation.md", "**Verdict**: CONFIRMED\n**Confidence**: high\n**Severity**: high\n**Task Type**: bug\n\n### Root Cause\ncheck() throws after reserve() with no release"); t.lean("contract.json", cs.contract()); return; }
|
|
137
|
+
if (n === 2) { cs.impl(t); t.commit("fix: release on 429"); res = { status: "fixed", summary: "release on 429", requirements: (cs.claims ?? claimAll)(cs.contract()), amendments: [] }; finish(t); return; }
|
|
138
|
+
if (cs.resume) { cs.resume(t, res); t.commit("fix: conformance"); }
|
|
139
|
+
finish(t);
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/** A diligent reviewer made deterministic: every row comes from the worktree the engine handed it. */
|
|
143
|
+
function oracle(c) {
|
|
144
|
+
const between = (tag) => c.prompt.split(`<${tag}>\n`)[1].split(`\n</${tag}>`)[0];
|
|
145
|
+
const k = JSON.parse(between("contract")), files = between("changed_files").split("\n").filter(Boolean);
|
|
146
|
+
const read = (p) => { try { return fs.readFileSync(path.join(c.cwd, p), "utf8"); } catch { return ""; } };
|
|
147
|
+
const lineOf = (p, s) => read(p).split("\n").findIndex((l) => l.includes(s)) + 1;
|
|
148
|
+
const R = { R1: "scrape", R2: "search", R3: "export" };
|
|
149
|
+
const rows = k.requirements.map((r) => {
|
|
150
|
+
if (r.disposition !== "deliver") return { id: r.id, status: r.disposition === "defer" ? "deferred" : r.disposition, deferral_ok: Boolean(r.why) };
|
|
151
|
+
if (R[r.id]) { const p = `src/routes/${R[r.id]}.js`; return read(p).includes("release(id)") ? { id: r.id, status: "implemented", evidence: `${p}:${lineOf(p, "release(id)")}`, validated_by: "V1" } : { id: r.id, status: "missing", evidence: `${p}:${lineOf(p, "check(")}`, note: "no release on 429" }; }
|
|
152
|
+
if (r.id === "R4") return { id: "R4", status: "implemented", evidence: `src/routes/scrape.js:${lineOf("src/routes/scrape.js", "throw e")}`, validated_by: "V1" };
|
|
153
|
+
return { id: r.id, status: read("test/leak.test.js") ? "implemented" : "missing", evidence: "test/leak.test.js:5", validated_by: "V1" };
|
|
154
|
+
});
|
|
155
|
+
const covered = (q) => k.requirements.some((r) => r.quote === q) || k.preserve.some((p) => p.what.includes(q));
|
|
156
|
+
const billing = git(["diff", "--name-only", "origin/staging...HEAD", "--", "src/billing.js"], c.cwd).trim() !== "";
|
|
157
|
+
const rv = { issue_coverage: Object.values(Q).map((q) => ({ quote: q, status: covered(q) ? "covered" : "missing" })), requirements: rows,
|
|
158
|
+
scope: files.map((f) => ({ file: f, authorized_by: k.deliverables.find((d) => d.path === f)?.id ?? (f.startsWith("test/") ? "test" : "none") })),
|
|
159
|
+
preserve: [{ id: "P1", status: billing ? "violated" : "intact", evidence: "src/billing.js:1" }, { id: "P2", status: "intact", evidence: "test/routes.test.js:1" }], regressions: [] };
|
|
160
|
+
const bad = rv.issue_coverage.some((x) => x.status !== "covered") || rows.some((r) => ["missing", "partial"].includes(r.status)) || rv.scope.some((s) => s.authorized_by === "none") || billing;
|
|
161
|
+
return "```json\n" + JSON.stringify({ ...rv, verdict: bad ? "NONCONFORMING" : "CONFORMS", summary: "oracle" }) + "\n```";
|
|
162
|
+
}
|
|
163
|
+
const generic = () => "```json\n" + JSON.stringify({ verdict: "PASS", fixes_issue: true, regression: false, reason: "looks good" }) + "\n```";
|
|
164
|
+
|
|
165
|
+
async function runCase(cs, conformance = "enforce") {
|
|
166
|
+
const dir = tmpDir("conf-e2e-");
|
|
167
|
+
const w = makeWorld(dir, { builder: builderScript(cs), reviewer: (n, c) => (cs.generic ? generic() : oracle(c)) });
|
|
168
|
+
const ret = await runCore({ issue: 101, repo: "o/r", lane: "staging", cwd: w.repo, worktreeRoot: path.join(dir, "wt"), dir: path.join(dir, "runs"), io: w.io, random: () => 0.5, stableMs: 0, livePollMs: 20, conformance });
|
|
169
|
+
return w.observe(ret);
|
|
170
|
+
}
|
|
171
|
+
const blocked = (o, code) => {
|
|
172
|
+
assert.equal(o.ret.terminalReason, "needs-human"); assert.equal(o.ret.code, code);
|
|
173
|
+
assert.equal(o.pr, undefined, "no PR"); assert.equal(o.issue.state, "OPEN");
|
|
174
|
+
assert.ok(o.issue.labels.has("needs-human")); assert.ok(![...o.issue.labels].some((l) => l.startsWith("workflow:")));
|
|
175
|
+
assert.ok(!o.staging.search && !o.staging.export, "staging untouched");
|
|
176
|
+
};
|
|
177
|
+
const merged = (o) => {
|
|
178
|
+
assert.equal(o.ret.terminalReason, "merged"); assert.equal(o.pr.state, "MERGED"); assert.equal(o.pr.base, "staging");
|
|
179
|
+
assert.equal(o.issue.state, "CLOSED"); assert.equal(o.issue.reason, "completed"); assert.deepEqual([...o.issue.labels], ["workflow:merged"]);
|
|
180
|
+
assert.match(o.pr.body, /Closes #101$/);
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
describe("conformance gate end-to-end (real git, disposable repo)", { skip: !HAVE_GIT && "git not available" }, () => {
|
|
184
|
+
it("1 complete: conforms, merges into staging, closes completed", async () => {
|
|
185
|
+
const o = await runCase({ contract: FULL, impl: (t) => fix(t, ["scrape", "search", "export"]) });
|
|
186
|
+
merged(o); assert.ok(o.staging.search && o.staging.export && !o.staging.billingChanged);
|
|
187
|
+
assert.deepEqual([o.counts.builder, o.counts.reviewer], [2, 1]);
|
|
188
|
+
assert.deepEqual(o.markers, ["FORGE:STARTED", "FORGE:INVESTIGATION", "FORGE:CONTRACT", "FORGE:IMPLEMENTATION", "FORGE:REVIEW", "FORGE:TRAJECTORY"]);
|
|
189
|
+
});
|
|
190
|
+
it("2 omission: the contract silently drops requirement 3; review catches it; issue stays open, nothing merged", async () => {
|
|
191
|
+
const o = await runCase({ contract: OMIT, impl: (t) => fix(t, ["scrape", "search"]) });
|
|
192
|
+
blocked(o, "conformance.omission"); assert.deepEqual([o.counts.builder, o.counts.reviewer], [3, 2]);
|
|
193
|
+
});
|
|
194
|
+
it("2b omission recovered by typed amendments on the resume: merges with all three routes fixed", async () => {
|
|
195
|
+
const o = await runCase({ contract: OMIT, impl: (t) => fix(t, ["scrape", "search"]),
|
|
196
|
+
resume: (t, res) => { fix(t, ["scrape", "search", "export"]); res.requirements.push({ id: "R3", status: "implemented" });
|
|
197
|
+
res.amendments = [{ kind: "add-requirement", requirement: req("R3"), why: "the issue's requirement 3 was missing" }, { kind: "add-deliverable", id: "D3", path: "src/routes/export.js", why: "implements R3" }]; } });
|
|
198
|
+
merged(o); assert.ok(o.staging.export); assert.ok(o.markers.includes("FORGE:AMENDMENT"));
|
|
199
|
+
});
|
|
200
|
+
it("3 scope creep: an unauthorized billing change is stopped by the precheck and reverted before it can reach staging", async () => {
|
|
201
|
+
const o = await runCase({ contract: FULL, impl: (t) => { fix(t, ["scrape", "search", "export"]); t.write({ "src/billing.js": "export function price(units) { return Math.round(units) * 0.01; }\n" }); },
|
|
202
|
+
resume: (t) => t.restore("src/billing.js") });
|
|
203
|
+
merged(o); assert.equal(o.staging.billingChanged, false); assert.deepEqual([o.counts.builder, o.counts.reviewer], [3, 1]);
|
|
204
|
+
});
|
|
205
|
+
it("4 a claimed follow-up without a tracking issue blocks merge before any review", async () => {
|
|
206
|
+
const o = await runCase({ contract: FULL, impl: (t) => fix(t, ["scrape", "search"]), claims: (k) => claimAll(k).map((r) => (r.id === "R3" ? { id: "R3", status: "deferred", note: "follow-up" } : r)) });
|
|
207
|
+
blocked(o, "conformance.untracked_deferral"); assert.equal(o.counts.reviewer, 0); assert.equal(o.followups.length, 0);
|
|
208
|
+
});
|
|
209
|
+
it("4b the deferral made typed on the resume is tracked, linked, and the issue closes", async () => {
|
|
210
|
+
const o = await runCase({ contract: FULL, impl: (t) => fix(t, ["scrape", "search"]), claims: (k) => claimAll(k).map((r) => (r.id === "R3" ? { id: "R3", status: "deferred" } : r)),
|
|
211
|
+
resume: (t, res) => { res.amendments = [{ kind: "defer", requirement: "R3", why: "export ships on its own release train" }]; } });
|
|
212
|
+
merged(o); assert.equal(o.followups.length, 1); assert.equal(o.followups[0].state, "OPEN");
|
|
213
|
+
assert.match(o.pr.body, new RegExp(`Remaining work tracked\\n- #${o.followups[0].number}`)); assert.ok(o.markers.includes("FORGE:TRACKED"));
|
|
214
|
+
});
|
|
215
|
+
it("5 a contracted deferral is filed once the reviewer accepts it, and the issue closes with the link", async () => {
|
|
216
|
+
const o = await runCase({ contract: DEFER, impl: (t) => fix(t, ["scrape", "search"]) });
|
|
217
|
+
merged(o); assert.equal(o.followups.length, 1); assert.deepEqual(o.followups[0].items, [`R3: ${Q.R3}`]);
|
|
218
|
+
assert.match(o.issue.comments.find((c) => c.includes("FORGE:CONTRACT")), /defer \(filed after review accepts it\)/);
|
|
219
|
+
assert.match(o.pr.body, new RegExp(`Remaining work tracked\\n- #${o.followups[0].number}`));
|
|
220
|
+
assert.ok(o.issue.comments.some((c) => c.includes("FORGE:TRACKED") && c.includes(`#${o.followups[0].number}`)));
|
|
221
|
+
});
|
|
222
|
+
it("shadow (default): an omission merges as today, and the posted review says the gate would block it", async () => {
|
|
223
|
+
const o = await runCase({ contract: OMIT, impl: (t) => fix(t, ["scrape", "search"]) }, "shadow");
|
|
224
|
+
merged(o); assert.equal(o.staging.export, false, "the omitted route still leaks, exactly as today");
|
|
225
|
+
assert.match(o.issue.comments.find((c) => c.includes("FORGE:REVIEW")), /would BLOCK \(nonconforming\)[\s\S]*conformance\.omission/);
|
|
226
|
+
assert.deepEqual([o.counts.builder, o.counts.reviewer], [2, 1]);
|
|
227
|
+
});
|
|
228
|
+
it("6 a generic PASS over an incomplete implementation never merges (review.incomplete)", async () => {
|
|
229
|
+
const o = await runCase({ contract: FULL, impl: (t) => fix(t, ["scrape", "export"]), generic: true });
|
|
230
|
+
blocked(o, "review.incomplete"); assert.deepEqual([o.counts.builder, o.counts.reviewer], [2, 2]);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* Conformance gate for the core work-on: the builder's frozen contract, typed amendments, deterministic prechecks,
|
|
5
|
+
* the structured conformance review and the close plan. Pure functions; lean.mjs owns every effect.
|
|
6
|
+
*
|
|
7
|
+
* The reviewer supplies per-row judgements; the engine computes the verdict. A review without rows (a generic
|
|
8
|
+
* "PASS") is incomplete, and incomplete never merges.
|
|
9
|
+
*/
|
|
10
|
+
import { OUTCOME_CODES } from "../engine/outcome-codes.mjs";
|
|
11
|
+
|
|
12
|
+
export const DISPOSITIONS = Object.freeze(["deliver", "defer", "not-applicable", "post-deploy"]);
|
|
13
|
+
/** Dispositions the engine files as a tracked follow-up issue before anything merges. */
|
|
14
|
+
export const TRACKED = Object.freeze(["defer", "post-deploy"]);
|
|
15
|
+
const REQ_STATUS = ["implemented", "partial", "missing", "deferred", "post-deploy", "not-applicable"];
|
|
16
|
+
const COVER_STATUS = ["covered", "narrowed", "missing"];
|
|
17
|
+
const PRESERVE_STATUS = ["intact", "violated", "unverified"];
|
|
18
|
+
const EVIDENCE_RE = /^([^\s:]+):(\d+)/;
|
|
19
|
+
/** Test files in any common ecosystem: test dirs, *.test.* / *.spec.*, test_*.*, *_test.*, *Test(s).java|kt|cs|php|scala, *_spec.rb, tests.rs, conftest.py, snapshots. */
|
|
20
|
+
const TEST_RE = /(^|\/)(test|tests|__tests__|spec|specs|testdata|__snapshots__)\/|\.(test|spec)\.[a-z0-9]+$|(^|\/)test_[^/]+\.[a-z0-9]+$|_test\.[a-z0-9]+$|Tests?\.(java|kt|kts|cs|php|scala|swift)$|_spec\.rb$|(^|\/)tests\.rs$|(^|\/)conftest\.py$|\.bats$|\.snap$/;
|
|
21
|
+
/** Files any change may touch without being a deliverable: changelogs and dependency lockfiles. */
|
|
22
|
+
const INCIDENTAL_RE = /(^|\/)(CHANGELOG|CHANGES|HISTORY)(\.[a-z]+)?$|(^|\/)(package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb?|Cargo\.lock|poetry\.lock|uv\.lock|Pipfile\.lock|go\.sum|Gemfile\.lock|composer\.lock|mix\.lock|pubspec\.lock|Package\.resolved|gradle\.lockfile)$/i;
|
|
23
|
+
export const MIN_QUOTE_CHARS = 12;
|
|
24
|
+
/** A contract larger than this cannot be posted whole in one comment (the comment cap is 60k). */
|
|
25
|
+
export const MAX_CONTRACT_CHARS = 40000;
|
|
26
|
+
const C = OUTCOME_CODES;
|
|
27
|
+
|
|
28
|
+
export const norm = (s) => String(s ?? "").replace(/[`*_>]/g, "").replace(/\s+/g, " ").trim().toLowerCase();
|
|
29
|
+
const ITEM_PREFIX_RE = /^\s*(?:[-*+]\s+)?(?:\[[ xX]\]\s+)?(?:\d+[.)]\s+)?/;
|
|
30
|
+
/**
|
|
31
|
+
* A quote must be verbatim (whitespace and markdown emphasis aside) from the issue text: a substring of at least
|
|
32
|
+
* MIN_QUOTE_CHARS, or a shorter quote that is a whole line or list item (e.g. "- [ ] add --dry-run").
|
|
33
|
+
*/
|
|
34
|
+
export function inIssue(quote, issueText) {
|
|
35
|
+
const q = norm(quote);
|
|
36
|
+
if (q.length >= MIN_QUOTE_CHARS) return norm(issueText).includes(q);
|
|
37
|
+
if (q.length < 3) return false;
|
|
38
|
+
return String(issueText ?? "").split("\n").some((l) => norm(l.replace(ITEM_PREFIX_RE, "")) === q);
|
|
39
|
+
}
|
|
40
|
+
export const isTestPath = (p) => TEST_RE.test(String(p));
|
|
41
|
+
export const isIncidentalPath = (p) => INCIDENTAL_RE.test(String(p));
|
|
42
|
+
const cleanPath = (p) => String(p ?? "").trim().replace(/^\.\//, "");
|
|
43
|
+
const globRe = (g) => new RegExp(`^${g.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*\/?/g, "\u0000").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]").replace(/\u0000/g, ".*")}$`);
|
|
44
|
+
/** True when deliverable `d` (a file, a dir ending in "/", or a glob) covers changed file `f`. */
|
|
45
|
+
export function pathCovers(d, f) {
|
|
46
|
+
const p = cleanPath(d), file = cleanPath(f);
|
|
47
|
+
if (!p) return false;
|
|
48
|
+
if (p.endsWith("/")) return file.startsWith(p);
|
|
49
|
+
if (/[*?]/.test(p)) return globRe(p).test(file);
|
|
50
|
+
return p === file;
|
|
51
|
+
}
|
|
52
|
+
/** The deliverable id that authorizes `f`, "test", "incidental", or null. */
|
|
53
|
+
export function authorizationOf(contract, f) {
|
|
54
|
+
const d = (contract.deliverables ?? []).find((x) => pathCovers(x.path, f));
|
|
55
|
+
if (d) return d.id;
|
|
56
|
+
if (isTestPath(f)) return "test";
|
|
57
|
+
if (isIncidentalPath(f)) return "incidental";
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
const blank = (s) => !String(s ?? "").trim() || /^none$/i.test(String(s).trim());
|
|
61
|
+
|
|
62
|
+
/** Shape check of the builder's contract.json against the issue text. Returns {ok, problems[]}. */
|
|
63
|
+
export function validateContract(c, issueText) {
|
|
64
|
+
const problems = [];
|
|
65
|
+
if (!c || typeof c !== "object" || Array.isArray(c)) return { ok: false, problems: ["contract.json is missing or not a JSON object"] };
|
|
66
|
+
const reqs = Array.isArray(c.requirements) ? c.requirements : [];
|
|
67
|
+
if (!reqs.length) problems.push("the contract lists no requirements");
|
|
68
|
+
const ids = new Set();
|
|
69
|
+
for (const r of reqs) {
|
|
70
|
+
const id = r?.id;
|
|
71
|
+
if (!id || ids.has(id)) problems.push(`requirement id missing or duplicated: ${id}`);
|
|
72
|
+
ids.add(id);
|
|
73
|
+
if (!DISPOSITIONS.includes(r?.disposition)) problems.push(`${id}: disposition must be one of ${DISPOSITIONS.join("|")}`);
|
|
74
|
+
if (!inIssue(r?.quote, issueText)) problems.push(`${id}: quote is not verbatim from the issue (at least ${MIN_QUOTE_CHARS} characters)`);
|
|
75
|
+
if (TRACKED.includes(r?.disposition) && blank(r?.why)) problems.push(`${id}: ${r.disposition} needs a why`);
|
|
76
|
+
if (r?.disposition === "not-applicable" && blank(r?.evidence)) problems.push(`${id}: not-applicable needs file:line evidence`);
|
|
77
|
+
}
|
|
78
|
+
const dels = Array.isArray(c.deliverables) ? c.deliverables : [];
|
|
79
|
+
if (reqs.some((r) => r?.disposition === "deliver") && !dels.length) problems.push("deliver requirements but no deliverables");
|
|
80
|
+
for (const d of dels) if (!d?.id || !d?.path) problems.push(`deliverable without id or path: ${JSON.stringify(d).slice(0, 80)}`);
|
|
81
|
+
for (const p of Array.isArray(c.preserve) ? c.preserve : []) if (!p?.id || blank(p?.what)) problems.push("preserve entry without id or what");
|
|
82
|
+
if (!Array.isArray(c.validate) || !c.validate.length) problems.push("the contract names no validate[] commands");
|
|
83
|
+
else for (const v of c.validate) if (!v?.id || blank(v?.command)) problems.push("validate entry without id or command");
|
|
84
|
+
if (JSON.stringify(c).length > MAX_CONTRACT_CHARS) problems.push(`the contract is over ${MAX_CONTRACT_CHARS} characters; keep quotes to the requirement sentence and descriptions short`);
|
|
85
|
+
return { ok: problems.length === 0, problems };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Apply result.json amendments to the frozen contract. Kinds: add-deliverable {path}, add-requirement {requirement},
|
|
90
|
+
* defer|post-deploy {requirement: id}. Every amendment needs a why. Returns {contract, applied[], toTrack[], problems[]}.
|
|
91
|
+
*/
|
|
92
|
+
export function applyAmendments(frozen, amendments, issueText = null) {
|
|
93
|
+
const c = structuredClone(frozen);
|
|
94
|
+
c.deliverables = c.deliverables ?? [];
|
|
95
|
+
const applied = [], toTrack = [], problems = [];
|
|
96
|
+
for (const a of Array.isArray(amendments) ? amendments : []) {
|
|
97
|
+
const tag = JSON.stringify(a ?? null).slice(0, 100);
|
|
98
|
+
if (blank(a?.why)) { problems.push(`amendment without why: ${tag}`); continue; }
|
|
99
|
+
if (a.kind === "add-deliverable" && !blank(a.path)) {
|
|
100
|
+
if (!c.deliverables.some((d) => d.path === a.path)) c.deliverables.push({ id: a.id ?? `A${c.deliverables.length + 1}`, path: a.path, change: a.why, amended: true });
|
|
101
|
+
} else if (a.kind === "add-requirement" && a.requirement?.id && !blank(a.requirement?.quote)) {
|
|
102
|
+
if (c.requirements.some((r) => r.id === a.requirement.id)) { problems.push(`add-requirement reuses id ${a.requirement.id}`); continue; }
|
|
103
|
+
if (issueText != null && !inIssue(a.requirement.quote, issueText)) { problems.push(`add-requirement ${a.requirement.id}: quote is not verbatim from the issue`); continue; }
|
|
104
|
+
const r = { disposition: "deliver", ...a.requirement, amended: true };
|
|
105
|
+
if (!DISPOSITIONS.includes(r.disposition)) { problems.push(`add-requirement ${r.id}: bad disposition ${r.disposition}`); continue; }
|
|
106
|
+
if (TRACKED.includes(r.disposition) && blank(r.why)) r.why = a.why;
|
|
107
|
+
c.requirements.push(r);
|
|
108
|
+
if (TRACKED.includes(r.disposition)) toTrack.push(r);
|
|
109
|
+
} else if (TRACKED.includes(a.kind) && a.requirement) {
|
|
110
|
+
const r = c.requirements.find((x) => x.id === a.requirement);
|
|
111
|
+
if (!r) { problems.push(`amendment names unknown requirement ${a.requirement}`); continue; }
|
|
112
|
+
if (!TRACKED.includes(r.disposition)) { r.disposition = a.kind; r.why = a.why; r.amended = true; toTrack.push(r); }
|
|
113
|
+
} else { problems.push(`unknown or incomplete amendment: ${tag}`); continue; }
|
|
114
|
+
applied.push({ kind: a.kind, target: a.path ?? a.requirement?.id ?? a.requirement, why: String(a.why).trim() });
|
|
115
|
+
}
|
|
116
|
+
return { contract: c, applied, toTrack, problems };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Deterministic checks before any reviewer tokens are spent: unauthorized files, failed or missing validation,
|
|
121
|
+
* requirements not implemented, and deferrals without a tracking issue. Returns {ok, failures[]}.
|
|
122
|
+
*/
|
|
123
|
+
export function precheck({ contract, result, changedFiles, ignore = [] }) {
|
|
124
|
+
const failures = [];
|
|
125
|
+
const creep = changedFiles.filter((f) => !ignore.some((p) => f.startsWith(p)) && !authorizationOf(contract, f));
|
|
126
|
+
if (creep.length) failures.push({ code: C.CONFORMANCE_SCOPE_CREEP, detail: `changed outside the contract's deliverables: ${creep.join(", ")}` });
|
|
127
|
+
const runs = Array.isArray(result?.validation) ? result.validation : [];
|
|
128
|
+
for (const v of contract.validate ?? []) {
|
|
129
|
+
const r = runs.find((x) => x?.id === v.id);
|
|
130
|
+
if (!r) failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, detail: `validate ${v.id} (\`${v.command}\`) has no recorded result` });
|
|
131
|
+
else if (r.exit !== 0) failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, detail: `validate ${v.id} failed (exit ${r.exit})` });
|
|
132
|
+
}
|
|
133
|
+
const st = Array.isArray(result?.requirements) ? result.requirements : [];
|
|
134
|
+
for (const r of contract.requirements ?? []) {
|
|
135
|
+
const s = st.find((x) => x?.id === r.id)?.status;
|
|
136
|
+
if (TRACKED.includes(r.disposition)) continue; // typed deferrals are judged by the reviewer, then filed before the PR
|
|
137
|
+
if (r.disposition === "deliver") {
|
|
138
|
+
if (s === "deferred" || s === "post-deploy") failures.push({ code: C.CONFORMANCE_UNTRACKED_DEFERRAL, detail: `${r.id} was contracted as deliver but reported "${s}" without a typed amendment` });
|
|
139
|
+
else if (s !== "implemented") failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, detail: `${r.id} reported "${s ?? "no status"}"` });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return { ok: failures.length === 0, failures };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The reviewer's JSON object (a json fence first, then the outermost braces), or null. */
|
|
146
|
+
export function parseReview(text) {
|
|
147
|
+
const s = String(text ?? "");
|
|
148
|
+
const fence = /```(?:json)?\s*([\s\S]*?)```/.exec(s)?.[1];
|
|
149
|
+
for (const cand of [fence, s.slice(s.indexOf("{"), s.lastIndexOf("}") + 1)]) {
|
|
150
|
+
if (!cand) continue;
|
|
151
|
+
try { const j = JSON.parse(cand); if (j && typeof j === "object" && !Array.isArray(j)) return j; } catch { /* next candidate */ }
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Row completeness: every contract requirement, preserve item and changed file has a well-formed row. */
|
|
157
|
+
export function checkCompleteness(rv, { contract, changedFiles, issueText, pathExists = () => false, ignore = [] }) {
|
|
158
|
+
const p = [];
|
|
159
|
+
const arr = (k) => (Array.isArray(rv?.[k]) ? rv[k] : null);
|
|
160
|
+
if (!["CONFORMS", "NONCONFORMING"].includes(rv?.verdict)) p.push("verdict must be CONFORMS or NONCONFORMING");
|
|
161
|
+
const cov = arr("issue_coverage");
|
|
162
|
+
if (!cov?.length) p.push("issue_coverage is missing or empty");
|
|
163
|
+
for (const c of cov ?? []) {
|
|
164
|
+
if (!COVER_STATUS.includes(c?.status)) p.push(`issue_coverage status invalid: ${c?.status}`);
|
|
165
|
+
if (!inIssue(c?.quote, issueText)) p.push(`issue_coverage quote is not verbatim from the issue: "${String(c?.quote ?? "").slice(0, 60)}"`);
|
|
166
|
+
}
|
|
167
|
+
const reqs = arr("requirements");
|
|
168
|
+
if (!reqs) p.push("requirements rows are missing");
|
|
169
|
+
for (const r of contract.requirements) {
|
|
170
|
+
const rows = (reqs ?? []).filter((x) => x?.id === r.id);
|
|
171
|
+
if (rows.length !== 1) { p.push(`requirement ${r.id} needs exactly one row (has ${rows.length})`); continue; }
|
|
172
|
+
const row = rows[0];
|
|
173
|
+
if (!REQ_STATUS.includes(row.status)) { p.push(`${r.id}: status invalid: ${row.status}`); continue; }
|
|
174
|
+
if (TRACKED.includes(r.disposition) && typeof row.deferral_ok !== "boolean") p.push(`${r.id}: a ${r.disposition} requirement needs deferral_ok true|false (is deferring it justified?)`);
|
|
175
|
+
if (row.status === "implemented") {
|
|
176
|
+
const m = EVIDENCE_RE.exec(String(row.evidence ?? ""));
|
|
177
|
+
if (!m) p.push(`${r.id}: implemented needs path:line evidence`);
|
|
178
|
+
else if (!changedFiles.includes(m[1]) && !pathExists(m[1])) p.push(`${r.id}: evidence path ${m[1]} does not exist`);
|
|
179
|
+
if (blank(row.validated_by)) p.push(`${r.id}: implemented needs validated_by (a validate id or test)`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const scope = arr("scope");
|
|
183
|
+
if (!scope) p.push("scope rows are missing");
|
|
184
|
+
for (const f of changedFiles.filter((x) => !ignore.some((pre) => x.startsWith(pre)))) if (!(scope ?? []).some((s) => s?.file === f)) p.push(`changed file ${f} has no scope row`);
|
|
185
|
+
const pres = arr("preserve") ?? [];
|
|
186
|
+
for (const c of contract.preserve ?? []) {
|
|
187
|
+
const row = pres.find((x) => x?.id === c.id);
|
|
188
|
+
if (!row || !PRESERVE_STATUS.includes(row.status)) p.push(`preserve ${c.id} needs a row with status ${PRESERVE_STATUS.join("|")}`);
|
|
189
|
+
}
|
|
190
|
+
if (rv && rv.regressions != null && !Array.isArray(rv.regressions)) p.push("regressions must be an array");
|
|
191
|
+
return { complete: p.length === 0, problems: p };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The engine's decision from the reviewer's rows. state: conforms | nonconforming | review.unparseable | review.incomplete.
|
|
196
|
+
* failures[] are {code, detail}; `inconsistent` marks a CONFORMS verdict whose rows fail.
|
|
197
|
+
*/
|
|
198
|
+
export function decide(rv, ctx) {
|
|
199
|
+
if (!rv) return { state: "review.unparseable", failures: [{ code: C.REVIEW_NO_VERDICT, detail: "no JSON object in the reviewer reply" }] };
|
|
200
|
+
const comp = checkCompleteness(rv, ctx);
|
|
201
|
+
if (!comp.complete) return { state: "review.incomplete", failures: comp.problems.map((d) => ({ code: C.REVIEW_INCOMPLETE, detail: d })) };
|
|
202
|
+
const f = [];
|
|
203
|
+
for (const c of rv.issue_coverage) if (c.status !== "covered") f.push({ code: C.CONFORMANCE_OMISSION, detail: `issue requirement ${c.status}: "${c.quote}"${c.note ? ` (${c.note})` : ""}` });
|
|
204
|
+
for (const r of ctx.contract.requirements) {
|
|
205
|
+
const row = rv.requirements.find((x) => x.id === r.id);
|
|
206
|
+
if (r.disposition === "deliver" && row.status !== "implemented") {
|
|
207
|
+
f.push({ code: ["deferred", "post-deploy"].includes(row.status) ? C.CONFORMANCE_UNTRACKED_DEFERRAL : C.CONFORMANCE_UNIMPLEMENTED, detail: `${r.id} ${row.status}${row.note ? `: ${row.note}` : ""}${row.evidence ? ` (${row.evidence})` : ""}` });
|
|
208
|
+
}
|
|
209
|
+
if (TRACKED.includes(r.disposition) && row.deferral_ok === false) f.push({ code: C.CONFORMANCE_UNJUSTIFIED_DEFERRAL, detail: `${r.id} ${r.disposition} is not justified${row.note ? `: ${row.note}` : ""}; implement it` });
|
|
210
|
+
}
|
|
211
|
+
for (const s of rv.scope) {
|
|
212
|
+
if (!blank(s.authorized_by)) continue;
|
|
213
|
+
const engine = authorizationOf(ctx.contract, s.file);
|
|
214
|
+
if (engine === "test" || engine === "incidental") continue; // always allowed; a reviewer "none" here is a misreading, not creep
|
|
215
|
+
f.push({ code: C.CONFORMANCE_SCOPE_CREEP, detail: `${s.file}: ${s.note || "not authorized by the contract"}` });
|
|
216
|
+
}
|
|
217
|
+
for (const p of rv.preserve ?? []) if (p.status !== "intact") f.push({ code: C.CONFORMANCE_PRESERVE_VIOLATED, detail: `${p.id} ${p.status}${p.evidence ? ` (${p.evidence})` : ""}${p.note ? `: ${p.note}` : ""}` });
|
|
218
|
+
for (const r of rv.regressions ?? []) f.push({ code: C.CONFORMANCE_REGRESSION, detail: `${r?.at ?? "?"}: ${r?.failure ?? ""}` });
|
|
219
|
+
if (f.length) return { state: "nonconforming", failures: f, inconsistent: rv.verdict === "CONFORMS" };
|
|
220
|
+
// A NONCONFORMING verdict with every row passing gives the builder nothing to fix: re-ask the reviewer.
|
|
221
|
+
if (rv.verdict !== "CONFORMS") return { state: "review.inconsistent", failures: [{ code: C.REVIEW_INCONSISTENT, detail: "verdict NONCONFORMING with no failing row" }] };
|
|
222
|
+
return { state: "conforms", failures: [] };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Requirements the reviewer accepted as deferred or post-deploy that still need a tracking issue. */
|
|
226
|
+
export const pendingTracking = (contract) => (contract.requirements ?? []).filter((r) => TRACKED.includes(r.disposition) && !r.tracked);
|
|
227
|
+
|
|
228
|
+
/** After a conforming merge: close as completed, linking every tracking issue. */
|
|
229
|
+
export function closePlan(contract) {
|
|
230
|
+
const tracked = [...new Set((contract.requirements ?? []).filter((r) => TRACKED.includes(r.disposition)).map((r) => r.tracked).filter(Boolean))];
|
|
231
|
+
return { reason: "completed", tracked };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** One line per requirement for the follow-up script: a stable key (id + quote) and a why. Tabs and newlines removed. */
|
|
235
|
+
export function followupItems(reqs) {
|
|
236
|
+
const flat = (s) => String(s ?? "").replace(/[\t\r\n`]+/g, " ").replace(/\s+/g, " ").trim();
|
|
237
|
+
return reqs.map((r) => ({ id: r.id, key: flat(`${r.id}: ${r.quote}`).slice(0, 240), why: flat(`${r.disposition}: ${r.why ?? ""}`).slice(0, 400) }));
|
|
238
|
+
}
|