forgedock 1.30.0 → 1.31.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 (155) 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 +362 -33
  6. package/bin/engine.mjs +47 -37
  7. package/bin/forgedock.mjs +35 -3
  8. package/bin/gc.mjs +50 -17
  9. package/bin/labels.json +5 -0
  10. package/bin/lean/apishape.mjs +170 -0
  11. package/bin/lean/apishape.test.mjs +187 -0
  12. package/bin/lean/batch.mjs +32 -0
  13. package/bin/lean/batch.test.mjs +294 -1
  14. package/bin/lean/budget.mjs +89 -0
  15. package/bin/lean/budget.test.mjs +106 -0
  16. package/bin/lean/checks.mjs +32 -9
  17. package/bin/lean/checks.test.mjs +78 -1
  18. package/bin/lean/conformance.e2e.test.mjs +27 -5
  19. package/bin/lean/conformance.mjs +150 -15
  20. package/bin/lean/conformance.test.mjs +175 -3
  21. package/bin/lean/context.mjs +116 -43
  22. package/bin/lean/context.test.mjs +139 -4
  23. package/bin/lean/decompose.mjs +90 -0
  24. package/bin/lean/decompose.test.mjs +105 -0
  25. package/bin/lean/depcache.mjs +11 -7
  26. package/bin/lean/depcache.test.mjs +81 -0
  27. package/bin/lean/e2e.mjs +102 -0
  28. package/bin/lean/e2e.test.mjs +86 -0
  29. package/bin/lean/effects.mjs +37 -15
  30. package/bin/lean/explore.mjs +47 -0
  31. package/bin/lean/explore.test.mjs +61 -0
  32. package/bin/lean/facts.mjs +84 -0
  33. package/bin/lean/facts.test.mjs +66 -0
  34. package/bin/lean/fixround.mjs +90 -0
  35. package/bin/lean/fixround.test.mjs +117 -0
  36. package/bin/lean/heavy.mjs +35 -2
  37. package/bin/lean/heavy.test.mjs +32 -2
  38. package/bin/lean/lean.mjs +650 -124
  39. package/bin/lean/lean.test.mjs +1450 -37
  40. package/bin/lean/ledger.mjs +5 -3
  41. package/bin/lean/ledger.test.mjs +18 -0
  42. package/bin/lean/light.mjs +135 -0
  43. package/bin/lean/light.test.mjs +173 -0
  44. package/bin/lean/limits.test.mjs +36 -1
  45. package/bin/lean/orchestrate.mjs +261 -38
  46. package/bin/lean/pitfalls.mjs +136 -0
  47. package/bin/lean/pitfalls.test.mjs +117 -0
  48. package/bin/lean/postmerge.mjs +85 -0
  49. package/bin/lean/postmerge.test.mjs +79 -0
  50. package/bin/lean/precheck.mjs +138 -13
  51. package/bin/lean/precheck.test.mjs +113 -1
  52. package/bin/lean/prefilter.mjs +159 -5
  53. package/bin/lean/prefilter.test.mjs +152 -3
  54. package/bin/lean/preflight.mjs +108 -3
  55. package/bin/lean/preflight.test.mjs +48 -1
  56. package/bin/lean/probe.mjs +297 -0
  57. package/bin/lean/probe.test.mjs +210 -0
  58. package/bin/lean/prompts.mjs +101 -13
  59. package/bin/lean/render.mjs +7 -3
  60. package/bin/lean/resume.mjs +67 -0
  61. package/bin/lean/resume.test.mjs +84 -0
  62. package/bin/lean/review-trigger.mjs +12 -0
  63. package/bin/lean/review-trigger.test.mjs +17 -1
  64. package/bin/lean/routing.mjs +74 -0
  65. package/bin/lean/routing.test.mjs +67 -0
  66. package/bin/lean/sched.test.mjs +127 -1
  67. package/bin/lean/schedd.mjs +55 -9
  68. package/bin/lean/schedd.test.mjs +149 -3
  69. package/bin/lean/selfreview.mjs +37 -0
  70. package/bin/lean/selfreview.test.mjs +51 -0
  71. package/bin/lean/seqlease.mjs +40 -0
  72. package/bin/lean/seqlease.test.mjs +101 -0
  73. package/bin/lean/sequence.mjs +296 -13
  74. package/bin/lean/sequence.test.mjs +263 -2
  75. package/bin/lean/toolchain.mjs +60 -15
  76. package/bin/lean/toolchain.test.mjs +120 -3
  77. package/bin/lean/toolstats.mjs +72 -0
  78. package/bin/lean/toolstats.test.mjs +47 -0
  79. package/bin/lean/verifier.mjs +232 -0
  80. package/bin/lean/verifier.test.mjs +163 -0
  81. package/bin/lean/verify.mjs +72 -10
  82. package/bin/lean/verify.test.mjs +68 -3
  83. package/bin/review-staging.mjs +533 -0
  84. package/bin/runner.mjs +82 -13
  85. package/bin/stats/aggregate.mjs +87 -2
  86. package/bin/stats/efficiency.mjs +319 -0
  87. package/bin/stats/efficiency.test.mjs +233 -0
  88. package/bin/stats/github-source.mjs +8 -3
  89. package/bin/stats/index.mjs +69 -6
  90. package/bin/stats/local-source.mjs +86 -3
  91. package/bin/stats/query.mjs +48 -0
  92. package/bin/stats/render.mjs +22 -0
  93. package/bin/stats/stats.test.mjs +224 -5
  94. package/commands/orchestrate.md +3 -1
  95. package/commands/review-pr-agents/protocols.md +1 -1
  96. package/commands/review-pr-staging.md +84 -831
  97. package/commands/review-pr.md +1 -1
  98. package/commands/work-on/build/implement.md +1 -0
  99. package/commands/work-on/build/validate.md +11 -1
  100. package/docs/CONFIG.md +80 -5
  101. package/docs/OUTCOME-CODES.md +2 -1
  102. package/docs/STATS.md +53 -1
  103. package/docs/TELEMETRY.md +1 -1
  104. package/docs/spec/review-protocol.md +1 -1
  105. package/package.json +1 -1
  106. package/scripts/ci-local/lib.mjs +20 -0
  107. package/scripts/ci-local/plan.mjs +2 -12
  108. package/scripts/ci-local/scoping.mjs +10 -1
  109. package/scripts/ci-local.test.sh +2 -0
  110. package/scripts/fixtures/apishape/other/go-change/base/x.go +3 -0
  111. package/scripts/fixtures/apishape/other/go-change/head/x.go +3 -0
  112. package/scripts/fixtures/apishape/python/generator-function/base/seq.py +3 -0
  113. package/scripts/fixtures/apishape/python/generator-function/head/seq.py +2 -0
  114. package/scripts/fixtures/apishape/python/ichunked/base/more.py +17 -0
  115. package/scripts/fixtures/apishape/python/ichunked/head/more.py +20 -0
  116. package/scripts/fixtures/apishape/python/private-edit/base/calc.py +6 -0
  117. package/scripts/fixtures/apishape/python/private-edit/head/calc.py +9 -0
  118. package/scripts/fixtures/apishape/python/removed/base/ops.py +6 -0
  119. package/scripts/fixtures/apishape/python/removed/head/ops.py +2 -0
  120. package/scripts/fixtures/apishape/python/renamed/base/text.py +2 -0
  121. package/scripts/fixtures/apishape/python/renamed/head/text.py +2 -0
  122. package/scripts/fixtures/apishape/python/signature/base/shapes.py +2 -0
  123. package/scripts/fixtures/apishape/python/signature/head/shapes.py +2 -0
  124. package/scripts/fixtures/apishape/python/sync-async/base/client.py +7 -0
  125. package/scripts/fixtures/apishape/python/sync-async/head/client.py +7 -0
  126. package/scripts/fixtures/apishape/ts/generator-function/base/seq.js +3 -0
  127. package/scripts/fixtures/apishape/ts/generator-function/head/seq.js +5 -0
  128. package/scripts/fixtures/apishape/ts/private-edit/base/calc.ts +7 -0
  129. package/scripts/fixtures/apishape/ts/private-edit/head/calc.ts +9 -0
  130. package/scripts/fixtures/apishape/ts/removed/base/ops.ts +7 -0
  131. package/scripts/fixtures/apishape/ts/removed/head/ops.ts +3 -0
  132. package/scripts/fixtures/apishape/ts/renamed/base/text.ts +3 -0
  133. package/scripts/fixtures/apishape/ts/renamed/head/text.ts +3 -0
  134. package/scripts/fixtures/apishape/ts/signature/base/shapes.ts +3 -0
  135. package/scripts/fixtures/apishape/ts/signature/head/shapes.ts +3 -0
  136. package/scripts/fixtures/apishape/ts/sync-async/base/client.mjs +5 -0
  137. package/scripts/fixtures/apishape/ts/sync-async/head/client.mjs +5 -0
  138. package/scripts/forge-root.test.sh +2 -2
  139. package/scripts/publish-staging-sync.test.sh +62 -4
  140. package/scripts/review-finalize.sh +17 -6
  141. package/scripts/review-finalize.test.sh +47 -0
  142. package/scripts/review-pr-arguments.test.sh +13 -1
  143. package/scripts/review-pr-delta-wiring.test.sh +2 -2
  144. package/scripts/review-pr-gate2.test.sh +6 -14
  145. package/scripts/review-pr-staging-finding-filter.test.sh +4 -4
  146. package/scripts/review-pr-staging-scope.test.sh +39 -33
  147. package/scripts/review-prepare.sh +73 -6
  148. package/scripts/review-prepare.test.sh +85 -0
  149. package/scripts/review-staging-post.sh +293 -0
  150. package/scripts/review-staging-post.test.sh +143 -0
  151. package/scripts/review-staging-pre.sh +401 -0
  152. package/scripts/review-staging-pre.test.sh +150 -0
  153. package/scripts/symbol-index.mjs +26 -7
  154. package/scripts/symbol-index.py +55 -9
  155. 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,28 @@
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
24
  export const CONTEXT_MODES = Object.freeze(["shadow", "on", "off"]);
