forgedock 1.27.0 → 1.29.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.
Files changed (159) hide show
  1. package/README.md +4 -1
  2. package/bin/bootstrap.mjs +55 -0
  3. package/bin/engine/outcome-codes.mjs +39 -0
  4. package/bin/engine/telemetry-seam.mjs +37 -8
  5. package/bin/engine/telemetry-seam.test.mjs +31 -0
  6. package/bin/engine-cli.mjs +601 -42
  7. package/bin/engine.mjs +8 -4
  8. package/bin/forgedock.mjs +75 -9
  9. package/bin/gc.mjs +580 -0
  10. package/bin/lean/admission.mjs +33 -3
  11. package/bin/lean/batch.mjs +22 -4
  12. package/bin/lean/batch.test.mjs +5 -3
  13. package/bin/lean/checks.mjs +199 -0
  14. package/bin/lean/checks.test.mjs +183 -0
  15. package/bin/lean/conformance.mjs +234 -57
  16. package/bin/lean/conformance.test.mjs +168 -7
  17. package/bin/lean/context.mjs +333 -0
  18. package/bin/lean/context.test.mjs +279 -0
  19. package/bin/lean/credentials.mjs +29 -0
  20. package/bin/lean/depcache.mjs +312 -0
  21. package/bin/lean/depcache.test.mjs +298 -0
  22. package/bin/lean/drift.mjs +198 -0
  23. package/bin/lean/drift.test.mjs +223 -0
  24. package/bin/lean/effects.mjs +104 -9
  25. package/bin/lean/execution.e2e.test.mjs +81 -0
  26. package/bin/lean/execution.mjs +431 -0
  27. package/bin/lean/execution.test.mjs +583 -0
  28. package/bin/lean/heavy.mjs +289 -0
  29. package/bin/lean/heavy.test.mjs +207 -0
  30. package/bin/lean/lean.mjs +723 -154
  31. package/bin/lean/lean.test.mjs +1603 -35
  32. package/bin/lean/ledger.mjs +27 -2
  33. package/bin/lean/ledger.test.mjs +36 -1
  34. package/bin/lean/limits.mjs +58 -0
  35. package/bin/lean/limits.test.mjs +150 -0
  36. package/bin/lean/orchestrate.mjs +389 -19
  37. package/bin/lean/prefilter.mjs +2 -2
  38. package/bin/lean/prefilter.test.mjs +3 -0
  39. package/bin/lean/prompts.mjs +106 -17
  40. package/bin/lean/recovery.test.mjs +230 -0
  41. package/bin/lean/refactor.mjs +276 -0
  42. package/bin/lean/refactor.test.mjs +359 -0
  43. package/bin/lean/render.mjs +49 -10
  44. package/bin/lean/review-trigger.mjs +134 -0
  45. package/bin/lean/review-trigger.test.mjs +104 -0
  46. package/bin/lean/sandbox-provider.contract.test.mjs +257 -0
  47. package/bin/lean/sandbox-remote.mjs +142 -0
  48. package/bin/lean/sched.mjs +221 -0
  49. package/bin/lean/sched.test.mjs +310 -0
  50. package/bin/lean/schedd.mjs +247 -0
  51. package/bin/lean/schedd.test.mjs +180 -0
  52. package/bin/lean/testmap.mjs +84 -0
  53. package/bin/lean/testmap.test.mjs +58 -0
  54. package/bin/lean/toolchain.mjs +228 -0
  55. package/bin/lean/toolchain.test.mjs +154 -0
  56. package/bin/lean/verify.mjs +66 -10
  57. package/bin/lean/verify.test.mjs +104 -3
  58. package/bin/lean/worktree.mjs +69 -0
  59. package/bin/lean/worktree.test.mjs +141 -0
  60. package/bin/runner.mjs +53 -10
  61. package/bin/stats/github-source.mjs +1 -1
  62. package/bin/stats/index.mjs +24 -2
  63. package/bin/stats/local-source.mjs +13 -0
  64. package/bin/stats/query.mjs +99 -0
  65. package/bin/stats/stats.test.mjs +94 -1
  66. package/commands/audit.md +2 -0
  67. package/commands/autopilot.md +2 -0
  68. package/commands/bootstrap.md +35 -0
  69. package/commands/cleanup.md +7 -22
  70. package/commands/forge-stats.md +2 -1
  71. package/commands/forgedock-init.md +7 -0
  72. package/commands/issue.md +89 -830
  73. package/commands/milestone.md +55 -6
  74. package/commands/orchestrate.md +62 -8
  75. package/commands/upgrade-deps.md +3 -3
  76. package/commands/work-on/build/validate.md +4 -0
  77. package/commands/work-on/close.md +2 -0
  78. package/commands/work-on/decompose.md +24 -6
  79. package/docs/CI.md +3 -3
  80. package/docs/CONFIG.md +192 -2
  81. package/docs/OUTCOME-CODES.md +22 -0
  82. package/docs/RELEASE-NOTES.md +9 -0
  83. package/docs/TELEMETRY.md +1 -1
  84. package/docs/site/command-reference.md +22 -0
  85. package/docs/site/getting-started.md +2 -0
  86. package/docs/site/start-a-new-project.md +327 -0
  87. package/package.json +1 -1
  88. package/scripts/affected-files-check.sh +91 -0
  89. package/scripts/affected-files-check.test.sh +80 -0
  90. package/scripts/bootstrap-create.mjs +393 -0
  91. package/scripts/bootstrap-validate.mjs +36 -2
  92. package/scripts/calibration.mjs +116 -0
  93. package/scripts/check-devdocs-freshness.sh +9 -4
  94. package/scripts/check-devdocs-freshness.test.sh +11 -1
  95. package/scripts/check-spec-markers.sh +4 -0
  96. package/scripts/check-spec-size.sh +29 -11
  97. package/scripts/check-spec-size.test.sh +27 -4
  98. package/scripts/ci-local/lib.mjs +8 -1
  99. package/scripts/ci-local/parallelism.test.mjs +27 -0
  100. package/scripts/ci-local/run.mjs +3 -3
  101. package/scripts/ci-local/scoping.mjs +82 -0
  102. package/scripts/ci-local.test.sh +1 -1
  103. package/scripts/fixtures/symbol-index/other/sample.rb +3 -0
  104. package/scripts/fixtures/symbol-index/py/pkg/__init__.py +1 -0
  105. package/scripts/fixtures/symbol-index/py/pkg/a.py +5 -0
  106. package/scripts/fixtures/symbol-index/py/pkg/b.py +14 -0
  107. package/scripts/fixtures/symbol-index/py/pkg/c.py +1 -0
  108. package/scripts/fixtures/symbol-index/py/sample.py +41 -0
  109. package/scripts/fixtures/symbol-index/ts/sample.ts +13 -0
  110. package/scripts/fixtures/symbol-move/gen_big.py +201 -0
  111. package/scripts/fixtures/symbol-move-ts/gen_big.mjs +56 -0
  112. package/scripts/fixtures/test-map/go/.github/workflows/ci.yml +23 -0
  113. package/scripts/fixtures/test-map/go/Makefile +5 -0
  114. package/scripts/fixtures/test-map/go/README.md +1 -0
  115. package/scripts/fixtures/test-map/go/go.mod +3 -0
  116. package/scripts/fixtures/test-map/go/internal/util/util.go +3 -0
  117. package/scripts/fixtures/test-map/go/main.go +3 -0
  118. package/scripts/fixtures/test-map/node/package.json +10 -0
  119. package/scripts/fixtures/test-map/node/packages/ui/package.json +4 -0
  120. package/scripts/fixtures/test-map/node/packages/ui/src/button.tsx +1 -0
  121. package/scripts/fixtures/test-map/node/pnpm-lock.yaml +1 -0
  122. package/scripts/fixtures/test-map/node/src/a.ts +1 -0
  123. package/scripts/fixtures/test-map/node/vitest.config.ts +1 -0
  124. package/scripts/fixtures/test-map/python/pkg/__init__.py +0 -0
  125. package/scripts/fixtures/test-map/python/pkg/mod.py +2 -0
  126. package/scripts/fixtures/test-map/python/pyproject.toml +9 -0
  127. package/scripts/fixtures/test-map/python/tests/test_mod.py +5 -0
  128. package/scripts/fixtures/test-map/python/uv.lock +1 -0
  129. package/scripts/forge-root.test.sh +1 -1
  130. package/scripts/hotspots.sh +100 -0
  131. package/scripts/hotspots.test.sh +101 -0
  132. package/scripts/issue-create.sh +197 -0
  133. package/scripts/issue-create.test.sh +166 -0
  134. package/scripts/maintenance-scan.sh +185 -0
  135. package/scripts/maintenance-scan.test.sh +89 -0
  136. package/scripts/milestone-drift.sh +206 -0
  137. package/scripts/milestone-drift.test.sh +120 -0
  138. package/scripts/refactor-verify.sh +368 -0
  139. package/scripts/refactor-verify.test.sh +202 -0
  140. package/scripts/repo-facts.sh +146 -0
  141. package/scripts/repo-facts.test.sh +106 -0
  142. package/scripts/structure-delta.sh +10 -5
  143. package/scripts/structure-delta.test.sh +3 -2
  144. package/scripts/symbol-index.mjs +253 -0
  145. package/scripts/symbol-index.py +354 -0
  146. package/scripts/symbol-index.test.sh +119 -0
  147. package/scripts/symbol-move-ts.test.sh +161 -0
  148. package/scripts/symbol-move.mjs +379 -0
  149. package/scripts/symbol-move.py +383 -0
  150. package/scripts/symbol-move.test.sh +168 -0
  151. package/scripts/test-map.sh +211 -0
  152. package/scripts/test-map.test.sh +165 -0
  153. package/scripts/wait-ci-green.sh +10 -4
  154. package/scripts/wait-ci-green.test.sh +9 -0
  155. package/templates/bootstrap/interview.md +34 -0
  156. package/templates/bootstrap/issue-body.md +42 -0
  157. package/templates/bootstrap/needs-human-issue.md +45 -0
  158. package/templates/bootstrap/stack-profiles.md +34 -0
  159. package/templates/bootstrap/technical-spike.md +75 -0
