forgedock 1.29.1 → 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 (166) hide show
  1. package/bin/engine/outcome-codes.mjs +5 -2
  2. package/bin/engine/phases.mjs +20 -3
  3. package/bin/engine/runlog.mjs +82 -12
  4. package/bin/engine/telemetry-seam.mjs +11 -4
  5. package/bin/engine/telemetry-seam.test.mjs +18 -0
  6. package/bin/engine-cli.mjs +453 -66
  7. package/bin/engine.mjs +50 -38
  8. package/bin/forgedock.mjs +35 -3
  9. package/bin/gc.mjs +50 -17
  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 +356 -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 +278 -22
  21. package/bin/lean/conformance.test.mjs +251 -5
  22. package/bin/lean/context.mjs +133 -55
  23. package/bin/lean/context.test.mjs +175 -9
  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 +55 -19
  31. package/bin/lean/enumerating.mjs +189 -0
  32. package/bin/lean/enumerating.test.mjs +97 -0
  33. package/bin/lean/explore.mjs +47 -0
  34. package/bin/lean/explore.test.mjs +61 -0
  35. package/bin/lean/facts.mjs +84 -0
  36. package/bin/lean/facts.test.mjs +66 -0
  37. package/bin/lean/fixround.mjs +90 -0
  38. package/bin/lean/fixround.test.mjs +117 -0
  39. package/bin/lean/heavy.mjs +35 -2
  40. package/bin/lean/heavy.test.mjs +32 -2
  41. package/bin/lean/lean.mjs +799 -127
  42. package/bin/lean/lean.test.mjs +1749 -37
  43. package/bin/lean/ledger.mjs +5 -3
  44. package/bin/lean/ledger.test.mjs +18 -0
  45. package/bin/lean/light.mjs +135 -0
  46. package/bin/lean/light.test.mjs +173 -0
  47. package/bin/lean/limits.test.mjs +36 -1
  48. package/bin/lean/orchestrate.mjs +341 -99
  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 +180 -0
  54. package/bin/lean/precheck.test.mjs +205 -0
  55. package/bin/lean/prefilter.mjs +180 -9
  56. package/bin/lean/prefilter.test.mjs +175 -3
  57. package/bin/lean/preflight.mjs +238 -0
  58. package/bin/lean/preflight.test.mjs +108 -0
  59. package/bin/lean/probe.mjs +297 -0
  60. package/bin/lean/probe.test.mjs +210 -0
  61. package/bin/lean/prompts.mjs +134 -12
  62. package/bin/lean/recovery.test.mjs +75 -0
  63. package/bin/lean/render.mjs +7 -3
  64. package/bin/lean/resume.mjs +67 -0
  65. package/bin/lean/resume.test.mjs +84 -0
  66. package/bin/lean/review-trigger.mjs +12 -0
  67. package/bin/lean/review-trigger.test.mjs +17 -1
  68. package/bin/lean/routing.mjs +74 -0
  69. package/bin/lean/routing.test.mjs +67 -0
  70. package/bin/lean/sched.test.mjs +127 -1
  71. package/bin/lean/schedd.mjs +55 -9
  72. package/bin/lean/schedd.test.mjs +149 -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 +385 -0
  78. package/bin/lean/sequence.test.mjs +373 -0
  79. package/bin/lean/testmap.mjs +43 -6
  80. package/bin/lean/testmap.test.mjs +38 -1
  81. package/bin/lean/toolchain.mjs +60 -15
  82. package/bin/lean/toolchain.test.mjs +120 -3
  83. package/bin/lean/toolstats.mjs +72 -0
  84. package/bin/lean/toolstats.test.mjs +47 -0
  85. package/bin/lean/verifier.mjs +232 -0
  86. package/bin/lean/verifier.test.mjs +163 -0
  87. package/bin/lean/verify.mjs +77 -10
  88. package/bin/lean/verify.test.mjs +74 -3
  89. package/bin/review-staging.mjs +533 -0
  90. package/bin/runner.mjs +82 -13
  91. package/bin/stats/aggregate.mjs +87 -2
  92. package/bin/stats/efficiency.mjs +319 -0
  93. package/bin/stats/efficiency.test.mjs +233 -0
  94. package/bin/stats/github-source.mjs +8 -3
  95. package/bin/stats/index.mjs +69 -6
  96. package/bin/stats/local-source.mjs +86 -3
  97. package/bin/stats/query.mjs +48 -0
  98. package/bin/stats/render.mjs +22 -0
  99. package/bin/stats/stats.test.mjs +224 -5
  100. package/commands/orchestrate.md +6 -2
  101. package/commands/review-pr-agents/protocols.md +1 -1
  102. package/commands/review-pr-staging.md +84 -831
  103. package/commands/review-pr.md +1 -1
  104. package/commands/work-on/build/implement.md +1 -0
  105. package/commands/work-on/build/validate.md +11 -1
  106. package/docs/CONFIG.md +88 -6
  107. package/docs/OUTCOME-CODES.md +7 -4
  108. package/docs/RELEASE-NOTES.md +10 -0
  109. package/docs/STATS.md +53 -1
  110. package/docs/TELEMETRY.md +1 -1
  111. package/docs/spec/review-protocol.md +1 -1
  112. package/package.json +1 -1
  113. package/scripts/acceptance-red-green.sh +13 -4
  114. package/scripts/acceptance-red-green.test.sh +14 -0
  115. package/scripts/ci-local/lib.mjs +20 -0
  116. package/scripts/ci-local/plan.mjs +2 -12
  117. package/scripts/ci-local/scoping.mjs +10 -1
  118. package/scripts/ci-local.test.sh +2 -0
  119. package/scripts/fixtures/apishape/other/go-change/base/x.go +3 -0
  120. package/scripts/fixtures/apishape/other/go-change/head/x.go +3 -0
  121. package/scripts/fixtures/apishape/python/generator-function/base/seq.py +3 -0
  122. package/scripts/fixtures/apishape/python/generator-function/head/seq.py +2 -0
  123. package/scripts/fixtures/apishape/python/ichunked/base/more.py +17 -0
  124. package/scripts/fixtures/apishape/python/ichunked/head/more.py +20 -0
  125. package/scripts/fixtures/apishape/python/private-edit/base/calc.py +6 -0
  126. package/scripts/fixtures/apishape/python/private-edit/head/calc.py +9 -0
  127. package/scripts/fixtures/apishape/python/removed/base/ops.py +6 -0
  128. package/scripts/fixtures/apishape/python/removed/head/ops.py +2 -0
  129. package/scripts/fixtures/apishape/python/renamed/base/text.py +2 -0
  130. package/scripts/fixtures/apishape/python/renamed/head/text.py +2 -0
  131. package/scripts/fixtures/apishape/python/signature/base/shapes.py +2 -0
  132. package/scripts/fixtures/apishape/python/signature/head/shapes.py +2 -0
  133. package/scripts/fixtures/apishape/python/sync-async/base/client.py +7 -0
  134. package/scripts/fixtures/apishape/python/sync-async/head/client.py +7 -0
  135. package/scripts/fixtures/apishape/ts/generator-function/base/seq.js +3 -0
  136. package/scripts/fixtures/apishape/ts/generator-function/head/seq.js +5 -0
  137. package/scripts/fixtures/apishape/ts/private-edit/base/calc.ts +7 -0
  138. package/scripts/fixtures/apishape/ts/private-edit/head/calc.ts +9 -0
  139. package/scripts/fixtures/apishape/ts/removed/base/ops.ts +7 -0
  140. package/scripts/fixtures/apishape/ts/removed/head/ops.ts +3 -0
  141. package/scripts/fixtures/apishape/ts/renamed/base/text.ts +3 -0
  142. package/scripts/fixtures/apishape/ts/renamed/head/text.ts +3 -0
  143. package/scripts/fixtures/apishape/ts/signature/base/shapes.ts +3 -0
  144. package/scripts/fixtures/apishape/ts/signature/head/shapes.ts +3 -0
  145. package/scripts/fixtures/apishape/ts/sync-async/base/client.mjs +5 -0
  146. package/scripts/fixtures/apishape/ts/sync-async/head/client.mjs +5 -0
  147. package/scripts/forge-root.test.sh +2 -2
  148. package/scripts/publish-staging-sync.test.sh +62 -4
  149. package/scripts/review-finalize.sh +17 -6
  150. package/scripts/review-finalize.test.sh +47 -0
  151. package/scripts/review-pr-arguments.test.sh +13 -1
  152. package/scripts/review-pr-delta-wiring.test.sh +2 -2
  153. package/scripts/review-pr-gate2.test.sh +6 -14
  154. package/scripts/review-pr-staging-finding-filter.test.sh +4 -4
  155. package/scripts/review-pr-staging-scope.test.sh +39 -33
  156. package/scripts/review-prepare.sh +73 -6
  157. package/scripts/review-prepare.test.sh +85 -0
  158. package/scripts/review-staging-post.sh +293 -0
  159. package/scripts/review-staging-post.test.sh +143 -0
  160. package/scripts/review-staging-pre.sh +401 -0
  161. package/scripts/review-staging-pre.test.sh +150 -0
  162. package/scripts/symbol-index.mjs +26 -7
  163. package/scripts/symbol-index.py +55 -9
  164. package/scripts/test-map.sh +63 -17
  165. package/scripts/test-map.test.sh +55 -0
  166. package/scripts/trusted-comments.test.sh +1 -1