23
- export const CONTEXT_SOURCES = Object.freeze(["graph", "attempts", "ledger", "facts"]);
25
+ export const CONTEXT_SOURCES = Object.freeze(["graph", "attempts", "pitfalls", "ledger", "facts"]);
24
26
  export const DEFAULT_CONTEXT_BYTES = 6144;
25
27
  export const DEFAULT_LEDGER_DAYS = 30;
28
+ /** #4375: a pitfall not seen in this many lanes of the repo ages out (forge.yaml engine.context_pack_pitfall_lanes). */
29
+ export const DEFAULT_PITFALL_LANES = 50;
26
30
  /** Bounds on GitHub reads per pack: linked issues read, ledger PRs inspected. */
27
31
  export const MAX_LINKS = 20;
28
32
  export const MAX_LEDGER_PRS = 8;
33
+ /** #4401: FORGE:FACTS records (earlier lanes) read into one pack: this issue's own attempts first, then the file-path ledger. */
34
+ export const MAX_FACT_LANES = 6;
29
35
  export const REPO_FACT_FILES = Object.freeze([REPO_FACTS_FILE, TEST_MAP_FILE]);
30
36
  const ATTEMPT_MARKERS = ["NEEDS_HUMAN", "BASE_SYNC", "FIX_CI"];
