forgedock 1.26.0 → 1.28.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 (137) hide show
  1. package/README.md +4 -1
  2. package/bin/bootstrap.mjs +55 -0
  3. package/bin/engine/outcome-codes.mjs +13 -0
  4. package/bin/engine/phases.mjs +38 -2
  5. package/bin/engine/telemetry-seam.mjs +41 -6
  6. package/bin/engine/telemetry-seam.test.mjs +35 -0
  7. package/bin/engine-cli.mjs +616 -54
  8. package/bin/engine.mjs +13 -5
  9. package/bin/forgedock.mjs +169 -39
  10. package/bin/gc.mjs +580 -0
  11. package/bin/init-detect.mjs +68 -3
  12. package/bin/init-enrich.mjs +3 -0
  13. package/bin/init-repo.mjs +213 -0
  14. package/bin/journey.mjs +278 -43
  15. package/bin/lean/admission.mjs +33 -3
  16. package/bin/lean/batch.mjs +24 -5
  17. package/bin/lean/batch.test.mjs +54 -4
  18. package/bin/lean/checks.mjs +199 -0
  19. package/bin/lean/checks.test.mjs +183 -0
  20. package/bin/lean/conformance.mjs +235 -37
  21. package/bin/lean/conformance.test.mjs +143 -5
  22. package/bin/lean/credentials.mjs +29 -0
  23. package/bin/lean/depcache.mjs +312 -0
  24. package/bin/lean/depcache.test.mjs +298 -0
  25. package/bin/lean/drift.mjs +198 -0
  26. package/bin/lean/drift.test.mjs +223 -0
  27. package/bin/lean/effects.mjs +178 -16
  28. package/bin/lean/execution.e2e.test.mjs +81 -0
  29. package/bin/lean/execution.mjs +431 -0
  30. package/bin/lean/execution.test.mjs +583 -0
  31. package/bin/lean/heavy.mjs +289 -0
  32. package/bin/lean/heavy.test.mjs +207 -0
  33. package/bin/lean/lean.mjs +877 -151
  34. package/bin/lean/lean.test.mjs +1798 -28
  35. package/bin/lean/ledger.mjs +92 -0
  36. package/bin/lean/ledger.test.mjs +100 -0
  37. package/bin/lean/limits.mjs +58 -0
  38. package/bin/lean/limits.test.mjs +150 -0
  39. package/bin/lean/orchestrate.mjs +321 -19
  40. package/bin/lean/prompts.mjs +125 -17
  41. package/bin/lean/refactor.mjs +276 -0
  42. package/bin/lean/refactor.test.mjs +359 -0
  43. package/bin/lean/render.mjs +25 -6
  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/toolchain.mjs +228 -0
  53. package/bin/lean/toolchain.test.mjs +154 -0
  54. package/bin/lean/verify.mjs +124 -0
  55. package/bin/lean/verify.test.mjs +118 -0
  56. package/bin/lean/worktree.mjs +69 -0
  57. package/bin/lean/worktree.test.mjs +141 -0
  58. package/bin/runner.mjs +53 -10
  59. package/bin/stats/github-source.mjs +1 -1
  60. package/bin/stats/index.mjs +14 -2
  61. package/bin/stats/query.mjs +72 -0
  62. package/bin/stats/stats.test.mjs +54 -0
  63. package/commands/adopt.md +3 -0
  64. package/commands/audit.md +5 -8
  65. package/commands/autopilot.md +2 -0
  66. package/commands/bootstrap.md +35 -0
  67. package/commands/cleanup.md +7 -22
  68. package/commands/forge-stats.md +2 -1
  69. package/commands/forgedock-init.md +125 -6
  70. package/commands/issue.md +89 -830
  71. package/commands/milestone.md +55 -6
  72. package/commands/orchestrate.md +10 -2
  73. package/commands/review-pr.md +2 -2
  74. package/commands/upgrade-deps.md +3 -3
  75. package/commands/work-on/build/validate.md +4 -0
  76. package/commands/work-on/close.md +2 -0
  77. package/commands/work-on/decompose.md +24 -6
  78. package/docs/CI.md +3 -3
  79. package/docs/CONFIG.md +227 -3
  80. package/docs/OUTCOME-CODES.md +11 -0
  81. package/docs/TELEMETRY.md +1 -1
  82. package/docs/site/command-reference.md +22 -0
  83. package/docs/site/getting-started.md +2 -0
  84. package/docs/site/start-a-new-project.md +327 -0
  85. package/package.json +1 -1
  86. package/scripts/affected-files-check.sh +91 -0
  87. package/scripts/affected-files-check.test.sh +80 -0
  88. package/scripts/bootstrap-create.mjs +393 -0
  89. package/scripts/bootstrap-validate.mjs +321 -0
  90. package/scripts/calibration.mjs +116 -0
  91. package/scripts/check-devdocs-freshness.sh +125 -0
  92. package/scripts/check-devdocs-freshness.test.sh +130 -0
  93. package/scripts/check-spec-markers.sh +3 -0
  94. package/scripts/check-spec-size.sh +29 -11
  95. package/scripts/check-spec-size.test.sh +27 -4
  96. package/scripts/ci-local/lib.mjs +8 -1
  97. package/scripts/ci-local/parallelism.test.mjs +27 -0
  98. package/scripts/ci-local/run.mjs +16 -5
  99. package/scripts/ci-local/scoping.mjs +82 -0
  100. package/scripts/ci-local.test.sh +14 -2
  101. package/scripts/fixtures/symbol-index/other/sample.rb +3 -0
  102. package/scripts/fixtures/symbol-index/py/pkg/__init__.py +1 -0
  103. package/scripts/fixtures/symbol-index/py/pkg/a.py +5 -0
  104. package/scripts/fixtures/symbol-index/py/pkg/b.py +14 -0
  105. package/scripts/fixtures/symbol-index/py/pkg/c.py +1 -0
  106. package/scripts/fixtures/symbol-index/py/sample.py +41 -0
  107. package/scripts/fixtures/symbol-index/ts/sample.ts +13 -0
  108. package/scripts/fixtures/symbol-move/gen_big.py +201 -0
  109. package/scripts/fixtures/symbol-move-ts/gen_big.mjs +56 -0
  110. package/scripts/forge-root.test.sh +1 -1
  111. package/scripts/hotspots.sh +100 -0
  112. package/scripts/hotspots.test.sh +101 -0
  113. package/scripts/issue-create.sh +197 -0
  114. package/scripts/issue-create.test.sh +166 -0
  115. package/scripts/maintenance-scan.sh +185 -0
  116. package/scripts/maintenance-scan.test.sh +89 -0
  117. package/scripts/milestone-drift.sh +206 -0
  118. package/scripts/milestone-drift.test.sh +120 -0
  119. package/scripts/northstar-docs.test.sh +68 -0
  120. package/scripts/refactor-verify.sh +368 -0
  121. package/scripts/refactor-verify.test.sh +202 -0
  122. package/scripts/review-prepare.sh +11 -9
  123. package/scripts/review-prepare.test.sh +14 -0
  124. package/scripts/structure-delta.sh +155 -0
  125. package/scripts/structure-delta.test.sh +136 -0
  126. package/scripts/symbol-index.mjs +253 -0
  127. package/scripts/symbol-index.py +354 -0
  128. package/scripts/symbol-index.test.sh +119 -0
  129. package/scripts/symbol-move-ts.test.sh +161 -0
  130. package/scripts/symbol-move.mjs +379 -0
  131. package/scripts/symbol-move.py +383 -0
  132. package/scripts/symbol-move.test.sh +168 -0
  133. package/templates/bootstrap/interview.md +34 -0
  134. package/templates/bootstrap/issue-body.md +42 -0
  135. package/templates/bootstrap/needs-human-issue.md +45 -0
  136. package/templates/bootstrap/stack-profiles.md +34 -0
  137. package/templates/bootstrap/technical-spike.md +75 -0
