forgedock 1.30.0 → 1.32.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 (162) hide show
  1. package/bin/engine/outcome-codes.mjs +3 -2
  2. package/bin/engine/runlog.mjs +82 -12
  3. package/bin/engine/telemetry-seam.mjs +11 -4
  4. package/bin/engine/telemetry-seam.test.mjs +18 -0
  5. package/bin/engine-cli.mjs +409 -38
  6. package/bin/engine.mjs +47 -37
  7. package/bin/forgedock.mjs +35 -3
  8. package/bin/gc.mjs +50 -17
  9. package/bin/hooks/reread-nudge.mjs +65 -0
  10. package/bin/labels.json +5 -0
  11. package/bin/lean/apishape.mjs +170 -0
  12. package/bin/lean/apishape.test.mjs +187 -0
  13. package/bin/lean/batch.mjs +32 -0
  14. package/bin/lean/batch.test.mjs +338 -2
  15. package/bin/lean/budget.mjs +89 -0
  16. package/bin/lean/budget.test.mjs +106 -0
  17. package/bin/lean/checks.mjs +32 -9
  18. package/bin/lean/checks.test.mjs +78 -1
  19. package/bin/lean/conformance.e2e.test.mjs +27 -5
  20. package/bin/lean/conformance.mjs +150 -15
  21. package/bin/lean/conformance.test.mjs +175 -3
  22. package/bin/lean/context.mjs +128 -44
  23. package/bin/lean/context.test.mjs +157 -5
  24. package/bin/lean/decompose.mjs +90 -0
  25. package/bin/lean/decompose.test.mjs +105 -0
  26. package/bin/lean/depcache.mjs +11 -7
  27. package/bin/lean/depcache.test.mjs +81 -0
  28. package/bin/lean/e2e.mjs +102 -0
  29. package/bin/lean/e2e.test.mjs +86 -0
  30. package/bin/lean/effects.mjs +48 -15
  31. package/bin/lean/explore.mjs +47 -0
  32. package/bin/lean/explore.test.mjs +61 -0
  33. package/bin/lean/facts.mjs +84 -0
  34. package/bin/lean/facts.test.mjs +66 -0
  35. package/bin/lean/fixround.mjs +90 -0
  36. package/bin/lean/fixround.test.mjs +117 -0
  37. package/bin/lean/heavy.mjs +35 -2
  38. package/bin/lean/heavy.test.mjs +32 -2
  39. package/bin/lean/lean.mjs +712 -134
  40. package/bin/lean/lean.test.mjs +1626 -37
  41. package/bin/lean/ledger.mjs +5 -3
  42. package/bin/lean/ledger.test.mjs +18 -0
  43. package/bin/lean/light.mjs +135 -0
  44. package/bin/lean/light.test.mjs +173 -0
  45. package/bin/lean/lightlane.mjs +219 -0
  46. package/bin/lean/lightlane.test.mjs +237 -0
  47. package/bin/lean/limits.test.mjs +36 -1
  48. package/bin/lean/orchestrate.mjs +303 -41
  49. package/bin/lean/pitfalls.mjs +136 -0
  50. package/bin/lean/pitfalls.test.mjs +117 -0
  51. package/bin/lean/postmerge.mjs +85 -0
  52. package/bin/lean/postmerge.test.mjs +79 -0
  53. package/bin/lean/precheck.mjs +138 -13
  54. package/bin/lean/precheck.test.mjs +118 -1
  55. package/bin/lean/prefilter.mjs +220 -5
  56. package/bin/lean/prefilter.test.mjs +260 -4
  57. package/bin/lean/preflight.mjs +108 -3
  58. package/bin/lean/preflight.test.mjs +48 -1
  59. package/bin/lean/probe.mjs +297 -0
  60. package/bin/lean/probe.test.mjs +210 -0
  61. package/bin/lean/prompts.mjs +104 -13
  62. package/bin/lean/render.mjs +7 -3
  63. package/bin/lean/resume.mjs +67 -0
  64. package/bin/lean/resume.test.mjs +84 -0
  65. package/bin/lean/review-trigger.mjs +12 -0
  66. package/bin/lean/review-trigger.test.mjs +17 -1
  67. package/bin/lean/routing.mjs +74 -0
  68. package/bin/lean/routing.test.mjs +67 -0
  69. package/bin/lean/sched.mjs +16 -1
  70. package/bin/lean/sched.test.mjs +175 -1
  71. package/bin/lean/schedd.mjs +55 -9
  72. package/bin/lean/schedd.test.mjs +170 -3
  73. package/bin/lean/selfreview.mjs +37 -0
  74. package/bin/lean/selfreview.test.mjs +51 -0
  75. package/bin/lean/seqlease.mjs +40 -0
  76. package/bin/lean/seqlease.test.mjs +101 -0
  77. package/bin/lean/sequence.mjs +296 -13
  78. package/bin/lean/sequence.test.mjs +263 -2
  79. package/bin/lean/toolchain.mjs +60 -15
  80. package/bin/lean/toolchain.test.mjs +120 -3
  81. package/bin/lean/toolstats.mjs +72 -0
  82. package/bin/lean/toolstats.test.mjs +47 -0
  83. package/bin/lean/verifier.mjs +232 -0
  84. package/bin/lean/verifier.test.mjs +163 -0
  85. package/bin/lean/verify.mjs +72 -10
  86. package/bin/lean/verify.test.mjs +68 -3
  87. package/bin/review-staging.mjs +533 -0
  88. package/bin/runner.mjs +82 -13
  89. package/bin/stats/aggregate.mjs +87 -2
  90. package/bin/stats/efficiency.mjs +335 -0
  91. package/bin/stats/efficiency.test.mjs +266 -0
  92. package/bin/stats/github-source.mjs +8 -3
  93. package/bin/stats/index.mjs +91 -6
  94. package/bin/stats/local-source.mjs +145 -8
  95. package/bin/stats/query.mjs +181 -3
  96. package/bin/stats/render.mjs +22 -0
  97. package/bin/stats/stats.test.mjs +403 -8
  98. package/commands/orchestrate.md +3 -1
  99. package/commands/review-pr-agents/protocols.md +1 -1
  100. package/commands/review-pr-staging.md +84 -831
  101. package/commands/review-pr.md +1 -1
  102. package/commands/work-on/build/implement.md +1 -0
  103. package/commands/work-on/build/validate.md +11 -1
  104. package/docs/CONFIG.md +80 -5
  105. package/docs/OUTCOME-CODES.md +2 -1
  106. package/docs/STATS.md +66 -1
  107. package/docs/TELEMETRY.md +1 -1
  108. package/docs/eval/corpus/lightlane-v1.json +55 -0
  109. package/docs/eval/lightlane-bench.md +91 -0
  110. package/docs/spec/review-protocol.md +1 -1
  111. package/package.json +1 -1
  112. package/scripts/bench-lightlane.mjs +286 -0
  113. package/scripts/ci-local/lib.mjs +20 -0
  114. package/scripts/ci-local/plan.mjs +2 -12
  115. package/scripts/ci-local/scoping.mjs +10 -1
  116. package/scripts/ci-local.test.sh +2 -0
  117. package/scripts/fixtures/apishape/other/go-change/base/x.go +3 -0
  118. package/scripts/fixtures/apishape/other/go-change/head/x.go +3 -0
  119. package/scripts/fixtures/apishape/python/generator-function/base/seq.py +3 -0
  120. package/scripts/fixtures/apishape/python/generator-function/head/seq.py +2 -0
  121. package/scripts/fixtures/apishape/python/ichunked/base/more.py +17 -0
  122. package/scripts/fixtures/apishape/python/ichunked/head/more.py +20 -0
  123. package/scripts/fixtures/apishape/python/private-edit/base/calc.py +6 -0
  124. package/scripts/fixtures/apishape/python/private-edit/head/calc.py +9 -0
  125. package/scripts/fixtures/apishape/python/removed/base/ops.py +6 -0
  126. package/scripts/fixtures/apishape/python/removed/head/ops.py +2 -0
  127. package/scripts/fixtures/apishape/python/renamed/base/text.py +2 -0
  128. package/scripts/fixtures/apishape/python/renamed/head/text.py +2 -0
  129. package/scripts/fixtures/apishape/python/signature/base/shapes.py +2 -0
  130. package/scripts/fixtures/apishape/python/signature/head/shapes.py +2 -0
  131. package/scripts/fixtures/apishape/python/sync-async/base/client.py +7 -0
  132. package/scripts/fixtures/apishape/python/sync-async/head/client.py +7 -0
  133. package/scripts/fixtures/apishape/ts/generator-function/base/seq.js +3 -0
  134. package/scripts/fixtures/apishape/ts/generator-function/head/seq.js +5 -0
  135. package/scripts/fixtures/apishape/ts/private-edit/base/calc.ts +7 -0
  136. package/scripts/fixtures/apishape/ts/private-edit/head/calc.ts +9 -0
  137. package/scripts/fixtures/apishape/ts/removed/base/ops.ts +7 -0
  138. package/scripts/fixtures/apishape/ts/removed/head/ops.ts +3 -0
  139. package/scripts/fixtures/apishape/ts/renamed/base/text.ts +3 -0
  140. package/scripts/fixtures/apishape/ts/renamed/head/text.ts +3 -0
  141. package/scripts/fixtures/apishape/ts/signature/base/shapes.ts +3 -0
  142. package/scripts/fixtures/apishape/ts/signature/head/shapes.ts +3 -0
  143. package/scripts/fixtures/apishape/ts/sync-async/base/client.mjs +5 -0
  144. package/scripts/fixtures/apishape/ts/sync-async/head/client.mjs +5 -0
  145. package/scripts/forge-root.test.sh +2 -2
  146. package/scripts/publish-staging-sync.test.sh +62 -4
  147. package/scripts/review-finalize.sh +17 -6
  148. package/scripts/review-finalize.test.sh +47 -0
  149. package/scripts/review-pr-arguments.test.sh +13 -1
  150. package/scripts/review-pr-delta-wiring.test.sh +2 -2
  151. package/scripts/review-pr-gate2.test.sh +6 -14
  152. package/scripts/review-pr-staging-finding-filter.test.sh +4 -4
  153. package/scripts/review-pr-staging-scope.test.sh +39 -33
  154. package/scripts/review-prepare.sh +73 -6
  155. package/scripts/review-prepare.test.sh +85 -0
  156. package/scripts/review-staging-post.sh +293 -0
  157. package/scripts/review-staging-post.test.sh +143 -0
  158. package/scripts/review-staging-pre.sh +401 -0
  159. package/scripts/review-staging-pre.test.sh +150 -0
  160. package/scripts/symbol-index.mjs +26 -7
  161. package/scripts/symbol-index.py +55 -9
  162. package/scripts/trusted-comments.test.sh +1 -1