31
37
  const LINE_MAX = 200;
@@ -38,13 +44,13 @@ const oneLine = (s, max = LINE_MAX) => {
38
44
  const stripComments = (s) => noComments(s).replace(/\r\n/g, "\n").trim();
39
45
  const byNum = (a, b) => a - b;
40
46
 
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 } = {}) {
47
+ /** forge.yaml engine.context_pack (+ _sources, _bytes, _days, _pitfall_lanes) normalised; invalid values fall back to the defaults. */
48
+ export function parseContextPackConfig({ mode, sources, bytes, days, pitfallLanes } = {}) {
43
49
  const m = String(mode ?? "").trim().toLowerCase();
44
50
  const list = Array.isArray(sources) ? sources : typeof sources === "string" && sources.trim() ? sources.split(/[\s,]+/) : null;
45
51
  const picked = list ? CONTEXT_SOURCES.filter((s) => list.map((x) => String(x).trim().toLowerCase()).includes(s)) : [...CONTEXT_SOURCES];
46
52
  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) };
53
+ 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) };
48
54
  }
49
55
 
50
56
  /** `**Parent**: #N` (decomposition sub-issue body line); null when absent. */
@@ -102,14 +108,15 @@ export function priorAttempts(body, comments = []) {
102
108
  }
103
109
 