@@ -12,10 +12,13 @@ import { OUTCOME_CODES } from "../engine/outcome-codes.mjs";
12
12
  export const DISPOSITIONS = Object.freeze(["deliver", "defer", "not-applicable", "post-deploy"]);
13
13
  /** Dispositions the engine files as a tracked follow-up issue before anything merges. */
14
14
  export const TRACKED = Object.freeze(["defer", "post-deploy"]);
15
+ /** forge#4165: where a plan decision's justification comes from; audits filter on lint|precedent. */
16
+ export const DECISION_SOURCES = Object.freeze(["doc", "lint", "precedent", "spec"]);
15
17
  const REQ_STATUS = ["implemented", "partial", "missing", "deferred", "post-deploy", "not-applicable"];
16
18
  const COVER_STATUS = ["covered", "narrowed", "missing"];
17
19
  const PRESERVE_STATUS = ["intact", "violated", "unverified"];
18
- const EVIDENCE_RE = /^([^\s:]+):(\d+)/;
20
+ /** The first path:line anywhere in the evidence text (backticks, quotes and brackets around it are ignored). */
21
+ const EVIDENCE_RE = /([^\s:`'"()[\]<>,;]+):(\d+)/;
19
22
  /** 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
23
  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
24
  /** Files any change may touch without being a deliverable: changelogs and dependency lockfiles. */
@@ -59,8 +62,16 @@ export function authorizationOf(contract, f) {
59
62
  }
60
63
  const blank = (s) => !String(s ?? "").trim() || /^none$/i.test(String(s).trim());
61
64
 
62
- /** Shape check of the builder's contract.json against the issue text. Returns {ok, problems[]}. */
63
- export function validateContract(c, issueText) {
65
+ /** forge.yaml lean.repro_test (#4271): "prefer" (default) asks a bug lane for a red-green repro test; "off" is today's contract. */
66
+ export const REPRO_MODES = Object.freeze(["off", "prefer"]);
67
+ /** True when the issue carries a `bug` or `type:bug` label (names or {name} objects). */
68
+ export const isBugIssue = (labels) => (Array.isArray(labels) ? labels : []).some((l) => ["bug", "type:bug"].includes(String(l?.name ?? l).trim().toLowerCase()));
69
+
70
+ /**
71
+ * Shape check of the builder's contract.json against the issue text. Returns {ok, problems[]}. `repro` (#4271): a bug
72
+ * lane with lean.repro_test on also needs repro_test: {path, id?, command}, or "none" with a repro_test_reason.
73
+ */
74
+ export function validateContract(c, issueText, { repro = false } = {}) {
64
75
  const problems = [];
65
76
  if (!c || typeof c !== "object" || Array.isArray(c)) return { ok: false, problems: ["contract.json is missing or not a JSON object"] };
66
77
  const reqs = Array.isArray(c.requirements) ? c.requirements : [];
@@ -79,8 +90,19 @@ export function validateContract(c, issueText) {
79
90
  if (reqs.some((r) => r?.disposition === "deliver") && !dels.length) problems.push("deliver requirements but no deliverables");
80
91
  for (const d of dels) if (!d?.id || !d?.path) problems.push(`deliverable without id or path: ${JSON.stringify(d).slice(0, 80)}`);
81
92
  for (const p of Array.isArray(c.preserve) ? c.preserve : []) if (!p?.id || blank(p?.what)) problems.push("preserve entry without id or what");
93
+ // forge#4165: decision provenance is optional (contracts frozen before it stay valid) and checked when present.
94
+ if (c.decisions != null && !Array.isArray(c.decisions)) problems.push("decisions must be an array");
95
+ for (const k of Array.isArray(c.decisions) ? c.decisions : []) {
96
+ if (!k?.id || blank(k?.choice)) problems.push(`decision without id or choice: ${JSON.stringify(k ?? null).slice(0, 80)}`);
97
+ else if (!DECISION_SOURCES.includes(k?.source)) problems.push(`${k.id}: decision source must be one of ${DECISION_SOURCES.join("|")}`);
98
+ }
82
99
  if (!Array.isArray(c.validate) || !c.validate.length) problems.push("the contract names no validate[] commands");
83
100
  else for (const v of c.validate) if (!v?.id || blank(v?.command)) problems.push("validate entry without id or command");
101
+ if (repro) {
102
+ const t = c.repro_test;
103
+ if (t === "none") { if (blank(c.repro_test_reason)) problems.push(`repro_test "none" needs a one-line repro_test_reason`); }
104
+ 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`);
105
+ }
84
106
  if (JSON.stringify(c).length > MAX_CONTRACT_CHARS) problems.push(`the contract is over ${MAX_CONTRACT_CHARS} characters; keep quotes to the requirement sentence and descriptions short`);
85
107
  return { ok: problems.length === 0, problems };
86
108
  }
