forgedock 1.28.0 → 1.29.1

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 (60) hide show
  1. package/bin/engine/outcome-codes.mjs +29 -0
  2. package/bin/engine-cli.mjs +77 -16
  3. package/bin/lean/batch.test.mjs +2 -2
  4. package/bin/lean/conformance.mjs +47 -2
  5. package/bin/lean/conformance.test.mjs +53 -1
  6. package/bin/lean/context.mjs +356 -0
  7. package/bin/lean/context.test.mjs +307 -0
  8. package/bin/lean/effects.mjs +19 -1
  9. package/bin/lean/lean.mjs +178 -37
  10. package/bin/lean/lean.test.mjs +441 -7
  11. package/bin/lean/orchestrate.mjs +112 -5
  12. package/bin/lean/prefilter.mjs +81 -16
  13. package/bin/lean/prefilter.test.mjs +91 -2
  14. package/bin/lean/prompts.mjs +22 -7
  15. package/bin/lean/recovery.test.mjs +419 -0
  16. package/bin/lean/render.mjs +38 -5
  17. package/bin/lean/testmap.mjs +101 -0
  18. package/bin/lean/testmap.test.mjs +73 -0
  19. package/bin/lean/verify.mjs +48 -3
  20. package/bin/lean/verify.test.mjs +81 -3
  21. package/bin/stats/index.mjs +12 -2
  22. package/bin/stats/local-source.mjs +13 -0
  23. package/bin/stats/query.mjs +27 -0
  24. package/bin/stats/stats.test.mjs +41 -2
  25. package/commands/deploy-pr.md +5 -4
  26. package/commands/orchestrate.md +54 -7
  27. package/commands/review-pr-staging.md +6 -1
  28. package/docs/CONFIG.md +15 -0
  29. package/docs/OUTCOME-CODES.md +14 -0
  30. package/docs/RELEASE-NOTES.md +9 -0
  31. package/package.json +1 -1
  32. package/scripts/check-spec-markers.sh +1 -0
  33. package/scripts/code-index.sh +33 -33
  34. package/scripts/code-index.test.sh +21 -2
  35. package/scripts/fixtures/test-map/go/.github/workflows/ci.yml +25 -0
  36. package/scripts/fixtures/test-map/go/Makefile +5 -0
  37. package/scripts/fixtures/test-map/go/README.md +1 -0
  38. package/scripts/fixtures/test-map/go/go.mod +3 -0
  39. package/scripts/fixtures/test-map/go/internal/util/util.go +3 -0
  40. package/scripts/fixtures/test-map/go/main.go +3 -0
  41. package/scripts/fixtures/test-map/node/package.json +10 -0
  42. package/scripts/fixtures/test-map/node/packages/ui/package.json +4 -0
  43. package/scripts/fixtures/test-map/node/packages/ui/src/button.tsx +1 -0
  44. package/scripts/fixtures/test-map/node/pnpm-lock.yaml +1 -0
  45. package/scripts/fixtures/test-map/node/src/a.ts +1 -0
  46. package/scripts/fixtures/test-map/node/vitest.config.ts +1 -0
  47. package/scripts/fixtures/test-map/python/pkg/__init__.py +0 -0
  48. package/scripts/fixtures/test-map/python/pkg/mod.py +2 -0
  49. package/scripts/fixtures/test-map/python/pyproject.toml +9 -0
  50. package/scripts/fixtures/test-map/python/tests/test_mod.py +5 -0
  51. package/scripts/fixtures/test-map/python/uv.lock +1 -0
  52. package/scripts/repo-facts.sh +146 -0
  53. package/scripts/repo-facts.test.sh +106 -0
  54. package/scripts/review-prepare.sh +28 -6
  55. package/scripts/review-prepare.test.sh +22 -2
  56. package/scripts/test-map.sh +317 -0
  57. package/scripts/test-map.test.sh +353 -0
  58. package/scripts/verify-phase-trail.test.sh +44 -44
  59. package/scripts/wait-ci-green.sh +10 -4
  60. package/scripts/wait-ci-green.test.sh +13 -0
