forgedock 1.36.0 → 1.37.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/bot-token-status.mjs +26 -0
- package/bin/engine/events.mjs +10 -1
- package/bin/engine/events.test.mjs +15 -1
- package/bin/engine/outcome-codes.mjs +39 -1
- package/bin/engine/phases.mjs +65 -5
- package/bin/engine/telemetry-seam.mjs +1 -1
- package/bin/engine/telemetry-seam.test.mjs +4 -0
- package/bin/engine-cli.mjs +268 -37
- package/bin/engine.mjs +3 -1
- package/bin/forgedock.mjs +108 -5
- package/bin/labels.json +50 -0
- package/bin/lean/batch.mjs +10 -4
- package/bin/lean/batch.test.mjs +75 -1
- package/bin/lean/conformance.e2e.test.mjs +1 -1
- package/bin/lean/conformance.mjs +125 -11
- package/bin/lean/conformance.test.mjs +122 -1
- package/bin/lean/decisions.mjs +181 -0
- package/bin/lean/decisions.test.mjs +71 -0
- package/bin/lean/effects.mjs +29 -14
- package/bin/lean/execution.mjs +60 -16
- package/bin/lean/execution.test.mjs +59 -2
- package/bin/lean/gatewatch.mjs +31 -0
- package/bin/lean/gatewatch.test.mjs +59 -0
- package/bin/lean/guardgate.mjs +170 -0
- package/bin/lean/guardgate.test.mjs +153 -0
- package/bin/lean/guards.mjs +243 -0
- package/bin/lean/guards.test.mjs +177 -0
- package/bin/lean/heavy.mjs +54 -8
- package/bin/lean/heavy.test.mjs +74 -1
- package/bin/lean/hostkill.mjs +1 -1
- package/bin/lean/integration.mjs +276 -0
- package/bin/lean/integration.test.mjs +189 -0
- package/bin/lean/lean.mjs +134 -28
- package/bin/lean/lean.test.mjs +239 -22
- package/bin/lean/lifecycle.mjs +129 -0
- package/bin/lean/lifecycle.test.mjs +118 -0
- package/bin/lean/light.mjs +1 -1
- package/bin/lean/milestonesync.mjs +130 -0
- package/bin/lean/milestonesync.test.mjs +133 -0
- package/bin/lean/orchestrate.mjs +83 -15
- package/bin/lean/parked-sweep.mjs +45 -0
- package/bin/lean/parked-sweep.test.mjs +71 -0
- package/bin/lean/postdeploy-check.mjs +38 -6
- package/bin/lean/postdeploy-check.test.mjs +62 -2
- package/bin/lean/postdeploy-monitor.mjs +12 -2
- package/bin/lean/postdeploy-monitor.test.mjs +27 -1
- package/bin/lean/prefilter.mjs +83 -23
- package/bin/lean/prefilter.test.mjs +73 -1
- package/bin/lean/probe.mjs +2 -2
- package/bin/lean/probe.test.mjs +1 -1
- package/bin/lean/prompts.mjs +15 -9
- package/bin/lean/resume.test.mjs +1 -1
- package/bin/lean/retry.mjs +28 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +8 -3
- package/bin/lean/sandbox-remote.mjs +4 -4
- package/bin/lean/schedd.mjs +53 -9
- package/bin/lean/schedd.test.mjs +77 -5
- package/bin/lean/sequence.mjs +24 -0
- package/bin/lean/sequence.test.mjs +31 -1
- package/bin/lean/signoff.mjs +66 -0
- package/bin/lean/signoff.test.mjs +102 -0
- package/bin/lean/train.mjs +177 -0
- package/bin/lean/train.test.mjs +113 -0
- package/bin/pool/cli.mjs +2 -0
- package/bin/pool/health.mjs +30 -0
- package/bin/review-context.mjs +298 -0
- package/bin/runner.mjs +26 -3
- package/bin/stats/compounding.mjs +157 -0
- package/bin/stats/compounding.test.mjs +74 -0
- package/bin/stats/index.mjs +21 -2
- package/commands/bootstrap.md +10 -8
- package/commands/deploy-info.md +3 -0
- package/commands/issue.md +2 -0
- package/commands/milestone.md +43 -54
- package/commands/orchestrate/phase-4-execution.md +2 -0
- package/commands/orchestrate.md +8 -1
- package/commands/pipeline-status.md +20 -0
- package/commands/review-pr.md +1 -0
- package/commands/work-on/build/implement.md +1 -1
- package/commands/work-on/build.md +4 -2
- package/commands/work-on/investigate.md +3 -1
- package/commands/work-on/remediate.md +1 -1
- package/docs/CONFIG.md +71 -2
- package/docs/EVENTS.md +1 -4
- package/docs/OUTCOME-CODES.md +9 -1
- package/docs/STATS.md +20 -0
- package/docs/site/start-a-new-project.md +1 -1
- package/docs/spec/label-state-machine.md +6 -0
- package/package.json +1 -1
- package/pool/install/install-agent.sh +4 -2
- package/pool/install/install-router.sh +4 -2
- package/pool/install/install-schedd.sh +28 -0
- package/pool/install/unit-hardening.sh +34 -0
- package/scripts/acceptance-red-green.sh +19 -2
- package/scripts/acceptance-red-green.test.sh +2 -0
- package/scripts/bootstrap-create.mjs +3 -2
- package/scripts/bootstrap-validate.mjs +31 -4
- package/scripts/file-deferred-followups.sh +285 -77
- package/scripts/file-deferred-followups.test.sh +111 -4
- package/scripts/hotspots.sh +23 -2
- package/scripts/hotspots.test.sh +9 -0
- package/scripts/plan-validate-live.mjs +111 -0
- package/scripts/readiness.mjs +141 -0
- package/scripts/review-prepare.sh +17 -1
- package/scripts/review-prepare.test.sh +17 -0
- package/scripts/settle-scope.mjs +74 -0
- package/scripts/systemd/forgedock-bot-token-refresh.service +7 -0
- package/scripts/systemd/forgedock-bot-token-refresh.timer +11 -0
- package/scripts/wait-ci-green.sh +14 -3
- package/scripts/wait-ci-green.test.sh +3 -1
- package/templates/bootstrap/needs-human-issue.md +4 -1
- package/templates/github-actions/migration-journal-guard.test.mjs +26 -0
package/bin/lean/conformance.mjs
CHANGED
|
@@ -28,6 +28,7 @@ export const MIN_QUOTE_CHARS = 12;
|
|
|
28
28
|
/** A contract larger than this cannot be posted whole in one comment (the comment cap is 60k). */
|
|
29
29
|
export const MAX_CONTRACT_CHARS = 40000;
|
|
30
30
|
import { CHECK_ID_RE } from "./postdeploy-check.mjs";
|
|
31
|
+
import { normaliseDecision, deadAnchors } from "./decisions.mjs";
|
|
31
32
|
const C = OUTCOME_CODES;
|
|
32
33
|
|
|
33
34
|
export const norm = (s) => String(s ?? "").replace(/[`*_>]/g, "").replace(/\s+/g, " ").trim().toLowerCase();
|
|
@@ -234,6 +235,8 @@ export function validateContract(c, issueText, { repro = false } = {}) {
|
|
|
234
235
|
if (TRACKED.includes(r?.disposition) && blank(r?.why)) problems.push(`${id}: ${r.disposition} needs a why`);
|
|
235
236
|
if (r?.disposition === "post-deploy" && !CHECK_ID_RE.test(String(r?.check ?? ""))) problems.push(`${id}: post-deploy needs a check id (\`check\`, e.g. "login-smoke") naming an entry of .forge/post-deploy-checks.json on the default branch`);
|
|
236
237
|
if (r?.disposition === "not-applicable" && blank(r?.evidence)) problems.push(`${id}: not-applicable needs file:line evidence`);
|
|
238
|
+
for (const m of bucketProblems(r)) problems.push(`${id}: ${m}`);
|
|
239
|
+
if (r?.class != null && !FOLLOWUP_CLASSES.includes(r.class)) problems.push(`${id}: class must be one of ${FOLLOWUP_CLASSES.join("|")}`);
|
|
237
240
|
if (r?.verify != null && !VERIFY_MODES.includes(r.verify)) problems.push(`${id}: verify must be one of ${VERIFY_MODES.join("|")}`);
|
|
238
241
|
if (isOutOfBand(r) && r?.disposition !== "deliver") problems.push(`${id}: verify out_of_band applies only to a deliver requirement`);
|
|
239
242
|
else if (isOutOfBand(r) && blank(r?.evidence)) problems.push(`${id}: verify out_of_band needs evidence naming the job or workflow that measures it (or set verify "in_lane" if this change can check it)`);
|
|
@@ -268,7 +271,8 @@ export function validateContract(c, issueText, { repro = false } = {}) {
|
|
|
268
271
|
/** forge#4519: a shell pattern as in scripts/hotspots.sh `case` (`*` crosses `/`, `?` one character), anchored. */
|
|
269
272
|
const shellGlobRe = (g) => new RegExp(`^${String(g).replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".")}$`);
|
|
270
273
|
|
|
271
|
-
const AMEND_KINDS = ["add-deliverable", "add-requirement", ...TRACKED, "amend-requirement"];
|
|
274
|
+
const AMEND_KINDS = ["add-deliverable", "add-requirement", ...TRACKED, "amend-requirement", "supersedes", "decision"];
|
|
275
|
+
const GUARD_ID_RE = /^#?\d+$/;
|
|
272
276
|
const AMEND_SLIPS = { "amend-requirement": "defer", limitation: "defer" };
|
|
273
277
|
|
|
274
278
|
/** forge#4574: common builder slips: `type` for `kind`, amend-requirement/limitation as defer, an `R#` in `id` as the requirement. */
|
|
@@ -282,11 +286,26 @@ function normalizeAmendment(a) {
|
|
|
282
286
|
return n;
|
|
283
287
|
}
|
|
284
288
|
|
|
289
|
+
const REF_SEP_RE = /\s*(?:[/,;&+]|\band\b)\s*/i, REF_RANGE_RE = /^R(\d+)\s*[-\u2013\u2014]\s*R?(\d+)$/i, REF_MAX = 50;
|
|
290
|
+
|
|
291
|
+
/** forge#4655: a slash/comma-joined or ranged requirement reference (`R2/R3`, `R2, R3`, `R2–R3`) as its individual ids; a single id or anything else is returned as is. */
|
|
292
|
+
function splitRequirementRefs(ref) {
|
|
293
|
+
if (typeof ref !== "string") return [ref];
|
|
294
|
+
const ids = [];
|
|
295
|
+
for (const part of ref.trim().split(REF_SEP_RE)) {
|
|
296
|
+
const m = REF_RANGE_RE.exec(part);
|
|
297
|
+
if (m && +m[1] <= +m[2] && +m[2] - +m[1] < REF_MAX) for (let i = +m[1]; i <= +m[2]; i++) ids.push(`R${i}`);
|
|
298
|
+
else ids.push(part);
|
|
299
|
+
}
|
|
300
|
+
return ids.length > 1 ? ids : [ref];
|
|
301
|
+
}
|
|
302
|
+
|
|
285
303
|
/** The rejection reason, naming the valid kinds or the missing field so the builder's correction round can fix it. */
|
|
286
304
|
function amendmentProblem(a, tag) {
|
|
287
305
|
const kind = a?.kind;
|
|
288
306
|
const need = kind === "add-deliverable" ? "needs a path" : kind === "add-requirement" ? "needs requirement {id, quote}"
|
|
289
|
-
: TRACKED.includes(kind) ? "needs requirement: \"R#\"" : kind === "amend-requirement" ? "needs requirement: \"R#\" and text (the revised wording)"
|
|
307
|
+
: kind === "supersedes" ? "needs guard: \"#N\" (the guarded issue)" : TRACKED.includes(kind) ? "needs requirement: \"R#\"" : kind === "amend-requirement" ? "needs requirement: \"R#\" and text (the revised wording)"
|
|
308
|
+
: kind === "decision" ? "needs decision {choice, anchors: [paths or globs], symbols?, supersedes?: decision id}" : null;
|
|
290
309
|
const why = need ? `amendment kind '${kind}' ${need}` : `unknown kind '${kind ?? ""}'; valid kinds are ${AMEND_KINDS.join(", ")}`;
|
|
291
310
|
return `unknown or incomplete amendment: ${why}: ${tag}`;
|
|
292
311
|
}
|
|
@@ -296,14 +315,25 @@ function amendmentProblem(a, tag) {
|
|
|
296
315
|
* defer|post-deploy {requirement: id}. Every amendment needs a why. Returns {contract, applied[], toTrack[], problems[]}.
|
|
297
316
|
* `generatedPaths` (forge#4519, forge.yaml maintenance.generated_paths via scripts/hotspots.sh): an add-deliverable on a
|
|
298
317
|
* matching path is refused; the repo's generator or bot owns that file.
|
|
318
|
+
* `decision` {decision:{choice, anchors, symbols?, supersedes?}} (#4695): every anchor must match a file of `baseFiles` (the
|
|
319
|
+
* base tree; null skips the check); `supersedes` names the decision whose engine-authored preserve row it replaces and
|
|
320
|
+
* drops that row; returns the records in `decisions`.
|
|
299
321
|
*/
|
|
300
|
-
export function applyAmendments(frozen, amendments, issueText = null, { generatedPaths = [] } = {}) {
|
|
322
|
+
export function applyAmendments(frozen, amendments, issueText = null, { generatedPaths = [], baseFiles = null } = {}) {
|
|
301
323
|
const c = structuredClone(frozen);
|
|
302
324
|
c.deliverables = c.deliverables ?? [];
|
|
303
|
-
const applied = [], toTrack = [], problems = [];
|
|
325
|
+
const applied = [], toTrack = [], problems = [], decisions = [];
|
|
304
326
|
const genRes = (Array.isArray(generatedPaths) ? generatedPaths : []).filter((g) => typeof g === "string" && g).map(shellGlobRe);
|
|
327
|
+
const rows = [];
|
|
305
328
|
for (const raw of Array.isArray(amendments) ? amendments : []) {
|
|
306
|
-
const
|
|
329
|
+
const n = normalizeAmendment(raw);
|
|
330
|
+
if (n && (TRACKED.includes(n.kind) || n.kind === "amend-requirement") && typeof n.requirement === "string") {
|
|
331
|
+
const ids = splitRequirementRefs(n.requirement);
|
|
332
|
+
if (ids.length > 1) { for (const id of ids) rows.push({ a: { ...n, requirement: id }, raw }); continue; }
|
|
333
|
+
}
|
|
334
|
+
rows.push({ a: n, raw });
|
|
335
|
+
}
|
|
336
|
+
for (const { a, raw } of rows) {
|
|
307
337
|
const tag = JSON.stringify(raw ?? null).slice(0, 400);
|
|
308
338
|
if (blank(a?.why)) { problems.push(`amendment without why: ${tag}`); continue; }
|
|
309
339
|
if (a.kind === "add-deliverable" && !blank(a.path)) {
|
|
@@ -325,6 +355,12 @@ export function applyAmendments(frozen, amendments, issueText = null, { generate
|
|
|
325
355
|
if (tagsOutOfBand(r)) r.verify = "out_of_band"; // #4404: same freeze-time tag as the planned requirements
|
|
326
356
|
c.requirements.push(r);
|
|
327
357
|
if (TRACKED.includes(r.disposition)) toTrack.push(r);
|
|
358
|
+
} else if (a.kind === "supersedes" && GUARD_ID_RE.test(String(a.guard ?? "").trim())) {
|
|
359
|
+
// #4693: retires one guard's guard_broken for this lane; the reviewer judges it, the engine tells the guarded issue after merge
|
|
360
|
+
const guard = `#${String(a.guard).trim().replace(/^#/, "")}`;
|
|
361
|
+
c.supersedes = [...(c.supersedes ?? []).filter((s) => s.guard !== guard), { guard, why: String(a.why).trim() }];
|
|
362
|
+
applied.push({ kind: a.kind, target: guard, why: String(a.why).trim() });
|
|
363
|
+
continue;
|
|
328
364
|
} else if (a.kind === "amend-requirement" && !blank(a.requirement)) {
|
|
329
365
|
// #4594: the requirement stays deliver and keeps its verbatim quote; the reviewer judges it against amendment.text
|
|
330
366
|
const r = c.requirements.find((x) => x.id === a.requirement);
|
|
@@ -334,14 +370,24 @@ export function applyAmendments(frozen, amendments, issueText = null, { generate
|
|
|
334
370
|
const r = c.requirements.find((x) => x.id === a.requirement);
|
|
335
371
|
if (!r) { problems.push(`amendment names unknown requirement ${a.requirement}`); continue; }
|
|
336
372
|
if (!TRACKED.includes(r.disposition)) {
|
|
373
|
+
if (a.bucket != null) r.bucket = a.bucket; // #4724
|
|
337
374
|
if (a.kind === "post-deploy" && !CHECK_ID_RE.test(String(a.check ?? r.check ?? ""))) { problems.push(`amendment post-deploy ${a.requirement} needs a check id (check)`); continue; }
|
|
338
|
-
r.disposition = a.kind; r.why = a.why; r.amended = true; toTrack.push(r);
|
|
375
|
+
r.disposition = a.kind; r.why = a.why; r.amended = true; r.bucket ??= "post-deploy"; toTrack.push(r); // #4724: an amendment without a bucket defaults to post-deploy
|
|
339
376
|
if (a.kind === "post-deploy") r.check = a.check ?? r.check; // #4626
|
|
340
377
|
}
|
|
378
|
+
} else if (a.kind === "decision") {
|
|
379
|
+
const d = normaliseDecision({ ...a.decision, why: a.decision?.why ?? a.why });
|
|
380
|
+
if (!d) { problems.push(amendmentProblem(a, tag)); continue; }
|
|
381
|
+
const dead = Array.isArray(baseFiles) ? deadAnchors(d.anchors, baseFiles) : [];
|
|
382
|
+
if (dead.length) { problems.push(`decision anchor(s) ${dead.join(", ")} match no file on the base; anchor a path that exists there`); continue; }
|
|
383
|
+
if (!blank(a.decision?.supersedes)) c.preserve = (c.preserve ?? []).filter((p) => p.decision !== a.decision.supersedes && p.id !== a.decision.supersedes);
|
|
384
|
+
decisions.push(a.decision?.supersedes ? { ...d, supersedes: String(a.decision.supersedes) } : d);
|
|
385
|
+
applied.push({ kind: a.kind, target: d.choice.slice(0, 60), why: String(a.why).trim() });
|
|
386
|
+
continue;
|
|
341
387
|
} else { problems.push(amendmentProblem(a, tag)); continue; }
|
|
342
388
|
applied.push({ kind: a.kind, target: a.path ?? a.requirement?.id ?? a.requirement, why: String(a.why).trim() });
|
|
343
389
|
}
|
|
344
|
-
return { contract: c, applied, toTrack, problems };
|
|
390
|
+
return { contract: c, applied, toTrack, problems, decisions };
|
|
345
391
|
}
|
|
346
392
|
|
|
347
393
|
const PY_MISSING_RE = /ModuleNotFoundError: No module named '([^']+)'|ImportError: No module named '?([\w.]+)'?/;
|
|
@@ -393,7 +439,7 @@ export function repoTracks(paths) {
|
|
|
393
439
|
* `ciCovered` (#4527): the validate ids accepted only because the head is CI-verified (no local result, or a missing host
|
|
394
440
|
* tool), for the trajectory; always an array.
|
|
395
441
|
*/
|
|
396
|
-
export function precheck({ contract, result, changedFiles, ignore = [], ciVerified = false, hotspots = null, repro = null, validateRuns = null, repoHas = () => false, frozen = null, checkIds = null }) {
|
|
442
|
+
export function precheck({ contract, result, changedFiles, ignore = [], ciVerified = false, hotspots = null, repro = null, validateRuns = null, repoHas = () => false, frozen = null, checkIds = null, guardFailures = null }) {
|
|
397
443
|
const failures = [], superseded = [], ciCovered = [];
|
|
398
444
|
// #4626: a post-deploy check id must be on the default branch or added by this PR (`checkIds`: ids of both checks files; null = not resolved)
|
|
399
445
|
if (checkIds) for (const r of contract.requirements ?? []) {
|
|
@@ -435,6 +481,9 @@ export function precheck({ contract, result, changedFiles, ignore = [], ciVerifi
|
|
|
435
481
|
}
|
|
436
482
|
failures.push(...hotspotGrowth({ contract, result, hotspots, changedFiles }));
|
|
437
483
|
if (repro) failures.push(...reproFailures(contract, repro));
|
|
484
|
+
// #4693: a broken guard fails unless the contract carries a supersedes amendment for it
|
|
485
|
+
const retired = new Set((contract.supersedes ?? []).map((s) => s.guard));
|
|
486
|
+
for (const f of guardFailures ?? []) if (!retired.has(f.guard)) failures.push(f);
|
|
438
487
|
return { ok: failures.length === 0, failures, superseded, ciCovered };
|
|
439
488
|
}
|
|
440
489
|
|
|
@@ -632,6 +681,26 @@ export function checkCompleteness(rv, { contract, changedFiles, issueText, pathE
|
|
|
632
681
|
return { complete: p.length === 0, problems: p, missing: [...ids] };
|
|
633
682
|
}
|
|
634
683
|
|
|
684
|
+
|
|
685
|
+
/** #4726: a requirement quote that carries a `[type:e2e]` criterion. */
|
|
686
|
+
const wantsE2e = (r) => /\[type:e2e\]/i.test(String(r?.quote ?? ""));
|
|
687
|
+
const E2E_CMD_RE = /\b(?:playwright|cypress|e2e|live[- ]stack|wdio)\b/i;
|
|
688
|
+
/**
|
|
689
|
+
* #4726: whether a validate command runs a real end-to-end check: an entry of `verify.e2e_checks`, a file of the e2e spec set
|
|
690
|
+
* (`e2e.isSpec`), or Playwright/Cypress/e2e/live-stack. A unit test file with stubbed I/O is not one.
|
|
691
|
+
*/
|
|
692
|
+
export function isE2eCommand(command, e2e = null) {
|
|
693
|
+
const cmd = String(command ?? "");
|
|
694
|
+
if ((e2e?.checks ?? []).some((c) => c && cmd.includes(c))) return true;
|
|
695
|
+
if (typeof e2e?.isSpec === "function" && cmd.split(/\s+/).some((t) => t && e2e.isSpec(t.replace(/^["']+|["']+$/g, "")))) return true;
|
|
696
|
+
return E2E_CMD_RE.test(cmd);
|
|
697
|
+
}
|
|
698
|
+
/** #4726: true when a V-ref named in the row's `validated_by` resolves to a contract validate command that is a real e2e check. */
|
|
699
|
+
export function e2eVerified(row, contract, e2e = null) {
|
|
700
|
+
const by = String(row?.validated_by ?? "");
|
|
701
|
+
const refs = (contract?.validate ?? []).filter((v) => v?.id && new RegExp(`(?:^|[^A-Za-z0-9_])${v.id}(?![A-Za-z0-9_])`).test(by));
|
|
702
|
+
return refs.some((v) => isE2eCommand(v.command, e2e)) || (!refs.length && isE2eCommand(by, e2e));
|
|
703
|
+
}
|
|
635
704
|
/**
|
|
636
705
|
* The engine's decision from the reviewer's rows. state: conforms | nonconforming | review.unparseable | review.incomplete
|
|
637
706
|
* | review.inconsistent. failures[] are {code, detail}; `inconsistent` marks a CONFORMS verdict whose rows fail;
|
|
@@ -653,6 +722,8 @@ export function decide(raw, ctx) {
|
|
|
653
722
|
if (row.instrumented !== true) f.push({ code: C.CONFORMANCE_UNIMPLEMENTED, detail: `${r.id} verified out of band${r.evidence ? ` by ${r.evidence}` : ""}: instrumentation missing${row.note ? `: ${row.note}` : ""}${row.evidence ? ` (${row.evidence})` : ""}` });
|
|
654
723
|
} else if (r.disposition === "deliver" && row.status !== "implemented") {
|
|
655
724
|
f.push({ code: ["deferred", "post-deploy"].includes(row.status) ? C.CONFORMANCE_UNTRACKED_DEFERRAL : C.CONFORMANCE_UNIMPLEMENTED, detail: `${r.amendment?.text ? `${r.id} amendment not accepted (amended to "${String(r.amendment.text).slice(0, 200)}"): ` : `${r.id} `}${row.status}${row.note ? `: ${row.note}` : ""}${row.evidence ? ` (${row.evidence})` : ""}` });
|
|
725
|
+
} else if (r.disposition === "deliver" && wantsE2e(r) && !e2eVerified(row, ctx.contract, ctx.e2e)) {
|
|
726
|
+
f.push({ code: C.CONFORMANCE_E2E_UNVERIFIED, detail: `${r.id} is a [type:e2e] criterion but ${row.validated_by || "its validated_by"} is not a real end-to-end check (the repo's e2e set, Playwright/browser/live stack); run one, or mark ${r.id} post-deploy with a tracked follow-up` });
|
|
656
727
|
}
|
|
657
728
|
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` });
|
|
658
729
|
}
|
|
@@ -752,11 +823,54 @@ export function formatShadowSummary(rows) {
|
|
|
752
823
|
return `conformance: ${sh.length} shadow lane${sh.length === 1 ? "" : "s"}, would block ${would} (${pct(would)}), false-block ${falseB} (${pct(falseB)}: would-block on an incomplete/inconsistent/unparseable review); risk-path enforced: ${risk}`;
|
|
753
824
|
}
|
|
754
825
|
|
|
755
|
-
/**
|
|
756
|
-
export
|
|
826
|
+
/** #4664: follow-up classes, one issue each per parent (scripts/file-deferred-followups.sh); post-deploy is one rollup per release lane (#4724). */
|
|
827
|
+
export const FOLLOWUP_CLASSES = ["engineering", "post-deploy", "external", "ops"];
|
|
828
|
+
/** #4724: the bucket every deferred row gets at contract time. `date:<YYYY-MM-DD>` and `owner:<action>` carry a value. */
|
|
829
|
+
export const BUCKETS = Object.freeze(["post-deploy", "verify-now", "date", "owner", "new-issue"]);
|
|
830
|
+
const DATE_BUCKET_RE = /^date:(\d{4})-(\d{2})-(\d{2})$/;
|
|
831
|
+
/** The kind of a bucket (`date:2026-12-01` -> `date`), or null when absent or unknown. */
|
|
832
|
+
export const bucketKind = (b) => { const k = String(b ?? "").split(":")[0]; return BUCKETS.includes(k) ? k : null; };
|
|
833
|
+
/** #4724: problems with a deferred row's bucket (a defer or post-deploy row needs exactly one; owner actions are never post-deploy). */
|
|
834
|
+
export function bucketProblems(r) {
|
|
835
|
+
const b = r?.bucket;
|
|
836
|
+
if (!TRACKED.includes(r?.disposition)) return b == null ? [] : ["bucket applies only to a defer or post-deploy requirement"];
|
|
837
|
+
if (b == null || b === "") return [`${r.disposition} needs a bucket (${BUCKETS.join("|")}; \`date:<YYYY-MM-DD>\`, \`owner:<action>\`)`];
|
|
838
|
+
const k = bucketKind(b), out = [];
|
|
839
|
+
if (!k || (k !== "date" && k !== "owner" && b !== k)) out.push(`bucket '${b}' must be post-deploy, verify-now, date:<YYYY-MM-DD>, owner:<action> or new-issue`);
|
|
840
|
+
else if (k === "date") {
|
|
841
|
+
const m = DATE_BUCKET_RE.exec(String(b)), d = m && new Date(Date.UTC(+m[1], +m[2] - 1, +m[3]));
|
|
842
|
+
if (!m || d.getUTCFullYear() !== +m[1] || d.getUTCMonth() !== +m[2] - 1 || d.getUTCDate() !== +m[3]) out.push(`bucket '${b}' must be an absolute date:<YYYY-MM-DD>, not a relative wait`);
|
|
843
|
+
} else if (k === "owner" && blank(String(b).slice(6))) out.push("bucket owner needs the action: owner:<action>");
|
|
844
|
+
if (r.disposition === "post-deploy" && (k === "owner" || k === "new-issue")) out.push(`a ${k} item is never post-deploy: use disposition defer`);
|
|
845
|
+
if (k === "owner" && r.class != null && r.class !== "external" && r.class !== "ops") out.push("an owner bucket belongs in class external or ops, never post-deploy");
|
|
846
|
+
return out;
|
|
847
|
+
}
|
|
848
|
+
/** The follow-up class of a requirement: owner -> external, new-issue -> engineering, other buckets post-deploy; else its `class`, else post-deploy for that disposition, else engineering. */
|
|
849
|
+
export function followupClass(r) {
|
|
850
|
+
const k = bucketKind(r?.bucket);
|
|
851
|
+
if (k === "owner") return r?.class === "ops" ? "ops" : "external";
|
|
852
|
+
if (k === "new-issue") return r?.class && r.class !== "post-deploy" && FOLLOWUP_CLASSES.includes(r.class) ? r.class : "engineering";
|
|
853
|
+
if (k) return "post-deploy";
|
|
854
|
+
return FOLLOWUP_CLASSES.includes(r?.class) ? r.class : r?.disposition === "post-deploy" ? "post-deploy" : "engineering";
|
|
855
|
+
}
|
|
856
|
+
/** #4724: deferred items per bucket kind (`none` = no bucket), for the batch telemetry line. */
|
|
857
|
+
export const bucketTally = (reqs) => (reqs ?? []).reduce((t, r) => { const k = bucketKind(r?.bucket) ?? "none"; t[k] = (t[k] ?? 0) + 1; return t; }, {});
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* One line per requirement for the follow-up script: a stable key (id + quote), a why and a class. Tabs and newlines removed.
|
|
861
|
+
* #4724: a bucketed row's why carries `[bucket:<kind>]` (the script counts and routes on it) and a date bucket's `due:<date>`
|
|
862
|
+
* plus `from:<date>` (the contract-time anchor, `now` ms) that the gate re-anchors when the parent ships later.
|
|
863
|
+
*/
|
|
864
|
+
export function followupItems(reqs, { now = Date.now() } = {}) {
|
|
757
865
|
const flat = (s) => String(s ?? "").replace(/[\t\r\n`]+/g, " ").replace(/\s+/g, " ").trim();
|
|
758
866
|
const check = (r) => (r.disposition === "post-deploy" && r.check ? `[check:${flat(r.check)}] ` : ""); // #4626: the follow-up item names its check
|
|
759
|
-
return reqs.map((r) =>
|
|
867
|
+
return reqs.map((r) => {
|
|
868
|
+
const k = bucketKind(r.bucket);
|
|
869
|
+
const tag = k ? `[bucket:${k}] ` : "";
|
|
870
|
+
const due = k === "date" ? `due:${String(r.bucket).slice(5)} from:${new Date(now).toISOString().slice(0, 10)} ` : "";
|
|
871
|
+
const head = k === "owner" ? `owner: ${flat(String(r.bucket).slice(6))}. ` : k && k !== "new-issue" ? "post-deploy: " : `${r.disposition}: `;
|
|
872
|
+
return { id: r.id, class: followupClass(r), key: flat(`${r.id}: ${r.quote}`).slice(0, 240).trim(), why: flat(`${head}${tag}${check(r)}${due}${r.why ?? ""}`).slice(0, 400).trim() };
|
|
873
|
+
});
|
|
760
874
|
}
|
|
761
875
|
|
|
762
876
|
/** #4388: the follow-up script's `key\tissue` lines as {[item.key]: issue}, matched on trimmed keys (the script trims what it echoes); unmatched items omitted. */
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
3
|
import { describe, it } from "node:test";
|
|
4
4
|
import assert from "node:assert/strict";
|
|
5
|
-
import { validateContract, applyAmendments, precheck, hotspotGrowth, reproFailures, isBugIssue, parseReview, checkCompleteness, decide, closePlan, isInvestigation, formatShadowSummary, followupItems, inIssue, isTestPath, normalizeEvidencePath, mergeReviewRows, reviewSkeleton, missingIds, resolveEvidencePath, movingBaseDiff, scopeText, evidencePathLine, evidencePathOnly, explainFailures, missingTool, repoTracks, tmpdirInputs, isScopeCheck, isPureScopeCheck, dropScopeValidates, tagOutOfBand, outOfBandRequirements, isOutOfBand, followupMap, VERIFY_MODES, OUT_OF_BAND_RE } from "./conformance.mjs";
|
|
5
|
+
import { validateContract, applyAmendments, precheck, hotspotGrowth, reproFailures, isBugIssue, parseReview, checkCompleteness, decide, closePlan, isInvestigation, formatShadowSummary, followupItems, inIssue, isTestPath, normalizeEvidencePath, mergeReviewRows, reviewSkeleton, missingIds, resolveEvidencePath, movingBaseDiff, scopeText, evidencePathLine, evidencePathOnly, explainFailures, missingTool, repoTracks, tmpdirInputs, isScopeCheck, isPureScopeCheck, dropScopeValidates, tagOutOfBand, outOfBandRequirements, isOutOfBand, followupMap, VERIFY_MODES, OUT_OF_BAND_RE, FOLLOWUP_CLASSES, followupClass, BUCKETS, bucketKind, bucketProblems, bucketTally, isE2eCommand, e2eVerified } from "./conformance.mjs";
|
|
6
6
|
|
|
7
7
|
const ISSUE = "## Requirements\n1. The scrape route must release the spend reservation on 429.\n2. The search route must release the spend reservation on 429.\n\n- [ ] Billing pricing is unchanged.";
|
|
8
8
|
const Q1 = "The scrape route must release the spend reservation on 429.";
|
|
@@ -60,6 +60,21 @@ describe("amendments", () => {
|
|
|
60
60
|
});
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
+
describe("#4655 multi-requirement amendment references", () => {
|
|
64
|
+
it("R1/R2, 'R1, R2' and an en-dash range split into one applied row per id", () => {
|
|
65
|
+
for (const requirement of ["R1/R2", "R1, R2", "R1\u2013R2", "R1 and R2"]) {
|
|
66
|
+
const a = applyAmendments(C, [{ kind: "defer", requirement, why: "w" }]);
|
|
67
|
+
assert.deepEqual(a.problems, [], requirement);
|
|
68
|
+
assert.deepEqual(a.toTrack.map((r) => r.id).sort(), ["R1", "R2"], requirement);
|
|
69
|
+
assert.deepEqual(a.applied.map((x) => x.target), ["R1", "R2"], requirement);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
it("an unknown id inside a list is still a problem; scope-note stays an unknown kind", () => {
|
|
73
|
+
assert.match(applyAmendments(C, [{ kind: "defer", requirement: "R1/R9", why: "w" }]).problems[0], /unknown requirement R9/);
|
|
74
|
+
assert.match(applyAmendments(C, [{ kind: "scope-note", requirement: "R1", why: "w" }]).problems[0], /unknown kind 'scope-note'; valid kinds are/);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
63
78
|
describe("#4574 amendment slips", () => {
|
|
64
79
|
it("type for kind, amend-requirement and limitation apply as defer; an R# id fills requirement", () => {
|
|
65
80
|
const a = applyAmendments(C, [{ kind: "amend-requirement", id: "R2", why: "stored hashed" }, { id: "A1", requirement: "R1", type: "limitation", why: "plugin limit" }]);
|
|
@@ -115,6 +130,11 @@ describe("precheck (deterministic, no reviewer)", () => {
|
|
|
115
130
|
const failed = precheck({ contract: C2, result: { ...res, validation: [{ id: "V1", exit: 1 }, { id: "V2", ci: true }, { id: "V3", exit: 0 }] }, changedFiles: files, ciVerified: true });
|
|
116
131
|
assert.deepEqual(failed.failures.map((f) => f.kind), ["validate_failed"]); assert.deepEqual(failed.ciCovered, ["V2"]);
|
|
117
132
|
});
|
|
133
|
+
it("#4708 a validate row carried forward across a CI fix round counts as recorded; a row absent outside a CI-verified head still fails", () => {
|
|
134
|
+
const carried = precheck({ contract: C, result: { ...res, validation: [{ id: "V1", exit: 0, carried: true }] }, changedFiles: files });
|
|
135
|
+
assert.deepEqual(codes(carried), []);
|
|
136
|
+
assert.deepEqual(precheck({ contract: C, result: { ...res, validation: [] }, changedFiles: files }).failures.map((f) => f.kind), ["validate_missing"]);
|
|
137
|
+
});
|
|
118
138
|
it("#4323 missingTool: commands, python and node modules; paths, relative and repo-tracked names are not the environment", () => {
|
|
119
139
|
assert.deepEqual(missingTool({ rc: 127, output: "sh: 1: jq: not found" }), { tool: "jq", kind: "command" });
|
|
120
140
|
assert.deepEqual(missingTool({ rc: 127, output: "" }, { command: "uv run pytest -q" }), { tool: "uv", kind: "command" });
|
|
@@ -691,8 +711,109 @@ describe("post-deploy check ids (#4626)", () => {
|
|
|
691
711
|
assert.ok(!precheck({ contract: c, result: res, changedFiles: files, checkIds: new Set(["login-smoke"]) }).failures.some((f) => f.kind === "check_unknown"));
|
|
692
712
|
assert.ok(!precheck({ contract: c, result: res, changedFiles: files }).failures.some((f) => f.kind === "check_unknown"));
|
|
693
713
|
});
|
|
714
|
+
it("#4664 followupItems carries the follow-up class; validateContract rejects an unknown class", () => {
|
|
715
|
+
const cls = (r) => followupItems([{ id: "R1", quote: Q1, why: "w", ...r }])[0].class;
|
|
716
|
+
assert.equal(cls({ disposition: "defer" }), "engineering");
|
|
717
|
+
assert.equal(cls({ disposition: "post-deploy", check: "x" }), "post-deploy");
|
|
718
|
+
assert.equal(cls({ disposition: "defer", class: "external" }), "external");
|
|
719
|
+
assert.equal(cls({ disposition: "defer", class: "bogus" }), "engineering");
|
|
720
|
+
assert.deepEqual(FOLLOWUP_CLASSES, ["engineering", "post-deploy", "external", "ops"]);
|
|
721
|
+
assert.equal(followupClass({ disposition: "post-deploy" }), "post-deploy"); assert.equal(followupClass({ class: "ops" }), "ops");
|
|
722
|
+
const v = validateContract({ requirements: [{ id: "R1", quote: Q1, disposition: "defer", why: "w", class: "bogus" }], deliverables: [], preserve: [], validate: [{ id: "V1", command: "true" }] }, Q1);
|
|
723
|
+
assert.ok(v.problems.some((x) => /R1: class must be one of/.test(x)), v.problems.join(";"));
|
|
724
|
+
});
|
|
694
725
|
it("followupItems names the check in a post-deploy item", () => {
|
|
695
726
|
const [i] = followupItems([{ id: "R1", quote: Q1, disposition: "post-deploy", why: "w", check: "login-smoke" }]);
|
|
696
727
|
assert.match(i.why, /^post-deploy: \[check:login-smoke\] w$/);
|
|
697
728
|
});
|
|
729
|
+
it("#4724 a deferred row needs exactly one valid bucket; owner actions are never post-deploy; dates are absolute", () => {
|
|
730
|
+
const contract = (r) => ({ requirements: [{ id: "R1", quote: Q1, why: "w", ...r }], deliverables: [], preserve: [], validate: [{ id: "V1", command: "true" }] });
|
|
731
|
+
const probs = (r) => validateContract(contract(r), Q1).problems.filter((x) => /^R1: .*(bucket|never post-deploy|owner)/.test(x));
|
|
732
|
+
assert.deepEqual(BUCKETS, ["post-deploy", "verify-now", "date", "owner", "new-issue"]);
|
|
733
|
+
assert.equal(probs({ disposition: "defer" }).length, 1); // missing bucket
|
|
734
|
+
for (const bucket of ["post-deploy", "verify-now", "new-issue", "date:2026-12-01", "owner:rotate the key"]) assert.deepEqual(probs({ disposition: "defer", bucket }), [], bucket);
|
|
735
|
+
for (const bucket of ["date:5 weeks after deploy", "date:2026-02-30", "date", "owner:", "owner", "later", "post-deploy:x"]) assert.equal(probs({ disposition: "defer", bucket }).length, 1, bucket);
|
|
736
|
+
assert.match(probs({ disposition: "post-deploy", check: "a-smoke", bucket: "owner:spend" })[0], /never post-deploy/);
|
|
737
|
+
assert.equal(probs({ disposition: "deliver", bucket: "post-deploy" }).length, 1);
|
|
738
|
+
assert.deepEqual(probs({ disposition: "deliver" }), []);
|
|
739
|
+
assert.deepEqual(bucketProblems({ disposition: "defer", bucket: "owner:x", class: "post-deploy" }).length, 1);
|
|
740
|
+
assert.equal(bucketKind("date:2026-12-01"), "date"); assert.equal(bucketKind("nope"), null);
|
|
741
|
+
});
|
|
742
|
+
it("#4724 the bucket decides the follow-up class and tags the item for the script and the gate", () => {
|
|
743
|
+
const one = (r) => followupItems([{ id: "R1", quote: Q1, disposition: "defer", why: "w", ...r }], { now: Date.UTC(2026, 9, 27) })[0];
|
|
744
|
+
assert.equal(followupClass({ disposition: "defer", bucket: "owner:spend" }), "external");
|
|
745
|
+
assert.equal(followupClass({ disposition: "defer", bucket: "owner:spend", class: "ops" }), "ops");
|
|
746
|
+
assert.equal(followupClass({ disposition: "defer", bucket: "owner:x", class: "post-deploy" }), "external");
|
|
747
|
+
assert.equal(followupClass({ disposition: "defer", bucket: "new-issue", class: "post-deploy" }), "engineering");
|
|
748
|
+
for (const bucket of ["post-deploy", "verify-now", "date:2026-12-01"]) assert.equal(followupClass({ disposition: "defer", bucket }), "post-deploy");
|
|
749
|
+
assert.equal(one({ bucket: "owner:rotate the key" }).why, "owner: rotate the key. [bucket:owner] w");
|
|
750
|
+
assert.equal(one({ bucket: "verify-now" }).why, "post-deploy: [bucket:verify-now] w");
|
|
751
|
+
assert.equal(one({ bucket: "date:2026-12-01" }).why, "post-deploy: [bucket:date] due:2026-12-01 from:2026-10-27 w");
|
|
752
|
+
assert.equal(one({ bucket: "new-issue" }).why, "defer: [bucket:new-issue] w");
|
|
753
|
+
assert.equal(one({}).why, "defer: w"); // an unbucketed (older) row is unchanged
|
|
754
|
+
});
|
|
755
|
+
it("#4724 bucketTally counts deferred items per bucket kind; an amendment may carry the bucket", () => {
|
|
756
|
+
assert.deepEqual(bucketTally([{ bucket: "date:2026-12-01" }, { bucket: "date:2026-12-02" }, { bucket: "owner:x" }, {}]), { date: 2, owner: 1, none: 1 });
|
|
757
|
+
const c = { requirements: [{ id: "R1", quote: Q1, disposition: "deliver" }], deliverables: [], preserve: [], validate: [] };
|
|
758
|
+
const r = applyAmendments(c, [{ kind: "defer", requirement: "R1", why: "w", bucket: "verify-now" }]);
|
|
759
|
+
assert.equal(r.contract.requirements[0].bucket, "verify-now"); assert.equal(r.toTrack.length, 1);
|
|
760
|
+
});
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
describe("#4726 a [type:e2e] requirement needs a real end-to-end validate", () => {
|
|
764
|
+
const Q = "Users can sign up and sign in with an emailed code alone [type:e2e]";
|
|
765
|
+
const mk = (cmd, quote = Q, extra = {}) => {
|
|
766
|
+
const contract = { ...C, requirements: [{ id: "R1", quote, disposition: "deliver", ...extra }, C.requirements[1]], validate: [{ id: "V1", command: cmd }] };
|
|
767
|
+
return { contract, ctx: { ...ctx, contract } };
|
|
768
|
+
};
|
|
769
|
+
const rv = (contract) => ({ ...good, issue_coverage: [{ quote: contract.requirements[0].quote, status: "covered" }, ...good.issue_coverage.slice(1)] });
|
|
770
|
+
const run = (cmd, quote, extra, e2e) => { const m = mk(cmd, quote, extra); return decide(rv(m.contract), { ...m.ctx, issueText: `${ISSUE}\n${m.contract.requirements[0].quote}`, e2e }); };
|
|
771
|
+
it("e2e requirement verified only by a unit test fails conformance (the #36414 shape)", () => {
|
|
772
|
+
const d = run("pytest tests/test_email_code_auth.py");
|
|
773
|
+
assert.equal(d.state, "nonconforming");
|
|
774
|
+
assert.deepEqual(codes(d), ["conformance.e2e_unverified"]);
|
|
775
|
+
});
|
|
776
|
+
it("a Playwright, configured e2e check or e2e spec file validate passes", () => {
|
|
777
|
+
assert.equal(run("npx playwright test e2e/signup.spec.ts").state, "conforms");
|
|
778
|
+
assert.equal(run("make smoke-live", Q, {}, { checks: ["make smoke-live"] }).state, "conforms");
|
|
779
|
+
assert.equal(run("node --test tests/signup.js", Q, {}, { isSpec: (f) => f === "tests/signup.js" }).state, "conforms");
|
|
780
|
+
});
|
|
781
|
+
it("isE2eCommand and e2eVerified resolve a V-ref to its contract command", () => {
|
|
782
|
+
assert.equal(isE2eCommand("npx playwright test"), true);
|
|
783
|
+
assert.equal(isE2eCommand("pytest tests/test_x.py"), false);
|
|
784
|
+
const contract = { validate: [{ id: "V1", command: "pytest tests/a.py" }, { id: "V2", command: "npx cypress run" }] };
|
|
785
|
+
assert.equal(e2eVerified({ validated_by: "V1" }, contract), false);
|
|
786
|
+
assert.equal(e2eVerified({ validated_by: "V1, V2" }, contract), true);
|
|
787
|
+
});
|
|
788
|
+
it("[type:unit] and [type:api] requirements keep their evidence rules; not-applicable is untouched", () => {
|
|
789
|
+
assert.equal(run("pytest tests/test_x.py", "Users can sign up with an emailed code [type:unit]").state, "conforms");
|
|
790
|
+
assert.equal(run("pytest tests/test_x.py", "Users can sign up with an emailed code [type:api]").state, "conforms");
|
|
791
|
+
const na = { ...C.requirements[0], quote: Q, disposition: "not-applicable", evidence: "src/a.js:1" };
|
|
792
|
+
const contract = { ...C, requirements: [na, C.requirements[1]] };
|
|
793
|
+
const r = { ...good, issue_coverage: [{ quote: Q, status: "covered" }, ...good.issue_coverage.slice(1)], requirements: [{ id: "R1", status: "not-applicable" }, good.requirements[1]] };
|
|
794
|
+
assert.notEqual(decide(r, { ...ctx, contract, issueText: `${ISSUE}\n${Q}` }).failures.some((f) => f.code === "conformance.e2e_unverified"), true);
|
|
795
|
+
});
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
describe("decision amendments (#4695)", () => {
|
|
799
|
+
const withRow = { ...C, preserve: [...C.preserve, { id: "H1", what: "use A", source: "decision", decision: "d9-1" }] };
|
|
800
|
+
const amend = (extra, opts = { baseFiles: ["src/scrape.js", "src/search.js"] }) => applyAmendments(withRow, [{ kind: "decision", why: "w", decision: { choice: "use B", anchors: ["src/**"], ...extra } }], null, opts);
|
|
801
|
+
it("applies a decision whose anchors exist on the base and returns the record", () => {
|
|
802
|
+
const a = amend({});
|
|
803
|
+
assert.deepEqual(a.problems, []);
|
|
804
|
+
assert.equal(a.decisions[0].choice, "use B");
|
|
805
|
+
assert.ok(a.contract.preserve.some((p) => p.id === "H1"));
|
|
806
|
+
});
|
|
807
|
+
it("rejects an anchor that matches no base file", () => {
|
|
808
|
+
const a = amend({ anchors: ["nope/**"] });
|
|
809
|
+
assert.match(a.problems[0], /match no file on the base/);
|
|
810
|
+
assert.equal(a.decisions.length, 0);
|
|
811
|
+
});
|
|
812
|
+
it("supersedes drops the engine-authored preserve row; without it the row stays", () => {
|
|
813
|
+
assert.ok(!amend({ supersedes: "d9-1" }).contract.preserve.some((p) => p.id === "H1"));
|
|
814
|
+
assert.ok(amend({}).contract.preserve.some((p) => p.id === "H1"));
|
|
815
|
+
});
|
|
816
|
+
it("a decision without choice or anchors is an incomplete amendment", () => {
|
|
817
|
+
assert.match(amend({ choice: "" }).problems[0], /needs decision/);
|
|
818
|
+
});
|
|
698
819
|
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* #4695: anchored decisions. A settled choice carries the paths/globs (and optional symbols) it governs and an optional
|
|
5
|
+
* `supersedes` link. The record of truth is a `<!-- FORGE:DECISION {json} -->` issue comment (the JSON sits on the
|
|
6
|
+
* marker line; prose FORGE:DECISION comments carry none and are ignored); `~/.forge/decisions/<repo>.json` indexes it.
|
|
7
|
+
* Supersession appends a fresh record of the old decision (status superseded, superseded_by) on the old issue: the
|
|
8
|
+
* latest record per id wins, nothing is edited. When a lane's deliverables hit an anchor the decision becomes an
|
|
9
|
+
* engine-authored preserve row `{id:"H<n>", what, source:"decision"}` (same shape as #4694), capped at HISTORY_ROWS_MAX.
|
|
10
|
+
* Pure except the index read/write; no LLM, gh or git here.
|
|
11
|
+
*/
|
|
12
|
+
import * as nodeFs from "node:fs";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { noComments } from "./render.mjs";
|
|
16
|
+
|
|
17
|
+
export const DECISION_MARKER = "FORGE:DECISION";
|
|
18
|
+
/** Preserve rows the engine adds from history per lane; guards (#4694) share this cap. */
|
|
19
|
+
export const HISTORY_ROWS_MAX = 5;
|
|
20
|
+
const TEXT_MAX = 200;
|
|
21
|
+
const ANCHORS_MAX = 10;
|
|
22
|
+
const MARKER_RE = /^<!--\s*FORGE:DECISION\s+(\{.*\})\s*-->/;
|
|
23
|
+
|
|
24
|
+
const one = (v, max = TEXT_MAX) => {
|
|
25
|
+
const t = typeof v === "string" ? noComments(v, " ").replace(/\s+/g, " ").trim() : "";
|
|
26
|
+
return t.length > max ? `${t.slice(0, max - 1)}…` : t;
|
|
27
|
+
};
|
|
28
|
+
const strs = (v, max = ANCHORS_MAX, len = 200) => [...new Set((Array.isArray(v) ? v : []).map((x) => one(x, len)).filter(Boolean))].slice(0, max);
|
|
29
|
+
const cleanAnchor = (a) => String(a).replace(/^\.\//, "").replace(/^\/+/, "");
|
|
30
|
+
|
|
31
|
+
/** A bounded decision record from raw input, or null when it has no choice or no anchor. `issue` is the register issue. */
|
|
32
|
+
export function normaliseDecision(raw, { issue = null, n = 1 } = {}) {
|
|
33
|
+
const r = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
|
|
34
|
+
const choice = one(r.choice), anchors = strs(r.anchors).map(cleanAnchor);
|
|
35
|
+
if (!choice || !anchors.length) return null;
|
|
36
|
+
const iss = Number.isInteger(r.issue) ? r.issue : issue;
|
|
37
|
+
const status = r.status === "superseded" ? "superseded" : "active";
|
|
38
|
+
const rec = { v: 1, id: one(r.id, 60) || `d${iss ?? 0}-${n}`, choice, why: one(r.why), anchors, issue: iss, status };
|
|
39
|
+
const symbols = strs(r.symbols, ANCHORS_MAX, 80);
|
|
40
|
+
if (symbols.length) rec.symbols = symbols;
|
|
41
|
+
if (one(r.supersedes, 60)) rec.supersedes = one(r.supersedes, 60);
|
|
42
|
+
if (status === "superseded" && one(r.superseded_by, 60)) rec.superseded_by = one(r.superseded_by, 60);
|
|
43
|
+
return rec;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The marker (for post(): `<!-- FORGE:DECISION {json} -->`) and readable body of a decision record. */
|
|
47
|
+
export function renderDecision(rec) {
|
|
48
|
+
const json = JSON.stringify(rec).replace(/>/g, "\\u003e").replace(/</g, "\\u003c"); // can never close the marker comment
|
|
49
|
+
const tail = [rec.supersedes ? `Supersedes \`${rec.supersedes}\`.` : "", rec.status === "superseded" ? `Superseded by \`${rec.superseded_by}\`.` : ""].filter(Boolean).join(" ");
|
|
50
|
+
return {
|
|
51
|
+
marker: `${DECISION_MARKER} ${json}`,
|
|
52
|
+
body: `## Decision \`${rec.id}\`${rec.status === "superseded" ? " (superseded)" : ""}\n**What**: ${rec.choice}\n**Why**: ${rec.why || "-"}\n**Governs**: ${rec.anchors.map((a) => `\`${a}\``).join(", ")}${tail ? `\n${tail}` : ""}`,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The record from a comment body whose first line is `<!-- FORGE:DECISION {json} -->`; null for prose or malformed. */
|
|
57
|
+
export function parseDecisionComment(body, { issue = null } = {}) {
|
|
58
|
+
const m = MARKER_RE.exec(String(body ?? "").split("\n", 1)[0]);
|
|
59
|
+
if (!m) return null;
|
|
60
|
+
try {
|
|
61
|
+
const raw = JSON.parse(m[1]);
|
|
62
|
+
return normaliseDecision({ ...raw, issue: Number.isInteger(raw?.issue) ? raw.issue : issue });
|
|
63
|
+
} catch { return null; }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** One record per id, the latest wins (input in posting order); ids ordered by first appearance. */
|
|
67
|
+
export function foldDecisions(records) {
|
|
68
|
+
const by = new Map();
|
|
69
|
+
for (const r of records ?? []) if (r?.id) by.set(r.id, { ...(by.get(r.id) ?? {}), ...r });
|
|
70
|
+
return [...by.values()];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const globRe = (g) => new RegExp(`^${String(g).replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*\/?/g, "\u0000").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]").replace(/\u0000/g, ".*")}$`);
|
|
74
|
+
/** True when `anchor` (a file, a `dir/` prefix or a glob) covers `path`. */
|
|
75
|
+
export function anchorMatches(anchor, path) {
|
|
76
|
+
const a = cleanAnchor(anchor), p = cleanAnchor(path);
|
|
77
|
+
if (!a || !p) return false;
|
|
78
|
+
if (a.endsWith("/")) return p.startsWith(a);
|
|
79
|
+
return a === p || globRe(a).test(p) || p.startsWith(`${a}/`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The anchors that match no file of `files`. */
|
|
83
|
+
export const deadAnchors = (anchors, files) => (anchors ?? []).filter((a) => !(files ?? []).some((f) => anchorMatches(a, f)));
|
|
84
|
+
|
|
85
|
+
/** Active decisions with at least one dead anchor on the base file list: [{decision, dead}]. Never applied. */
|
|
86
|
+
export function staleDecisions(records, baseFiles) {
|
|
87
|
+
const out = [];
|
|
88
|
+
for (const d of foldDecisions(records)) {
|
|
89
|
+
if (d.status === "superseded") continue;
|
|
90
|
+
const dead = deadAnchors(d.anchors, baseFiles);
|
|
91
|
+
if (dead.length) out.push({ decision: d, dead });
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Preserve rows for the active decisions an anchor of which covers a deliverable path: most specific anchor first, then
|
|
98
|
+
* newest issue, then id; at most `cap` rows, ids H<start>… Deterministic: the same inputs give the same rows.
|
|
99
|
+
*/
|
|
100
|
+
export function historyRows(records, deliverablePaths, { cap = HISTORY_ROWS_MAX, start = 1 } = {}) {
|
|
101
|
+
const paths = (deliverablePaths ?? []).filter(Boolean);
|
|
102
|
+
if (!paths.length || cap <= 0) return [];
|
|
103
|
+
const hits = [];
|
|
104
|
+
for (const d of foldDecisions(records)) {
|
|
105
|
+
if (d.status === "superseded") continue;
|
|
106
|
+
const spec = Math.max(-1, ...d.anchors.filter((a) => paths.some((p) => anchorMatches(a, p))).map((a) => cleanAnchor(a).length));
|
|
107
|
+
if (spec >= 0) hits.push({ d, spec });
|
|
108
|
+
}
|
|
109
|
+
hits.sort((x, y) => y.spec - x.spec || (y.d.issue ?? 0) - (x.d.issue ?? 0) || (x.d.id < y.d.id ? -1 : x.d.id > y.d.id ? 1 : 0));
|
|
110
|
+
return hits.slice(0, cap).map(({ d }, i) => ({
|
|
111
|
+
id: `H${start + i}`,
|
|
112
|
+
what: `${d.choice}${d.why ? ` (${d.why})` : ""} [decision ${d.id}${d.issue ? `, #${d.issue}` : ""}; to change it, amend {kind:"decision", decision:{choice, anchors, supersedes:"${d.id}"}, why}]`,
|
|
113
|
+
source: "decision",
|
|
114
|
+
decision: d.id,
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The contract with history rows appended to preserve; the same object when none apply (the hash stays byte-identical). */
|
|
119
|
+
export function withDecisionRows(contract, records) {
|
|
120
|
+
const have = new Set((contract?.preserve ?? []).map((p) => p.id));
|
|
121
|
+
let start = 1;
|
|
122
|
+
while (have.has(`H${start}`)) start++;
|
|
123
|
+
const room = HISTORY_ROWS_MAX - (contract?.preserve ?? []).filter((p) => /^H\d+$/.test(String(p.id))).length;
|
|
124
|
+
const rows = historyRows(records, (contract?.deliverables ?? []).map((d) => d.path), { cap: room, start });
|
|
125
|
+
return rows.length ? { ...contract, preserve: [...(contract.preserve ?? []), ...rows] } : contract;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** {old, next}: the old record marked superseded by the new id, the new one linked back with `supersedes`. */
|
|
129
|
+
export function supersede(old, next) {
|
|
130
|
+
return { old: { ...old, status: "superseded", superseded_by: next.id }, next: { ...next, supersedes: old.id } };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export const decisionsDir = () => process.env.FORGEDOCK_DECISIONS_DIR || join(homedir(), ".forge", "decisions");
|
|
134
|
+
export const indexFile = (repo) => join(decisionsDir(), `${String(repo ?? "repo").replace(/[^A-Za-z0-9._-]/g, "_")}.json`);
|
|
135
|
+
|
|
136
|
+
/** The indexed records (posting order); [] when the index is absent or unreadable. One file read. */
|
|
137
|
+
export function readDecisionIndex(repo, { fs = nodeFs } = {}) {
|
|
138
|
+
try {
|
|
139
|
+
const j = JSON.parse(fs.readFileSync(indexFile(repo), "utf8"));
|
|
140
|
+
return (Array.isArray(j?.records) ? j.records : []).map((r) => normaliseDecision(r)).filter(Boolean);
|
|
141
|
+
} catch { return []; }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Replaces the index (written atomically); best effort, the register of record is GitHub. */
|
|
145
|
+
export function writeDecisionIndex(repo, records, { fs = nodeFs } = {}) {
|
|
146
|
+
try {
|
|
147
|
+
const file = indexFile(repo);
|
|
148
|
+
fs.mkdirSync(decisionsDir(), { recursive: true });
|
|
149
|
+
fs.writeFileSync(`${file}.tmp`, JSON.stringify({ v: 1, records: foldDecisions(records) }));
|
|
150
|
+
fs.renameSync(`${file}.tmp`, file);
|
|
151
|
+
return true;
|
|
152
|
+
} catch { return false; }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Posts the lane's decision records as FORGE:DECISION on `issue` and, for a `supersedes`, the old record marked
|
|
157
|
+
* superseded on its own issue (both records updated); then updates the index. `post(marker, body, onIssue)` posts a
|
|
158
|
+
* comment. Returns the records posted. Best effort per record: a failed post is skipped, never fails the lane.
|
|
159
|
+
*/
|
|
160
|
+
export async function recordDecisions(list, { issue, repo, post, fs = nodeFs }) {
|
|
161
|
+
const index = foldDecisions(readDecisionIndex(repo, { fs })), out = [];
|
|
162
|
+
let n = 0;
|
|
163
|
+
for (const raw of list ?? []) {
|
|
164
|
+
n++;
|
|
165
|
+
let next = normaliseDecision({ ...raw, id: undefined, issue }, { issue, n });
|
|
166
|
+
if (!next) continue;
|
|
167
|
+
const old = raw.supersedes ? index.find((d) => d.id === raw.supersedes) : null;
|
|
168
|
+
let oldRec = null;
|
|
169
|
+
if (old) ({ old: oldRec, next } = supersede(old, next));
|
|
170
|
+
else if (raw.supersedes) next = { ...next, supersedes: String(raw.supersedes) };
|
|
171
|
+
try {
|
|
172
|
+
const m = renderDecision(next);
|
|
173
|
+
await post(m.marker, m.body, issue);
|
|
174
|
+
if (oldRec?.issue) { const o = renderDecision(oldRec); await post(o.marker, o.body, oldRec.issue); }
|
|
175
|
+
} catch { continue; }
|
|
176
|
+
index.push(...[oldRec, next].filter(Boolean));
|
|
177
|
+
out.push(next);
|
|
178
|
+
}
|
|
179
|
+
if (out.length) writeDecisionIndex(repo, index, { fs });
|
|
180
|
+
return out;
|
|
181
|
+
}
|