@@ -97,7 +119,11 @@ export function applyAmendments(frozen, amendments, issueText = null) {
97
119
  const tag = JSON.stringify(a ?? null).slice(0, 100);
98
120
  if (blank(a?.why)) { problems.push(`amendment without why: ${tag}`); continue; }
99
121
  if (a.kind === "add-deliverable" && !blank(a.path)) {
100
- if (!c.deliverables.some((d) => d.path === a.path)) c.deliverables.push({ id: a.id ?? `A${c.deliverables.length + 1}`, path: a.path, change: a.why, amended: true });
122
+ const have = c.deliverables.find((d) => d.path === a.path);
123
+ // An existing deliverable keeps its id and gains the why, so an amendment naming a package on an already-listed
124
+ // package.json declares the edge (forge#4159); before, it was reported as applied but changed nothing.
125
+ if (have) { have.change = have.change ? `${have.change}; ${a.why}` : a.why; have.amended = true; }
126
+ else c.deliverables.push({ id: a.id ?? `A${c.deliverables.length + 1}`, path: a.path, change: a.why, amended: true });
101
127
  } else if (a.kind === "add-requirement" && a.requirement?.id && !blank(a.requirement?.quote)) {
102
128
  if (c.requirements.some((r) => r.id === a.requirement.id)) { problems.push(`add-requirement reuses id ${a.requirement.id}`); continue; }
103
129
  if (issueText != null && !inIssue(a.requirement.quote, issueText)) { problems.push(`add-requirement ${a.requirement.id}: quote is not verbatim from the issue`); continue; }
@@ -118,30 +144,69 @@ export function applyAmendments(frozen, amendments, issueText = null) {
118
144
 
119
145
  /**
120
146
  * Deterministic checks before any reviewer tokens are spent: unauthorized files, failed or missing validation,
121
- * requirements not implemented, and deferrals without a tracking issue. Returns {ok, failures[]}.
147
+ * requirements not implemented, deferrals without a tracking issue, and unexempted hotspot growth. Returns {ok, failures[]}; each failure carries a
148
+ * `kind` (scope_creep, validate_missing, validate_failed, requirement_unimplemented, untracked_deferral, hotspot_growth, repro_red_green) so callers can
149
+ * tell the builder's own failed checks apart from bookkeeping gaps. `ciVerified` (#4167): the current head passed the
150
+ * 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.
122
151
  */
123
- export function precheck({ contract, result, changedFiles, ignore = [] }) {
152
+ export function precheck({ contract, result, changedFiles, ignore = [], ciVerified = false, hotspots = null, repro = null }) {
124
153
  const failures = [];
125
154
  const creep = changedFiles.filter((f) => !ignore.some((p) => f.startsWith(p)) && !authorizationOf(contract, f));
126
- if (creep.length) failures.push({ code: C.CONFORMANCE_SCOPE_CREEP, detail: `changed outside the contract's deliverables: ${creep.join(", ")}` });
155
+ if (creep.length) failures.push({ code: C.CONFORMANCE_SCOPE_CREEP, kind: "scope_creep", detail: `changed outside the contract's deliverables: ${creep.join(", ")}` });
127
156
  const runs = Array.isArray(result?.validation) ? result.validation : [];
128
157
  for (const v of contract.validate ?? []) {
129
158
  const r = runs.find((x) => x?.id === v.id);
130
- if (!r) failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, detail: `validate ${v.id} (\`${v.command}\`) has no recorded result` });
131
- else if (r.exit !== 0) failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, detail: `validate ${v.id} failed (exit ${r.exit})` });
159
+ // #4167: a head the engine verified on CI covers validate ids the builder left to CI (no result, or ci:true without an exit)
160
+ const leftToCi = !r || (r.ci === true && r.exit === undefined);
161
+ if (ciVerified && leftToCi) continue;
162
+ if (leftToCi) failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "validate_missing", detail: `validate ${v.id} (\`${v.command}\`) has no recorded result` });
163
+ else if (r.exit !== 0) failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "validate_failed", detail: `validate ${v.id} failed (exit ${r.exit})` });
132
164
  }
133
165
  const st = Array.isArray(result?.requirements) ? result.requirements : [];
134
166
  for (const r of contract.requirements ?? []) {
135
167
  const s = st.find((x) => x?.id === r.id)?.status;
136
168
  if (TRACKED.includes(r.disposition)) continue; // typed deferrals are judged by the reviewer, then filed before the PR
137
169
  if (r.disposition === "deliver") {
138
- if (s === "deferred" || s === "post-deploy") failures.push({ code: C.CONFORMANCE_UNTRACKED_DEFERRAL, detail: `${r.id} was contracted as deliver but reported "${s}" without a typed amendment` });
139
- else if (s !== "implemented") failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, detail: `${r.id} reported "${s ?? "no status"}"` });
170
+ if (s === "deferred" || s === "post-deploy") failures.push({ code: C.CONFORMANCE_UNTRACKED_DEFERRAL, kind: "untracked_deferral", detail: `${r.id} was contracted as deliver but reported "${s}" without a typed amendment` });
171
+ else if (s !== "implemented") failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "requirement_unimplemented", detail: `${r.id} reported "${s ?? "no status"}"` });
140
172
  }
141
173
  }
174
+ failures.push(...hotspotGrowth({ contract, result, hotspots, changedFiles }));
175
+ if (repro) failures.push(...reproFailures(contract, repro));
142
176
  return { ok: failures.length === 0, failures };
143
177
  }
144
178
 