@@ -8,14 +8,18 @@
8
8
  * "PASS") is incomplete, and incomplete never merges.
9
9
  */
10
10
  import { OUTCOME_CODES } from "../engine/outcome-codes.mjs";
11
+ import { noComments } from "./context.mjs";
11
12
 
12
13
  export const DISPOSITIONS = Object.freeze(["deliver", "defer", "not-applicable", "post-deploy"]);
13
14
  /** Dispositions the engine files as a tracked follow-up issue before anything merges. */
14
15
  export const TRACKED = Object.freeze(["defer", "post-deploy"]);
16
+ /** forge#4165: where a plan decision's justification comes from; audits filter on lint|precedent. */
17
+ export const DECISION_SOURCES = Object.freeze(["doc", "lint", "precedent", "spec"]);
15
18
  const REQ_STATUS = ["implemented", "partial", "missing", "deferred", "post-deploy", "not-applicable"];
16
19
  const COVER_STATUS = ["covered", "narrowed", "missing"];
17
20
  const PRESERVE_STATUS = ["intact", "violated", "unverified"];
18
- const EVIDENCE_RE = /^([^\s:]+):(\d+)/;
21
+ /** The first path:line anywhere in the evidence text (backticks, quotes and brackets around it are ignored). */
22
+ const EVIDENCE_RE = /([^\s:`'"()[\]<>,;]+):(\d+)/;
19
23
  /** 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
24
  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
25
  /** Files any change may touch without being a deliverable: changelogs and dependency lockfiles. */
@@ -37,6 +41,29 @@ export function inIssue(quote, issueText) {
37
41
  if (q.length < 3) return false;
38
42
  return String(issueText ?? "").split("\n").some((l) => norm(l.replace(ITEM_PREFIX_RE, "")) === q);
39
43
  }
44
+ // Scan-based (not a one-pass regex), so nested or unclosed markers cannot survive into the scope hash.
45
+ const stripHtmlComments = (s) => noComments(s).replace(/\r\n/g, "\n").trim();
46
+ /**
47
+ * #4265: the human scope of an issue: title, body and trusted comments without the engine's own annotations (HTML comment
48
+ * blocks such as FORGE:STATE, and comments the engine posted, which start with "<!-- FORGE:"). Its hash is the edit signal
49
+ * and the provenance of a frozen contract; quote validation keeps using the full text.
50
+ * #4298 (SEC-5): EVERY HTML comment is cut, human ones too. GitHub does not render them, so they are not the scope a
51
+ * human reads, and an edit that only touches a hidden comment is not a scope edit.
52
+ */
53
+ export function scopeText(iss, trustedComments = []) {
54
+ const human = (trustedComments ?? []).map((c) => String(c?.body ?? "")).filter((b) => !b.trimStart().startsWith("<!-- FORGE:"));
55
+ return [iss?.title ?? "", stripHtmlComments(iss?.body), ...human.map(stripHtmlComments)].join("\n\n");
56
+ }
57
+ /** #4265: failures whose detail starts with a contract requirement id also quote that requirement, so a needs-human names what is unmet. */
58
+ export function explainFailures(failures, contract) {
59
+ const reqs = new Map((contract?.requirements ?? []).map((r) => [String(r?.id ?? ""), String(r?.quote ?? "")]));
60
+ return (failures ?? []).map((f) => {
61
+ const id = /^([A-Za-z][\w-]*)\b/.exec(String(f?.detail ?? ""))?.[1];
62
+ const q = id && reqs.get(id);
63
+ if (!q) return f;
64
+ return { ...f, detail: `${f.detail} ("${q.length > 160 ? `${q.slice(0, 157)}...` : q}")` };
65
+ });
66
+ }
40
67
  export const isTestPath = (p) => TEST_RE.test(String(p));
41
68
  export const isIncidentalPath = (p) => INCIDENTAL_RE.test(String(p));
42
69
  const cleanPath = (p) => String(p ?? "").trim().replace(/^\.\//, "");
@@ -59,35 +86,33 @@ export function authorizationOf(contract, f) {
59
86
  }
60
87
  const blank = (s) => !String(s ?? "").trim() || /^none$/i.test(String(s).trim());
61
88
 
89
+ /** forge.yaml lean.repro_test (#4271): "prefer" (default) asks a bug lane for a red-green repro test; "off" is today's contract. */
90
+ export const REPRO_MODES = Object.freeze(["off", "prefer"]);
91
+ /** True when the issue carries a `bug` or `type:bug` label (names or {name} objects). */
92
+ export const isBugIssue = (labels) => (Array.isArray(labels) ? labels : []).some((l) => ["bug", "type:bug"].includes(String(l?.name ?? l).trim().toLowerCase()));
93
+
62
94
  /**
63
- * #4160: an evidence path the reviewer gave as a bare or partial name (`route.ts`, `pdf/route.ts`) resolves to the one
64
- * changed file it names. Returns the repo path, or null when it is not a unique match.
95
+ * Shape check of the builder's contract.json against the issue text. Returns {ok, problems[]}. `repro` (#4271): a bug
96
+ * lane with lean.repro_test on also needs repro_test: {path, id?, command}, or "none" with a repro_test_reason.
65
97
  */
66
- export function resolveEvidencePath(p, changedFiles = []) {
67
- const s = String(p ?? "").replace(/^\.\//, "");
68
- if (!s) return null;
69
- const hits = changedFiles.filter((f) => f === s || f.endsWith(`/${s}`));
70
- return hits.length === 1 ? hits[0] : null;
71
- }
72
-
73
98
  /**
74
- * #4160: the rows the engine requires, pre-filled with every id so the reviewer fills statuses instead of composing rows.
75
- * Scope rows are listed only for changed files the contract does not already authorize (deliverable, test or
76
- * incidental); issue_coverage starts from the contract's verbatim quotes, and the reviewer adds any the contract missed.
99
+ * A validate command that diffs against a remote branch tip (`git diff origin/x`, `git diff origin/x HEAD`) breaks as
100
+ * soon as the base moves on, even when the change is fine. Returns the remote ref it diffs against, or null when every
101
+ * `git diff` segment compares against the merge base (`origin/x...HEAD`, `$(git merge-base …)`) or no remote ref.
102
+ * Only revisions count: anything after a standalone `--` is a pathspec (`git diff HEAD -- origin/x.txt`, #4298).
77
103
  */
78
- export function reviewSkeleton({ contract, changedFiles = [], ignore = [] }) {
79
- const reqs = contract?.requirements ?? [];
80
- return {
81
- issue_coverage: reqs.map((r) => ({ quote: r.quote, contract_ids: [r.id], status: "" })),
82
- requirements: reqs.map((r) => ({ id: r.id, status: "", evidence: "", validated_by: "", ...(TRACKED.includes(r.disposition) ? { deferral_ok: null } : {}), note: "" })),
83
- scope: changedFiles.filter((f) => !ignore.some((pre) => f.startsWith(pre)) && !authorizationOf(contract, f)).map((f) => ({ file: f, authorized_by: "", note: "" })),
84
- preserve: (contract?.preserve ?? []).map((x) => ({ id: x.id, status: "", evidence: "" })),
85
- regressions: [], verdict: "", summary: "",
86
- };
104
+ export function movingBaseDiff(command) {
105
+ for (const whole of String(command ?? "").split(/&&|\|\||;|\|/)) {
106
+ const seg = whole.split(/(?:^|\s)--(?=\s|$)/)[0];
107
+ if (!/\bgit\b(?:\s+-C\s+\S+)?(?:\s+-c\s+\S+)*\s+diff\b/.test(seg)) continue;
108
+ const ref = /(?:^|[\s=])(origin\/[A-Za-z0-9._\/-]+)/.exec(seg)?.[1];
109
+ if (!ref || seg.includes("...") || /merge-base/.test(seg)) continue;
110
+ return ref.replace(/\.\.?$/, "");
111
+ }
112
+ return null;
87
113
  }
88
114
 
89
- /** Shape check of the builder's contract.json against the issue text. Returns {ok, problems[]}. */
90
- export function validateContract(c, issueText) {
115
+ export function validateContract(c, issueText, { repro = false } = {}) {
91
116
  const problems = [];
92
117
  if (!c || typeof c !== "object" || Array.isArray(c)) return { ok: false, problems: ["contract.json is missing or not a JSON object"] };
93
118
  const reqs = Array.isArray(c.requirements) ? c.requirements : [];
@@ -106,8 +131,23 @@ export function validateContract(c, issueText) {
106
131
  if (reqs.some((r) => r?.disposition === "deliver") && !dels.length) problems.push("deliver requirements but no deliverables");
107
132
  for (const d of dels) if (!d?.id || !d?.path) problems.push(`deliverable without id or path: ${JSON.stringify(d).slice(0, 80)}`);
108
133
  for (const p of Array.isArray(c.preserve) ? c.preserve : []) if (!p?.id || blank(p?.what)) problems.push("preserve entry without id or what");
134
+ // forge#4165: decision provenance is optional (contracts frozen before it stay valid) and checked when present.
135
+ if (c.decisions != null && !Array.isArray(c.decisions)) problems.push("decisions must be an array");
136
+ for (const k of Array.isArray(c.decisions) ? c.decisions : []) {
137
+ if (!k?.id || blank(k?.choice)) problems.push(`decision without id or choice: ${JSON.stringify(k ?? null).slice(0, 80)}`);
138
+ else if (!DECISION_SOURCES.includes(k?.source)) problems.push(`${k.id}: decision source must be one of ${DECISION_SOURCES.join("|")}`);
139
+ }
109
140
  if (!Array.isArray(c.validate) || !c.validate.length) problems.push("the contract names no validate[] commands");
110
- else for (const v of c.validate) if (!v?.id || blank(v?.command)) problems.push("validate entry without id or command");
141
+ else for (const v of c.validate) {
142
+ if (!v?.id || blank(v?.command)) { problems.push("validate entry without id or command"); continue; }
143
+ const ref = movingBaseDiff(v.command);
144
+ if (ref) problems.push(`${v.id}: \`git diff\` against the moving ${ref} tip fails as soon as the base moves on, even when the change is fine; compare against the merge base instead (\`${ref}...HEAD\` or \`$(git merge-base HEAD ${ref})\`)`);
145
+ }
146
+ if (repro) {
147
+ const t = c.repro_test;
148
+ if (t === "none") { if (blank(c.repro_test_reason)) problems.push(`repro_test "none" needs a one-line repro_test_reason`); }
149
+ else if (!t || typeof t !== "object" || Array.isArray(t) || blank(t.path) || blank(t.command)) problems.push(`this is a bug: repro_test must be {"path","id","command"} (a test that fails on the base and passes after the fix) or "none" with a repro_test_reason`);
150
+ }
111
151
  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`);
112
152
  return { ok: problems.length === 0, problems };
113
153
  }
@@ -149,12 +189,12 @@ export function applyAmendments(frozen, amendments, issueText = null) {
149
189
 
150
190
  /**
151
191
  * Deterministic checks before any reviewer tokens are spent: unauthorized files, failed or missing validation,
152
- * requirements not implemented, and deferrals without a tracking issue. Returns {ok, failures[]}; each failure carries a
153
- * `kind` (scope_creep, validate_missing, validate_failed, requirement_unimplemented, untracked_deferral) so callers can
192
+ * requirements not implemented, deferrals without a tracking issue, and unexempted hotspot growth. Returns {ok, failures[]}; each failure carries a
193
+ * `kind` (scope_creep, validate_missing, validate_failed, requirement_unimplemented, untracked_deferral, hotspot_growth, repro_red_green) so callers can
154
194
  * tell the builder's own failed checks apart from bookkeeping gaps. `ciVerified` (#4167): the current head passed the
155
- * engine's CI verification, so a validate id left to CI is not missing; a recorded non-zero exit still fails.
195
+ * engine's CI verification, so a validate id left to CI is not missing; a recorded non-zero exit still fails. `repro` (#4271): the repro_test red-green words, checked when given.
156
196
  */
157
- export function precheck({ contract, result, changedFiles, ignore = [], ciVerified = false }) {
197
+ export function precheck({ contract, result, changedFiles, ignore = [], ciVerified = false, hotspots = null, repro = null }) {
158
198
  const failures = [];
159
199
  const creep = changedFiles.filter((f) => !ignore.some((p) => f.startsWith(p)) && !authorizationOf(contract, f));
160
200
  if (creep.length) failures.push({ code: C.CONFORMANCE_SCOPE_CREEP, kind: "scope_creep", detail: `changed outside the contract's deliverables: ${creep.join(", ")}` });