104
110
  /**
105
- * Cache key for a compiled pack: issue + base head SHA, plus the config that shapes it and (#4298, #4304) the hash of the
111
+ * 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
112
  * issue's human scope text (`scope`) and prior-attempt count, so an edit or a new attempt on the same base compiles a
107
113
  * fresh pack. Not `updatedAt`: the lane's own FORGE:STATE/CONTEXT posts and label edits bump it, so the cache never hit.
114
+ * #4375: `stamp.pitfalls` (the selected pitfall signatures) joins the key, so a newly learned pitfall is never served stale.
108
115
  */
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,
116
+ export const contextPackCacheKey = (issue, headSha, cfg = {}, stamp = {}, repo = null) =>
117
+ `context-${runLogKey(issue, repo)}-${String(headSha ?? "").trim().slice(0, 40) || "nohead"}-${hash12(JSON.stringify([cfg.sources ?? [], cfg.budgetBytes ?? null, cfg.days ?? null,
111
118
  ...(stamp?.scope != null || stamp?.attempts != null ? [String(stamp.scope ?? ""), Number(stamp.attempts) || 0] : []),
112
- ...(stamp?.links ? [`links:${stamp.links}`] : [])]))}.json`;
119
+ ...(stamp?.links ? [`links:${stamp.links}`] : []), ...(stamp?.pitfalls?.length ? [`pitfalls:${stamp.pitfalls.join(",")}`] : [])]))}.json`;
113
120
 
114
121
  /** Max referenced issues whose state is read for the cache stamp (#4318: one GraphQL call for all of them). */
115
122
  export const LINK_STAMP_MAX = 100;
@@ -156,10 +163,6 @@ const outcomeOf = (st) => {
156
163
  return codeOf(c) && c !== "unclassified" ? c : null;
157
164
  };
158
165
  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
166
  /** First line of a FORGE record that is not a marker or a heading. */
164
167
  const recordLine = (body) => oneLine(noComments(body).split("\n").map((l) => l.trim()).find((l) => l && !/^#+\s/.test(l)) ?? "");
165
168
  const markerOf = (body) => /^\s*<!--\s*FORGE:([A-Z_]+)\s*-->/.exec(String(body ?? ""))?.[1] ?? null;
@@ -174,10 +177,11 @@ async function readComments(gh, repo, n) {
174
177
 
175
178
  /**
176
179
  * 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.
180
+ * base ref; `checkout` (the main checkout, optional) is where repo-fact files absent from the worktree are looked up.
181
+ * Every source is independent and best effort: a failing source is listed in `errors` and contributes nothing.
178
182
  */
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: [] };
183
+ export async function gatherContextFacts({ io, repo, issue, iss, comments = [], cwd, checkout = null, base, sources = CONTEXT_SOURCES, days = DEFAULT_LEDGER_DAYS, now = Date.now(), pitfalls = [] }) {
184
+ const facts = { issue: Number(issue), links: [], attempts: [], pitfalls: [], ledger: [], facts: [], lanes: [], errors: [] };
181
185
  const body = String(iss?.body ?? "");
182
186
  const on = new Set(sources);
183
187
  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 +234,54 @@ export async function gatherContextFacts({ io, repo, issue, iss, comments = [],
230
234
  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
235
  });
232
236
 
233
- await step("ledger", async () => {
234
- const paths = namedPaths(body);
235
- const symbols = issueSymbols(body);
236
- if (!paths.length || !base) return;
237
+ // #4375: already read and selected by the engine from the local ledger (pitfalls.mjs selectPitfalls); no I/O here
238
+ await step("pitfalls", async () => {
239
+ for (const p of Array.isArray(pitfalls) ? pitfalls : []) {
240
+ 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" });
241
+ }
242
+ });
243
+
244
+ // The file-path ledger walk (merged PRs on the issue's named paths) and each PR's trusted records, shared by the ledger
245
+ // and facts sources (#4401): run once per gather, and only when a source that needs them is on.
246
+ const paths = namedPaths(body);
247
+ let walked = null;
248
+ const walk = () => (walked ??= (async () => {
249
+ if (!paths.length || !base) return [];
237
250
  const since = new Date(now - days * 86400000).toISOString().slice(0, 10);
238
251
  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) => {
252
+ return out.split("\x1e").filter((x) => x.trim()).map((rec) => {
240
253
  const [sha, subject = "", msg = "", files = ""] = rec.split("\x1f");
241
254
  const pr = Number(/Merge pull request #(\d+)/.exec(subject)?.[1] ?? /\(#(\d+)\)\s*$/.exec(subject)?.[1]) || null;
242
255
  return { sha: sha.trim(), subject: subject.trim(), msg, pr, files: files.split("\n").map((f) => f.trim()).filter(Boolean),
243
256
  revert: /^Revert\b/.test(subject.trim()) || /\/revert-\d+-/.test(subject) || /^This reverts commit [0-9a-f]{7,40}/m.test(msg) };
244
257
  });
258
+ })());
259
+ const prReads = new Map();
260
+ const prInfo = (n) => {
261
+ if (!prReads.has(n)) prReads.set(n, (async () => {
262
+ const pr = JSON.parse(String(await io.gh(["api", `repos/${repo}/pulls/${n}`, "--jq", "{title, body, head: .head.ref}"])));
263
+ 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;
264
+ const recs = [...(await readComments(io.gh, repo, n)), ...(linked ? await readComments(io.gh, repo, linked) : [])].filter(trusted);
265
+ return { title: pr.title, linked, recs };
266
+ })());
267
+ return prReads.get(n);
268
+ };
269
+ const ledgerPrs = async () => (await walk()).filter((x) => x.pr || x.revert).slice(0, MAX_LEDGER_PRS);
270
+
271
+ await step("ledger", async () => {
272
+ const symbols = issueSymbols(body);
273
+ const commits = await walk();
274
+ if (!commits.length) return;
245
275
  const reverts = commits.filter((c) => c.revert);
246
276
  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)) {
277
+ for (const c of await ledgerPrs()) {
248
278
  let title = c.subject, notes = [];
249
279
  const reverted = c.revert ? null : revertedBy(c);
250
280
  if (c.pr) {
251
281
  try {
252
- const pr = JSON.parse(String(await io.gh(["api", `repos/${repo}/pulls/${c.pr}`, "--jq", "{title, body, head: .head.ref}"])));
282
+ const pr = await prInfo(c.pr);
253
283
  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) {
284
+ for (const r of pr.recs) {
257
285
  const m = markerOf(r.body);
258
286
  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
287
  if (m === "CONTRACT") for (const d of parseContractComment(r.body)?.contract?.decisions ?? []) notes.push(oneLine(`decision ${d?.id ?? ""}: ${d?.choice ?? ""}`, 160));
@@ -269,7 +297,32 @@ export async function gatherContextFacts({ io, repo, issue, iss, comments = [],
269
297
  });
270
298
 
271
299
  await step("facts", async () => {
272
- for (const p of REPO_FACT_FILES) if (await io.exists(`${cwd}/${p}`)) facts.facts.push(p);
300
+ // #4401: FORGE:FACTS of earlier lanes: this issue's own attempts (newest first), then merged PRs on the issue's files
301
+ // whose record names an overlapping file. Only trusted comments; a malformed record contributes nothing.
302
+ const seen = new Set();
303
+ const add = (rec, ref, n, pr) => {
304
+ const key = JSON.stringify(rec);
305
+ if (!rec || seen.has(key) || facts.lanes.length >= MAX_FACT_LANES) return;
306
+ seen.add(key);
307
+ facts.lanes.push({ ref, issue: n, pr, ...rec });
308
+ };
309
+ for (const c of [...comments].reverse()) if (trusted(c)) add(parseFactsComment(c?.body), `#${issue} earlier attempt`, Number(issue), null);
310
+ const overlaps = (files) => files.some((f) => paths.some((p) => f === p || f.startsWith(`${p.replace(/\/+$/, "")}/`) || p.startsWith(`${f}/`)));
311
+ let prs = [];
312
+ try { prs = await ledgerPrs(); } catch (e) { facts.errors.push(`facts: ledger ${oneLine(e?.message ?? e, 80)}`); }
313
+ for (const c of prs) {
314
+ if (!c.pr || facts.lanes.length >= MAX_FACT_LANES) continue;
315
+ let pr;
316
+ try { pr = await prInfo(c.pr); } catch (e) { facts.errors.push(`facts: PR #${c.pr} ${oneLine(e?.message ?? e, 80)}`); continue; }
317
+ for (const r of [...pr.recs].reverse()) {
318
+ const rec = parseFactsComment(r.body);
319
+ if (rec && overlaps(rec.files)) add(rec, `${pr.linked ? `#${pr.linked}, ` : ""}PR #${c.pr}`, pr.linked, c.pr);
320
+ }
321
+ }
322
+ // #4400: the .forge/ caches are untracked, so a fresh lane worktree lacks them: the main checkout is the fallback
323
+ for (const p of REPO_FACT_FILES) {
324
+ for (const d of [...new Set([cwd, checkout].filter(Boolean))]) if (await io.exists(`${d}/${p}`)) { facts.facts.push(p); break; }
325
+ }
273
326
  });