@@ -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,34 +108,40 @@ 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
- /** Max referenced issues whose state is read for the cache stamp (one REST call each). */
115
- export const LINK_STAMP_MAX = 20;
121
+ /** Max referenced issues whose state is read for the cache stamp (#4318: one GraphQL call for all of them). */
122
+ export const LINK_STAMP_MAX = 100;
116
123
  /**
117
124
  * #4316: the linked-issue part of the cache stamp: a hash of `n:STATE` for each issue the body references (parent, Depends
118
- * on, `#N` links), so a pack never shows a linked issue as open after it closed on the same base. Timeline cross-refs are
119
- * left to the gather. An unreadable issue counts as `n:?`; never throws; '' when the body references nothing.
125
+ * on, `#N` links), so a pack never shows a linked issue as open after it closed on the same base. #4318: one GraphQL call
126
+ * reads every state plus the issue's own cross-reference/connected timeline count (a new relation changes the stamp too);
127
+ * above LINK_STAMP_MAX the highest (most recent) numbers are kept. A failed call counts every number as `n:?`; never
128
+ * throws; '' (and no call) when the body references nothing.
120
129
  */
121
130
  export async function linkStateStamp({ gh, repo, issue, body }) {
122
131
  const parent = parentOf(body);
123
132
  const nums = [...new Set([...(parent ? [parent] : []), ...parseDependsOn(stripComments(body), issue), ...bodyLinks(body, repo, issue)])]
124
- .filter((n) => Number.isSafeInteger(n) && n > 0 && n !== Number(issue)).sort(byNum).slice(0, LINK_STAMP_MAX);
133
+ .filter((n) => Number.isSafeInteger(n) && n > 0 && n !== Number(issue)).sort(byNum).slice(-LINK_STAMP_MAX);
125
134
  if (!nums.length) return "";
126
- const states = [];
127
- for (const n of nums) {
128
- let st = "?";
129
- try { st = String(await gh(["api", `repos/${repo}/issues/${n}`, "--jq", ".state"])).trim().toUpperCase() || "?"; } catch { /* unreadable: n:? */ }
130
- states.push(`${n}:${st}`);
131
- }
132
- return hash12(states.join(","));
135
+ const [owner, name] = String(repo ?? "").split("/");
136
+ const self = Number(issue);
137
+ const q = `query($o:String!,$n:String!){repository(owner:$o,name:$n){${Number.isSafeInteger(self) && self > 0
138
+ ? `self:issueOrPullRequest(number:${self}){... on Issue{timelineItems(itemTypes:[CROSS_REFERENCED_EVENT,CONNECTED_EVENT,DISCONNECTED_EVENT]){totalCount}}}` : ""}${
139
+ nums.map((n) => `i${n}:issueOrPullRequest(number:${n}){... on Issue{state} ... on PullRequest{state}}`).join(" ")}}}`;
140
+ let r = null;
141
+ try { r = JSON.parse(String(await gh(["api", "graphql", "-f", `query=${q}`, "-f", `o=${owner ?? ""}`, "-f", `n=${name ?? ""}`])))?.data?.repository ?? null; } catch { r = null; }
142
+ const states = nums.map((n) => `${n}:${String(r?.[`i${n}`]?.state ?? "").toUpperCase() || "?"}`);
143
+ const rel = r?.self?.timelineItems?.totalCount;
144
+ return hash12([...states, `rel:${Number.isInteger(rel) ? rel : "?"}`].join(","));
133
145
  }