179
+ /**
180
+ * #4271: the frozen repro_test's red-green proof ({base, head} words from scripts/acceptance-red-green.sh). Anything but
181
+ * HEAD=RAN BASE=FAILED fails; code conformance.unimplemented, so it blocks in shadow too and goes back to the builder.
182
+ */
183
+ export function reproFailures(contract, { base, head } = {}) {
184
+ if (head === "RAN" && base === "FAILED") return [];
185
+ const t = contract?.repro_test ?? {};
186
+ const name = [t.path, t.id].filter((x) => !blank(x)).join(" ") || "repro_test";
187
+ return [{ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "repro_red_green",
188
+ detail: `repro test ${name} (\`${t.command ?? ""}\`) must fail on the base and pass on head; got BASE=${base ?? "UNRUNNABLE"} HEAD=${head ?? "UNRUNNABLE"}` }];
189
+ }
190
+
191
+ const EXEMPT_RE = /hotspot-exempt:\s*\S/i;
192
+ /**
193
+ * forge#4195: a changed file already over `file_lines` (not generated, vendored or glob-excluded) that grew by more
194
+ * than `growth_lines` since the base, with no `hotspot-exempt: <reason>` in a covering deliverable's change or an
195
+ * add-deliverable amendment's why. `hotspots` is scripts/hotspots.sh --base output; null or disabled checks nothing.
196
+ */
197
+ export function hotspotGrowth({ contract, result, hotspots, changedFiles = [] }) {
198
+ if (!hotspots || hotspots.enabled === false || !Array.isArray(hotspots.files)) return [];
199
+ const limit = Number(hotspots.growth_lines) || 0, max = Number(hotspots.file_lines) || 0;
200
+ const changed = new Set(changedFiles.map(cleanPath));
201
+ const amends = Array.isArray(result?.amendments) ? result.amendments : [];
202
+ const exempt = (f) => (contract?.deliverables ?? []).some((d) => pathCovers(d.path, f) && EXEMPT_RE.test(String(d.change ?? "")))
203
+ || amends.some((a) => a?.kind === "add-deliverable" && pathCovers(a.path, f) && EXEMPT_RE.test(String(a.why ?? "")));
204
+ return hotspots.files
205
+ .filter((h) => h?.over && !h.excluded && Number(h.growth) > limit && changed.has(cleanPath(h.path)) && !exempt(h.path))
206
+ .map((h) => ({ code: C.MAINTENANCE_HOTSPOT_GROWTH, kind: "hotspot_growth",
207
+ detail: `${h.path} grew by ${h.growth} lines (now ${h.lines}, limit +${limit} over ${max}) without hotspot-exempt` }));
208
+ }
209
+
145
210
  /** The reviewer's JSON object (a json fence first, then the outermost braces), or null. */
146
211
  export function parseReview(text) {
147
212
  const s = String(text ?? "");
@@ -153,52 +218,114 @@ export function parseReview(text) {
153
218
  return null;
154
219
  }
155
220
 
221
+ /**
222
+ * A basename or path suffix in reviewer evidence, resolved to the one repo path it names (changed files first, then
223
+ * `repoFiles`). An exact match, an ambiguous name or an unknown name is returned unchanged.
224
+ */
225
+ export function normalizeEvidencePath(path, { changedFiles = [], repoFiles = [], listed = null } = {}) {
226
+ const p = cleanPath(path);
227
+ if (listed) repoFiles = listed;
228
+ if (!p) return p;
229
+ for (const pool of [changedFiles, repoFiles]) {
230
+ const files = [...pool];
231
+ if (files.includes(p)) return p;
232
+ const hits = [...new Set(files.filter((f) => String(f).endsWith(`/${p}`)))];
233
+ if (hits.length === 1) return hits[0];
234
+ if (hits.length > 1) return p;
235
+ }
236
+ return p;
237
+ }
238
+ /**
239
+ * #4160: an evidence path the reviewer gave as a bare or partial name (`route.ts`, `pdf/route.ts`) resolves to the one
240
+ * changed file it names. Returns the repo path, or null when it is not a unique match.
241
+ */
242
+ export function resolveEvidencePath(p, changedFiles = []) {
243
+ const n = normalizeEvidencePath(p, { changedFiles });
244
+ return n && changedFiles.includes(n) ? n : null;
245
+ }
246
+ /** The path:line in an evidence string, with the path normalized; null when there is none. */
247
+ function evidenceOf(text, ctx) {
248
+ const m = EVIDENCE_RE.exec(String(text ?? ""));
249
+ return m ? { path: normalizeEvidencePath(m[1], ctx), line: m[2] } : null;
250
+ }
251
+ const reviewedFiles = (changedFiles, ignore) => changedFiles.filter((x) => !ignore.some((pre) => x.startsWith(pre)));
252
+
253
+ /**
254
+ * The rows the engine can fill itself, so bookkeeping is never "incomplete": a scope row per changed file from the
255
+ * contract's own authorization ("none" when unauthorized, still scope creep), a preserve row per id as "unverified"
256
+ * (still preserve_violated), and implemented evidence rewritten to `path:line` with the repo path. Returns a new object.
257
+ */
258
+ export function fillEngineRows(rv, { contract, changedFiles = [], repoFiles = [], ignore = [] }) {
259
+ if (!rv || typeof rv !== "object") return rv;
260
+ const out = { ...rv };
261
+ const scope = Array.isArray(rv.scope) ? [...rv.scope] : [];
262
+ 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 });
263
+ out.scope = scope;
264
+ const pres = Array.isArray(rv.preserve) ? [...rv.preserve] : [];
265
+ 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 });
266
+ out.preserve = pres;
267
+ if (Array.isArray(rv.requirements)) {
268
+ out.requirements = rv.requirements.map((r) => {
269
+ if (r?.status !== "implemented") return r;
270
+ const e = evidenceOf(r.evidence, { changedFiles, repoFiles });
271
+ return e ? { ...r, evidence: `${e.path}:${e.line}` } : r;
272
+ });
273
+ }
274
+ return out;
275
+ }
276
+
156
277
  /** Row completeness: every contract requirement, preserve item and changed file has a well-formed row. */