274
327
  return facts;
275
328
  }
@@ -279,25 +332,33 @@ export async function gatherContextFacts({ io, repo, issue, iss, comments = [],
279
332
  // forge#4297: codes are re-checked here too, so facts read back from the cache get the same whitelist
280
333
  const linkLine = (l) => { const fails = (Array.isArray(l.failures) ? l.failures : []).map(codeOf).filter(Boolean), res = codeOf(l.result);
281
334
  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(", ")}` : ""}`; };
335
+ const pitfallLine = (p) => `- [${p.trigger}] ${oneLine(p.lesson, 160)}${p.globs?.length ? ` (${p.globs.join(", ")})` : ""}; seen ${p.count}×`;
282
336
  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
337
 
284
338
  /**
285
339
  * 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
340
+ * 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
341
  * 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}}
342
+ * `pitfalls` lists the kept pitfalls ({sig, match}) for the run log (#4375).
343
+ * @returns {{text:string, bytes:number, items:number, truncated:number, sources:object, found:object, hash:string|null, pitfalls:object[]}}
289
344
  */
290
345
  export function compileContextPack(facts, { budgetBytes = DEFAULT_CONTEXT_BYTES, sources = CONTEXT_SOURCES } = {}) {
291
346
  const on = new Set(sources);
292
347
  const cand = [];
293
348
  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
349
  if (on.has("attempts")) for (const a of facts?.attempts ?? []) cand.push({ src: "attempts", line: `- ${a.ref}: ${oneLine(a.line)}` });
350
+ 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
351
  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)` });
352
+ if (on.has("facts")) {
353
+ // #4401: earlier lanes' facts by kind (rejected approaches first, across lanes), then the repo-fact file pointers
354
+ const lanes = (facts?.lanes ?? []).map((l) => factsLines(l, l.ref));
355
+ for (const k of FACTS_RANK) for (const l of lanes) for (const line of l[k]) cand.push({ src: "facts", line: oneLine(line) });
356
+ for (const f of facts?.facts ?? []) cand.push({ src: "facts", line: `- ${f} (repo facts file; read it if relevant)` });
357
+ }
297
358
  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 };
359
+ const empty = { text: "", bytes: 0, items: 0, truncated: 0, sources: Object.fromEntries(CONTEXT_SOURCES.map((s) => [s, 0])), found, hash: null, pitfalls: [] };
299
360
  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" };
361
+ 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
362
  const head = `Context pack for #${facts.issue}`;
302
363
  const size = (s) => Buffer.byteLength(s, "utf8");
303
364
  const kept = [];
@@ -306,7 +367,7 @@ export function compileContextPack(facts, { budgetBytes = DEFAULT_CONTEXT_BYTES,
306
367
  const secCost = kept.some((k) => k.src === c.src) ? 0 : size(`\n${titles[c.src]}:`) + 1;
307
368
  const pick = [c.full, c.line].find((t) => t && used + secCost + size(t) + 1 <= budgetBytes);
308
369
  if (!pick) { truncated++; continue; }
309
- kept.push({ src: c.src, text: pick });
370
+ kept.push({ src: c.src, text: pick, pit: c.pit });
310
371
  used += secCost + size(pick) + 1;
311
372
  }
312
373
  if (!kept.length) return { ...empty, truncated };
@@ -316,13 +377,25 @@ export function compileContextPack(facts, { budgetBytes = DEFAULT_CONTEXT_BYTES,
316
377
  if (rows.length) parts.push(`\n${titles[s]}:`, ...rows.map((r) => r.text));
317
378
  }
318
379
  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) };
380
+ 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) };
381
+ }
382
+
383
+ /** The pack text without its pitfalls section (#4375: pitfalls are local only and never posted). */
384
+ export function publicPackText(text) {
385
+ const out = [];
386
+ let skip = false;
387
+ for (const l of String(text ?? "").split("\n")) {
388
+ if (/^[^\s-].*:$/.test(l)) skip = l === "Recurring pitfalls in this repo (past rework):";
389
+ else if (skip && l === "") skip = false;
390
+ if (!skip) out.push(l);
391
+ }
392
+ return out.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd();
320
393
  }