@@ -165,7 +205,7 @@ export function precheck({ contract, result, changedFiles, ignore = [], ciVerifi
165
205
  const leftToCi = !r || (r.ci === true && r.exit === undefined);
166
206
  if (ciVerified && leftToCi) continue;
167
207
  if (leftToCi) failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "validate_missing", detail: `validate ${v.id} (\`${v.command}\`) has no recorded result` });
168
- else if (r.exit !== 0) failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "validate_failed", detail: `validate ${v.id} failed (exit ${r.exit})` });
208
+ else if (r.exit !== 0) failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "validate_failed", detail: `validate ${v.id} (\`${v.command}\`) failed (exit ${r.exit})` });
169
209
  }
170
210
  const st = Array.isArray(result?.requirements) ? result.requirements : [];
171
211
  for (const r of contract.requirements ?? []) {
@@ -176,9 +216,42 @@ export function precheck({ contract, result, changedFiles, ignore = [], ciVerifi
176
216
  else if (s !== "implemented") failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "requirement_unimplemented", detail: `${r.id} reported "${s ?? "no status"}"` });
177
217
  }
178
218
  }
219
+ failures.push(...hotspotGrowth({ contract, result, hotspots, changedFiles }));
220
+ if (repro) failures.push(...reproFailures(contract, repro));
179
221
  return { ok: failures.length === 0, failures };