157
- export function checkCompleteness(rv, { contract, changedFiles, issueText, pathExists = () => false, ignore = [] }) {
158
- const p = [];
278
+ export function checkCompleteness(rv, { contract, changedFiles, issueText, pathExists = () => false, ignore = [], repoFiles = [] }) {
279
+ const p = [], ids = new Set();
280
+ const miss = (id, msg) => { p.push(msg); ids.add(id); };
159
281
  const arr = (k) => (Array.isArray(rv?.[k]) ? rv[k] : null);
160
- if (!["CONFORMS", "NONCONFORMING"].includes(rv?.verdict)) p.push("verdict must be CONFORMS or NONCONFORMING");
282
+ if (!["CONFORMS", "NONCONFORMING"].includes(rv?.verdict)) miss("verdict", "verdict must be CONFORMS or NONCONFORMING");
161
283
  const cov = arr("issue_coverage");
162
- if (!cov?.length) p.push("issue_coverage is missing or empty");
284
+ if (!cov?.length) miss("issue_coverage", "issue_coverage is missing or empty");
163
285
  for (const c of cov ?? []) {
164
- if (!COVER_STATUS.includes(c?.status)) p.push(`issue_coverage status invalid: ${c?.status}`);
165
- if (!inIssue(c?.quote, issueText)) p.push(`issue_coverage quote is not verbatim from the issue: "${String(c?.quote ?? "").slice(0, 60)}"`);
286
+ if (!COVER_STATUS.includes(c?.status)) miss("issue_coverage", `issue_coverage status invalid: ${c?.status}`);
287
+ if (!inIssue(c?.quote, issueText)) miss("issue_coverage", `issue_coverage quote is not verbatim from the issue: "${String(c?.quote ?? "").slice(0, 60)}"`);
166
288
  }
167
289
  const reqs = arr("requirements");
168
290
  if (!reqs) p.push("requirements rows are missing");
169
291
  for (const r of contract.requirements) {
170
292
  const rows = (reqs ?? []).filter((x) => x?.id === r.id);
171
- if (rows.length !== 1) { p.push(`requirement ${r.id} needs exactly one row (has ${rows.length})`); continue; }
293
+ if (rows.length !== 1) { miss(r.id, `requirement ${r.id} needs exactly one row (has ${rows.length})`); continue; }
172
294
  const row = rows[0];
173
- if (!REQ_STATUS.includes(row.status)) { p.push(`${r.id}: status invalid: ${row.status}`); continue; }
174
- if (TRACKED.includes(r.disposition) && typeof row.deferral_ok !== "boolean") p.push(`${r.id}: a ${r.disposition} requirement needs deferral_ok true|false (is deferring it justified?)`);
295
+ if (!REQ_STATUS.includes(row.status)) { miss(r.id, `${r.id}: status invalid: ${row.status}`); continue; }
296
+ 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?)`);
175
297
  if (row.status === "implemented") {
176
- const m = EVIDENCE_RE.exec(String(row.evidence ?? ""));
177
- if (!m) p.push(`${r.id}: implemented needs path:line evidence`);
178
- else if (!changedFiles.includes(m[1]) && !pathExists(m[1])) p.push(`${r.id}: evidence path ${m[1]} does not exist`);
179
- if (blank(row.validated_by)) p.push(`${r.id}: implemented needs validated_by (a validate id or test)`);
298
+ const e = evidenceOf(row.evidence, { changedFiles, repoFiles });
299
+ if (!e) miss(r.id, `${r.id}: implemented needs path:line evidence`);
300
+ else if (!changedFiles.includes(e.path) && !pathExists(e.path)) miss(r.id, `${r.id}: evidence path ${e.path} does not exist`);
301
+ if (blank(row.validated_by)) miss(r.id, `${r.id}: implemented needs validated_by (a validate id or test)`);
180
302
  }
181
303
  }
182
304
  const scope = arr("scope");
183
- if (!scope) p.push("scope rows are missing");
184
- for (const f of changedFiles.filter((x) => !ignore.some((pre) => x.startsWith(pre)))) if (!(scope ?? []).some((s) => s?.file === f)) p.push(`changed file ${f} has no scope row`);
305
+ if (!scope) miss("scope", "scope rows are missing");
306
+ // #4160: a file the contract authorizes (deliverable, test, incidental) is already settled by the deterministic precheck;
307
+ // a row is required only for the files it could not authorize (decide() fills those as "none"). A given row is still judged.
308
+ 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`);
185
309
  const pres = arr("preserve") ?? [];
186
310
  for (const c of contract.preserve ?? []) {
187
311
  const row = pres.find((x) => x?.id === c.id);
188
- if (!row || !PRESERVE_STATUS.includes(row.status)) p.push(`preserve ${c.id} needs a row with status ${PRESERVE_STATUS.join("|")}`);
312
+ if (!row || !PRESERVE_STATUS.includes(row.status)) miss(c.id, `preserve ${c.id} needs a row with status ${PRESERVE_STATUS.join("|")}`);
189
313
  }
190
- if (rv && rv.regressions != null && !Array.isArray(rv.regressions)) p.push("regressions must be an array");
191
- return { complete: p.length === 0, problems: p };
314
+ if (rv && rv.regressions != null && !Array.isArray(rv.regressions)) miss("regressions", "regressions must be an array");
315
+ if (!reqs) for (const r of contract.requirements) ids.add(r.id);
316
+ return { complete: p.length === 0, problems: p, missing: [...ids] };
192
317
  }
193
318
 
194
319
  /**
195
- * The engine's decision from the reviewer's rows. state: conforms | nonconforming | review.unparseable | review.incomplete.
196
- * failures[] are {code, detail}; `inconsistent` marks a CONFORMS verdict whose rows fail.
320
+ * The engine's decision from the reviewer's rows. state: conforms | nonconforming | review.unparseable | review.incomplete
321
+ * | review.inconsistent. failures[] are {code, detail}; `inconsistent` marks a CONFORMS verdict whose rows fail;
322
+ * `missing` lists the ids (or sections) a targeted re-ask must fill; `review` is the reply with the engine-filled rows.
197
323
  */