134
146
 
135
147
  // ---- gather (I/O) ----
@@ -151,10 +163,6 @@ const outcomeOf = (st) => {
151
163
  return codeOf(c) && c !== "unclassified" ? c : null;
152
164
  };
153
165
  const failuresOf = (st) => (Array.isArray(st?.failureClasses) ? [...new Set(st.failureClasses.map(codeOf).filter(Boolean))].sort().slice(0, 5) : []);
154
- const trusted = (c) => {
155
- const login = String(c?.author?.login ?? c?.user?.login ?? "");
156
- return isTrustedComment({ author_association: c?.authorAssociation ?? c?.author_association, user: { login, type: c?.user?.type ?? (/\[bot\]$|^app\//.test(login) ? "Bot" : "User") } });
157
- };
158
166
  /** First line of a FORGE record that is not a marker or a heading. */
159
167
  const recordLine = (body) => oneLine(noComments(body).split("\n").map((l) => l.trim()).find((l) => l && !/^#+\s/.test(l)) ?? "");
160
168
  const markerOf = (body) => /^\s*<!--\s*FORGE:([A-Z_]+)\s*-->/.exec(String(body ?? ""))?.[1] ?? null;
@@ -169,10 +177,11 @@ async function readComments(gh, repo, n) {
169
177
 
170
178
  /**
171
179
  * Collect the facts for one issue's pack. `io`: {gh, git, exists}; git runs in `cwd` (the lane worktree), base is the
172
- * 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.
173
182
  */
174
- export async function gatherContextFacts({ io, repo, issue, iss, comments = [], cwd, base, sources = CONTEXT_SOURCES, days = DEFAULT_LEDGER_DAYS, now = Date.now() }) {
175
- 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: [] };
176
185
  const body = String(iss?.body ?? "");
177
186
  const on = new Set(sources);
178
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)}`); } };
@@ -225,30 +234,54 @@ export async function gatherContextFacts({ io, repo, issue, iss, comments = [],
225
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)` }); }
226
235
  });
227
236
 