180
222
  }
181
223
 
224
+ /**
225
+ * #4271: the frozen repro_test's red-green proof ({base, head} words from scripts/acceptance-red-green.sh). Anything but
226
+ * HEAD=RAN BASE=FAILED fails; code conformance.unimplemented, so it blocks in shadow too and goes back to the builder.
227
+ */
228
+ export function reproFailures(contract, { base, head } = {}) {
229
+ if (head === "RAN" && base === "FAILED") return [];
230
+ const t = contract?.repro_test ?? {};
231
+ const name = [t.path, t.id].filter((x) => !blank(x)).join(" ") || "repro_test";
232
+ return [{ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "repro_red_green",
233
+ detail: `repro test ${name} (\`${t.command ?? ""}\`) must fail on the base and pass on head; got BASE=${base ?? "UNRUNNABLE"} HEAD=${head ?? "UNRUNNABLE"}` }];
234
+ }
235
+
236
+ const EXEMPT_RE = /hotspot-exempt:\s*\S/i;
237
+ /**
238
+ * forge#4195: a changed file already over `file_lines` (not generated, vendored or glob-excluded) that grew by more
239
+ * than `growth_lines` since the base, with no `hotspot-exempt: <reason>` in a covering deliverable's change or an
240
+ * add-deliverable amendment's why. `hotspots` is scripts/hotspots.sh --base output; null or disabled checks nothing.
241
+ */
242
+ export function hotspotGrowth({ contract, result, hotspots, changedFiles = [] }) {
243
+ if (!hotspots || hotspots.enabled === false || !Array.isArray(hotspots.files)) return [];
244
+ const limit = Number(hotspots.growth_lines) || 0, max = Number(hotspots.file_lines) || 0;
245
+ const changed = new Set(changedFiles.map(cleanPath));
246
+ const amends = Array.isArray(result?.amendments) ? result.amendments : [];
247
+ const exempt = (f) => (contract?.deliverables ?? []).some((d) => pathCovers(d.path, f) && EXEMPT_RE.test(String(d.change ?? "")))
248
+ || amends.some((a) => a?.kind === "add-deliverable" && pathCovers(a.path, f) && EXEMPT_RE.test(String(a.why ?? "")));
249
+ return hotspots.files
250
+ .filter((h) => h?.over && !h.excluded && Number(h.growth) > limit && changed.has(cleanPath(h.path)) && !exempt(h.path))
251
+ .map((h) => ({ code: C.MAINTENANCE_HOTSPOT_GROWTH, kind: "hotspot_growth",
252
+ detail: `${h.path} grew by ${h.growth} lines (now ${h.lines}, limit +${limit} over ${max}) without hotspot-exempt` }));
253
+ }
254
+
182
255
  /** The reviewer's JSON object (a json fence first, then the outermost braces), or null. */