198
- export function decide(rv, ctx) {
199
- if (!rv) return { state: "review.unparseable", failures: [{ code: C.REVIEW_NO_VERDICT, detail: "no JSON object in the reviewer reply" }] };
324
+ export function decide(raw, ctx) {
325
+ if (!raw) return { state: "review.unparseable", failures: [{ code: C.REVIEW_NO_VERDICT, detail: "no JSON object in the reviewer reply" }] };
326
+ const rv = fillEngineRows(raw, ctx);
200
327
  const comp = checkCompleteness(rv, ctx);
201
- if (!comp.complete) return { state: "review.incomplete", failures: comp.problems.map((d) => ({ code: C.REVIEW_INCOMPLETE, detail: d })) };
328
+ if (!comp.complete) return { state: "review.incomplete", failures: comp.problems.map((d) => ({ code: C.REVIEW_INCOMPLETE, detail: d })), missing: comp.missing, review: rv };
202
329
  const f = [];
203
330
  for (const c of rv.issue_coverage) if (c.status !== "covered") f.push({ code: C.CONFORMANCE_OMISSION, detail: `issue requirement ${c.status}: "${c.quote}"${c.note ? ` (${c.note})` : ""}` });
204
331
  for (const r of ctx.contract.requirements) {
@@ -216,10 +343,54 @@ export function decide(rv, ctx) {
216
343
  }
217
344
  for (const p of rv.preserve ?? []) if (p.status !== "intact") f.push({ code: C.CONFORMANCE_PRESERVE_VIOLATED, detail: `${p.id} ${p.status}${p.evidence ? ` (${p.evidence})` : ""}${p.note ? `: ${p.note}` : ""}` });
218
345
  for (const r of rv.regressions ?? []) f.push({ code: C.CONFORMANCE_REGRESSION, detail: `${r?.at ?? "?"}: ${r?.failure ?? ""}` });
219
- if (f.length) return { state: "nonconforming", failures: f, inconsistent: rv.verdict === "CONFORMS" };
346
+ if (f.length) return { state: "nonconforming", failures: f, inconsistent: rv.verdict === "CONFORMS", review: rv };
220
347
  // A NONCONFORMING verdict with every row passing gives the builder nothing to fix: re-ask the reviewer.
221
- if (rv.verdict !== "CONFORMS") return { state: "review.inconsistent", failures: [{ code: C.REVIEW_INCONSISTENT, detail: "verdict NONCONFORMING with no failing row" }] };
222
- return { state: "conforms", failures: [] };
348
+ if (rv.verdict !== "CONFORMS") return { state: "review.inconsistent", failures: [{ code: C.REVIEW_INCONSISTENT, detail: "verdict NONCONFORMING with no failing row" }], missing: ["verdict"], review: rv };
349
+ return { state: "conforms", failures: [], review: rv };
350
+ }
351
+
352
+ /** The ids (or sections) a targeted re-ask must fill: requirement ids without a valid row, issue_coverage problems, ...; [] when complete. */
353
+ export function missingIds(rv, ctx) {
354
+ if (!rv || typeof rv !== "object") return [];
355
+ return checkCompleteness(fillEngineRows(rv, ctx), ctx).missing;
356
+ }
357
+
358
+ /**
359
+ * The reviewer's first reply with the rows of a targeted re-ask applied: a repair row replaces the row with the same id
360
+ * (scope: file), issue_coverage is replaced when the repair sends any, verdict and summary follow the repair when valid.
361
+ */
362
+ export function mergeReviewRows(first, repair) {
363
+ if (!first || typeof first !== "object") return repair ?? null;
364
+ if (!repair || typeof repair !== "object") return first;
365
+ const out = { ...first };
366
+ for (const [k, key] of [["requirements", "id"], ["preserve", "id"], ["scope", "file"]]) {
367
+ if (!Array.isArray(repair[k])) continue;
368
+ const rows = Array.isArray(first[k]) ? first[k].filter((x) => !x?.filled) : [];
369
+ 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); }
370
+ out[k] = rows;
371
+ }
372
+ if (Array.isArray(repair.issue_coverage) && repair.issue_coverage.length) out.issue_coverage = repair.issue_coverage;
373
+ if (Array.isArray(repair.regressions)) out.regressions = [...(Array.isArray(first.regressions) ? first.regressions : []), ...repair.regressions];
374
+ if (["CONFORMS", "NONCONFORMING"].includes(repair.verdict)) { out.verdict = repair.verdict; if (repair.summary) out.summary = repair.summary; }
375
+ return out;
376
+ }
377
+
378
+ /**
379
+ * #4160: the rows the engine requires, pre-filled with every id so the reviewer fills statuses instead of composing rows:
380
+ * one requirement row per R id (status empty), one preserve row per P id ("unverified" until checked), one scope row per
381
+ * changed file pre-filled with the engine's authorization, and one issue_coverage row per contract quote (the reviewer adds
382
+ * any the contract missed). Called as (contract, changedFiles, ignore) or ({contract, changedFiles, ignore}).
383
+ */
384
+ export function reviewSkeleton(contract, changedFiles = [], ignore = []) {
385
+ if (contract && !Array.isArray(contract.requirements) && "contract" in contract) ({ contract, changedFiles = [], ignore = [] } = contract);
386
+ const reqs = contract?.requirements ?? [];
387
+ return {
388
+ issue_coverage: reqs.map((r) => ({ quote: r.quote, contract_ids: [r.id], status: "" })),
389
+ requirements: reqs.map((r) => ({ id: r.id, status: "", evidence: "", validated_by: "", ...(TRACKED.includes(r.disposition) ? { deferral_ok: null } : {}), note: "" })),
390
+ scope: reviewedFiles(changedFiles, ignore).map((f) => ({ file: f, authorized_by: authorizationOf(contract ?? {}, f) ?? "none", note: "" })),
391
+ preserve: (contract?.preserve ?? []).map((x) => ({ id: x.id, status: "unverified", evidence: "" })),
392
+ regressions: [], verdict: "", summary: "",
393
+ };
223
394
  }
224
395
 
225
396
  /** Requirements the reviewer accepted as deferred or post-deploy that still need a tracking issue. */