228
- await step("ledger", async () => {
229
- const paths = namedPaths(body);
230
- const symbols = issueSymbols(body);
231
- 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 [];
232
250
  const since = new Date(now - days * 86400000).toISOString().slice(0, 10);
233
251
  const out = String(await git(["log", "--first-parent", `--since=${since}`, "--format=%x1e%H%x1f%s%x1f%b%x1f", "--name-only", base, "--", ...paths]));
234
- const commits = out.split("\x1e").filter((x) => x.trim()).map((rec) => {
252
+ return out.split("\x1e").filter((x) => x.trim()).map((rec) => {
235
253
  const [sha, subject = "", msg = "", files = ""] = rec.split("\x1f");
236
254
  const pr = Number(/Merge pull request #(\d+)/.exec(subject)?.[1] ?? /\(#(\d+)\)\s*$/.exec(subject)?.[1]) || null;
237
255
  return { sha: sha.trim(), subject: subject.trim(), msg, pr, files: files.split("\n").map((f) => f.trim()).filter(Boolean),
238
256
  revert: /^Revert\b/.test(subject.trim()) || /\/revert-\d+-/.test(subject) || /^This reverts commit [0-9a-f]{7,40}/m.test(msg) };
239
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;
240
275
  const reverts = commits.filter((c) => c.revert);
241
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))));
242
- for (const c of commits.filter((x) => x.pr || x.revert).slice(0, MAX_LEDGER_PRS)) {
277
+ for (const c of await ledgerPrs()) {
243
278
  let title = c.subject, notes = [];
244
279
  const reverted = c.revert ? null : revertedBy(c);
245
280
  if (c.pr) {
246
281
  try {
247
- 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);
248
283
  title = oneLine(pr.title ?? title, 120);
249
- 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;
250
- const recs = [...(await readComments(io.gh, repo, c.pr)), ...(linked ? await readComments(io.gh, repo, linked) : [])].filter(trusted);
251
- for (const r of recs) {
284
+ for (const r of pr.recs) {
252
285
  const m = markerOf(r.body);
253
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}`));
254
287
  if (m === "CONTRACT") for (const d of parseContractComment(r.body)?.contract?.decisions ?? []) notes.push(oneLine(`decision ${d?.id ?? ""}: ${d?.choice ?? ""}`, 160));
@@ -264,7 +297,32 @@ export async function gatherContextFacts({ io, repo, issue, iss, comments = [],
264
297
  });
265
298
 
266
299
  await step("facts", async () => {
267
- 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
+ }
268
326
  });
269
327
  return facts;
270
328
  }
@@ -274,25 +332,33 @@ export async function gatherContextFacts({ io, repo, issue, iss, comments = [],
274
332
  // forge#4297: codes are re-checked here too, so facts read back from the cache get the same whitelist
275
333
  const linkLine = (l) => { const fails = (Array.isArray(l.failures) ? l.failures : []).map(codeOf).filter(Boolean), res = codeOf(l.result);
276
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}×`;
277
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("; ")}` : ""}`;
278
337
 
279
338
  /**
280
339
  * The pack text for `facts`, ranked: explicit links (parent > Depends on > body links > cross-references > siblings),
281
- * 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
282
341
  * carries its full body when it fits. Items that do not fit `budgetBytes` are dropped whole and counted in `truncated`.
283
- * @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[]}}
284
344
  */
285
345
  export function compileContextPack(facts, { budgetBytes = DEFAULT_CONTEXT_BYTES, sources = CONTEXT_SOURCES } = {}) {
286
346
  const on = new Set(sources);
287
347
  const cand = [];
288
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 });
289
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 } });
290
351
  if (on.has("ledger")) for (const p of facts?.ledger ?? []) cand.push({ src: "ledger", line: ledgerLine(p) });
291
- 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
+ }
292
358
  const found = Object.fromEntries(CONTEXT_SOURCES.map((s) => [s, cand.filter((c) => c.src === s).length]));
293
- 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: [] };
294
360
  if (!cand.length) return empty;
295
- 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" };
296
362
  const head = `Context pack for #${facts.issue}`;
297
363
  const size = (s) => Buffer.byteLength(s, "utf8");
298
364
  const kept = [];