183
256
  export function parseReview(text) {
184
257
  const s = String(text ?? "");
@@ -190,54 +263,114 @@ export function parseReview(text) {
190
263
  return null;
191
264
  }
192
265
 
266
+ /**
267
+ * A basename or path suffix in reviewer evidence, resolved to the one repo path it names (changed files first, then
268
+ * `repoFiles`). An exact match, an ambiguous name or an unknown name is returned unchanged.
269
+ */
270
+ export function normalizeEvidencePath(path, { changedFiles = [], repoFiles = [], listed = null } = {}) {
271
+ const p = cleanPath(path);
272
+ if (listed) repoFiles = listed;
273
+ if (!p) return p;
274
+ for (const pool of [changedFiles, repoFiles]) {
275
+ const files = [...pool];
276
+ if (files.includes(p)) return p;
277
+ const hits = [...new Set(files.filter((f) => String(f).endsWith(`/${p}`)))];
278
+ if (hits.length === 1) return hits[0];
279
+ if (hits.length > 1) return p;
280
+ }
281
+ return p;
282
+ }
283
+ /**
284
+ * #4160: an evidence path the reviewer gave as a bare or partial name (`route.ts`, `pdf/route.ts`) resolves to the one
285
+ * changed file it names. Returns the repo path, or null when it is not a unique match.
286
+ */
287
+ export function resolveEvidencePath(p, changedFiles = []) {
288
+ const n = normalizeEvidencePath(p, { changedFiles });
289
+ return n && changedFiles.includes(n) ? n : null;
290
+ }
291
+ /** The path:line in an evidence string, with the path normalized; null when there is none. */
292
+ function evidenceOf(text, ctx) {
293
+ const m = EVIDENCE_RE.exec(String(text ?? ""));
294
+ return m ? { path: normalizeEvidencePath(m[1], ctx), line: m[2] } : null;
295
+ }
296
+ const reviewedFiles = (changedFiles, ignore) => changedFiles.filter((x) => !ignore.some((pre) => x.startsWith(pre)));
297
+
298
+ /**
299
+ * The rows the engine can fill itself, so bookkeeping is never "incomplete": a scope row per changed file from the
300
+ * contract's own authorization ("none" when unauthorized, still scope creep), a preserve row per id as "unverified"
301
+ * (still preserve_violated), and implemented evidence rewritten to `path:line` with the repo path. Returns a new object.
302
+ */
303
+ export function fillEngineRows(rv, { contract, changedFiles = [], repoFiles = [], ignore = [] }) {
304
+ if (!rv || typeof rv !== "object") return rv;
305
+ const out = { ...rv };
306
+ const scope = Array.isArray(rv.scope) ? [...rv.scope] : [];
307
+ for (const f of reviewedFiles(changedFiles, ignore)) if (!scope.some((s) => s?.file === f)) scope.push({ file: f, authorized_by: authorizationOf(contract, f) ?? "none", note: "row filled by the engine", filled: true });
308
+ out.scope = scope;
309
+ const pres = Array.isArray(rv.preserve) ? [...rv.preserve] : [];
310
+ for (const c of contract.preserve ?? []) if (!pres.some((x) => x?.id === c.id)) pres.push({ id: c.id, status: "unverified", note: "no row from the reviewer", filled: true });
311
+ out.preserve = pres;
312
+ if (Array.isArray(rv.requirements)) {
313
+ out.requirements = rv.requirements.map((r) => {
314
+ if (r?.status !== "implemented") return r;
315
+ const e = evidenceOf(r.evidence, { changedFiles, repoFiles });
316
+ return e ? { ...r, evidence: `${e.path}:${e.line}` } : r;
317
+ });
318
+ }
319
+ return out;
320
+ }
321
+
193
322
  /** Row completeness: every contract requirement, preserve item and changed file has a well-formed row. */
194
- export function checkCompleteness(rv, { contract, changedFiles, issueText, pathExists = () => false, ignore = [] }) {
195
- const p = [];
323
+ export function checkCompleteness(rv, { contract, changedFiles, issueText, pathExists = () => false, ignore = [], repoFiles = [] }) {
324
+ const p = [], ids = new Set();
325
+ const miss = (id, msg) => { p.push(msg); ids.add(id); };
196
326
  const arr = (k) => (Array.isArray(rv?.[k]) ? rv[k] : null);
197
- if (!["CONFORMS", "NONCONFORMING"].includes(rv?.verdict)) p.push("verdict must be CONFORMS or NONCONFORMING");
327
+ if (!["CONFORMS", "NONCONFORMING"].includes(rv?.verdict)) miss("verdict", "verdict must be CONFORMS or NONCONFORMING");
198
328
  const cov = arr("issue_coverage");
199
- if (!cov?.length) p.push("issue_coverage is missing or empty");
329
+ if (!cov?.length) miss("issue_coverage", "issue_coverage is missing or empty");
200
330
  for (const c of cov ?? []) {
201
- if (!COVER_STATUS.includes(c?.status)) p.push(`issue_coverage status invalid: ${c?.status}`);
202
- if (!inIssue(c?.quote, issueText)) p.push(`issue_coverage quote is not verbatim from the issue: "${String(c?.quote ?? "").slice(0, 60)}"`);
331
+ if (!COVER_STATUS.includes(c?.status)) miss("issue_coverage", `issue_coverage status invalid: ${c?.status}`);
332
+ if (!inIssue(c?.quote, issueText)) miss("issue_coverage", `issue_coverage quote is not verbatim from the issue: "${String(c?.quote ?? "").slice(0, 60)}"`);
203
333
  }
204
334
  const reqs = arr("requirements");
205
335
  if (!reqs) p.push("requirements rows are missing");
206
336
  for (const r of contract.requirements) {
207
337
  const rows = (reqs ?? []).filter((x) => x?.id === r.id);
208
- if (rows.length !== 1) { p.push(`requirement ${r.id} needs exactly one row (has ${rows.length})`); continue; }
338
+ if (rows.length !== 1) { miss(r.id, `requirement ${r.id} needs exactly one row (has ${rows.length})`); continue; }
209
339
  const row = rows[0];
210
- if (!REQ_STATUS.includes(row.status)) { p.push(`${r.id}: status invalid: ${row.status}`); continue; }
211
- 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?)`);
340
+ if (!REQ_STATUS.includes(row.status)) { miss(r.id, `${r.id}: status invalid: ${row.status}`); continue; }
341
+ if (TRACKED.includes(r.disposition) && typeof row.deferral_ok !== "boolean") miss(r.id, `${r.id}: a ${r.disposition} requirement needs deferral_ok true|false (is deferring it justified?)`);
212
342
  if (row.status === "implemented") {
213
- const m = EVIDENCE_RE.exec(String(row.evidence ?? ""));
214
- if (!m) p.push(`${r.id}: implemented needs path:line evidence`);
215
- else if (!changedFiles.includes(m[1]) && !pathExists(m[1]) && !resolveEvidencePath(m[1], changedFiles)) p.push(`${r.id}: evidence path ${m[1]} does not exist`);
216
- if (blank(row.validated_by)) p.push(`${r.id}: implemented needs validated_by (a validate id or test)`);
343
+ const e = evidenceOf(row.evidence, { changedFiles, repoFiles });
344
+ if (!e) miss(r.id, `${r.id}: implemented needs path:line evidence`);
345
+ else if (!changedFiles.includes(e.path) && !pathExists(e.path)) miss(r.id, `${r.id}: evidence path ${e.path} does not exist`);
346
+ if (blank(row.validated_by)) miss(r.id, `${r.id}: implemented needs validated_by (a validate id or test)`);
217
347
  }
218
348
  }
219
349
  const scope = arr("scope");
220
- if (!scope) p.push("scope rows are missing");
350
+ if (!scope) miss("scope", "scope rows are missing");
221
351
  // #4160: a file the contract authorizes (deliverable, test, incidental) is already settled by the deterministic precheck;
222
- // a row is required only for the files it could not authorize. A row the reviewer does give is still judged in decide().
223
- for (const f of changedFiles.filter((x) => !ignore.some((pre) => x.startsWith(pre)) && !authorizationOf(contract, x))) if (!(scope ?? []).some((s) => s?.file === f)) p.push(`changed file ${f} has no scope row`);
352
+ // a row is required only for the files it could not authorize (decide() fills those as "none"). A given row is still judged.
353
+ for (const f of reviewedFiles(changedFiles, ignore).filter((x) => !authorizationOf(contract, x))) if (!(scope ?? []).some((s) => s?.file === f)) miss(f, `changed file ${f} has no scope row`);
224
354
  const pres = arr("preserve") ?? [];
225
355
  for (const c of contract.preserve ?? []) {
226
356
  const row = pres.find((x) => x?.id === c.id);
227
- if (!row || !PRESERVE_STATUS.includes(row.status)) p.push(`preserve ${c.id} needs a row with status ${PRESERVE_STATUS.join("|")}`);
357
+ if (!row || !PRESERVE_STATUS.includes(row.status)) miss(c.id, `preserve ${c.id} needs a row with status ${PRESERVE_STATUS.join("|")}`);
228
358
  }
229
- if (rv && rv.regressions != null && !Array.isArray(rv.regressions)) p.push("regressions must be an array");
230
- return { complete: p.length === 0, problems: p };
359
+ if (rv && rv.regressions != null && !Array.isArray(rv.regressions)) miss("regressions", "regressions must be an array");
360
+ if (!reqs) for (const r of contract.requirements) ids.add(r.id);
361
+ return { complete: p.length === 0, problems: p, missing: [...ids] };
231
362
  }
232
363
 
233
364
  /**
234
- * The engine's decision from the reviewer's rows. state: conforms | nonconforming | review.unparseable | review.incomplete.
235
- * failures[] are {code, detail}; `inconsistent` marks a CONFORMS verdict whose rows fail.
365
+ * The engine's decision from the reviewer's rows. state: conforms | nonconforming | review.unparseable | review.incomplete
366
+ * | review.inconsistent. failures[] are {code, detail}; `inconsistent` marks a CONFORMS verdict whose rows fail;
367
+ * `missing` lists the ids (or sections) a targeted re-ask must fill; `review` is the reply with the engine-filled rows.
236
368
  */
237
- export function decide(rv, ctx) {
238
- if (!rv) return { state: "review.unparseable", failures: [{ code: C.REVIEW_NO_VERDICT, detail: "no JSON object in the reviewer reply" }] };
369
+ export function decide(raw, ctx) {
370
+ if (!raw) return { state: "review.unparseable", failures: [{ code: C.REVIEW_NO_VERDICT, detail: "no JSON object in the reviewer reply" }] };
371
+ const rv = fillEngineRows(raw, ctx);
239
372
  const comp = checkCompleteness(rv, ctx);
240
- if (!comp.complete) return { state: "review.incomplete", failures: comp.problems.map((d) => ({ code: C.REVIEW_INCOMPLETE, detail: d })) };
373
+ if (!comp.complete) return { state: "review.incomplete", failures: comp.problems.map((d) => ({ code: C.REVIEW_INCOMPLETE, detail: d })), missing: comp.missing, review: rv };
241
374
  const f = [];
242
375
  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})` : ""}` });
243
376
  for (const r of ctx.contract.requirements) {
@@ -255,10 +388,54 @@ export function decide(rv, ctx) {
255
388
  }
256
389
  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}` : ""}` });
257
390
  for (const r of rv.regressions ?? []) f.push({ code: C.CONFORMANCE_REGRESSION, detail: `${r?.at ?? "?"}: ${r?.failure ?? ""}` });
258
- if (f.length) return { state: "nonconforming", failures: f, inconsistent: rv.verdict === "CONFORMS" };
391
+ if (f.length) return { state: "nonconforming", failures: f, inconsistent: rv.verdict === "CONFORMS", review: rv };
259
392
  // A NONCONFORMING verdict with every row passing gives the builder nothing to fix: re-ask the reviewer.
260
- if (rv.verdict !== "CONFORMS") return { state: "review.inconsistent", failures: [{ code: C.REVIEW_INCONSISTENT, detail: "verdict NONCONFORMING with no failing row" }] };
261
- return { state: "conforms", failures: [] };
393
+ if (rv.verdict !== "CONFORMS") return { state: "review.inconsistent", failures: [{ code: C.REVIEW_INCONSISTENT, detail: "verdict NONCONFORMING with no failing row" }], missing: ["verdict"], review: rv };
394
+ return { state: "conforms", failures: [], review: rv };
395
+ }
396
+
397
+ /** The ids (or sections) a targeted re-ask must fill: requirement ids without a valid row, issue_coverage problems, ...; [] when complete. */
398
+ export function missingIds(rv, ctx) {
399
+ if (!rv || typeof rv !== "object") return [];
400
+ return checkCompleteness(fillEngineRows(rv, ctx), ctx).missing;
401
+ }
402
+
403
+ /**
404
+ * The reviewer's first reply with the rows of a targeted re-ask applied: a repair row replaces the row with the same id
405
+ * (scope: file), issue_coverage is replaced when the repair sends any, verdict and summary follow the repair when valid.
406
+ */
407
+ export function mergeReviewRows(first, repair) {
408
+ if (!first || typeof first !== "object") return repair ?? null;
409
+ if (!repair || typeof repair !== "object") return first;
410
+ const out = { ...first };
411
+ for (const [k, key] of [["requirements", "id"], ["preserve", "id"], ["scope", "file"]]) {
412
+ if (!Array.isArray(repair[k])) continue;
413
+ const rows = Array.isArray(first[k]) ? first[k].filter((x) => !x?.filled) : [];
414
+ for (const r of repair[k]) { const i = rows.findIndex((x) => x?.[key] === r?.[key]); if (i >= 0) rows[i] = r; else rows.push(r); }
415
+ out[k] = rows;
416
+ }
417
+ if (Array.isArray(repair.issue_coverage) && repair.issue_coverage.length) out.issue_coverage = repair.issue_coverage;
418
+ if (Array.isArray(repair.regressions)) out.regressions = [...(Array.isArray(first.regressions) ? first.regressions : []), ...repair.regressions];
419
+ if (["CONFORMS", "NONCONFORMING"].includes(repair.verdict)) { out.verdict = repair.verdict; if (repair.summary) out.summary = repair.summary; }
420
+ return out;
421
+ }
422
+
423
+ /**
424
+ * #4160: the rows the engine requires, pre-filled with every id so the reviewer fills statuses instead of composing rows:
425
+ * one requirement row per R id (status empty), one preserve row per P id ("unverified" until checked), one scope row per
426
+ * changed file pre-filled with the engine's authorization, and one issue_coverage row per contract quote (the reviewer adds
427
+ * any the contract missed). Called as (contract, changedFiles, ignore) or ({contract, changedFiles, ignore}).
428
+ */
429
+ export function reviewSkeleton(contract, changedFiles = [], ignore = []) {
430
+ if (contract && !Array.isArray(contract.requirements) && "contract" in contract) ({ contract, changedFiles = [], ignore = [] } = contract);
431
+ const reqs = contract?.requirements ?? [];
432
+ return {
433
+ issue_coverage: reqs.map((r) => ({ quote: r.quote, contract_ids: [r.id], status: "" })),
434
+ requirements: reqs.map((r) => ({ id: r.id, status: "", evidence: "", validated_by: "", ...(TRACKED.includes(r.disposition) ? { deferral_ok: null } : {}), note: "" })),
435
+ scope: reviewedFiles(changedFiles, ignore).map((f) => ({ file: f, authorized_by: authorizationOf(contract ?? {}, f) ?? "none", note: "" })),
436
+ preserve: (contract?.preserve ?? []).map((x) => ({ id: x.id, status: "unverified", evidence: "" })),
437
+ regressions: [], verdict: "", summary: "",
438
+ };
262
439
  }
263
440
 
264
441
  /** Requirements the reviewer accepted as deferred or post-deploy that still need a tracking issue. */
@@ -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, parseReview, checkCompleteness, decide, closePlan, followupItems, inIssue, isTestPath, reviewSkeleton, resolveEvidencePath } from "./conformance.mjs";
5
+ import { validateContract, applyAmendments, precheck, hotspotGrowth, reproFailures, isBugIssue, parseReview, checkCompleteness, decide, closePlan, followupItems, inIssue, isTestPath, normalizeEvidencePath, mergeReviewRows, reviewSkeleton, missingIds, resolveEvidencePath, movingBaseDiff, scopeText, explainFailures } 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.";
@@ -95,11 +95,55 @@ describe("conformance review: the engine computes the verdict from complete rows
95
95
  assert.ok(d.failures.every((f) => f.code === "review.incomplete"));
96
96
  });
97
97
  it("no JSON is unparseable", () => { assert.equal(parseReview("LGTM"), null); assert.equal(decide(null, ctx).state, "review.unparseable"); });
98
- it("incomplete: missing requirement row, missing scope row for an unauthorized changed file, missing preserve row", () => {
99
- assert.equal(decide({ ...good, requirements: good.requirements.slice(1) }, ctx).state, "review.incomplete");
98
+ it("incomplete: a missing requirement row (only the reviewer can judge it), and missing lists that id for the re-ask", () => {
99
+ const d = decide({ ...good, requirements: good.requirements.slice(1) }, ctx);
100
+ assert.equal(d.state, "review.incomplete"); assert.deepEqual(d.missing, ["R1"]);
101
+ });
102
+ it("#4160 missing scope and preserve rows are filled by the engine: complete, with the honest verdict", () => {
103
+ assert.equal(decide({ ...good, scope: good.scope.slice(1) }, ctx).state, "conforms", "an authorized file needs no reviewer row");
104
+ const creep = decide({ ...good }, { ...ctx, changedFiles: [...files, "src/pricing.js"] });
105
+ assert.deepEqual(codes(creep), ["conformance.scope_creep"], "an unauthorized file without a row is still creep");
106
+ const noPres = decide({ ...good, preserve: [] }, ctx);
107
+ assert.deepEqual(codes(noPres), ["conformance.preserve_violated"], "a missing preserve row is unverified, not incomplete");
108
+ assert.match(noPres.failures[0].detail, /P1 unverified/);
109
+ assert.equal(decide({ ...good, scope: undefined, preserve: undefined }, ctx).state, "nonconforming");
110
+ });
111
+ it("#4160 basename and wrapped evidence are normalized to the repo path; an ambiguous or unknown name is still incomplete", () => {
112
+ assert.equal(decide(row("R1", { evidence: "scrape.js:7" }), ctx).state, "conforms");
113
+ assert.equal(decide(row("R1", { evidence: "see `src/scrape.js:7` (guard)" }), ctx).state, "conforms");
114
+ assert.equal(decide(row("R1", { evidence: "billing.js:1" }), { ...ctx, repoFiles: ["src/billing.js"], pathExists: (p) => p === "src/billing.js" }).state, "conforms");
115
+ assert.equal(decide(row("R1", { evidence: "nope.js:3" }), ctx).state, "review.incomplete");
116
+ assert.equal(normalizeEvidencePath("route.ts", { changedFiles: ["app/a/route.ts", "app/b/route.ts"] }), "route.ts", "ambiguous stays as is");
117
+ assert.equal(normalizeEvidencePath("b/route.ts", { changedFiles: ["app/a/route.ts", "app/b/route.ts"] }), "app/b/route.ts");
118
+ assert.equal(decide(row("R1", { evidence: "scrape.js:7" }), ctx).review.requirements[0].evidence, "src/scrape.js:7");
119
+ });
120
+ it("#4160 a targeted re-ask merges by id: the repair rows complete the first reply", () => {
121
+ const first = { ...good, requirements: [good.requirements[1]], preserve: [] };
122
+ const d1 = decide(first, ctx);
123
+ assert.equal(d1.state, "review.incomplete"); assert.deepEqual(d1.missing, ["R1"]);
124
+ const merged = mergeReviewRows(first, { requirements: [good.requirements[0]], preserve: good.preserve });
125
+ assert.equal(decide(merged, ctx).state, "conforms");
126
+ assert.equal(mergeReviewRows(first, null), first, "an unparseable repair keeps the first reply");
127
+ assert.equal(decide(mergeReviewRows({ ...good, verdict: "NONCONFORMING" }, { verdict: "CONFORMS" }), ctx).state, "conforms");
128
+ });
129
+ it("#4160 the skeleton has one row per requirement, preserve id and changed file, scope pre-filled by the engine", () => {
130
+ const s = reviewSkeleton(C, [...files, ".forge-lean/x", "src/pricing.js"], [".forge-lean/"]);
131
+ assert.deepEqual(s.requirements.map((r) => r.id), ["R1", "R2"]);
132
+ assert.deepEqual(s.preserve.map((r) => r.id), ["P1"]);
133
+ assert.deepEqual(s.scope.map((r) => [r.file, r.authorized_by]), [["src/scrape.js", "D1"], ["src/search.js", "D2"], ["test/leak.test.js", "test"], ["src/pricing.js", "none"]]);
134
+ assert.deepEqual(s.issue_coverage.map((r) => r.quote), [Q1, Q2]);
135
+ assert.ok(s.requirements.every((r) => r.status === ""), "requirement status is left for the reviewer");
136
+ assert.ok(s.preserve.every((r) => r.status === "unverified"), "preserve starts unverified");
137
+ });
138
+ it("#4160 missingIds lists only what a targeted re-ask must fill", () => {
139
+ assert.deepEqual(missingIds(good, ctx), []);
140
+ assert.deepEqual(missingIds({ ...good, requirements: [good.requirements[1]], preserve: [], scope: [] }, ctx), ["R1"], "engine-filled preserve/scope rows are not asked for");
141
+ assert.deepEqual(missingIds({ ...good, issue_coverage: [] }, ctx), ["issue_coverage"]);
142
+ assert.equal(normalizeEvidencePath("billing.js", { listed: new Set(["src/billing.js"]) }), "src/billing.js", "listed is the repo file list");
143
+ });
144
+ it("#4160 checkCompleteness on the raw reply still lists a missing scope row for an unauthorized changed file (decide fills it as creep)", () => {
100
145
  const extra = { ...ctx, changedFiles: [...files, "src/billing.js"] };
101
146
  assert.equal(checkCompleteness(good, extra).problems.includes("changed file src/billing.js has no scope row"), true);
102
- assert.equal(decide({ ...good, preserve: [] }, ctx).state, "review.incomplete");
103
147
  });
104
148
  it("#4160: a changed file the contract already authorizes (deliverable, test, incidental) needs no scope row", () => {
105
149
  assert.equal(decide({ ...good, scope: [] }, ctx).state, "conforms");
@@ -117,17 +161,33 @@ describe("conformance review: the engine computes the verdict from complete rows
117
161
  assert.equal(resolveEvidencePath("nope.js", files), null);
118
162
  assert.equal(decide(row("R1", { evidence: "scrape.js:4" }), ctx).state, "conforms");
119
163
  });
120
- it("#4160: reviewSkeleton lists every required row, pre-filled, with scope rows only for unauthorized files", () => {
164
+ it("a validate command that diffs against the moving origin/<lane> tip invalidates the contract; a merge-base diff does not", () => {
165
+ const withV = (command) => ({ ...C, validate: [{ id: "V4", command }] });
166
+ const bad = validateContract(withV("./scripts/sync.sh --check-only && git diff --quiet origin/staging -- infra/a infra/b"), ISSUE);
167
+ assert.equal(bad.ok, false);
168
+ assert.ok(bad.problems.some((x) => /^V4: `git diff` against the moving origin\/staging tip/.test(x) && x.includes("origin/staging...HEAD")), bad.problems.join("; "));
169
+ for (const ok of ["git diff --quiet origin/staging...HEAD -- infra/a", "git diff --quiet $(git merge-base HEAD origin/staging) -- infra/a", "node --test test/leak.test.js", "git log origin/staging..HEAD --oneline"]) {
170
+ assert.equal(validateContract(withV(ok), ISSUE).ok, true, ok);
171
+ }
172
+ });
173
+ it("movingBaseDiff finds the remote ref in any git diff segment, and ignores merge-base forms and other commands", () => {
174
+ assert.equal(movingBaseDiff("git diff --name-only origin/main HEAD"), "origin/main");
175
+ assert.equal(movingBaseDiff("git -C sub diff origin/milestone/foo | wc -l"), "origin/milestone/foo");
176
+ assert.equal(movingBaseDiff("git diff HEAD~1"), null);
177
+ assert.equal(movingBaseDiff(""), null);
178
+ });
179
+ it("#4160: reviewSkeleton (object form) lists every required row, scope rows pre-filled with the engine's authorization", () => {
121
180
  const k = { ...C, requirements: [...C.requirements, { id: "R9", quote: "Billing pricing is unchanged.", disposition: "defer", why: "w" }] };
122
181
  const sk = reviewSkeleton({ contract: k, changedFiles: [...files, "src/billing.js", ".forge-lean/x"], ignore: [".forge-lean/"] });
123
182
  assert.deepEqual(sk.requirements.map((r) => r.id), k.requirements.map((r) => r.id));
124
183
  assert.equal(sk.requirements.find((r) => r.id === "R9").deferral_ok, null);
125
184
  assert.ok(!("deferral_ok" in sk.requirements[0]));
126
185
  assert.deepEqual(sk.preserve.map((p) => p.id), (k.preserve ?? []).map((p) => p.id));
127
- assert.deepEqual(sk.scope.map((s) => s.file), ["src/billing.js"]);
186
+ assert.deepEqual(sk.scope.map((s) => [s.file, s.authorized_by]), [...files.map((f) => [f, f.startsWith("test/") ? "test" : f === "src/scrape.js" ? "D1" : "D2"]), ["src/billing.js", "none"]]);
128
187
  assert.deepEqual(sk.issue_coverage.map((c) => c.quote), k.requirements.map((r) => r.quote));
129
188
  assert.ok(sk.issue_coverage.every((c) => inIssue(c.quote, ISSUE)), "seeded quotes pass the verbatim check");
130
- assert.ok([...sk.requirements, ...sk.preserve, ...sk.issue_coverage].every((r) => r.status === ""), "no status is pre-judged");
189
+ assert.ok([...sk.requirements, ...sk.issue_coverage].every((r) => r.status === ""), "no requirement or coverage status is pre-judged");
190
+ assert.ok(sk.preserve.every((p) => p.status === "unverified"), "preserve starts unverified");
131
191
  });
132
192
  it("incomplete: implemented without path:line, with evidence in a nonexistent file, or without validation", () => {
133
193
  assert.equal(decide(row("R1", { evidence: "looks fine" }), ctx).state, "review.incomplete");
@@ -190,3 +250,104 @@ describe("close plan and follow-up items", () => {
190
250
  assert.equal(i.key, "R2: a b c"); assert.equal(i.why, "defer: x y"); assert.doesNotMatch(i.key + i.why, /[\t\n`]/);