@@ -236,3 +407,30 @@ export function followupItems(reqs) {
236
407
  const flat = (s) => String(s ?? "").replace(/[\t\r\n`]+/g, " ").replace(/\s+/g, " ").trim();
237
408
  return reqs.map((r) => ({ id: r.id, key: flat(`${r.id}: ${r.quote}`).slice(0, 240), why: flat(`${r.disposition}: ${r.why ?? ""}`).slice(0, 400) }));
238
409
  }
410
+
411
+ /**
412
+ * forge#4159: package.json edges between workspace packages (scripts/structure-delta.sh `package_edges_added`) that
413
+ * the contract does not name. Named = a deliverable covering the edge's manifest whose `change` names the target
414
+ * package (an add-deliverable amendment counts: its change is the why). A null or `{}` delta names nothing to check.
415
+ */
416
+ export function unnamedEdges(contract, delta) {
417
+ const edges = Array.isArray(delta?.package_edges_added) ? delta.package_edges_added : [];
418
+ const dels = contract?.deliverables ?? [];
419
+ return edges.filter((e) => e?.manifest && e?.to && !dels.some((d) => pathCovers(d.path, e.manifest) && String(d.change ?? "").includes(e.to)));
420
+ }
421
+
422
+ /**
423
+ * forge#4159: the one report-only line for the existing reviewer prompt, or "" when there is no import-pair growth
424
+ * and no boundary-config loosening. Never blocks; adds no reviewer run.
425
+ */
426
+ export function structureNote(delta) {
427
+ const grow = (Array.isArray(delta?.import_pairs) ? delta.import_pairs : []).filter((p) => p?.files_added > 0);
428
+ const b = delta?.boundary_config_changed;
429
+ const loosened = Boolean(b?.loosens);
430
+ if (!grow.length && !loosened) return "";
431
+ const pairs = grow.map((p) => `${p.files_added} value import${p.files_added === 1 ? "" : "s"} ${p.from}→${p.to}`).join(", ");
432
+ const files = (Array.isArray(b?.files) ? b.files : []).join(", ");
433
+ const config = loosened ? `the repo's boundary config was loosened in this PR (${files})` : "the repo's boundary config allows it";
434
+ const s = grow.length ? `this PR adds ${pairs} (files); ${config}` : `this PR loosens the repo's boundary config (${files})`;
435
+ return `Structure (report only): ${s}; flag only if the contract or the architecture doc says otherwise.`.replace(/[\r\n]+/g, " ");
436
+ }
@@ -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 } from "./conformance.mjs";
5
+ import { validateContract, applyAmendments, precheck, hotspotGrowth, reproFailures, isBugIssue, parseReview, checkCompleteness, decide, closePlan, followupItems, inIssue, isTestPath, normalizeEvidencePath, mergeReviewRows, reviewSkeleton, missingIds, resolveEvidencePath } 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,10 +95,84 @@ 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 a changed file, missing preserve row", () => {
99
- assert.equal(decide({ ...good, requirements: good.requirements.slice(1) }, ctx).state, "review.incomplete");
100
- assert.equal(decide({ ...good, scope: good.scope.slice(1) }, ctx).state, "review.incomplete");
101
- assert.equal(decide({ ...good, preserve: [] }, 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)", () => {
145
+ const extra = { ...ctx, changedFiles: [...files, "src/billing.js"] };
146
+ assert.equal(checkCompleteness(good, extra).problems.includes("changed file src/billing.js has no scope row"), true);
147
+ });
148
+ it("#4160: a changed file the contract already authorizes (deliverable, test, incidental) needs no scope row", () => {
149
+ assert.equal(decide({ ...good, scope: [] }, ctx).state, "conforms");
150
+ assert.equal(checkCompleteness({ ...good, scope: [] }, { ...ctx, changedFiles: [...files, "CHANGELOG.md"] }).complete, true);
151
+ });
152
+ it("#4160: a scope row the reviewer gives for an authorized file is still judged (none on a deliverable is creep)", () => {
153
+ const s = good.scope.map((x) => (x.file === "src/scrape.js" ? { ...x, authorized_by: "none", note: "unrelated rewrite" } : x));
154
+ assert.equal(decide({ ...good, scope: s }, ctx).state, "nonconforming");
155
+ });
156
+ it("#4160: evidence given as a bare or partial file name resolves to the one changed file it names", () => {
157
+ assert.equal(resolveEvidencePath("scrape.js", files), "src/scrape.js");
158
+ assert.equal(resolveEvidencePath("./src/scrape.js", files), "src/scrape.js");
159
+ assert.equal(resolveEvidencePath("leak.test.js", files), "test/leak.test.js");
160
+ assert.equal(resolveEvidencePath("x.js", ["a/x.js", "b/x.js"]), null, "ambiguous");
161
+ assert.equal(resolveEvidencePath("nope.js", files), null);
162
+ assert.equal(decide(row("R1", { evidence: "scrape.js:4" }), ctx).state, "conforms");
163
+ });
164
+ it("#4160: reviewSkeleton (object form) lists every required row, scope rows pre-filled with the engine's authorization", () => {
165
+ const k = { ...C, requirements: [...C.requirements, { id: "R9", quote: "Billing pricing is unchanged.", disposition: "defer", why: "w" }] };
166
+ const sk = reviewSkeleton({ contract: k, changedFiles: [...files, "src/billing.js", ".forge-lean/x"], ignore: [".forge-lean/"] });
167
+ assert.deepEqual(sk.requirements.map((r) => r.id), k.requirements.map((r) => r.id));
168
+ assert.equal(sk.requirements.find((r) => r.id === "R9").deferral_ok, null);
169
+ assert.ok(!("deferral_ok" in sk.requirements[0]));
170
+ assert.deepEqual(sk.preserve.map((p) => p.id), (k.preserve ?? []).map((p) => p.id));
171
+ 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"]]);
172
+ assert.deepEqual(sk.issue_coverage.map((c) => c.quote), k.requirements.map((r) => r.quote));
173
+ assert.ok(sk.issue_coverage.every((c) => inIssue(c.quote, ISSUE)), "seeded quotes pass the verbatim check");
174
+ assert.ok([...sk.requirements, ...sk.issue_coverage].every((r) => r.status === ""), "no requirement or coverage status is pre-judged");
175
+ assert.ok(sk.preserve.every((p) => p.status === "unverified"), "preserve starts unverified");
102
176
  });