@@ -2,7 +2,7 @@
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
3
  import { describe, it } from "node:test";
4
4
  import assert from "node:assert/strict";
5
- import { validateContract, applyAmendments, precheck, hotspotGrowth, reproFailures, isBugIssue, parseReview, checkCompleteness, decide, closePlan, followupItems, inIssue, isTestPath, normalizeEvidencePath, mergeReviewRows, reviewSkeleton, missingIds, resolveEvidencePath, movingBaseDiff, scopeText, evidencePathLine, evidencePathOnly, explainFailures, missingTool, repoTracks, tmpdirInputs } from "./conformance.mjs";
5
+ import { validateContract, applyAmendments, precheck, hotspotGrowth, reproFailures, isBugIssue, parseReview, checkCompleteness, decide, closePlan, isInvestigation, formatShadowSummary, followupItems, inIssue, isTestPath, normalizeEvidencePath, mergeReviewRows, reviewSkeleton, missingIds, resolveEvidencePath, movingBaseDiff, scopeText, evidencePathLine, evidencePathOnly, explainFailures, missingTool, repoTracks, tmpdirInputs, isScopeCheck, isPureScopeCheck, dropScopeValidates, tagOutOfBand, outOfBandRequirements, isOutOfBand, followupMap, VERIFY_MODES, OUT_OF_BAND_RE } 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.";
@@ -62,7 +62,7 @@ describe("amendments", () => {
62
62
 
63
63
  describe("precheck (deterministic, no reviewer)", () => {
64
64
  const res = { requirements: [{ id: "R1", status: "implemented" }, { id: "R2", status: "implemented" }], validation: [{ id: "V1", exit: 0 }] };
65
- it("passes a complete, validated, in-scope change", () => assert.deepEqual(precheck({ contract: C, result: res, changedFiles: files }), { ok: true, failures: [] }));
65
+ it("passes a complete, validated, in-scope change", () => assert.deepEqual(precheck({ contract: C, result: res, changedFiles: files }), { ok: true, failures: [], superseded: [] }));
66
66
  it("unauthorized file -> scope_creep; tests and ignored paths are always allowed", () => {
67
67
  assert.deepEqual(codes(precheck({ contract: C, result: res, changedFiles: [...files, "src/billing.js", ".forge-lean/x", "tests/test_x.py", "pkg/foo_test.go"], ignore: [".forge-lean/"] })), ["conformance.scope_creep"]);
68
68
  for (const t of ["test/a.js", "src/a.test.ts", "tests/test_a.py", "a_test.go", "spec/a.spec.js"]) assert.ok(isTestPath(t), t);
@@ -309,10 +309,44 @@ describe("close plan and follow-up items", () => {
309
309
  const k = { requirements: [{ id: "R1", disposition: "deliver" }, { id: "R2", disposition: "defer", tracked: "#9" }, { id: "R3", disposition: "post-deploy", tracked: "#9" }] };
310
310
  assert.deepEqual(closePlan(k).tracked, ["#9"]);
311
311
  });
312
+ it("#4398 isInvestigation reads the investigate( / investigate: title type only", () => {
313
+ for (const t of ["investigate(stealth): which signal", "Investigate: why", " investigate(x)"]) assert.equal(isInvestigation(t), true, t);
314
+ for (const t of ["fix(investigate): x", "feat: investigate later", "investigation(x)", "", null]) assert.equal(isInvestigation(t), false, String(t));
315
+ });
316
+ it("#4398 an investigation closes partial when a requirement is deferred or its answer is not implemented; otherwise completed", () => {
317
+ const title = "investigate(stealth): which device signal is rejected";
318
+ const k = { requirements: [{ id: "R1", disposition: "deliver" }, { id: "R2", disposition: "defer", tracked: "#9" }, { id: "R3", disposition: "not-applicable" }] };
319
+ const p = closePlan(k, { title });
320
+ assert.equal(p.reason, "partial"); assert.deepEqual(p.open.map((r) => r.id), ["R2"]); assert.deepEqual(p.tracked, ["#9"]);
321
+ assert.deepEqual(closePlan(k, { title: "fix: foo" }), { reason: "completed", tracked: ["#9"] }, "only investigations change");
322
+ const d = { requirements: [{ id: "R1", disposition: "deliver" }, { id: "R2", disposition: "deliver" }] };
323
+ const rows = (s1, s2) => ({ requirements: [{ id: "R1", status: s1 }, { id: "R2", status: s2 }] });
324
+ assert.deepEqual(closePlan(d, { title, review: rows("implemented", "implemented") }), { reason: "completed", tracked: [] });
325
+ assert.deepEqual(closePlan(d, { title, review: rows("implemented", "deferred") }).open.map((r) => r.id), ["R2"]);
326
+ assert.deepEqual(closePlan(d, { title, review: { requirements: [{ id: "R1", status: "implemented" }] } }).open.map((r) => r.id), ["R2"], "a missing row is not an answer");
327
+ assert.equal(closePlan(d, { title, review: null }).reason, "completed", "a skipped review judges dispositions only");
328
+ });
329
+ it("#4398 formatShadowSummary: shadow would-block and false-block shares, risk-enforced lanes; null without conformance rows", () => {
330
+ assert.equal(formatShadowSummary([{ issue: 1 }]), null); assert.equal(formatShadowSummary(undefined), null);
331
+ const rows = [{ conformance: { mode: "shadow", would: "conforms", risk: false } }, { conformance: { mode: "shadow", would: "nonconforming", risk: false } },
332
+ { conformance: { mode: "shadow", would: "review.incomplete", risk: false } }, { conformance: { mode: "shadow", would: null, risk: false } },
333
+ { conformance: { mode: "enforce", would: null, risk: true } }, { issue: 9 }];
334
+ assert.equal(formatShadowSummary(rows), "conformance: 4 shadow lanes, would block 2 (50%), false-block 1 (25%: would-block on an incomplete/inconsistent/unparseable review); risk-path enforced: 1");
335
+ assert.match(formatShadowSummary([{ conformance: { mode: "enforce", would: null, risk: false } }]), /0 shadow lanes, would block 0 \(n\/a\)/);
336
+ });
312
337
  it("follow-up keys are single-line, tab-free and stable", () => {
313
338
  const [i] = followupItems([{ id: "R2", quote: "a\tb\n`c`", disposition: "defer", why: "x\ny" }]);
314
339
  assert.equal(i.key, "R2: a b c"); assert.equal(i.why, "defer: x y"); assert.doesNotMatch(i.key + i.why, /[\t\n`]/);
315
340
  });
341
+ it("a follow-up key whose 240-char cut ends on a space has no trailing whitespace (#4388)", () => {
342
+ const quote = `${"x".repeat(234)} tail of the quote past the cut`; // "R12: " + 234 chars puts the space at index 239
343
+ assert.equal(`R12: ${quote}`[239], " ");
344
+ const [i] = followupItems([{ id: "R12", quote, disposition: "defer", why: "w" }]);
345
+ assert.equal(i.key, i.key.trim()); assert.equal(i.key.length, 239); assert.doesNotMatch(i.key, /\s$/);
346
+ });
347
+ it("followupMap resolves untrimmed item keys from the script's trimmed echo (#4388)", () => {
348
+ assert.deepEqual(followupMap("R1: a\t42\nnoise\nR2: b\tx\n", [{ key: "R1: a " }, { key: "R2: b" }]), { "R1: a ": 42 });
349
+ });
316
350
  });
317
351
 
318
352
  describe("hotspot growth precheck (forge#4195)", () => {
@@ -364,7 +398,7 @@ describe("repro_test on bug lanes (#4271)", () => {
364
398
  });
365
399
  it("red on base and green on head passes; anything else fails the precheck as conformance.unimplemented", () => {
366
400
  const k = { ...C, repro_test: RT };
367
- assert.deepEqual(precheck({ contract: k, result: res, changedFiles: files, repro: { base: "FAILED", head: "RAN" } }), { ok: true, failures: [] });
401
+ assert.deepEqual(precheck({ contract: k, result: res, changedFiles: files, repro: { base: "FAILED", head: "RAN" } }), { ok: true, failures: [], superseded: [] });
368
402
  for (const repro of [{ base: "RAN", head: "RAN" }, { base: "SKIPPED", head: "FAILED" }, { base: "UNRUNNABLE", head: "RAN" }, { base: "SKIPPED", head: "RAN_NOTHING" }]) {
369
403
  const pc = precheck({ contract: k, result: res, changedFiles: files, repro });
370
404
  assert.deepEqual(pc.failures.map((f) => [f.code, f.kind]), [["conformance.unimplemented", "repro_red_green"]], JSON.stringify(repro));
@@ -425,3 +459,141 @@ describe("#4265 issue scope and failure explanations", () => {
425
459
  assert.equal(r.failures[0].detail, "validate V1 (`npm test`) failed (exit 2)");
426
460
  });
427
461
  });
462
+
463
+ describe("#4350 the engine's scope gate is the authority over a builder's scope-check validate", () => {
464
+ const ALLOW = 'test -z "$(git diff --name-only origin/main...HEAD | grep -v -x -e src/tool.js -e test/tool.test.js)"';
465
+ const frozen = {
466
+ requirements: [{ id: "R1", quote: Q1, disposition: "deliver" }],
467
+ deliverables: [{ id: "D1", path: "src/tool.js" }],
468
+ validate: [{ id: "V1", command: "node --test test/tool.test.js" }, { id: "V2", command: ALLOW }],
469
+ };
470
+ const amendments = [{ kind: "add-deliverable", path: "docs/tool.md", why: "the docs-freshness rule needs the module doc" }];
471
+ const effective = applyAmendments(frozen, amendments).contract;
472
+ const changed = ["src/tool.js", "test/tool.test.js", "docs/tool.md", ".forge-lean/result.json"];
473
+ const result = { requirements: [{ id: "R1", status: "implemented" }], validation: [{ id: "V1", exit: 0 }, { id: "V2", exit: 1 }], amendments };
474
+ const run = (o = {}) => precheck({ contract: effective, result, changedFiles: changed, ignore: [".forge-lean/"], validateRuns: { V2: { rc: 1, output: "" } }, frozen, ...o });
475
+ it("#4350 a scope-allowlist validate that fails only for an amended deliverable is superseded, not conformance.unimplemented", () => {
476
+ const pc = run();
477
+ assert.deepEqual(pc.failures, []);
478
+ assert.equal(pc.ok, true, "the lane is not ended by the stale check");
479
+ assert.deepEqual(pc.superseded, [{ id: "V2", files: ["docs/tool.md"] }]);
480
+ });
481
+ it("#4350 a real out-of-scope edit is still scope creep, and the stale validate failure stands", () => {
482
+ const pc = run({ changedFiles: [...changed, "src/other.js"] });
483
+ assert.deepEqual(codes(pc), ["conformance.scope_creep", "conformance.unimplemented"]);
484
+ assert.match(pc.failures[0].detail, /src\/other\.js/);
485
+ assert.deepEqual(pc.superseded, []);
486
+ });
487
+ it("#4350 a failing non-scope validate, a scope check failing with no amended file, and callers without frozen are unchanged", () => {
488
+ const testFail = { ...result, validation: [{ id: "V1", exit: 1 }, { id: "V2", exit: 0 }] };
489
+ assert.deepEqual(codes(run({ result: testFail, validateRuns: { V1: { rc: 1, output: "AssertionError" } } })), ["conformance.unimplemented"]);
490
+ assert.deepEqual(codes(run({ changedFiles: ["src/tool.js", "test/tool.test.js"] })), ["conformance.unimplemented"], "nothing amended in the diff: the validate failed for its own reason");
491
+ const legacy = run({ frozen: undefined });
492
+ assert.deepEqual(codes(legacy), ["conformance.unimplemented"]);
493
+ assert.deepEqual(legacy.superseded, []);
494
+ });
495
+ it("#4350 isScopeCheck recognises file-listing git commands only", () => {
496
+ for (const c of [ALLOW, "git diff --name-status origin/x...HEAD | grep -c .", "git -C pkg diff --stat $(git merge-base HEAD origin/x)", "git log --name-only -1", "test -z \"$(git status --porcelain)\""]) assert.ok(isScopeCheck(c), c);
497
+ for (const c of ["npm test", "git diff --check origin/x...HEAD", "pytest -k name_only", "git diff --exit-code -- src/a.js"]) assert.ok(!isScopeCheck(c), c);
498
+ });
499
+ });
500
+
501
+ describe("#4353 the engine's scope gate is the only scope authority: pure scope validates are dropped at freeze", () => {
502
+ const ALLOW = 'test -z "$(git diff --name-only origin/main...HEAD | grep -v -x -e src/tool.js -e test/tool.test.js)"';
503
+ it("#4353 isPureScopeCheck: an allowlist or denylist made only of git listing and filter commands; anything that also runs code is kept", () => {
504
+ for (const c of [ALLOW, "git diff --name-status origin/x...HEAD | grep -c .", "git -C pkg diff --stat $(git merge-base HEAD origin/x)", "! git diff --name-only origin/x...HEAD | grep -qv '^src/'", "test -z \"$(git status --porcelain)\"", "[ \"$(git diff --name-only origin/x...HEAD | wc -l)\" -le 3 ]"]) assert.ok(isPureScopeCheck(c), c);
505
+ for (const c of ["npm test", "node --test x && git diff --stat origin/x...HEAD", "git diff --name-only origin/x...HEAD | xargs node --check", "pytest -k name_only", "git diff --exit-code -- src/a.js", "git diff --name-only HEAD~1 | sh"]) assert.ok(!isPureScopeCheck(c), c);
506
+ });
507
+ it("#4353 dropScopeValidates removes only pure scope validates and reports them; other contracts are unchanged", () => {
508
+ const c = { requirements: [{ id: "R1", quote: Q1, disposition: "deliver" }], deliverables: [{ id: "D1", path: "src/tool.js" }],
509
+ validate: [{ id: "V1", command: "node --test test/tool.test.js" }, { id: "V2", command: ALLOW }, { id: "V3", command: "node --test x && git diff --stat" }] };
510
+ const d = dropScopeValidates(c);
511
+ assert.deepEqual(d.dropped, [{ id: "V2", command: ALLOW }]);
512
+ assert.deepEqual(d.contract.validate.map((v) => v.id), ["V1", "V3"]);
513
+ assert.equal(c.validate.length, 3, "the input is not mutated");
514
+ assert.equal(validateContract(d.contract, `${Q1}`).ok, true, "the dropped contract still freezes");
515
+ const plain = { ...c, validate: [c.validate[0]] };
516
+ assert.equal(dropScopeValidates(plain).contract, plain); assert.deepEqual(dropScopeValidates(plain).dropped, []);
517
+ for (const x of [null, [], "x", { requirements: [] }]) assert.deepEqual(dropScopeValidates(x), { contract: x, dropped: [] });
518
+ });
519
+ it("#4353 a legacy frozen contract that still carries the allowlist keeps the #4350 superseded path", () => {
520
+ const frozen = { requirements: [{ id: "R1", quote: Q1, disposition: "deliver" }], deliverables: [{ id: "D1", path: "src/tool.js" }], validate: [{ id: "V1", command: "node --test test/tool.test.js" }, { id: "V2", command: ALLOW }] };
521
+ const amendments = [{ kind: "add-deliverable", path: "docs/tool.md", why: "module doc" }];
522
+ const pc = precheck({ contract: applyAmendments(frozen, amendments).contract, frozen, changedFiles: ["src/tool.js", "docs/tool.md"], validateRuns: { V2: { rc: 1, output: "" } },
523
+ result: { requirements: [{ id: "R1", status: "implemented" }], validation: [{ id: "V1", exit: 0 }, { id: "V2", exit: 1 }], amendments } });
524
+ assert.equal(pc.ok, true); assert.deepEqual(pc.superseded, [{ id: "V2", files: ["docs/tool.md"] }]);
525
+ });
526
+ });
527
+
528
+ describe("#4404 verified out of band: a requirement measured outside the lane is judged by its instrumentation", () => {
529
+ const LH = "Lighthouse performance score >= 95 on the landing page.";
530
+ const TTI = "Interactive < 1 s on a throttled mid-range Android profile.";
531
+ const NIGHTLY = "The nightly perf suite reports the 1,000-event render time.";
532
+ const OOB_ISSUE = `${ISSUE}\n- [ ] ${LH}\n- [ ] ${TTI}\n- [ ] ${NIGHTLY}\n- [ ] Field perf stays within budget.`;
533
+ const k = { ...C, requirements: [...C.requirements, { id: "R3", quote: LH, disposition: "deliver", evidence: "lighthouse-nightly" }] };
534
+ const tagged = tagOutOfBand(k).contract;
535
+ const oobRow = { id: "R3", status: "partial", evidence: "web/perf/budget.test.js:4", validated_by: "V1", verify: "out_of_band", job: "lighthouse-nightly", instrumented: true };
536
+ const oobCtx = { ...ctx, contract: tagged, changedFiles: [...files, "web/perf/budget.test.js"], issueText: OOB_ISSUE };
537
+ const withRow = (r) => ({ ...good, requirements: [...good.requirements, r], issue_coverage: [...good.issue_coverage, { quote: LH, status: "covered" }] });
538
+
539
+ it("a contract requirement mentioning Lighthouse or field perf is tagged out_of_band at plan time", () => {
540
+ const c = { ...C, requirements: [...C.requirements,
541
+ { id: "R3", quote: LH, disposition: "deliver" }, { id: "R4", quote: "Field perf stays within budget.", disposition: "deliver" },
542
+ { id: "R5", quote: TTI, disposition: "deliver" }, { id: "R6", quote: NIGHTLY, disposition: "deliver" },
543
+ { id: "R7", quote: LH, disposition: "deliver", verify: "in_lane" }, { id: "R8", quote: LH, disposition: "post-deploy", why: "w" }] };
544
+ const t = tagOutOfBand(c);
545
+ assert.deepEqual(t.tagged, ["R3", "R4", "R5", "R6"]);
546
+ assert.deepEqual(t.contract.requirements.filter(isOutOfBand).map((r) => r.id), ["R3", "R4", "R5", "R6"]);
547
+ assert.equal(t.contract.requirements.find((r) => r.id === "R7").verify, "in_lane"); // an explicit verify wins
548
+ assert.equal(t.contract.requirements.find((r) => r.id === "R8").verify, undefined); // only deliver requirements
549
+ assert.equal(c.requirements[2].verify, undefined); // the input contract is not mutated
550
+ assert.equal(tagOutOfBand(C).contract, C); assert.deepEqual(tagOutOfBand(C).tagged, []);
551
+ for (const x of [null, [], "x", {}]) assert.deepEqual(tagOutOfBand(x), { contract: x, tagged: [] });
552
+ assert.deepEqual(VERIFY_MODES, ["in_lane", "out_of_band"]);
553
+ for (const q of [LH, TTI, NIGHTLY, "Field performance holds.", "Web vitals pass.", "A report-only bench workflow tracks it."]) assert.match(q, OUT_OF_BAND_RE, q);
554
+ for (const q of ["The nightly build. Then a suite.", "Add a unit test for the parser.", "Run the job locally."]) assert.doesNotMatch(q, OUT_OF_BAND_RE, q);
555
+ });
556
+
557
+ it("an add-requirement amendment is tagged the same way", () => {
558
+ const a = applyAmendments(C, [{ kind: "add-requirement", requirement: { id: "R9", quote: LH }, why: "missed" }], OOB_ISSUE);
559
+ assert.equal(a.contract.requirements.find((r) => r.id === "R9").verify, "out_of_band");
560
+ });
561
+
562
+ it("validateContract: an out-of-band requirement names its job; verify takes only known modes and deliver", () => {
563
+ assert.deepEqual(validateContract(tagged, OOB_ISSUE), { ok: true, problems: [] });
564
+ const noJob = tagOutOfBand({ ...C, requirements: [...C.requirements, { id: "R3", quote: LH, disposition: "deliver" }] }).contract;
565
+ assert.match(validateContract(noJob, OOB_ISSUE).problems.join("\n"), /R3: verify out_of_band needs evidence naming the job/);
566
+ const bad = { ...C, requirements: [{ ...C.requirements[0], verify: "later" }, { ...C.requirements[1], disposition: "defer", why: "w", verify: "out_of_band", evidence: "j" }] };
567
+ const p = validateContract(bad, OOB_ISSUE).problems.join("\n");
568
+ assert.match(p, /R1: verify must be one of in_lane\|out_of_band/); assert.match(p, /R2: verify out_of_band applies only to a deliver requirement/);
569
+ });
570
+
571
+ it("with the instrumentation present the reviewer passes the requirement; without it it is still unimplemented", () => {
572
+ assert.equal(decide(withRow(oobRow), oobCtx).state, "conforms");
573
+ const missing = decide(withRow({ ...oobRow, status: "missing", instrumented: false, evidence: "" }), oobCtx);
574
+ assert.equal(missing.state, "nonconforming"); assert.deepEqual(codes(missing), ["conformance.unimplemented"]);
575
+ assert.match(missing.failures[0].detail, /R3 verified out of band by lighthouse-nightly: instrumentation missing/);
576
+ // instrumented is required on an out-of-band row, and instrumented true needs path:line evidence
577
+ assert.deepEqual(checkCompleteness(withRow({ ...oobRow, instrumented: null }), oobCtx).missing, ["R3"]);
578
+ assert.deepEqual(checkCompleteness(withRow({ ...oobRow, evidence: "" }), oobCtx).missing, ["R3"]);
579
+ // the same partial row on an in-lane requirement still fails (unchanged behaviour)
580
+ const inLane = { ...oobCtx, contract: { ...tagged, requirements: tagged.requirements.map((r) => (r.id === "R3" ? { ...r, verify: "in_lane" } : r)) } };
581
+ assert.deepEqual(codes(decide(withRow(oobRow), inLane)), ["conformance.unimplemented"]);
582
+ });
583
+
584
+ it("precheck: the builder's instrumented out-of-band row is not unimplemented; a bare partial one is", () => {
585
+ const res = { requirements: [{ id: "R1", status: "implemented" }, { id: "R2", status: "implemented" }], validation: [{ id: "V1", exit: 0 }] };
586
+ const ok = { requirements: [...res.requirements, { id: "R3", status: "partial", instrumented: true }], validation: res.validation };
587
+ assert.equal(precheck({ contract: tagged, result: ok, changedFiles: files }).ok, true);
588
+ const no = { requirements: [...res.requirements, { id: "R3", status: "partial" }], validation: res.validation };
589
+ const f = precheck({ contract: tagged, result: no, changedFiles: files }).failures;
590
+ assert.deepEqual(f.map((x) => x.kind), ["requirement_unimplemented"]); assert.match(f[0].detail, /R3 is verified out of band by lighthouse-nightly/);
591
+ });
592
+
593
+ it("the skeleton row carries the job and an empty instrumented; outOfBandRequirements lists the job", () => {
594
+ const sk = reviewSkeleton(tagged, files);
595
+ assert.deepEqual(sk.requirements.find((r) => r.id === "R3"), { id: "R3", status: "", evidence: "", validated_by: "", verify: "out_of_band", job: "lighthouse-nightly", instrumented: null, note: "" });
596
+ assert.equal("instrumented" in sk.requirements[0], false);
597
+ assert.deepEqual(outOfBandRequirements(tagged), [{ id: "R3", quote: LH, job: "lighthouse-nightly" }]);
598
+ });
599
+ });
@@ -10,22 +10,29 @@
10
10
  */
11
11
  import { createHash } from "node:crypto";
12
12
  import { join } from "node:path";
13
- import { isTrustedComment, parseDependsOn } from "../engine/phases.mjs";
13
+ import { parseDependsOn } from "../engine/phases.mjs";
14
14
  import { codeForTerminalReason } from "../engine/outcome-codes.mjs";
15
- import { namedPaths } from "./prefilter.mjs";
15
+ import { runLogKey } from "../engine/runlog.mjs";
16
+ import { namedPaths, isTrustedIssueComment as trusted } from "./prefilter.mjs";
16
17
  import { parseContractComment, noComments } from "./render.mjs";
17
18
  import { REPO_FACTS_FILE, TEST_MAP_FILE } from "./testmap.mjs";
19
+ import { parseFactsComment, factsLines, FACTS_RANK } from "./facts.mjs";
18
20
 
19
21
  // noComments lives in render.mjs (#4298: one comment-strip helper for context, conformance and prefilter).
20
22
  export { noComments };
21
23
 
22
- export const CONTEXT_MODES = Object.freeze(["shadow", "on", "off"]);
23
- export const CONTEXT_SOURCES = Object.freeze(["graph", "attempts", "ledger", "facts"]);
24
+ /** #4444: `ab` is the experiment mode: each issue gets a stable arm, `on` or `shadow` (contextArm). */
25
+ export const CONTEXT_MODES = Object.freeze(["shadow", "on", "off", "ab"]);
26
+ export const CONTEXT_SOURCES = Object.freeze(["graph", "attempts", "pitfalls", "ledger", "facts"]);
24
27
  export const DEFAULT_CONTEXT_BYTES = 6144;
25
28
  export const DEFAULT_LEDGER_DAYS = 30;
29
+ /** #4375: a pitfall not seen in this many lanes of the repo ages out (forge.yaml engine.context_pack_pitfall_lanes). */
30
+ export const DEFAULT_PITFALL_LANES = 50;
26
31
  /** Bounds on GitHub reads per pack: linked issues read, ledger PRs inspected. */
27
32
  export const MAX_LINKS = 20;
28
33
  export const MAX_LEDGER_PRS = 8;
34
+ /** #4401: FORGE:FACTS records (earlier lanes) read into one pack: this issue's own attempts first, then the file-path ledger. */
35
+ export const MAX_FACT_LANES = 6;
29
36
  export const REPO_FACT_FILES = Object.freeze([REPO_FACTS_FILE, TEST_MAP_FILE]);
30
37
  const ATTEMPT_MARKERS = ["NEEDS_HUMAN", "BASE_SYNC", "FIX_CI"];
31
38
  const LINE_MAX = 200;
@@ -38,13 +45,23 @@ const oneLine = (s, max = LINE_MAX) => {
38
45
  const stripComments = (s) => noComments(s).replace(/\r\n/g, "\n").trim();
39
46
  const byNum = (a, b) => a - b;
40
47
 
41
- /** forge.yaml engine.context_pack (+ _sources, _bytes, _days) normalised; invalid values fall back to the defaults. */
42
- export function parseContextPackConfig({ mode, sources, bytes, days } = {}) {
48
+ /** forge.yaml engine.context_pack (+ _sources, _bytes, _days, _pitfall_lanes) normalised; invalid values fall back to the defaults. */
49
+ export function parseContextPackConfig({ mode, sources, bytes, days, pitfallLanes } = {}) {
43
50
  const m = String(mode ?? "").trim().toLowerCase();
44
51
  const list = Array.isArray(sources) ? sources : typeof sources === "string" && sources.trim() ? sources.split(/[\s,]+/) : null;
45
52
  const picked = list ? CONTEXT_SOURCES.filter((s) => list.map((x) => String(x).trim().toLowerCase()).includes(s)) : [...CONTEXT_SOURCES];
46
53
  const pos = (v, d) => { const n = Number(v); return Number.isFinite(n) && n > 0 ? Math.floor(n) : d; };
47
- return { mode: CONTEXT_MODES.includes(m) ? m : "shadow", sources: picked, budgetBytes: pos(bytes, DEFAULT_CONTEXT_BYTES), days: pos(days, DEFAULT_LEDGER_DAYS) };
54
+ return { mode: CONTEXT_MODES.includes(m) ? m : "shadow", sources: picked, budgetBytes: pos(bytes, DEFAULT_CONTEXT_BYTES), days: pos(days, DEFAULT_LEDGER_DAYS), pitfallLanes: pos(pitfallLanes, DEFAULT_PITFALL_LANES) };
55
+ }
56
+
57
+ /**
58
+ * #4444: the effective mode of one lane. In `ab` the arm comes from a stable hash of `repo#issue`, so every rerun of an
59
+ * issue stays in its arm and the arms are random, matched cohorts per lane type; any other mode passes through.
60
+ */
61
+ export function contextArm({ mode, repo = "", issue } = {}) {
62
+ if (mode !== "ab") return { mode, ab: false };
63
+ const byte = createHash("sha256").update(`${String(repo).toLowerCase()}#${issue}`).digest()[0];
64
+ return { mode: byte % 2 === 0 ? "on" : "shadow", ab: true };
48
65
  }
49
66
 
50
67
  /** `**Parent**: #N` (decomposition sub-issue body line); null when absent. */
@@ -102,14 +119,15 @@ export function priorAttempts(body, comments = []) {
102
119
  }
103
120
 
104
121
  /**
105
- * Cache key for a compiled pack: issue + base head SHA, plus the config that shapes it and (#4298, #4304) the hash of the
122
+ * Cache key for a compiled pack: issue (with `repo`, #4432: repo-scoped) + base head SHA, plus the config that shapes it and (#4298, #4304) the hash of the
106
123
  * issue's human scope text (`scope`) and prior-attempt count, so an edit or a new attempt on the same base compiles a
107
124
  * fresh pack. Not `updatedAt`: the lane's own FORGE:STATE/CONTEXT posts and label edits bump it, so the cache never hit.
125
+ * #4375: `stamp.pitfalls` (the selected pitfall signatures) joins the key, so a newly learned pitfall is never served stale.
108
126
  */
109
- export const contextPackCacheKey = (issue, headSha, cfg = {}, stamp = {}) =>
110
- `context-${issue}-${String(headSha ?? "").trim().slice(0, 40) || "nohead"}-${hash12(JSON.stringify([cfg.sources ?? [], cfg.budgetBytes ?? null, cfg.days ?? null,
127
+ export const contextPackCacheKey = (issue, headSha, cfg = {}, stamp = {}, repo = null) =>
128
+ `context-${runLogKey(issue, repo)}-${String(headSha ?? "").trim().slice(0, 40) || "nohead"}-${hash12(JSON.stringify([cfg.sources ?? [], cfg.budgetBytes ?? null, cfg.days ?? null,
111
129
  ...(stamp?.scope != null || stamp?.attempts != null ? [String(stamp.scope ?? ""), Number(stamp.attempts) || 0] : []),
112
- ...(stamp?.links ? [`links:${stamp.links}`] : [])]))}.json`;
130
+ ...(stamp?.links ? [`links:${stamp.links}`] : []), ...(stamp?.pitfalls?.length ? [`pitfalls:${stamp.pitfalls.join(",")}`] : [])]))}.json`;
113
131
 
114
132
  /** Max referenced issues whose state is read for the cache stamp (#4318: one GraphQL call for all of them). */
115
133
  export const LINK_STAMP_MAX = 100;
@@ -156,10 +174,6 @@ const outcomeOf = (st) => {
156
174
  return codeOf(c) && c !== "unclassified" ? c : null;
157
175
  };
158
176
  const failuresOf = (st) => (Array.isArray(st?.failureClasses) ? [...new Set(st.failureClasses.map(codeOf).filter(Boolean))].sort().slice(0, 5) : []);
159
- const trusted = (c) => {
160
- const login = String(c?.author?.login ?? c?.user?.login ?? "");
161
- return isTrustedComment({ author_association: c?.authorAssociation ?? c?.author_association, user: { login, type: c?.user?.type ?? (/\[bot\]$|^app\//.test(login) ? "Bot" : "User") } });
162
- };
163
177
  /** First line of a FORGE record that is not a marker or a heading. */
164
178
  const recordLine = (body) => oneLine(noComments(body).split("\n").map((l) => l.trim()).find((l) => l && !/^#+\s/.test(l)) ?? "");
165
179
  const markerOf = (body) => /^\s*<!--\s*FORGE:([A-Z_]+)\s*-->/.exec(String(body ?? ""))?.[1] ?? null;
@@ -174,10 +188,11 @@ async function readComments(gh, repo, n) {
174
188
 
175
189
  /**
176
190
  * Collect the facts for one issue's pack. `io`: {gh, git, exists}; git runs in `cwd` (the lane worktree), base is the
177
- * base ref. Every source is independent and best effort: a failing source is listed in `errors` and contributes nothing.
191
+ * base ref; `checkout` (the main checkout, optional) is where repo-fact files absent from the worktree are looked up.
192
+ * Every source is independent and best effort: a failing source is listed in `errors` and contributes nothing.
178
193
  */
179
- export async function gatherContextFacts({ io, repo, issue, iss, comments = [], cwd, base, sources = CONTEXT_SOURCES, days = DEFAULT_LEDGER_DAYS, now = Date.now() }) {
180
- const facts = { issue: Number(issue), links: [], attempts: [], ledger: [], facts: [], errors: [] };
194
+ export async function gatherContextFacts({ io, repo, issue, iss, comments = [], cwd, checkout = null, base, sources = CONTEXT_SOURCES, days = DEFAULT_LEDGER_DAYS, now = Date.now(), pitfalls = [] }) {
195
+ const facts = { issue: Number(issue), links: [], attempts: [], pitfalls: [], ledger: [], facts: [], lanes: [], errors: [] };
181
196
  const body = String(iss?.body ?? "");
182
197
  const on = new Set(sources);
183
198
  const step = async (name, fn) => { if (!on.has(name)) return; try { await fn(); } catch (e) { facts.errors.push(`${name}: ${oneLine(e?.message ?? e, 120)}`); } };
@@ -230,30 +245,54 @@ export async function gatherContextFacts({ io, repo, issue, iss, comments = [],
230
245
  for (const l of refs) { const [ref, sha] = l.split(" "); facts.attempts.push({ kind: "salvage", ref, line: `salvaged uncommitted work at ${sha ?? "?"} (git diff HEAD ${ref} --stat)` }); }
231
246
  });
232
247
 
233
- await step("ledger", async () => {
234
- const paths = namedPaths(body);
235
- const symbols = issueSymbols(body);
236
- if (!paths.length || !base) return;
248
+ // #4375: already read and selected by the engine from the local ledger (pitfalls.mjs selectPitfalls); no I/O here
249
+ await step("pitfalls", async () => {
250
+ for (const p of Array.isArray(pitfalls) ? pitfalls : []) {
251
+ if (p && typeof p.sig === "string") facts.pitfalls.push({ sig: p.sig, trigger: String(p.trigger ?? ""), lesson: oneLine(p.lesson ?? "", 160), globs: (p.globs ?? []).slice(0, 5), count: Number(p.count) || 1, match: p.match === "files" ? "files" : "repo" });
252
+ }
253
+ });
254
+
255
+ // The file-path ledger walk (merged PRs on the issue's named paths) and each PR's trusted records, shared by the ledger
256
+ // and facts sources (#4401): run once per gather, and only when a source that needs them is on.
257
+ const paths = namedPaths(body);
258
+ let walked = null;
259
+ const walk = () => (walked ??= (async () => {
260
+ if (!paths.length || !base) return [];
237
261
  const since = new Date(now - days * 86400000).toISOString().slice(0, 10);
238
262
  const out = String(await git(["log", "--first-parent", `--since=${since}`, "--format=%x1e%H%x1f%s%x1f%b%x1f", "--name-only", base, "--", ...paths]));
239
- const commits = out.split("\x1e").filter((x) => x.trim()).map((rec) => {
263
+ return out.split("\x1e").filter((x) => x.trim()).map((rec) => {
240
264
  const [sha, subject = "", msg = "", files = ""] = rec.split("\x1f");
241
265
  const pr = Number(/Merge pull request #(\d+)/.exec(subject)?.[1] ?? /\(#(\d+)\)\s*$/.exec(subject)?.[1]) || null;
242
266
  return { sha: sha.trim(), subject: subject.trim(), msg, pr, files: files.split("\n").map((f) => f.trim()).filter(Boolean),
243
267
  revert: /^Revert\b/.test(subject.trim()) || /\/revert-\d+-/.test(subject) || /^This reverts commit [0-9a-f]{7,40}/m.test(msg) };
244
268
  });
269
+ })());
270
+ const prReads = new Map();
271
+ const prInfo = (n) => {
272
+ if (!prReads.has(n)) prReads.set(n, (async () => {
273
+ const pr = JSON.parse(String(await io.gh(["api", `repos/${repo}/pulls/${n}`, "--jq", "{title, body, head: .head.ref}"])));
274
+ const linked = Number(/issue-(\d+)/.exec(String(pr.head ?? ""))?.[1] ?? /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)/i.exec(String(pr.body ?? ""))?.[1]) || null;
275
+ const recs = [...(await readComments(io.gh, repo, n)), ...(linked ? await readComments(io.gh, repo, linked) : [])].filter(trusted);
276
+ return { title: pr.title, linked, recs };
277
+ })());
278
+ return prReads.get(n);
279
+ };
280
+ const ledgerPrs = async () => (await walk()).filter((x) => x.pr || x.revert).slice(0, MAX_LEDGER_PRS);
281
+
282
+ await step("ledger", async () => {
283
+ const symbols = issueSymbols(body);
284
+ const commits = await walk();
285
+ if (!commits.length) return;
245
286
  const reverts = commits.filter((c) => c.revert);
246
287
  const revertedBy = (c) => reverts.find((r) => r !== c && ((c.pr && (new RegExp(`#${c.pr}\\b`).test(`${r.subject}\n${r.msg}`) || new RegExp(`/revert-${c.pr}-`).test(r.subject))) || r.msg.includes(c.sha) || r.msg.includes(c.sha.slice(0, 12))));
247
- for (const c of commits.filter((x) => x.pr || x.revert).slice(0, MAX_LEDGER_PRS)) {
288
+ for (const c of await ledgerPrs()) {
248
289
  let title = c.subject, notes = [];
249
290
  const reverted = c.revert ? null : revertedBy(c);
250
291
  if (c.pr) {
251
292
  try {
252
- const pr = JSON.parse(String(await io.gh(["api", `repos/${repo}/pulls/${c.pr}`, "--jq", "{title, body, head: .head.ref}"])));
293
+ const pr = await prInfo(c.pr);
253
294
  title = oneLine(pr.title ?? title, 120);
254
- const linked = Number(/issue-(\d+)/.exec(String(pr.head ?? ""))?.[1] ?? /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)/i.exec(String(pr.body ?? ""))?.[1]) || null;
255
- const recs = [...(await readComments(io.gh, repo, c.pr)), ...(linked ? await readComments(io.gh, repo, linked) : [])].filter(trusted);
256
- for (const r of recs) {
295
+ for (const r of pr.recs) {
257
296
  const m = markerOf(r.body);
258
297
  if (m === "DECISION") notes.push(...String(r.body).split("\n").slice(1).map((l) => oneLine(l, 160)).filter((l) => l && !l.startsWith("#")).map((l) => `decision: ${l}`));
259
298
  if (m === "CONTRACT") for (const d of parseContractComment(r.body)?.contract?.decisions ?? []) notes.push(oneLine(`decision ${d?.id ?? ""}: ${d?.choice ?? ""}`, 160));
@@ -269,7 +308,32 @@ export async function gatherContextFacts({ io, repo, issue, iss, comments = [],
269
308
  });
270
309
 
271
310
  await step("facts", async () => {
272
- for (const p of REPO_FACT_FILES) if (await io.exists(`${cwd}/${p}`)) facts.facts.push(p);
311
+ // #4401: FORGE:FACTS of earlier lanes: this issue's own attempts (newest first), then merged PRs on the issue's files
312
+ // whose record names an overlapping file. Only trusted comments; a malformed record contributes nothing.
313
+ const seen = new Set();
314
+ const add = (rec, ref, n, pr) => {
315
+ const key = JSON.stringify(rec);
316
+ if (!rec || seen.has(key) || facts.lanes.length >= MAX_FACT_LANES) return;
317
+ seen.add(key);
318
+ facts.lanes.push({ ref, issue: n, pr, ...rec });
319
+ };
320
+ for (const c of [...comments].reverse()) if (trusted(c)) add(parseFactsComment(c?.body), `#${issue} earlier attempt`, Number(issue), null);
321
+ const overlaps = (files) => files.some((f) => paths.some((p) => f === p || f.startsWith(`${p.replace(/\/+$/, "")}/`) || p.startsWith(`${f}/`)));
322
+ let prs = [];
323
+ try { prs = await ledgerPrs(); } catch (e) { facts.errors.push(`facts: ledger ${oneLine(e?.message ?? e, 80)}`); }
324
+ for (const c of prs) {
325
+ if (!c.pr || facts.lanes.length >= MAX_FACT_LANES) continue;
326
+ let pr;
327
+ try { pr = await prInfo(c.pr); } catch (e) { facts.errors.push(`facts: PR #${c.pr} ${oneLine(e?.message ?? e, 80)}`); continue; }
328
+ for (const r of [...pr.recs].reverse()) {
329
+ const rec = parseFactsComment(r.body);
330
+ if (rec && overlaps(rec.files)) add(rec, `${pr.linked ? `#${pr.linked}, ` : ""}PR #${c.pr}`, pr.linked, c.pr);
331
+ }
332
+ }
333
+ // #4400: the .forge/ caches are untracked, so a fresh lane worktree lacks them: the main checkout is the fallback
334
+ for (const p of REPO_FACT_FILES) {
335
+ for (const d of [...new Set([cwd, checkout].filter(Boolean))]) if (await io.exists(`${d}/${p}`)) { facts.facts.push(p); break; }
336
+ }
273
337
  });
274
338
  return facts;
275
339
  }
@@ -279,25 +343,33 @@ export async function gatherContextFacts({ io, repo, issue, iss, comments = [],
279
343
  // forge#4297: codes are re-checked here too, so facts read back from the cache get the same whitelist
280
344
  const linkLine = (l) => { const fails = (Array.isArray(l.failures) ? l.failures : []).map(codeOf).filter(Boolean), res = codeOf(l.result);
281
345
  return `- #${l.number} [${l.rel}${l.kind === "pr" ? ", PR" : ""}] ${l.state ?? "?"}${l.title ? ` "${l.title}"` : ""}${codeOf(l.code) ? `; outcome ${l.code}` : ""}${res ? `; ${res}` : ""}${fails.length ? `; failures ${fails.join(", ")}` : ""}`; };
346
+ const pitfallLine = (p) => `- [${p.trigger}] ${oneLine(p.lesson, 160)}${p.globs?.length ? ` (${p.globs.join(", ")})` : ""}; seen ${p.count}×`;
282
347
  const ledgerLine = (p) => `- ${p.pr ? `PR #${p.pr}` : "commit"} ${p.sha}${p.revert ? " [revert]" : ""}${p.reverted ? ` [reverted by ${p.reverted}]` : ""} "${p.title}"${p.files?.length ? ` (${p.files.join(", ")})` : ""}${p.notes?.length ? ` — ${p.notes.join("; ")}` : ""}`;
283
348
 
284
349
  /**
285
350
  * The pack text for `facts`, ranked: explicit links (parent > Depends on > body links > cross-references > siblings),
286
- * prior attempts, the symbol ledger (newest first), repo-fact pointers. One line per item; a parent or dependency also
351
+ * prior attempts, recurring pitfalls of the repo (#4375, in the engine's selected order), the symbol ledger (newest first), repo-fact pointers. One line per item; a parent or dependency also
287
352
  * carries its full body when it fits. Items that do not fit `budgetBytes` are dropped whole and counted in `truncated`.
288
- * @returns {{text:string, bytes:number, items:number, truncated:number, sources:object, found:object, hash:string|null}}
353
+ * `pitfalls` lists the kept pitfalls ({sig, match}) for the run log (#4375).
354
+ * @returns {{text:string, bytes:number, items:number, truncated:number, sources:object, found:object, hash:string|null, pitfalls:object[]}}
289
355
  */
290
356
  export function compileContextPack(facts, { budgetBytes = DEFAULT_CONTEXT_BYTES, sources = CONTEXT_SOURCES } = {}) {
291
357
  const on = new Set(sources);
292
358
  const cand = [];
293
359
  if (on.has("graph")) for (const l of facts?.links ?? []) cand.push({ src: "graph", line: linkLine(l), full: l.body ? `${linkLine(l)}\n${l.body.split("\n").map((x) => ` > ${x}`).join("\n")}` : null });
294
360
  if (on.has("attempts")) for (const a of facts?.attempts ?? []) cand.push({ src: "attempts", line: `- ${a.ref}: ${oneLine(a.line)}` });
361
+ if (on.has("pitfalls")) for (const p of facts?.pitfalls ?? []) cand.push({ src: "pitfalls", line: pitfallLine(p), pit: { sig: p.sig, match: p.match } });
295
362
  if (on.has("ledger")) for (const p of facts?.ledger ?? []) cand.push({ src: "ledger", line: ledgerLine(p) });
296
- if (on.has("facts")) for (const f of facts?.facts ?? []) cand.push({ src: "facts", line: `- ${f} (repo facts file; read it if relevant)` });
363
+ if (on.has("facts")) {
364
+ // #4401: earlier lanes' facts by kind (rejected approaches first, across lanes), then the repo-fact file pointers
365
+ const lanes = (facts?.lanes ?? []).map((l) => factsLines(l, l.ref));
366
+ for (const k of FACTS_RANK) for (const l of lanes) for (const line of l[k]) cand.push({ src: "facts", line: oneLine(line) });
367
+ for (const f of facts?.facts ?? []) cand.push({ src: "facts", line: `- ${f} (repo facts file; read it if relevant)` });
368
+ }
297
369
  const found = Object.fromEntries(CONTEXT_SOURCES.map((s) => [s, cand.filter((c) => c.src === s).length]));
298
- const empty = { text: "", bytes: 0, items: 0, truncated: 0, sources: Object.fromEntries(CONTEXT_SOURCES.map((s) => [s, 0])), found, hash: null };
370
+ const empty = { text: "", bytes: 0, items: 0, truncated: 0, sources: Object.fromEntries(CONTEXT_SOURCES.map((s) => [s, 0])), found, hash: null, pitfalls: [] };
299
371
  if (!cand.length) return empty;
300
- const titles = { graph: "Linked issues and PRs", attempts: "Prior attempts at this issue", ledger: "Merged PRs on the same symbols (reverts, decisions, findings)", facts: "Repo facts" };
372
+ const titles = { graph: "Linked issues and PRs", attempts: "Prior attempts at this issue", pitfalls: "Recurring pitfalls in this repo (past rework)", ledger: "Merged PRs on the same symbols (reverts, decisions, findings)", facts: "Facts from earlier lanes and repo facts" };
301
373
  const head = `Context pack for #${facts.issue}`;
302
374
  const size = (s) => Buffer.byteLength(s, "utf8");
303
375
  const kept = [];
@@ -306,7 +378,7 @@ export function compileContextPack(facts, { budgetBytes = DEFAULT_CONTEXT_BYTES,
306
378
  const secCost = kept.some((k) => k.src === c.src) ? 0 : size(`\n${titles[c.src]}:`) + 1;
307
379
  const pick = [c.full, c.line].find((t) => t && used + secCost + size(t) + 1 <= budgetBytes);
308
380
  if (!pick) { truncated++; continue; }
309
- kept.push({ src: c.src, text: pick });
381
+ kept.push({ src: c.src, text: pick, pit: c.pit });
310
382
  used += secCost + size(pick) + 1;
311
383
  }
312
384
  if (!kept.length) return { ...empty, truncated };
@@ -316,13 +388,25 @@ export function compileContextPack(facts, { budgetBytes = DEFAULT_CONTEXT_BYTES,
316
388
  if (rows.length) parts.push(`\n${titles[s]}:`, ...rows.map((r) => r.text));
317
389
  }
318
390
  const text = parts.join("\n");
319
- return { text, bytes: size(text), items: kept.length, truncated, sources: Object.fromEntries(CONTEXT_SOURCES.map((s) => [s, kept.filter((k) => k.src === s).length])), found, hash: hash12(text) };
391
+ return { text, bytes: size(text), items: kept.length, truncated, sources: Object.fromEntries(CONTEXT_SOURCES.map((s) => [s, kept.filter((k) => k.src === s).length])), found, hash: hash12(text), pitfalls: kept.filter((k) => k.pit).map((k) => k.pit) };
392
+ }
393
+
394
+ /** The pack text without its pitfalls section (#4375: pitfalls are local only and never posted). */
395
+ export function publicPackText(text) {
396
+ const out = [];
397
+ let skip = false;
398
+ for (const l of String(text ?? "").split("\n")) {
399
+ if (/^[^\s-].*:$/.test(l)) skip = l === "Recurring pitfalls in this repo (past rework):";
400
+ else if (skip && l === "") skip = false;
401
+ if (!skip) out.push(l);
402
+ }
403
+ return out.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd();
320
404
  }
321
405
 
322
- /** The collapsed FORGE:CONTEXT issue record body (without the marker). */
406
+ /** The collapsed FORGE:CONTEXT issue record body (without the marker); pitfalls show as a count only (#4375). */
323
407
  export function renderContextRecord(pack, { mode, laneType: lt }) {
324
- const src = CONTEXT_SOURCES.map((s) => `${s} ${pack.sources[s]}/${pack.found[s]}`).join(", ");
325
- return `<details><summary>Context pack: ${pack.items} item(s), ${pack.bytes} bytes, ${pack.truncated} dropped (mode ${mode}, lane ${lt}, hash ${pack.hash})</summary>\n\nSources (included/found): ${src}\n\n~~~~text\n${noComments(pack.text).replace(/~~~~/g, "~~~")}\n~~~~\n</details>`;
408
+ const src = CONTEXT_SOURCES.map((s) => `${s} ${pack.sources?.[s] ?? 0}/${pack.found?.[s] ?? 0}`).join(", ");
409
+ return `<details><summary>Context pack: ${pack.items} item(s), ${pack.bytes} bytes, ${pack.truncated} dropped (mode ${mode}, lane ${lt}, hash ${pack.hash})</summary>\n\nSources (included/found): ${src}${pack.sources?.pitfalls ? " (pitfalls are local only, not shown)" : ""}\n\n~~~~text\n${noComments(publicPackText(pack.text)).replace(/~~~~/g, "~~~")}\n~~~~\n</details>`;
326
410
  }
327
411
 
328
412
  /**
@@ -331,19 +415,19 @@ export function renderContextRecord(pack, { mode, laneType: lt }) {
331
415
  * reported source errors is not cached (#4298), the write goes through a temp file and a rename, and the issue's other
332
416
  * cached packs are pruned. A cache miss or any write/rename/prune failure is not an error.
333
417
  */
334
- export async function contextPackFor({ cfg, issue, headSha, cacheDir, fs, gather, stamp = {} }) {
335
- const name = cacheDir && headSha ? contextPackCacheKey(issue, headSha, cfg, stamp) : null;
418
+ export async function contextPackFor({ cfg, issue, repo = null, headSha, cacheDir, fs, gather, stamp = {} }) {
419
+ const name = cacheDir && headSha ? contextPackCacheKey(issue, headSha, cfg, stamp, repo) : null;
336
420
  const file = name ? join(cacheDir, name) : null;
337
421
  if (file && fs?.read) {
338
422
  try { const c = JSON.parse(await fs.read(file)); if (c?.pack && typeof c.pack.text === "string") return { ...c, cached: true }; } catch { /* miss */ }
339
423
  }
340
424
  const facts = await gather();
341
425
  const out = { pack: compileContextPack(facts, cfg), errors: facts?.errors ?? [] };
342
- if (file && fs?.write && !out.errors.length) await cachePack(fs, cacheDir, file, name, issue, JSON.stringify(out));
426
+ if (file && fs?.write && !out.errors.length) await cachePack(fs, cacheDir, file, name, runLogKey(issue, repo), JSON.stringify(out));
343
427
  return { ...out, cached: false };
344
428
  }
345
429
 
346
- async function cachePack(fs, cacheDir, file, name, issue, text) {
430
+ async function cachePack(fs, cacheDir, file, name, key, text) {
347
431
  try {
348
432
  if (typeof fs.rename === "function") {
349
433
  const tmp = `${file}.${process.pid}.tmp`;
@@ -353,7 +437,7 @@ async function cachePack(fs, cacheDir, file, name, issue, text) {
353
437
  } catch { return; /* cache is optional */ }
354
438
  if (typeof fs.list !== "function" || typeof fs.rm !== "function") return;
355
439
  try {
356
- const prefix = `context-${issue}-`;
440
+ const prefix = `context-${key}-`;
357
441
  for (const f of await fs.list(cacheDir)) {
358
442
  if (f !== name && f.startsWith(prefix) && f.endsWith(".json")) { try { await fs.rm(join(cacheDir, f)); } catch { /* best effort */ } }
359
443
  }