@@ -301,7 +367,7 @@ export function compileContextPack(facts, { budgetBytes = DEFAULT_CONTEXT_BYTES,
301
367
  const secCost = kept.some((k) => k.src === c.src) ? 0 : size(`\n${titles[c.src]}:`) + 1;
302
368
  const pick = [c.full, c.line].find((t) => t && used + secCost + size(t) + 1 <= budgetBytes);
303
369
  if (!pick) { truncated++; continue; }
304
- kept.push({ src: c.src, text: pick });
370
+ kept.push({ src: c.src, text: pick, pit: c.pit });
305
371
  used += secCost + size(pick) + 1;
306
372
  }
307
373
  if (!kept.length) return { ...empty, truncated };
@@ -311,13 +377,25 @@ export function compileContextPack(facts, { budgetBytes = DEFAULT_CONTEXT_BYTES,
311
377
  if (rows.length) parts.push(`\n${titles[s]}:`, ...rows.map((r) => r.text));
312
378
  }
313
379
  const text = parts.join("\n");
314
- 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();
315
393
  }
316
394
 
317
- /** 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). */
318
396
  export function renderContextRecord(pack, { mode, laneType: lt }) {
319
- const src = CONTEXT_SOURCES.map((s) => `${s} ${pack.sources[s]}/${pack.found[s]}`).join(", ");
320
- 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>`;
321
399
  }
322
400
 
323
401
  /**
@@ -326,19 +404,19 @@ export function renderContextRecord(pack, { mode, laneType: lt }) {
326
404
  * reported source errors is not cached (#4298), the write goes through a temp file and a rename, and the issue's other
327
405
  * cached packs are pruned. A cache miss or any write/rename/prune failure is not an error.
328
406
  */
329
- export async function contextPackFor({ cfg, issue, headSha, cacheDir, fs, gather, stamp = {} }) {
330
- 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;
331
409
  const file = name ? join(cacheDir, name) : null;
332
410
  if (file && fs?.read) {
333
411
  try { const c = JSON.parse(await fs.read(file)); if (c?.pack && typeof c.pack.text === "string") return { ...c, cached: true }; } catch { /* miss */ }
334
412
  }
335
413
  const facts = await gather();
336
414
  const out = { pack: compileContextPack(facts, cfg), errors: facts?.errors ?? [] };
337
- 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));
338
416
  return { ...out, cached: false };
339
417
  }
340
418
 
341
- async function cachePack(fs, cacheDir, file, name, issue, text) {
419
+ async function cachePack(fs, cacheDir, file, name, key, text) {
342
420
  try {
343
421
  if (typeof fs.rename === "function") {
344
422
  const tmp = `${file}.${process.pid}.tmp`;
@@ -348,7 +426,7 @@ async function cachePack(fs, cacheDir, file, name, issue, text) {
348
426
  } catch { return; /* cache is optional */ }
349
427
  if (typeof fs.list !== "function" || typeof fs.rm !== "function") return;
350
428
  try {
351
- const prefix = `context-${issue}-`;
429
+ const prefix = `context-${key}-`;
352
430
  for (const f of await fs.list(cacheDir)) {
353
431
  if (f !== name && f.startsWith(prefix) && f.endsWith(".json")) { try { await fs.rm(join(cacheDir, f)); } catch { /* best effort */ } }
354
432
  }
@@ -2,7 +2,8 @@
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 { gatherContextFacts, compileContextPack, contextPackFor, contextPackCacheKey, linkStateStamp, parseContextPackConfig, laneType, priorAttempts, issueSymbols, bodyLinks, parentOf, renderContextRecord, DEFAULT_CONTEXT_BYTES, noComments } from "./context.mjs";
5
+ import { normaliseFacts, renderFactsRecord, FACTS_MARKER } from "./facts.mjs";
6
+ import { gatherContextFacts, compileContextPack, contextPackFor, contextPackCacheKey, linkStateStamp, LINK_STAMP_MAX, parseContextPackConfig, laneType, priorAttempts, issueSymbols, bodyLinks, parentOf, renderContextRecord, DEFAULT_CONTEXT_BYTES, noComments } from "./context.mjs";
6
7
 
7
8
  const REPO = "o/r";
8
9
  const BOT = { author_association: "NONE", user: { type: "Bot", login: "forge[bot]" } };
@@ -33,7 +34,7 @@ function fakes(o = {}) {
33
34
  101: [{ id: 2, body: "<!-- FORGE:DECISION -->\nKeep `compileContextPack` pure; I/O lives in gather.", ...BOT }],
34
35
  102: [],
35
36
  103: [{ id: 3, body: "<!-- FORGE:DECISION -->\nUnrelated decision about logging.", ...BOT }],
36
- 33: [{ id: 4, body: "<!-- FORGE:CONTRACT -->\nx", ...BOT }],
37
+ 33: [{ id: 4, body: "<!-- FORGE:CONTRACT -->\nx", ...BOT }, ...(o.facts33 ?? [])],
37
38
  };
38
39
  const prs = { 101: { title: "feat: ranking", body: "Closes #33", head: "feat/issue-33-lean" }, 102: { title: "feat: speedup", body: "", head: "feat/issue-34-lean" }, 103: { title: "chore: logs", body: "", head: "x" } };
39
40
  const log = [
@@ -109,6 +110,21 @@ describe("context pack (forge#4275)", () => {
109
110
  assert.notEqual(contextPackCacheKey(50, "abc", cfg), contextPackCacheKey(51, "abc", cfg));
110
111
  });
111
112
 
113
+ it("#4432: the cache key and the prune prefix are repo-scoped, so another repo's pack for the same number survives", async () => {
114
+ const store = new Map();
115
+ const fs = { read: async (p) => { if (!store.has(p)) throw new Error("ENOENT"); return store.get(p); }, write: async (p, t) => { store.set(p, t); },
116
+ list: async () => [...store.keys()].map((p) => p.slice("/runs/".length)), rm: async (p) => { store.delete(p); } };
117
+ const cfg = parseContextPackConfig({});
118
+ const g = async () => gather(fakes());
119
+ assert.match(contextPackCacheKey(50, "abc", cfg, {}, "acme/one"), /^context-acme__one__50-abc-/);
120
+ assert.equal(contextPackCacheKey(50, "abc", cfg, {}), contextPackCacheKey(50, "abc", cfg), "no repo: today's key");
121
+ await contextPackFor({ cfg, issue: 50, repo: "acme/one", headSha: "abc", cacheDir: "/runs", fs, gather: g });
122
+ await contextPackFor({ cfg, issue: 50, repo: "acme/two", headSha: "def", cacheDir: "/runs", fs, gather: g });
123
+ assert.deepEqual([...store.keys()].map((k) => k.split("-").slice(0, 2).join("-")).sort(), ["/runs/context-acme__one__50", "/runs/context-acme__two__50"]);
124
+ const again = await contextPackFor({ cfg, issue: 50, repo: "acme/one", headSha: "abc", cacheDir: "/runs", fs, gather: g });
125
+ assert.equal(again.cached, true);
126
+ });
127
+
112
128
  it("drops whole items past the byte cap, falls back to one line for a parent body, counts truncation", async () => {
113
129
  const facts = await gather(fakes());
114
130
  const p = compileContextPack(facts, { budgetBytes: 130 });
@@ -121,7 +137,10 @@ describe("context pack (forge#4275)", () => {
121
137
  const f = fakes();
122
138
  const facts = await gather(f, { sources: ["graph", "facts"] });
123
139
  assert.equal(facts.ledger.length + facts.attempts.length, 0);
124
- assert.ok(!f.calls.some((c) => c[1] === "log" || c[1] === "for-each-ref"), "a disabled source makes no calls");
140
+ assert.ok(!f.calls.some((c) => c[1] === "for-each-ref"), "a disabled source makes no calls");
141
+ const g = fakes();
142
+ await gather(g, { sources: ["graph"] });
143
+ assert.ok(!g.calls.some((c) => c[1] === "log" || c[1] === "for-each-ref"), "no ledger walk without the ledger or facts source (#4401)");
125
144
  const p = compileContextPack(facts, { sources: ["graph"] });
126
145
  assert.ok(p.text.includes("#40") && !p.text.includes("test-map"));
127
146
  assert.deepEqual(parseContextPackConfig({ sources: "ledger, bogus,graph" }).sources, ["graph", "ledger"]);
@@ -166,7 +185,9 @@ describe("context pack (forge#4275)", () => {
166
185
  });
167
186
 
168
187
  it("config, lane type and parsers", () => {
169
- assert.deepEqual(parseContextPackConfig({}), { mode: "shadow", sources: ["graph", "attempts", "ledger", "facts"], budgetBytes: 6144, days: 30 });
188
+ assert.deepEqual(parseContextPackConfig({}), { mode: "shadow", sources: ["graph", "attempts", "pitfalls", "ledger", "facts"], budgetBytes: 6144, days: 30, pitfallLanes: 50 });
189
+ assert.equal(parseContextPackConfig({ pitfallLanes: 12 }).pitfallLanes, 12);
190
+ assert.equal(parseContextPackConfig({ pitfallLanes: -3 }).pitfallLanes, 50);
170
191
  assert.equal(parseContextPackConfig({ mode: "ON" }).mode, "on");
171
192
  assert.equal(parseContextPackConfig({ mode: "nope" }).mode, "shadow");
172
193
  assert.equal(laneType({ body: "x\n**Parent**: #3" }), "child");
@@ -183,7 +204,7 @@ describe("context pack (forge#4275)", () => {
183
204
  const p = compileContextPack(await gather(fakes()));
184
205
  const r = renderContextRecord(p, { mode: "shadow", laneType: "rerun" });
185
206
  assert.match(r, /^<details><summary>Context pack: \d+ item\(s\), \d+ bytes, 0 dropped \(mode shadow, lane rerun, hash [0-9a-f]{12}\)<\/summary>/);
186
- assert.match(r, /Sources \(included\/found\): graph 5\/5, attempts 3\/3, ledger 3\/3, facts 1\/1/);
207
+ assert.match(r, /Sources \(included\/found\): graph 5\/5, attempts 3\/3, pitfalls 0\/0, ledger 3\/3, facts 1\/1/);
187
208
  assert.ok(r.includes(p.text));
188
209
  });
189
210
  });
@@ -221,20 +242,51 @@ describe("#4298 context-pack cache and shared names", () => {
221
242
  assert.notEqual(k({ scope: "aaa111", attempts: 1 }), k({ scope: "aaa111", attempts: 2 }));
222
243
  });
223
244
 
245
+ it("#4318: above LINK_STAMP_MAX the highest numbers are kept, still in one call", async () => {
246
+ const calls = [];
247
+ const gh = async (args) => { calls.push(args); return JSON.stringify({ data: { repository: {} } }); };
248
+ const body = Array.from({ length: LINK_STAMP_MAX + 5 }, (_, i) => `#${i + 1}`).join(" ");
249
+ await linkStateStamp({ gh, repo: "o/r", issue: 9999, body });
250
+ assert.equal(calls.length, 1);
251
+ const nums = [...(calls[0].find((x) => x.startsWith("query=")) ?? "").matchAll(/i(\d+):/g)].map((m) => Number(m[1]));
252
+ assert.equal(nums.length, LINK_STAMP_MAX);
253
+ assert.equal(nums[0], 6, "the lowest numbers are dropped");
254
+ assert.equal(nums.at(-1), LINK_STAMP_MAX + 5);
255
+ });
256
+
224
257
  it("#4316: linked-issue states are in the key; a state change recompiles; no links keeps the old key", async () => {
225
258
  const states = { 7: "open", 8: "open", 9: "closed" };
259
+ let rel = 2;
226
260
  const calls = [];
227
- const gh = async (args) => { calls.push(args); const n = Number(/issues\/(\d+)$/.exec(args[1])?.[1]); if (n === 9 && states.fail9) throw new Error("HTTP 502"); return `${states[n]}\n`; };
261
+ // #4318: one GraphQL call; an alias per referenced number, plus the issue's own timeline relation count
262
+ const gh = async (args) => {
263
+ calls.push(args);
264
+ if (states.fail) throw new Error("HTTP 502");
265
+ const q = args.find((a) => a.startsWith("query=")) ?? "";
266
+ const repository = { self: { timelineItems: { totalCount: rel } } };
267
+ for (const m of q.matchAll(/i(\d+):issueOrPullRequest/g)) repository[`i${m[1]}`] = states[m[1]] ? { state: states[m[1]].toUpperCase() } : null;
268
+ return JSON.stringify({ data: { repository } });
269
+ };
270
+ const aliases = (a) => [...(a.find((x) => x.startsWith("query=")) ?? "").matchAll(/i(\d+):/g)].map((m) => Number(m[1]));
228
271
  const body = "**Parent**: #7\nDepends on #8\nSee #9 and #50 (self).";
229
272
  const s1 = await linkStateStamp({ gh, repo: "o/r", issue: 50, body });
230
273
  assert.match(s1, /^[0-9a-f]{12}$/);
231
- assert.deepEqual(calls.map((a) => a[1]), ["repos/o/r/issues/7", "repos/o/r/issues/8", "repos/o/r/issues/9"], "parent, depends-on and links once each, never self");
274
+ assert.equal(calls.length, 1, "one call for every reference");
275
+ assert.deepEqual(calls[0].slice(0, 2), ["api", "graphql"]);
276
+ assert.deepEqual(aliases(calls[0]), [7, 8, 9], "parent, depends-on and links once each, never self");
277
+ assert.ok(calls[0].includes("o=o") && calls[0].includes("n=r"));
232
278
  assert.equal(await linkStateStamp({ gh, repo: "o/r", issue: 50, body }), s1, "stable");
233
279
  states[8] = "closed";
234
280
  const s2 = await linkStateStamp({ gh, repo: "o/r", issue: 50, body });
235
281
  assert.notEqual(s2, s1, "a linked issue closing changes the stamp");
236
- states.fail9 = true;
237
- assert.match(await linkStateStamp({ gh, repo: "o/r", issue: 50, body }), /^[0-9a-f]{12}$/, "an unreadable link never throws");
282
+ rel = 3;
283
+ assert.notEqual(await linkStateStamp({ gh, repo: "o/r", issue: 50, body }), s2, "a new timeline relation changes the stamp");
284
+ rel = 2;
285
+ states.fail = true;
286
+ const sf = await linkStateStamp({ gh, repo: "o/r", issue: 50, body });
287
+ assert.match(sf, /^[0-9a-f]{12}$/, "a failed call never throws");
288
+ assert.notEqual(sf, s2, "a failed call is n:? for every number");
289
+ delete states.fail;
238
290
  assert.equal(await linkStateStamp({ gh: async () => { throw new Error("x"); }, repo: "o/r", issue: 50, body: "no links here" }), "");
239
291
  const k = (s) => contextPackCacheKey(50, "abc", cfg, s);
240
292
  assert.equal(k({ scope: "s", attempts: 0, links: "" }), k({ scope: "s", attempts: 0 }), "no links: the pre-#4316 key");
@@ -303,5 +355,119 @@ describe("#4298 context-pack cache and shared names", () => {
303
355
  writeFileSync(`${d}/forge.yaml`, "engine:\n context_pack: on\n");
304
356
  assert.deepEqual(readContextPack(d, (m) => warns.push(m)), { mode: "on" });
305
357
  assert.equal(warns.length, 1, "a valid mode does not warn");
358
+ writeFileSync(`${d}/forge.yaml`, "engine:\n context_pack_pitfall_lanes: 20\n");
359
+ assert.deepEqual(readContextPack(d, (m) => warns.push(m)), { pitfallLanes: 20 }, "#4375: the pitfall aging window");
360
+ });
361
+ });
362
+
363
+ describe("#4375 recurring pitfalls in the context pack", () => {
364
+ const PITS = [
365
+ { sig: "precheck:aaaaaaaaaaaa", trigger: "precheck", lesson: "precheck: src/a.ts:12:5 error 'x' is defined but never used no-unused-vars", globs: ["src/*.ts"], count: 4, match: "files" },
366
+ { sig: "docs:bbbbbbbbbbbb", trigger: "docs", lesson: "docs: devdocs/modules/lean.md is stale for bin/lean/lean.mjs", globs: ["devdocs/modules/*.md"], count: 2, match: "repo" },
367
+ ];
368
+ const facts = (pitfalls) => gatherContextFacts({ io: { gh: async () => { throw new Error("no gh"); }, git: async () => "", exists: async () => false }, repo: REPO, issue: 50, iss: { number: 50, body: "plain" }, comments: [], cwd: "/wt", base: "origin/staging", sources: ["pitfalls"], pitfalls });
369
+
370
+ it("compiles the given pitfalls in order, inside the byte budget, and lists the kept signatures", async () => {
371
+ const p = compileContextPack(await facts(PITS));
372
+ assert.ok(p.text.includes("Recurring pitfalls in this repo (past rework):"), p.text);
373
+ const a = p.text.indexOf("[precheck]"), b = p.text.indexOf("[docs]");
374
+ assert.ok(a > 0 && b > a, p.text);
375
+ assert.ok(p.text.includes("(src/*.ts); seen 4×"));
376
+ assert.deepEqual(p.pitfalls, [{ sig: PITS[0].sig, match: "files" }, { sig: PITS[1].sig, match: "repo" }]);
377
+ assert.equal(p.sources.pitfalls, 2);
378
+ const small = compileContextPack(await facts(PITS), { budgetBytes: 200 });
379
+ assert.ok(small.bytes <= 200 && small.truncated >= 1, `${small.bytes} ${small.truncated}`);
380
+ assert.deepEqual(small.pitfalls, small.text.includes("[docs]") ? p.pitfalls : [p.pitfalls[0]]);
381
+ assert.equal(compileContextPack(await facts(PITS), { sources: ["graph"] }).pitfalls.length, 0, "source off: no pitfalls");
382
+ });
383
+
384
+ it("the posted FORGE:CONTEXT record never carries pitfall text, only the count", async () => {
385
+ const f = await gather(fakes());
386
+ const p = compileContextPack({ ...f, pitfalls: (await facts(PITS)).pitfalls });
387
+ assert.ok(p.text.includes("no-unused-vars"));
388
+ const r = renderContextRecord(p, { mode: "on", laneType: "first" });
389
+ for (const x of ["no-unused-vars", "Recurring pitfalls", "src/*.ts", "devdocs/modules/lean.md is stale"]) assert.ok(!r.includes(x), x);
390
+ assert.match(r, /pitfalls 2\/2/);
391
+ assert.ok(r.includes("Prior attempts at this issue:") && r.includes("Merged PRs on the same symbols"), "the other sections stay");
392
+ });
393
+
394
+ it("the cache key changes when the selected pitfall signatures change", () => {
395
+ const cfg = parseContextPackConfig({});
396
+ const k0 = contextPackCacheKey(50, "abc", cfg, { scope: "s", attempts: 0 });
397
+ assert.equal(contextPackCacheKey(50, "abc", cfg, { scope: "s", attempts: 0, pitfalls: [] }), k0, "no pitfalls: today's key");
398
+ const k1 = contextPackCacheKey(50, "abc", cfg, { scope: "s", attempts: 0, pitfalls: ["precheck:aaaaaaaaaaaa"] });
399
+ assert.notEqual(k1, k0);
400
+ assert.notEqual(contextPackCacheKey(50, "abc", cfg, { scope: "s", attempts: 0, pitfalls: ["precheck:aaaaaaaaaaaa", "docs:bbbbbbbbbbbb"] }), k1);
401
+ });
402
+
403
+ it("without pitfalls the pack text and hash are unchanged", async () => {
404
+ const f = await gather(fakes());
405
+ const a = compileContextPack(f), b = compileContextPack({ ...f, pitfalls: [] });
406
+ assert.equal(a.text, b.text); assert.equal(a.hash, b.hash);
407
+ assert.ok(!a.text.includes("Recurring pitfalls"));
408
+ });
409
+ });
410
+
411
+ describe("FORGE:FACTS in the context pack (#4401)", () => {
412
+ const rec = (files, approach, why = "it only hid the symptom") => `<!-- ${FACTS_MARKER} -->\n${renderFactsRecord(normaliseFacts({ facts: { rejected: [{ approach, why }], hotspots: [{ at: `${files[0]}:7`, why: "ranking loop" }] } }, { files }))}`;
413
+ const STRANGER = { author_association: "NONE", user: { type: "User", login: "rando" } };
414
+
415
+ it("a later lane on overlapping files receives the prior rejected approaches; the facts source is non-zero", async () => {
416
+ const f = fakes({ facts33: [
417
+ { id: 5, body: rec(["bin/lean/context.mjs"], "detection-only check"), ...BOT },
418
+ { id: 6, body: rec(["docs/other.md"], "unrelated file approach"), ...BOT },
419
+ { id: 7, body: rec(["bin/lean/context.mjs"], "forged by a stranger"), ...STRANGER },
420
+ ] });
421
+ const own = { id: "IC_9", body: rec(["bin/lean/context.mjs"], "own earlier attempt approach"), author: { login: "forge[bot]" }, authorAssociation: "NONE" };
422
+ const facts = await gather(f, { comments: [...COMMENTS, own] });
423
+ const p = compileContextPack(facts);
424
+ const t = p.text;
425
+ const at = (s) => { const i = t.indexOf(s); assert.ok(i >= 0, `missing ${s}\n${t}`); return i; };
426
+ const ownAt = at("- rejected: own earlier attempt approach — it only hid the symptom (#50 earlier attempt)");
427
+ const prior = at("- rejected: detection-only check — it only hid the symptom (#33, PR #101)");
428
+ const hot = at("- hot spot bin/lean/context.mjs:7: ranking loop (#33, PR #101)");
429
+ const ptr = at("- .forge/test-map.json");
430
+ assert.ok(ownAt < prior && prior < hot && hot < ptr, t);
431
+ assert.ok(t.includes("Facts from earlier lanes and repo facts:"));
432
+ assert.ok(!t.includes("unrelated file approach"), "a record on non-overlapping files is left out");
433
+ assert.ok(!t.includes("forged by a stranger"), "untrusted records are never read");
434
+ assert.equal(p.sources.facts, 5);
435
+ assert.equal(f.calls.filter((c) => c[0] === "gh" && c.some((x) => String(x).includes("pulls/101"))).length, 1, "ledger and facts share one read per PR");
436
+ });
437
+
438
+ it("works with the ledger source off and contributes nothing when no record exists", async () => {
439
+ const f = fakes({ facts33: [{ id: 5, body: rec(["bin/lean/context.mjs"], "detection-only check"), ...BOT }] });
440
+ const facts = await gather(f, { sources: ["facts"] });
441
+ assert.equal(facts.ledger.length, 0);
442
+ assert.ok(compileContextPack(facts, { sources: ["facts"] }).text.includes("detection-only check"));
443
+ const none = await gather(fakes(), { sources: ["facts"] });
444
+ assert.deepEqual([none.lanes, none.facts], [[], [".forge/test-map.json"]]);
445
+ });
446
+ });
447
+
448
+ describe("populated pack sources (#4400)", () => {
449
+ it("repo-fact files that exist only in the main checkout are listed; the worktree alone (the old lookup) lists none", async () => {
450
+ const io = (dir) => ({ ...fakes().io, exists: async (p) => p.startsWith(`${dir}/`) && (p.endsWith(".forge/repo-facts.json") || p.endsWith(".forge/test-map.json")) });
451
+ const inCheckout = await gather({ io: io("/main") }, { sources: ["facts"], checkout: "/main" });
452
+ assert.deepEqual(inCheckout.facts, [".forge/repo-facts.json", ".forge/test-map.json"]);
453
+ const worktreeOnly = await gather({ io: io("/main") }, { sources: ["facts"] });
454
+ assert.deepEqual(worktreeOnly.facts, []);
455
+ const both = await gather({ io: { ...fakes().io, exists: async (p) => p.endsWith(".forge/repo-facts.json") } }, { sources: ["facts"], checkout: "/main" });
456
+ assert.deepEqual(both.facts, [".forge/repo-facts.json"], "a file in both dirs is listed once");
457
+ });
458
+
459
+ it("an issue with a prior run, a ledger PR on its file and a FORGE:FACTS record has non-zero attempts, ledger and facts", async () => {
460
+ const rec = `<!-- ${FACTS_MARKER} -->\n${renderFactsRecord(normaliseFacts({ facts: { rejected: [{ approach: "retry only", why: "hid the race" }] } }, { files: ["bin/lean/context.mjs"] }))}`;
461
+ const f = fakes({ facts33: [{ id: 8, body: rec, ...BOT }] });
462
+ const p = compileContextPack(await gather(f, { checkout: "/main" }));
463
+ for (const s of ["attempts", "ledger", "facts"]) assert.ok(p.sources[s] > 0, `${s} is zero: ${JSON.stringify(p.sources)}`);
464
+ });
465
+ });
466
+
467
+ describe("#4467: priorAttempts reads App comments in gh issue view shape", () => {
468
+ const ghShape = (login) => ({ author: { login }, authorAssociation: "CONTRIBUTOR", body: "<!-- FORGE:NEEDS_HUMAN -->\nstuck" });
469
+ it("counts the App's NEEDS_HUMAN, not a stranger's", () => {
470
+ assert.equal(priorAttempts("", [ghShape("rapiercraft-forgedock")]), 1);
471
+ assert.equal(priorAttempts("", [ghShape("someone")]), 0);
306
472
  });
307
473
  });