103
177
  it("incomplete: implemented without path:line, with evidence in a nonexistent file, or without validation", () => {
104
178
  assert.equal(decide(row("R1", { evidence: "looks fine" }), ctx).state, "review.incomplete");
@@ -161,3 +235,67 @@ describe("close plan and follow-up items", () => {
161
235
  assert.equal(i.key, "R2: a b c"); assert.equal(i.why, "defer: x y"); assert.doesNotMatch(i.key + i.why, /[\t\n`]/);
162
236
  });
163
237
  });
238
+
239
+ describe("hotspot growth precheck (forge#4195)", () => {
240
+ const H = (o = {}) => ({ path: "src/scrape.js", lines: 2600, commits_90d: 30, hotspot: 78000, over: true, excluded: false, base_lines: 2400, growth: 200, ...o });
241
+ const hs = (fs, o = {}) => ({ enabled: true, file_lines: 2000, growth_lines: 150, files: fs, ...o });
242
+ const result = { validation: [{ id: "V1", exit: 0 }], requirements: [{ id: "R1", status: "implemented" }, { id: "R2", status: "implemented" }] };
243
+ const run = (hotspots, contract = C, r = result) => precheck({ contract, result: r, changedFiles: files, hotspots });
244
+
245
+ it("an over-threshold file grown past growth_lines fails with maintenance.hotspot_growth", () => {
246
+ const pc = run(hs([H()]));
247
+ assert.equal(pc.ok, false);
248
+ assert.deepEqual(pc.failures.map((f) => [f.code, f.kind]), [["maintenance.hotspot_growth", "hotspot_growth"]]);
249
+ assert.match(pc.failures[0].detail, /^src\/scrape\.js grew by 200 lines \(now 2600, limit \+150 over 2000\) without hotspot-exempt$/);
250
+ });
251
+
252
+ it("within the limit, under the threshold, excluded, unchanged, disabled or no facts: no failure", () => {
253
+ for (const h of [hs([H({ growth: 150 })]), hs([H({ over: false, lines: 1900 })]), hs([H({ excluded: true })]), hs([H({ path: "src/other.js" })]),
254
+ hs([H()], { enabled: false }), hs([H({ growth: null })]), null, undefined, {}]) assert.equal(run(h).ok, true, JSON.stringify(h));
255
+ assert.deepEqual(precheck({ contract: C, result, changedFiles: files }), run(null), "callers without hotspots are unchanged");
256
+ });
257
+
258
+ it("hotspot-exempt: <reason> in a covering deliverable's change or an add-deliverable amendment's why exempts the file", () => {
259
+ const withChange = (change) => ({ ...C, deliverables: [{ id: "D1", path: "src/scrape.js", change }, C.deliverables[1]] });
260
+ assert.equal(run(hs([H()]), withChange("route fix; Hotspot-Exempt: the handler table must stay in one file")).ok, true);
261
+ assert.equal(run(hs([H()]), withChange("hotspot-exempt: ")).ok, false, "an empty reason does not exempt");
262
+ 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");
263
+ const amended = { ...result, amendments: [{ kind: "add-deliverable", path: "src/scrape.js", why: "hotspot-exempt: one-off migration table" }] };
264
+ assert.equal(run(hs([H()]), C, amended).ok, true);
265
+ assert.equal(run(hs([H(), H({ path: "src/search.js" })]), withChange("hotspot-exempt: ok")).failures.length, 1, "the exemption covers only its own file");
266
+ assert.equal(hotspotGrowth({ contract: C, hotspots: hs([H()]), changedFiles: ["./src/scrape.js"] }).length, 1);
267
+ });
268
+ });
269
+
270
+ describe("repro_test on bug lanes (#4271)", () => {
271
+ const res = { requirements: [{ id: "R1", status: "implemented" }, { id: "R2", status: "implemented" }], validation: [{ id: "V1", exit: 0 }] };
272
+ const RT = { path: "test/leak.test.js", id: "releases on 429", command: "node --test test/leak.test.js" };
273
+ it("isBugIssue reads bug and type:bug labels, as names or {name}", () => {
274
+ assert.equal(isBugIssue([{ name: "bug" }]), true);
275
+ assert.equal(isBugIssue(["P2", "type:bug"]), true);
276
+ assert.equal(isBugIssue([{ name: "feature" }, "debug"]), false);
277
+ assert.equal(isBugIssue(null), false);
278
+ });
279
+ it("a testable bug needs repro_test {path, command}; a non-testable one records none with a reason", () => {
280
+ assert.deepEqual(validateContract({ ...C, repro_test: RT }, ISSUE, { repro: true }), { ok: true, problems: [] });
281
+ assert.deepEqual(validateContract({ ...C, repro_test: "none", repro_test_reason: "spec-only change" }, ISSUE, { repro: true }), { ok: true, problems: [] });
282
+ assert.match(validateContract(C, ISSUE, { repro: true }).problems.join(), /repro_test must be/);
283
+ assert.match(validateContract({ ...C, repro_test: { path: "test/x.js" } }, ISSUE, { repro: true }).problems.join(), /repro_test must be/);
284
+ assert.match(validateContract({ ...C, repro_test: "none" }, ISSUE, { repro: true }).problems.join(), /repro_test_reason/);
285
+ });
286
+ it("red on base and green on head passes; anything else fails the precheck as conformance.unimplemented", () => {
287
+ const k = { ...C, repro_test: RT };
288
+ assert.deepEqual(precheck({ contract: k, result: res, changedFiles: files, repro: { base: "FAILED", head: "RAN" } }), { ok: true, failures: [] });
289
+ for (const repro of [{ base: "RAN", head: "RAN" }, { base: "SKIPPED", head: "FAILED" }, { base: "UNRUNNABLE", head: "RAN" }, { base: "UNRUNNABLE", head: "UNRUNNABLE" }]) {
290
+ const pc = precheck({ contract: k, result: res, changedFiles: files, repro });
291
+ assert.deepEqual(pc.failures.map((f) => [f.code, f.kind]), [["conformance.unimplemented", "repro_red_green"]], JSON.stringify(repro));
292
+ assert.match(pc.failures[0].detail, new RegExp(`BASE=${repro.base} HEAD=${repro.head}`));
293
+ }
294
+ assert.match(reproFailures(k, {})[0].detail, /test\/leak\.test\.js releases on 429.*BASE=UNRUNNABLE HEAD=UNRUNNABLE/);
295
+ });
296
+ it("off (no repro option) is today's contract check and precheck exactly", () => {
297
+ assert.deepEqual(validateContract(C, ISSUE), validateContract(C, ISSUE, { repro: false }));
298
+ assert.deepEqual(validateContract(C, ISSUE), { ok: true, problems: [] });
299
+ assert.deepEqual(precheck({ contract: C, result: res, changedFiles: files }), precheck({ contract: C, result: res, changedFiles: files, repro: null }));
300
+ });
301
+ });
@@ -0,0 +1,29 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Credential lookup for sandbox providers (#4254): the env first, then the OS keychain under service `forgedock`
5
+ * (Linux `secret-tool`, macOS `security`). A value is returned to the caller only; it is never logged, and a failed
6
+ * lookup is `null`, never an error carrying the value or the keychain's output.
7
+ */
8
+ import { execFileSync } from "node:child_process";
9
+
10
+ const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
11
+
12
+ /** `security`/`secret-tool` lookup of `name` under service `forgedock`; null when absent, unsupported or failing. */
13
+ export function defaultKeychain(name, platform = process.platform) {
14
+ const argv = platform === "darwin" ? ["security", ["find-generic-password", "-s", "forgedock", "-a", name, "-w"]]
15
+ : platform === "linux" ? ["secret-tool", ["lookup", "service", "forgedock", "account", name]] : null;
16
+ if (!argv) return null;
17
+ try {
18
+ const out = execFileSync(argv[0], argv[1], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 10000 });
19
+ return out.replace(/\r?\n$/, "") || null;
20
+ } catch { return null; }
21
+ }
22
+
23
+ /** `env[name]` when non-empty, else the keychain entry `name`, else null. */
24
+ export function resolveCredential(name, { env = process.env, keychain = defaultKeychain, platform = process.platform } = {}) {
25
+ if (!name || !NAME_RE.test(String(name))) return null;
26
+ const v = env?.[name];
27
+ if (v !== undefined && v !== null && String(v) !== "") return String(v);
28
+ try { const k = keychain(String(name), platform); return k === undefined || k === null || String(k) === "" ? null : String(k); } catch { return null; }
29
+ }