321
394
 
322
- /** The collapsed FORGE:CONTEXT issue record body (without the marker). */
395
+ /** The collapsed FORGE:CONTEXT issue record body (without the marker); pitfalls show as a count only (#4375). */
323
396
  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>`;
397
+ const src = CONTEXT_SOURCES.map((s) => `${s} ${pack.sources?.[s] ?? 0}/${pack.found?.[s] ?? 0}`).join(", ");
398
+ 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
399
  }
327
400
 
328
401
  /**
@@ -331,19 +404,19 @@ export function renderContextRecord(pack, { mode, laneType: lt }) {
331
404
  * reported source errors is not cached (#4298), the write goes through a temp file and a rename, and the issue's other
332
405
  * cached packs are pruned. A cache miss or any write/rename/prune failure is not an error.
333
406
  */
334
- export async function contextPackFor({ cfg, issue, headSha, cacheDir, fs, gather, stamp = {} }) {
335
- const name = cacheDir && headSha ? contextPackCacheKey(issue, headSha, cfg, stamp) : null;
407
+ export async function contextPackFor({ cfg, issue, repo = null, headSha, cacheDir, fs, gather, stamp = {} }) {
408
+ const name = cacheDir && headSha ? contextPackCacheKey(issue, headSha, cfg, stamp, repo) : null;
336
409
  const file = name ? join(cacheDir, name) : null;
337
410
  if (file && fs?.read) {
338
411
  try { const c = JSON.parse(await fs.read(file)); if (c?.pack && typeof c.pack.text === "string") return { ...c, cached: true }; } catch { /* miss */ }
339
412
  }
340
413
  const facts = await gather();
341
414
  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));
415
+ if (file && fs?.write && !out.errors.length) await cachePack(fs, cacheDir, file, name, runLogKey(issue, repo), JSON.stringify(out));
343
416
  return { ...out, cached: false };
344
417
  }
345
418
 
346
- async function cachePack(fs, cacheDir, file, name, issue, text) {
419
+ async function cachePack(fs, cacheDir, file, name, key, text) {
347
420
  try {
348
421
  if (typeof fs.rename === "function") {
349
422
  const tmp = `${file}.${process.pid}.tmp`;
@@ -353,7 +426,7 @@ async function cachePack(fs, cacheDir, file, name, issue, text) {
353
426
  } catch { return; /* cache is optional */ }
354
427
  if (typeof fs.list !== "function" || typeof fs.rm !== "function") return;
355
428
  try {
356
- const prefix = `context-${issue}-`;
429
+ const prefix = `context-${key}-`;
357
430
  for (const f of await fs.list(cacheDir)) {
358
431
  if (f !== name && f.startsWith(prefix) && f.endsWith(".json")) { try { await fs.rm(join(cacheDir, f)); } catch { /* best effort */ } }
359
432
  }