191
251
  });
192
252
  });
253
+
254
+ describe("hotspot growth precheck (forge#4195)", () => {
255
+ const H = (o = {}) => ({ path: "src/scrape.js", lines: 2600, commits_90d: 30, hotspot: 78000, over: true, excluded: false, base_lines: 2400, growth: 200, ...o });
256
+ const hs = (fs, o = {}) => ({ enabled: true, file_lines: 2000, growth_lines: 150, files: fs, ...o });
257
+ const result = { validation: [{ id: "V1", exit: 0 }], requirements: [{ id: "R1", status: "implemented" }, { id: "R2", status: "implemented" }] };
258
+ const run = (hotspots, contract = C, r = result) => precheck({ contract, result: r, changedFiles: files, hotspots });
259
+
260
+ it("an over-threshold file grown past growth_lines fails with maintenance.hotspot_growth", () => {
261
+ const pc = run(hs([H()]));
262
+ assert.equal(pc.ok, false);
263
+ assert.deepEqual(pc.failures.map((f) => [f.code, f.kind]), [["maintenance.hotspot_growth", "hotspot_growth"]]);
264
+ assert.match(pc.failures[0].detail, /^src\/scrape\.js grew by 200 lines \(now 2600, limit \+150 over 2000\) without hotspot-exempt$/);
265
+ });
266
+
267
+ it("within the limit, under the threshold, excluded, unchanged, disabled or no facts: no failure", () => {
268
+ for (const h of [hs([H({ growth: 150 })]), hs([H({ over: false, lines: 1900 })]), hs([H({ excluded: true })]), hs([H({ path: "src/other.js" })]),
269
+ hs([H()], { enabled: false }), hs([H({ growth: null })]), null, undefined, {}]) assert.equal(run(h).ok, true, JSON.stringify(h));
270
+ assert.deepEqual(precheck({ contract: C, result, changedFiles: files }), run(null), "callers without hotspots are unchanged");
271
+ });
272
+
273
+ it("hotspot-exempt: <reason> in a covering deliverable's change or an add-deliverable amendment's why exempts the file", () => {
274
+ const withChange = (change) => ({ ...C, deliverables: [{ id: "D1", path: "src/scrape.js", change }, C.deliverables[1]] });
275
+ assert.equal(run(hs([H()]), withChange("route fix; Hotspot-Exempt: the handler table must stay in one file")).ok, true);
276
+ assert.equal(run(hs([H()]), withChange("hotspot-exempt: ")).ok, false, "an empty reason does not exempt");
277
+ assert.equal(run(hs([H()]), { ...C, deliverables: [{ id: "D1", path: "src/", change: "hotspot-exempt: generated routes" }, C.deliverables[1]] }).ok, true, "a covering directory deliverable");
278
+ const amended = { ...result, amendments: [{ kind: "add-deliverable", path: "src/scrape.js", why: "hotspot-exempt: one-off migration table" }] };
279
+ assert.equal(run(hs([H()]), C, amended).ok, true);
280
+ assert.equal(run(hs([H(), H({ path: "src/search.js" })]), withChange("hotspot-exempt: ok")).failures.length, 1, "the exemption covers only its own file");
281
+ assert.equal(hotspotGrowth({ contract: C, hotspots: hs([H()]), changedFiles: ["./src/scrape.js"] }).length, 1);
282
+ });
283
+ });
284
+
285
+ describe("repro_test on bug lanes (#4271)", () => {
286
+ const res = { requirements: [{ id: "R1", status: "implemented" }, { id: "R2", status: "implemented" }], validation: [{ id: "V1", exit: 0 }] };
287
+ const RT = { path: "test/leak.test.js", id: "releases on 429", command: "node --test test/leak.test.js" };
288
+ it("isBugIssue reads bug and type:bug labels, as names or {name}", () => {
289
+ assert.equal(isBugIssue([{ name: "bug" }]), true);
290
+ assert.equal(isBugIssue(["P2", "type:bug"]), true);
291
+ assert.equal(isBugIssue([{ name: "feature" }, "debug"]), false);
292
+ assert.equal(isBugIssue(null), false);
293
+ });
294
+ it("a testable bug needs repro_test {path, command}; a non-testable one records none with a reason", () => {
295
+ assert.deepEqual(validateContract({ ...C, repro_test: RT }, ISSUE, { repro: true }), { ok: true, problems: [] });
296
+ assert.deepEqual(validateContract({ ...C, repro_test: "none", repro_test_reason: "spec-only change" }, ISSUE, { repro: true }), { ok: true, problems: [] });
297
+ assert.match(validateContract(C, ISSUE, { repro: true }).problems.join(), /repro_test must be/);
298
+ assert.match(validateContract({ ...C, repro_test: { path: "test/x.js" } }, ISSUE, { repro: true }).problems.join(), /repro_test must be/);
299
+ assert.match(validateContract({ ...C, repro_test: "none" }, ISSUE, { repro: true }).problems.join(), /repro_test_reason/);
300
+ });
301
+ it("red on base and green on head passes; anything else fails the precheck as conformance.unimplemented", () => {
302
+ const k = { ...C, repro_test: RT };
303
+ assert.deepEqual(precheck({ contract: k, result: res, changedFiles: files, repro: { base: "FAILED", head: "RAN" } }), { ok: true, failures: [] });
304
+ for (const repro of [{ base: "RAN", head: "RAN" }, { base: "SKIPPED", head: "FAILED" }, { base: "UNRUNNABLE", head: "RAN" }, { base: "UNRUNNABLE", head: "UNRUNNABLE" }]) {
305
+ const pc = precheck({ contract: k, result: res, changedFiles: files, repro });
306
+ assert.deepEqual(pc.failures.map((f) => [f.code, f.kind]), [["conformance.unimplemented", "repro_red_green"]], JSON.stringify(repro));
307
+ assert.match(pc.failures[0].detail, new RegExp(`BASE=${repro.base} HEAD=${repro.head}`));
308
+ }
309
+ assert.match(reproFailures(k, {})[0].detail, /test\/leak\.test\.js releases on 429.*BASE=UNRUNNABLE HEAD=UNRUNNABLE/);
310
+ });
311
+ it("off (no repro option) is today's contract check and precheck exactly", () => {
312
+ assert.deepEqual(validateContract(C, ISSUE), validateContract(C, ISSUE, { repro: false }));
313
+ assert.deepEqual(validateContract(C, ISSUE), { ok: true, problems: [] });
314
+ assert.deepEqual(precheck({ contract: C, result: res, changedFiles: files }), precheck({ contract: C, result: res, changedFiles: files, repro: null }));
315
+ });
316
+ });
317
+
318
+ describe("#4265 issue scope and failure explanations", () => {
319
+ const iss = { title: "t", body: "Fix foo.\n\n<!-- FORGE:STATE\n{\"lease\":1}\n-->" };
320
+ const human = { body: "Also bar." };
321
+ it("scopeText ignores the engine's HTML-comment blocks and FORGE comments, keeps human text", () => {
322
+ const base = scopeText(iss, [human]);
323
+ assert.equal(scopeText({ ...iss, body: iss.body.replace("\"lease\":1", "\"lease\":2") }, [human, { body: "<!-- FORGE:CONTRACT -->\n## Builder Contract" }, { body: " <!-- FORGE:STARTED -->\nStarted" }]), base);
324
+ assert.match(base, /Fix foo\.[\s\S]*Also bar\./);
325
+ assert.doesNotMatch(base, /FORGE:STATE|lease/);
326
+ assert.notEqual(scopeText(iss, [human, { body: "Scope: also baz." }]), base);
327
+ assert.notEqual(scopeText({ ...iss, body: "Fix foo and qux." }, [human]), base);
328
+ });
329
+ it("#4298 SEC-5: an edit that only touches a human HTML comment is not a scope edit", () => {
330
+ const withNote = { ...iss, body: `${iss.body}\n<!-- note: maybe also qux -->` };
331
+ assert.equal(scopeText(withNote, [human]), scopeText({ ...withNote, body: withNote.body.replace("maybe also qux", "definitely qux") }, [human]));
332
+ assert.equal(scopeText(withNote, [human]), scopeText(iss, [human]));
333
+ });
334
+ it("#4298: movingBaseDiff reads only the revisions before `--`", () => {
335
+ assert.equal(movingBaseDiff("git diff HEAD -- origin/x.txt"), null);
336
+ assert.equal(movingBaseDiff("git diff --stat -- origin/foo/bar && echo ok"), null);
337
+ assert.equal(movingBaseDiff("git diff origin/main -- src"), "origin/main");
338
+ assert.equal(movingBaseDiff("git diff --name-only origin/main --"), "origin/main");
339
+ });
340
+ it("explainFailures appends the quote of the requirement a detail names, and leaves other details alone", () => {
341
+ const contract = { requirements: [{ id: "R1", quote: "foo must handle null" }, { id: "R2", quote: "x".repeat(300) }] };
342
+ const out = explainFailures([{ code: "c", detail: "R1 reported \"partial\"" }, { code: "c", detail: "R2 missing" }, { code: "c", detail: "validate V1 failed (exit 1)" }, { code: "c", detail: "R9 missing" }], contract);
343
+ assert.equal(out[0].detail, 'R1 reported "partial" ("foo must handle null")');
344
+ assert.ok(out[1].detail.endsWith('...")') && out[1].detail.length < 200);
345
+ assert.equal(out[2].detail, "validate V1 failed (exit 1)");
346
+ assert.equal(out[3].detail, "R9 missing");
347
+ assert.equal(out[0].code, "c");
348
+ });
349
+ it("a failed validate names its command", () => {
350
+ const r = precheck({ contract: { requirements: [], deliverables: [], validate: [{ id: "V1", command: "npm test" }] }, result: { validation: [{ id: "V1", exit: 2 }] }, changedFiles: [] });
351
+ assert.equal(r.failures[0].detail, "validate V1 (`npm test`) failed (exit 2)");
352
+ });
353
+ });