@@ -0,0 +1,356 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * forge#4275: the context compiler. Before the builder's plan call the engine compiles the issue's GitHub/git
5
+ * neighbourhood into a small, budgeted context pack: explicit links (parent, Depends on, cross-references, siblings),
6
+ * prior attempts at this issue, merged PRs on the issue's files that were reverted or whose decisions/findings name a
7
+ * symbol from the issue, and repo-fact file pointers. Only GitHub and git are read; nothing here is ForgeDock-specific
8
+ * beyond the FORGE:* records lanes already write. `gatherContextFacts` does the I/O; `compileContextPack` is pure, so the
9
+ * same facts always give a byte-identical pack. The pack is a hint: it never enters the issue text contracts quote from.
10
+ */
11
+ import { createHash } from "node:crypto";
12
+ import { join } from "node:path";
13
+ import { isTrustedComment, parseDependsOn } from "../engine/phases.mjs";
14
+ import { codeForTerminalReason } from "../engine/outcome-codes.mjs";
15
+ import { namedPaths } from "./prefilter.mjs";
16
+ import { parseContractComment, noComments } from "./render.mjs";
17
+ import { REPO_FACTS_FILE, TEST_MAP_FILE } from "./testmap.mjs";
18
+
19
+ // noComments lives in render.mjs (#4298: one comment-strip helper for context, conformance and prefilter).
20
+ export { noComments };
21
+
22
+ export const CONTEXT_MODES = Object.freeze(["shadow", "on", "off"]);
23
+ export const CONTEXT_SOURCES = Object.freeze(["graph", "attempts", "ledger", "facts"]);
24
+ export const DEFAULT_CONTEXT_BYTES = 6144;
25
+ export const DEFAULT_LEDGER_DAYS = 30;
26
+ /** Bounds on GitHub reads per pack: linked issues read, ledger PRs inspected. */
27
+ export const MAX_LINKS = 20;
28
+ export const MAX_LEDGER_PRS = 8;
29
+ export const REPO_FACT_FILES = Object.freeze([REPO_FACTS_FILE, TEST_MAP_FILE]);
30
+ const ATTEMPT_MARKERS = ["NEEDS_HUMAN", "BASE_SYNC", "FIX_CI"];
31
+ const LINE_MAX = 200;
32
+
33
+ const hash12 = (s) => createHash("sha256").update(String(s)).digest("hex").slice(0, 12);
34
+ const oneLine = (s, max = LINE_MAX) => {
35
+ const t = noComments(s, " ").replace(/\s+/g, " ").trim();
36
+ return t.length > max ? `${t.slice(0, max - 1)}…` : t;
37
+ };
38
+ const stripComments = (s) => noComments(s).replace(/\r\n/g, "\n").trim();
39
+ const byNum = (a, b) => a - b;
40
+
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 } = {}) {
43
+ const m = String(mode ?? "").trim().toLowerCase();
44
+ const list = Array.isArray(sources) ? sources : typeof sources === "string" && sources.trim() ? sources.split(/[\s,]+/) : null;
45
+ const picked = list ? CONTEXT_SOURCES.filter((s) => list.map((x) => String(x).trim().toLowerCase()).includes(s)) : [...CONTEXT_SOURCES];
46
+ 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) };
48
+ }
49
+
50
+ /** `**Parent**: #N` (decomposition sub-issue body line); null when absent. */
51
+ export function parentOf(body) {
52
+ const m = /^[ \t]*(?:[-*+][ \t]+)?(?:\*\*|__)?parent(?:\*\*|__)?[ \t]*:?(?:\*\*|__)?[ \t]*:?[ \t]*#(\d+)\b/im.exec(stripComments(body));
53
+ return m ? Number(m[1]) : null;
54
+ }
55
+
56
+ /** The FORGE:STATE record a projector keeps in an issue body, or null. */
57
+ export function stateOf(body) {
58
+ const m = /<!--\s*FORGE:STATE\s*(\{[\s\S]*?\})\s*-->/.exec(String(body ?? ""));
59
+ if (!m) return null;
60
+ try { const j = JSON.parse(m[1]); return j && typeof j === "object" ? j : null; } catch { return null; }
61
+ }
62
+
63
+ /** Issue numbers the body links to: `#N` and same-repo issue/PR URLs (HTML comments ignored). */
64
+ export function bodyLinks(body, repo, self) {
65
+ const s = stripComments(body);
66
+ const out = new Set([...s.matchAll(/(?:^|[^\w&/#])#(\d+)\b/g)].map((m) => Number(m[1])));
67
+ if (repo) {
68
+ const esc = String(repo).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
69
+ for (const m of s.matchAll(new RegExp(`github\\.com/${esc}/(?:issues|pull)/(\\d+)`, "g"))) out.add(Number(m[1]));
70
+ }
71
+ out.delete(Number(self));
72
+ return [...out].filter((n) => Number.isSafeInteger(n) && n > 0).sort(byNum);
73
+ }
74
+
75
+ /**
76
+ * Code symbols the issue names in backticks: identifiers with a case change, an underscore, a dot or a call `()`.
77
+ * Plain lowercase words (`shadow`, `on`) and paths are left out, so a match means a specific function or symbol.
78
+ */
79
+ export function issueSymbols(body) {
80
+ const out = new Set();
81
+ for (const m of stripComments(body).matchAll(/`([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)(\(\))?`/g)) {
82
+ const id = m[1];
83
+ if (id.length < 3) continue;
84
+ if (m[2] || /[a-z][A-Z]|_|\./.test(id) || /^[A-Z][a-z]+[A-Z]/.test(id)) out.add(id.split(".").at(-1));
85
+ }
86
+ return [...out].sort();
87
+ }
88
+
89
+ const reEscape = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
90
+ const namesSymbol = (text, symbols) => symbols.some((s) => new RegExp(`(?:^|[^\\w$])${reEscape(s)}(?:$|[^\\w$])`).test(String(text)));
91
+
92
+ /** Rerun (an earlier run left a record or salvage), decomposed child (`**Parent**`), or first attempt. */
93
+ export function laneType({ resumed = false, attempts = 0, body = "" } = {}) {
94
+ if (resumed || attempts > 0) return "rerun";
95
+ if (parentOf(body) != null) return "child";
96
+ return "first";
97
+ }
98
+
99
+ /** Earlier runs visible without any extra call: a FORGE:STATE terminal reason in the body plus trusted attempt records. */
100
+ export function priorAttempts(body, comments = []) {
101
+ return (stateOf(body)?.terminalReason ? 1 : 0) + comments.filter((c) => ATTEMPT_MARKERS.includes(markerOf(c?.body)) && trusted(c)).length;
102
+ }
103
+
104
+ /**
105
+ * Cache key for a compiled pack: issue + base head SHA, plus the config that shapes it and (#4298, #4304) the hash of the
106
+ * issue's human scope text (`scope`) and prior-attempt count, so an edit or a new attempt on the same base compiles a
107
+ * fresh pack. Not `updatedAt`: the lane's own FORGE:STATE/CONTEXT posts and label edits bump it, so the cache never hit.
108
+ */
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,
111
+ ...(stamp?.scope != null || stamp?.attempts != null ? [String(stamp.scope ?? ""), Number(stamp.attempts) || 0] : []),
112
+ ...(stamp?.links ? [`links:${stamp.links}`] : [])]))}.json`;
113
+
114
+ /** Max referenced issues whose state is read for the cache stamp (one REST call each). */
115
+ export const LINK_STAMP_MAX = 20;
116
+ /**
117
+ * #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.
120
+ */
121
+ export async function linkStateStamp({ gh, repo, issue, body }) {
122
+ const parent = parentOf(body);
123
+ 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);
125
+ 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(","));
133
+ }
134
+
135
+ // ---- gather (I/O) ----
136
+
137
+ const parseLines = (out) => String(out ?? "").split("\n").map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter((x) => x && typeof x === "object");
138
+ const commentId = (c) => /#issuecomment-(\d+)/.exec(String(c?.url ?? ""))?.[1] ?? c?.id ?? null;
139
+ /**
140
+ * forge#4297: a FORGE:STATE value as an outcome code (`ci.red`, `needs-human`, `conformance.unimplemented`), or null. A
141
+ * linked issue's body is anyone's text, so a multi-line, marked-up or over-long value never reaches the pack.
142
+ */
143
+ export const codeOf = (v) => (typeof v === "string" && /^[a-z][a-z0-9_.-]{0,63}$/.test(v) ? v : null);
144
+ /**
145
+ * The typed outcome of a FORGE:STATE record: the `terminalCode` and `failureClasses` the engine writes at the end of a run
146
+ * (forge#4275); older records fall back to the code their terminal reason maps to (null for the catch-all).
147
+ */
148
+ const outcomeOf = (st) => {
149
+ const reason = codeOf(st?.terminalReason);
150
+ const c = codeOf(st?.terminalCode) ?? (reason ? codeForTerminalReason(reason) : null);
151
+ return codeOf(c) && c !== "unclassified" ? c : null;
152
+ };
153
+ 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
+ /** First line of a FORGE record that is not a marker or a heading. */
159
+ const recordLine = (body) => oneLine(noComments(body).split("\n").map((l) => l.trim()).find((l) => l && !/^#+\s/.test(l)) ?? "");
160
+ const markerOf = (body) => /^\s*<!--\s*FORGE:([A-Z_]+)\s*-->/.exec(String(body ?? ""))?.[1] ?? null;
161
+
162
+ async function readIssue(gh, repo, n) {
163
+ const j = JSON.parse(String(await gh(["api", `repos/${repo}/issues/${n}`])));
164
+ return { number: Number(j.number ?? n), title: oneLine(j.title, 120), state: String(j.state ?? "").toUpperCase() || null, stateReason: j.state_reason ?? null, body: String(j.body ?? ""), pr: Boolean(j.pull_request) };
165
+ }
166
+ async function readComments(gh, repo, n) {
167
+ return parseLines(await gh(["api", "--paginate", `repos/${repo}/issues/${n}/comments`, "--jq", ".[] | {id, body, author_association, user: {type: .user.type, login: .user.login}} | @json"]));
168
+ }
169
+
170
+ /**
171
+ * 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.
173
+ */
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: [] };
176
+ const body = String(iss?.body ?? "");
177
+ const on = new Set(sources);
178
+ const step = async (name, fn) => { if (!on.has(name)) return; try { await fn(); } catch (e) { facts.errors.push(`${name}: ${oneLine(e?.message ?? e, 120)}`); } };
179
+ const git = (args) => io.git(args, { cwd });
180
+
181
+ await step("graph", async () => {
182
+ const rel = new Map();
183
+ const parent = parentOf(body);
184
+ if (parent) rel.set(parent, "parent");
185
+ for (const d of parseDependsOn(stripComments(body), issue)) if (!rel.has(d)) rel.set(d, "depends-on");
186
+ for (const n of bodyLinks(body, repo, issue)) if (!rel.has(n)) rel.set(n, "link");
187
+ let xrefs = [];
188
+ try {
189
+ // forge#4297: a reference from another repo carries that repo's number; only same-repo references are read here
190
+ const own = `/repos/${String(repo).toLowerCase()}`;
191
+ xrefs = String(await io.gh(["api", "--paginate", `repos/${repo}/issues/${issue}/timeline`, "--jq", '.[] | select(.event == "cross-referenced") | "\(.source.issue.repository_url) \(.source.issue.number)"']))
192
+ .split("\n").map((l) => l.trim().split(" ")).filter(([url]) => String(url ?? "").toLowerCase().endsWith(own))
193
+ .map(([, n]) => Number(n)).filter((n) => Number.isSafeInteger(n) && n > 0 && n !== Number(issue));
194
+ } catch (e) { facts.errors.push(`graph: timeline ${oneLine(e?.message ?? e, 80)}`); }
195
+ for (const n of [...new Set(xrefs)].sort(byNum)) if (!rel.has(n)) rel.set(n, "cross-ref");
196
+ if (parent) {
197
+ try {
198
+ const pc = (await readComments(io.gh, repo, parent)).filter(trusted);
199
+ const dec = [...pc].reverse().find((c) => markerOf(c.body) === "DECOMPOSED");
200
+ const subs = dec ? [...String(dec.body).matchAll(/^[ \t]*[-*][ \t]+#(\d+)\b/gm)].map((m) => Number(m[1])) : [];
201
+ for (const s of [...new Set(subs)].sort(byNum)) if (s !== Number(issue) && !rel.has(s)) rel.set(s, "sibling");
202
+ } catch (e) { facts.errors.push(`graph: siblings ${oneLine(e?.message ?? e, 80)}`); }
203
+ }
204
+ const order = { parent: 0, "depends-on": 1, link: 2, "cross-ref": 3, sibling: 4 };
205
+ const picked = [...rel.entries()].sort((a, b) => order[a[1]] - order[b[1]] || a[0] - b[0]).slice(0, MAX_LINKS);
206
+ for (const [n, r] of picked) {
207
+ let rec;
208
+ try { rec = await readIssue(io.gh, repo, n); } catch { facts.links.push({ number: n, rel: r, title: null, state: null, code: null, result: "unreadable" }); continue; }
209
+ const st = stateOf(rec.body);
210
+ const reason = codeOf(st?.terminalReason), fails = failuresOf(st);
211
+ facts.links.push({ number: n, rel: r, kind: rec.pr ? "pr" : "issue", title: rec.title, state: rec.state, code: outcomeOf(st),
212
+ result: reason ?? (rec.state === "CLOSED" ? rec.stateReason ?? "closed" : null), ...(fails.length ? { failures: fails } : {}), ...(r === "parent" || r === "depends-on" ? { body: stripComments(rec.body) } : {}) });
213
+ }
214
+ });
215
+
216
+ await step("attempts", async () => {
217
+ const st = stateOf(body), reason = codeOf(st?.terminalReason), pr = Number(st?.pr);
218
+ if (reason) facts.attempts.push({ kind: "state", ref: `#${issue} FORGE:STATE`, line: `last run ended ${reason}${outcomeOf(st) ? ` (${outcomeOf(st)})` : ""}${failuresOf(st).length ? `; failures ${failuresOf(st).join(", ")}` : ""}${Number.isSafeInteger(pr) && pr > 0 ? `, PR #${pr}` : ""}` });
219
+ for (const c of comments) {
220
+ const m = markerOf(c?.body);
221
+ if (!m || !ATTEMPT_MARKERS.includes(m) || !trusted(c)) continue;
222
+ facts.attempts.push({ kind: m.toLowerCase().replace("_", "-"), ref: `comment ${commentId(c) ?? "?"}`, line: `FORGE:${m}: ${recordLine(c.body)}` });
223
+ }
224
+ const refs = String(await git(["for-each-ref", "--format=%(refname) %(objectname:short)", `refs/forge/salvage/${issue}/`])).split("\n").map((l) => l.trim()).filter(Boolean).sort();
225
+ 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
+ });
227
+
228
+ await step("ledger", async () => {
229
+ const paths = namedPaths(body);
230
+ const symbols = issueSymbols(body);
231
+ if (!paths.length || !base) return;
232
+ const since = new Date(now - days * 86400000).toISOString().slice(0, 10);
233
+ 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) => {
235
+ const [sha, subject = "", msg = "", files = ""] = rec.split("\x1f");
236
+ const pr = Number(/Merge pull request #(\d+)/.exec(subject)?.[1] ?? /\(#(\d+)\)\s*$/.exec(subject)?.[1]) || null;
237
+ return { sha: sha.trim(), subject: subject.trim(), msg, pr, files: files.split("\n").map((f) => f.trim()).filter(Boolean),
238
+ revert: /^Revert\b/.test(subject.trim()) || /\/revert-\d+-/.test(subject) || /^This reverts commit [0-9a-f]{7,40}/m.test(msg) };
239
+ });
240
+ const reverts = commits.filter((c) => c.revert);
241
+ 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)) {
243
+ let title = c.subject, notes = [];
244
+ const reverted = c.revert ? null : revertedBy(c);
245
+ if (c.pr) {
246
+ try {
247
+ const pr = JSON.parse(String(await io.gh(["api", `repos/${repo}/pulls/${c.pr}`, "--jq", "{title, body, head: .head.ref}"])));
248
+ 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) {
252
+ const m = markerOf(r.body);
253
+ 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
+ if (m === "CONTRACT") for (const d of parseContractComment(r.body)?.contract?.decisions ?? []) notes.push(oneLine(`decision ${d?.id ?? ""}: ${d?.choice ?? ""}`, 160));
255
+ if (m === "REVIEW") notes.push(...String(r.body).split("\n").filter((l) => /^- `[\w.-]+`:/.test(l.trim())).map((l) => oneLine(`finding ${l.trim().slice(2)}`, 160)));
256
+ }
257
+ } catch (e) { facts.errors.push(`ledger: PR #${c.pr} ${oneLine(e?.message ?? e, 80)}`); }
258
+ }
259
+ const named = [...new Set(notes)].filter((n) => namesSymbol(n, symbols));
260
+ const titleNames = namesSymbol(`${title}\n${c.subject}`, symbols);
261
+ if (!(c.revert || reverted || named.length || titleNames)) continue;
262
+ facts.ledger.push({ pr: c.pr, sha: c.sha.slice(0, 12), title, revert: c.revert, reverted: reverted ? reverted.sha.slice(0, 12) : null, files: c.files.slice(0, 4), notes: named.slice(0, 2) });
263
+ }
264
+ });
265
+
266
+ await step("facts", async () => {
267
+ for (const p of REPO_FACT_FILES) if (await io.exists(`${cwd}/${p}`)) facts.facts.push(p);
268
+ });
269
+ return facts;
270
+ }
271
+
272
+ // ---- compile (pure) ----
273
+
274
+ // forge#4297: codes are re-checked here too, so facts read back from the cache get the same whitelist
275
+ const linkLine = (l) => { const fails = (Array.isArray(l.failures) ? l.failures : []).map(codeOf).filter(Boolean), res = codeOf(l.result);
276
+ 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(", ")}` : ""}`; };
277
+ 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
+
279
+ /**
280
+ * 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
282
+ * 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}}
284
+ */
285
+ export function compileContextPack(facts, { budgetBytes = DEFAULT_CONTEXT_BYTES, sources = CONTEXT_SOURCES } = {}) {
286
+ const on = new Set(sources);
287
+ const cand = [];
288
+ 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
+ if (on.has("attempts")) for (const a of facts?.attempts ?? []) cand.push({ src: "attempts", line: `- ${a.ref}: ${oneLine(a.line)}` });
290
+ 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)` });
292
+ 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 };
294
+ 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" };
296
+ const head = `Context pack for #${facts.issue}`;
297
+ const size = (s) => Buffer.byteLength(s, "utf8");
298
+ const kept = [];
299
+ let used = size(head) + 1, truncated = 0;
300
+ for (const c of cand) {
301
+ const secCost = kept.some((k) => k.src === c.src) ? 0 : size(`\n${titles[c.src]}:`) + 1;
302
+ const pick = [c.full, c.line].find((t) => t && used + secCost + size(t) + 1 <= budgetBytes);
303
+ if (!pick) { truncated++; continue; }
304
+ kept.push({ src: c.src, text: pick });
305
+ used += secCost + size(pick) + 1;
306
+ }
307
+ if (!kept.length) return { ...empty, truncated };
308
+ const parts = [head];
309
+ for (const s of CONTEXT_SOURCES) {
310
+ const rows = kept.filter((k) => k.src === s);
311
+ if (rows.length) parts.push(`\n${titles[s]}:`, ...rows.map((r) => r.text));
312
+ }
313
+ 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) };
315
+ }
316
+
317
+ /** The collapsed FORGE:CONTEXT issue record body (without the marker). */
318
+ 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>`;
321
+ }
322
+
323
+ /**
324
+ * The pack for one run: read from the cache (`cacheDir`, keyed by issue + base head SHA + config + `stamp` {scope, attempts}) or gathered
325
+ * and compiled, then cached. `gather` returns the facts; `fs` is {read, write, rename?, list?, rm?}. A gather that
326
+ * reported source errors is not cached (#4298), the write goes through a temp file and a rename, and the issue's other
327
+ * cached packs are pruned. A cache miss or any write/rename/prune failure is not an error.
328
+ */
329
+ export async function contextPackFor({ cfg, issue, headSha, cacheDir, fs, gather, stamp = {} }) {
330
+ const name = cacheDir && headSha ? contextPackCacheKey(issue, headSha, cfg, stamp) : null;
331
+ const file = name ? join(cacheDir, name) : null;
332
+ if (file && fs?.read) {
333
+ try { const c = JSON.parse(await fs.read(file)); if (c?.pack && typeof c.pack.text === "string") return { ...c, cached: true }; } catch { /* miss */ }
334
+ }
335
+ const facts = await gather();
336
+ 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));
338
+ return { ...out, cached: false };
339
+ }
340
+
341
+ async function cachePack(fs, cacheDir, file, name, issue, text) {
342
+ try {
343
+ if (typeof fs.rename === "function") {
344
+ const tmp = `${file}.${process.pid}.tmp`;
345
+ await fs.write(tmp, text);
346
+ await fs.rename(tmp, file);
347
+ } else await fs.write(file, text);
348
+ } catch { return; /* cache is optional */ }
349
+ if (typeof fs.list !== "function" || typeof fs.rm !== "function") return;
350
+ try {
351
+ const prefix = `context-${issue}-`;
352
+ for (const f of await fs.list(cacheDir)) {
353
+ if (f !== name && f.startsWith(prefix) && f.endsWith(".json")) { try { await fs.rm(join(cacheDir, f)); } catch { /* best effort */ } }
354
+ }
355
+ } catch { /* best effort */ }
356
+ }