forgedock 1.26.0 → 1.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/README.md +4 -1
  2. package/bin/bootstrap.mjs +55 -0
  3. package/bin/engine/outcome-codes.mjs +13 -0
  4. package/bin/engine/phases.mjs +38 -2
  5. package/bin/engine/telemetry-seam.mjs +41 -6
  6. package/bin/engine/telemetry-seam.test.mjs +35 -0
  7. package/bin/engine-cli.mjs +616 -54
  8. package/bin/engine.mjs +13 -5
  9. package/bin/forgedock.mjs +169 -39
  10. package/bin/gc.mjs +580 -0
  11. package/bin/init-detect.mjs +68 -3
  12. package/bin/init-enrich.mjs +3 -0
  13. package/bin/init-repo.mjs +213 -0
  14. package/bin/journey.mjs +278 -43
  15. package/bin/lean/admission.mjs +33 -3
  16. package/bin/lean/batch.mjs +24 -5
  17. package/bin/lean/batch.test.mjs +54 -4
  18. package/bin/lean/checks.mjs +199 -0
  19. package/bin/lean/checks.test.mjs +183 -0
  20. package/bin/lean/conformance.mjs +235 -37
  21. package/bin/lean/conformance.test.mjs +143 -5
  22. package/bin/lean/credentials.mjs +29 -0
  23. package/bin/lean/depcache.mjs +312 -0
  24. package/bin/lean/depcache.test.mjs +298 -0
  25. package/bin/lean/drift.mjs +198 -0
  26. package/bin/lean/drift.test.mjs +223 -0
  27. package/bin/lean/effects.mjs +178 -16
  28. package/bin/lean/execution.e2e.test.mjs +81 -0
  29. package/bin/lean/execution.mjs +431 -0
  30. package/bin/lean/execution.test.mjs +583 -0
  31. package/bin/lean/heavy.mjs +289 -0
  32. package/bin/lean/heavy.test.mjs +207 -0
  33. package/bin/lean/lean.mjs +877 -151
  34. package/bin/lean/lean.test.mjs +1798 -28
  35. package/bin/lean/ledger.mjs +92 -0
  36. package/bin/lean/ledger.test.mjs +100 -0
  37. package/bin/lean/limits.mjs +58 -0
  38. package/bin/lean/limits.test.mjs +150 -0
  39. package/bin/lean/orchestrate.mjs +321 -19
  40. package/bin/lean/prompts.mjs +125 -17
  41. package/bin/lean/refactor.mjs +276 -0
  42. package/bin/lean/refactor.test.mjs +359 -0
  43. package/bin/lean/render.mjs +25 -6
  44. package/bin/lean/review-trigger.mjs +134 -0
  45. package/bin/lean/review-trigger.test.mjs +104 -0
  46. package/bin/lean/sandbox-provider.contract.test.mjs +257 -0
  47. package/bin/lean/sandbox-remote.mjs +142 -0
  48. package/bin/lean/sched.mjs +221 -0
  49. package/bin/lean/sched.test.mjs +310 -0
  50. package/bin/lean/schedd.mjs +247 -0
  51. package/bin/lean/schedd.test.mjs +180 -0
  52. package/bin/lean/toolchain.mjs +228 -0
  53. package/bin/lean/toolchain.test.mjs +154 -0
  54. package/bin/lean/verify.mjs +124 -0
  55. package/bin/lean/verify.test.mjs +118 -0
  56. package/bin/lean/worktree.mjs +69 -0
  57. package/bin/lean/worktree.test.mjs +141 -0
  58. package/bin/runner.mjs +53 -10
  59. package/bin/stats/github-source.mjs +1 -1
  60. package/bin/stats/index.mjs +14 -2
  61. package/bin/stats/query.mjs +72 -0
  62. package/bin/stats/stats.test.mjs +54 -0
  63. package/commands/adopt.md +3 -0
  64. package/commands/audit.md +5 -8
  65. package/commands/autopilot.md +2 -0
  66. package/commands/bootstrap.md +35 -0
  67. package/commands/cleanup.md +7 -22
  68. package/commands/forge-stats.md +2 -1
  69. package/commands/forgedock-init.md +125 -6
  70. package/commands/issue.md +89 -830
  71. package/commands/milestone.md +55 -6
  72. package/commands/orchestrate.md +10 -2
  73. package/commands/review-pr.md +2 -2
  74. package/commands/upgrade-deps.md +3 -3
  75. package/commands/work-on/build/validate.md +4 -0
  76. package/commands/work-on/close.md +2 -0
  77. package/commands/work-on/decompose.md +24 -6
  78. package/docs/CI.md +3 -3
  79. package/docs/CONFIG.md +227 -3
  80. package/docs/OUTCOME-CODES.md +11 -0
  81. package/docs/TELEMETRY.md +1 -1
  82. package/docs/site/command-reference.md +22 -0
  83. package/docs/site/getting-started.md +2 -0
  84. package/docs/site/start-a-new-project.md +327 -0
  85. package/package.json +1 -1
  86. package/scripts/affected-files-check.sh +91 -0
  87. package/scripts/affected-files-check.test.sh +80 -0
  88. package/scripts/bootstrap-create.mjs +393 -0
  89. package/scripts/bootstrap-validate.mjs +321 -0
  90. package/scripts/calibration.mjs +116 -0
  91. package/scripts/check-devdocs-freshness.sh +125 -0
  92. package/scripts/check-devdocs-freshness.test.sh +130 -0
  93. package/scripts/check-spec-markers.sh +3 -0
  94. package/scripts/check-spec-size.sh +29 -11
  95. package/scripts/check-spec-size.test.sh +27 -4
  96. package/scripts/ci-local/lib.mjs +8 -1
  97. package/scripts/ci-local/parallelism.test.mjs +27 -0
  98. package/scripts/ci-local/run.mjs +16 -5
  99. package/scripts/ci-local/scoping.mjs +82 -0
  100. package/scripts/ci-local.test.sh +14 -2
  101. package/scripts/fixtures/symbol-index/other/sample.rb +3 -0
  102. package/scripts/fixtures/symbol-index/py/pkg/__init__.py +1 -0
  103. package/scripts/fixtures/symbol-index/py/pkg/a.py +5 -0
  104. package/scripts/fixtures/symbol-index/py/pkg/b.py +14 -0
  105. package/scripts/fixtures/symbol-index/py/pkg/c.py +1 -0
  106. package/scripts/fixtures/symbol-index/py/sample.py +41 -0
  107. package/scripts/fixtures/symbol-index/ts/sample.ts +13 -0
  108. package/scripts/fixtures/symbol-move/gen_big.py +201 -0
  109. package/scripts/fixtures/symbol-move-ts/gen_big.mjs +56 -0
  110. package/scripts/forge-root.test.sh +1 -1
  111. package/scripts/hotspots.sh +100 -0
  112. package/scripts/hotspots.test.sh +101 -0
  113. package/scripts/issue-create.sh +197 -0
  114. package/scripts/issue-create.test.sh +166 -0
  115. package/scripts/maintenance-scan.sh +185 -0
  116. package/scripts/maintenance-scan.test.sh +89 -0
  117. package/scripts/milestone-drift.sh +206 -0
  118. package/scripts/milestone-drift.test.sh +120 -0
  119. package/scripts/northstar-docs.test.sh +68 -0
  120. package/scripts/refactor-verify.sh +368 -0
  121. package/scripts/refactor-verify.test.sh +202 -0
  122. package/scripts/review-prepare.sh +11 -9
  123. package/scripts/review-prepare.test.sh +14 -0
  124. package/scripts/structure-delta.sh +155 -0
  125. package/scripts/structure-delta.test.sh +136 -0
  126. package/scripts/symbol-index.mjs +253 -0
  127. package/scripts/symbol-index.py +354 -0
  128. package/scripts/symbol-index.test.sh +119 -0
  129. package/scripts/symbol-move-ts.test.sh +161 -0
  130. package/scripts/symbol-move.mjs +379 -0
  131. package/scripts/symbol-move.py +383 -0
  132. package/scripts/symbol-move.test.sh +168 -0
  133. package/templates/bootstrap/interview.md +34 -0
  134. package/templates/bootstrap/issue-body.md +42 -0
  135. package/templates/bootstrap/needs-human-issue.md +45 -0
  136. package/templates/bootstrap/stack-profiles.md +34 -0
  137. package/templates/bootstrap/technical-spike.md +75 -0
@@ -0,0 +1,198 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Milestone drift checkpoint (forge#4161). No LLM anywhere: facts that merged lanes already computed are summed, and
5
+ * an issue is filed only when a threshold is crossed.
6
+ *
7
+ * - Summary: each merged milestone lane's merge-time STRUCTURE_DELTA (`result.structure`, lean.mjs recordStructure) is
8
+ * folded into one `<!-- FORGE:DRIFT {json} -->` block in the GitHub milestone description (GET + PATCH per merge).
9
+ * The fold is keyed by PR, so a re-fold is a no-op. Text outside the block is kept byte for byte.
10
+ * - Report: `scripts/milestone-drift.sh` recomputes pair growth, new edges and size growth from git (fork point to
11
+ * the milestone head). The summary only attributes PRs.
12
+ * - Crossings (only with `orchestrate.drift` thresholds): one templated issue per crossing in the milestone,
13
+ * deduplicated by its `<!-- FORGE:DRIFT:<slug>:<kind>:<key> -->` marker against the milestone's open issues.
14
+ * - block_dispatch (off by default): a queued issue on the same milestone lane whose Affected Files sit under a
15
+ * flagged package dir is held.
16
+ */
17
+ import { execFile } from "node:child_process";
18
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
19
+ import { tmpdir } from "node:os";
20
+ import { join } from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+
23
+ export const SUMMARY_MARKER = "FORGE:DRIFT";
24
+ const SUMMARY_RE = /<!-- FORGE:DRIFT (\{[\s\S]*?\}) -->/;
25
+ // The summary keeps the largest growth entries only, so the description stays small.
26
+ export const MAX_GROWTH_FILES = 50;
27
+ const SCRIPT = fileURLToPath(new URL("../../scripts/milestone-drift.sh", import.meta.url));
28
+
29
+ export const emptySummary = (milestone) => ({ v: 1, milestone, merged: [], pairs: {}, deps_added: [], deps_removed: [], growth: {} });
30
+
31
+ const arr = (x) => (Array.isArray(x) ? x : []);
32
+ const edgeKey = (e) => `${e?.from}:${e?.to}`;
33
+
34
+ /**
35
+ * Fold one merged run's structure record into the summary (pure; returns a new object). Keyed by PR: a PR already in
36
+ * `merged` leaves the summary unchanged.
37
+ */
38
+ export function foldSummary(summary, { issue, pr, milestone, structure }) {
39
+ const s = summary && typeof summary === "object" ? summary : emptySummary(milestone ?? null);
40
+ const merged = arr(s.merged);
41
+ if (pr != null && merged.some((m) => m?.pr === pr)) return s;
42
+ const st = structure ?? {};
43
+ const pairs = { ...(s.pairs ?? {}) };
44
+ const touch = (e, field, n) => {
45
+ if (!e?.from || !e?.to || !(n > 0)) return;
46
+ const k = edgeKey(e), p = pairs[k] ?? { files_added: 0, files_removed: 0, prs: [] };
47
+ pairs[k] = { ...p, [field]: (p[field] ?? 0) + n, prs: field === "files_added" && pr != null && !p.prs.includes(pr) ? [...p.prs, pr] : p.prs };
48
+ };
49
+ for (const e of arr(st.new_edges)) touch(e, "files_added", Number(e?.files) || 0);
50
+ for (const e of arr(st.removed_edges)) touch(e, "files_removed", Number(e?.files) || 0);
51
+ const deps = (old, add) => [...arr(old), ...arr(add).filter((e) => e?.from && e?.to).map((e) => ({ from: e.from, to: e.to, ...(e.kind ? { kind: e.kind } : {}), pr: pr ?? null }))];
52
+ const growth = { ...(s.growth ?? {}) };
53
+ for (const g of arr(st.top_growth)) if (g?.file && Number(g.lines) > 0) growth[g.file] = (growth[g.file] ?? 0) + Number(g.lines);
54
+ const topGrowth = Object.fromEntries(Object.entries(growth).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, MAX_GROWTH_FILES));
55
+ return { v: 1, milestone: s.milestone ?? milestone ?? null, merged: [...merged, { issue: issue ?? null, pr: pr ?? null }], pairs,
56
+ deps_added: deps(s.deps_added, st.deps_added), deps_removed: deps(s.deps_removed, st.deps_removed), growth: topGrowth };
57
+ }
58
+
59
+ /** The FORGE:DRIFT summary in a milestone description, or null. */
60
+ export function parseSummary(description) {
61
+ const m = SUMMARY_RE.exec(String(description ?? ""));
62
+ if (!m) return null;
63
+ try { const s = JSON.parse(m[1]); return s && typeof s === "object" && !Array.isArray(s) ? s : null; } catch { return null; }
64
+ }
65
+
66
+ /** `description` with its FORGE:DRIFT block replaced (or appended); the text around it is unchanged. */
67
+ export function renderSummary(description, summary) {
68
+ const d = String(description ?? "");
69
+ // `>` never appears in the JSON's structure, so escaping it keeps `-->` out of the comment and the JSON valid.
70
+ const block = `<!-- ${SUMMARY_MARKER} ${JSON.stringify(summary).replace(/>/g, "\\u003e")} -->`;
71
+ if (SUMMARY_RE.test(d)) return d.replace(SUMMARY_RE, () => block);
72
+ return d ? `${d}\n\n${block}` : block;
73
+ }
74
+
75
+ export const crossingMarker = (slug, c) => `FORGE:DRIFT:${slug}:${c.kind}:${c.key}`;
76
+
77
+ /** One issue per crossing: {title, body, marker}. The body is a template (numbers and files from the report). */
78
+ export function crossingIssues(report, { slug, milestone }) {
79
+ const fork = String(report?.fork_point ?? "").slice(0, 12);
80
+ const files = arr(report?.files);
81
+ return arr(report?.crossings).map((c) => {
82
+ const marker = crossingMarker(slug, c);
83
+ let evidence = [];
84
+ if (c.kind === "pair_growth") {
85
+ const [from, to] = String(c.key).split("->");
86
+ const p = arr(report?.pairs).find((x) => x.from === from && x.to === to) ?? {};
87
+ evidence = [...arr(p.files).map((f) => `- \`${f}\``), ...(arr(p.prs).length ? [`- PRs: ${p.prs.map((n) => `#${n}`).join(", ")}`] : [])];
88
+ } else if (c.kind === "package_lines") {
89
+ evidence = files.filter((f) => f.package === c.key).map((f) => `- \`${f.path}\` +${f.added} -${f.removed}`);
90
+ } else {
91
+ const f = files.find((x) => x.path === c.key);
92
+ if (f) evidence = [`- \`${f.path}\` +${f.added} -${f.removed}`];
93
+ }
94
+ const what = { pair_growth: "value-import files", package_lines: "lines of growth", file_lines: "lines of growth" }[c.kind] ?? "growth";
95
+ const body = [
96
+ "## Problem", "",
97
+ `\`milestone/${slug}\` (${milestone}) crossed \`orchestrate.drift.${c.kind}\` since its fork point \`${fork}\`: \`${c.key}\` has ${c.value} ${what} (threshold ${c.threshold}). No single PR shows this; found by \`scripts/milestone-drift.sh\` after a lane merged.`, "",
98
+ "## Evidence", "", ...(evidence.length ? evidence : ["- (see `scripts/milestone-drift.sh " + slug + "`)"]), "",
99
+ "## Acceptance Criteria", "",
100
+ `- [ ] \`scripts/milestone-drift.sh ${slug}\` no longer reports \`${c.kind}\` for \`${c.key}\`, or the growth is recorded as intended in the repo's architecture doc`, "",
101
+ `<!-- ${marker} -->`,
102
+ ].join("\n");
103
+ return { title: `drift(milestone/${slug}): ${c.kind} ${c.key} ${c.value} > ${c.threshold}`, body, marker, kind: c.kind, key: c.key };
104
+ });
105
+ }
106
+
107
+ /**
108
+ * File the crossings that no open issue of the milestone carries yet: one `gh issue list` read, one create per new
109
+ * crossing. `seen` (markers filed earlier in this process) guards against a list that lags a fresh create.
110
+ * @returns {Promise<{filed: Array<{marker:string, number:?number}>, skipped: string[]}>}
111
+ */
112
+ export async function fileCrossings({ gh, repo, milestone, issues, seen = new Set() }) {
113
+ if (!issues.length) return { filed: [], skipped: [] };
114
+ const open = JSON.parse(String(await gh(["issue", "list", "-R", repo, "--milestone", milestone, "--state", "open", "--limit", "500", "--json", "number,body"])) || "[]");
115
+ const bodies = arr(open).map((x) => String(x?.body ?? ""));
116
+ const filed = [], skipped = [];
117
+ for (const it of issues) {
118
+ if (seen.has(it.marker) || bodies.some((b) => b.includes(`<!-- ${it.marker} -->`))) { skipped.push(it.marker); continue; }
119
+ const out = String(await gh(["issue", "create", "-R", repo, "--title", it.title, "--body", it.body, "--milestone", milestone]));
120
+ seen.add(it.marker);
121
+ filed.push({ marker: it.marker, number: Number(/\/issues\/(\d+)/.exec(out)?.[1]) || null });
122
+ }
123
+ return { filed, skipped };
124
+ }
125
+
126
+ /** Package dirs a report flags: package_lines crossings and the importing side of pair_growth crossings. */
127
+ export function flaggedPackages(report) {
128
+ return [...new Set(arr(report?.crossings).filter((c) => (c.kind === "package_lines" || c.kind === "pair_growth") && c.package_path).map((c) => c.package_path))].sort();
129
+ }
130
+
131
+ /** The first flagged package dir that one of `files` sits under (or names), else null. */
132
+ export function heldBy(files, flagged) {
133
+ for (const d of arr(flagged)) if (arr(files).some((f) => f === d || String(f).startsWith(`${d}/`))) return d;
134
+ return null;
135
+ }
136
+
137
+ /** {number, description} of the milestone titled `title`, or null. One `gh api` read. */
138
+ export async function readMilestone({ gh, repo, title }) {
139
+ const list = JSON.parse(String(await gh(["api", `repos/${repo}/milestones?state=all&per_page=100`])) || "[]");
140
+ const m = arr(list).find((x) => x?.title === title);
141
+ return m ? { number: m.number, description: m.description ?? "" } : null;
142
+ }
143
+
144
+ const defaultRunScript = (args, { cwd }) => new Promise((res, rej) => {
145
+ execFile("bash", [SCRIPT, ...args], { cwd, timeout: 60000, maxBuffer: 16 * 1024 * 1024 }, (e, stdout) => (e ? rej(e) : res(stdout)));
146
+ });
147
+
148
+ /**
149
+ * The orchestrate seam: `{ onMerged(issue, row, result), holds? }`. `config` is engine-cli `readDrift()` (null: the
150
+ * summary only, no script run, no issue, no hold). `holds` exists only with `config.blockDispatch`. Calls to onMerged
151
+ * are serialised, so parallel merges never race on the milestone description.
152
+ */
153
+ export function makeDrift({ repo, cwd, io, config = null, stagingBranch = "staging", affectedFiles = () => [], runScript = defaultRunScript, onEvent = () => {} }) {
154
+ const flagged = new Map(); // lane -> flagged package dirs (from the last report of that milestone)
155
+ const seen = new Set();
156
+ let chain = Promise.resolve();
157
+ const thresholds = config && ["pairGrowth", "packageLines", "fileLines"].some((k) => config[k] != null);
158
+
159
+ async function check(issue, row, result) {
160
+ const lane = String(result?.lane ?? row?.lane ?? "");
161
+ const title = result?.milestone;
162
+ if (!lane.startsWith("milestone/") || !title || !result?.structure) return null;
163
+ const slug = lane.slice("milestone/".length);
164
+ const ms = await readMilestone({ gh: io.gh, repo, title });
165
+ if (!ms) { onEvent({ issue, detail: `drift: milestone "${title}" not found` }); return null; }
166
+ const before = parseSummary(ms.description);
167
+ const summary = foldSummary(before ?? emptySummary(title), { issue, pr: result.pr ?? row?.pr ?? null, milestone: title, structure: result.structure });
168
+ if (summary !== before) await io.gh(["api", "-X", "PATCH", `repos/${repo}/milestones/${ms.number}`, "-f", `description=${renderSummary(ms.description, summary)}`]);
169
+ if (!thresholds) return { summary };
170
+ for (const b of [lane, stagingBranch]) { try { await io.git(["fetch", "origin", b], { cwd }); } catch { /* offline: the local refs */ } }
171
+ const dir = mkdtempSync(join(tmpdir(), "milestone-drift-")), file = join(dir, "summary.json");
172
+ let report;
173
+ try {
174
+ writeFileSync(file, JSON.stringify(summary));
175
+ const t = (flag, v) => (v != null ? [flag, String(v)] : []);
176
+ report = JSON.parse(String(await runScript([slug, "--summary", file, ...t("--pair-growth", config.pairGrowth), ...t("--package-lines", config.packageLines), ...t("--file-lines", config.fileLines)], { cwd })));
177
+ } finally { rmSync(dir, { recursive: true, force: true }); }
178
+ flagged.set(lane, flaggedPackages(report));
179
+ const filed = await fileCrossings({ gh: io.gh, repo, milestone: title, issues: crossingIssues(report, { slug, milestone: title }), seen });
180
+ if (filed.filed.length) onEvent({ issue, detail: `drift: filed ${filed.filed.map((f) => (f.number ? `#${f.number}` : f.marker)).join(", ")}` });
181
+ return { summary, report, ...filed };
182
+ }
183
+
184
+ const seam = {
185
+ onMerged(issue, row, result) {
186
+ const p = chain.then(() => check(issue, row, result));
187
+ chain = p.catch(() => {});
188
+ return p;
189
+ },
190
+ };
191
+ if (config?.blockDispatch) {
192
+ seam.holds = (issue, { body, lane }) => {
193
+ const f = flagged.get(lane);
194
+ return f?.length ? heldBy(affectedFiles(body), f) : null;
195
+ };
196
+ }
197
+ return seam;
198
+ }
@@ -0,0 +1,223 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ import { describe, it } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { writeFileSync } from "node:fs";
6
+ import { tmpDir } from "../tests/_tmp.mjs";
7
+ import { join } from "node:path";
8
+ import { foldSummary, emptySummary, parseSummary, renderSummary, crossingIssues, fileCrossings, flaggedPackages, heldBy, makeDrift } from "./drift.mjs";
9
+ import { orchestrate } from "./orchestrate.mjs";
10
+ import { readDrift } from "../engine-cli.mjs";
11
+ import { affectedFiles } from "../orchestrate-preflight.mjs";
12
+
13
+ const S1 = { deps_added: [{ from: "@s/web", to: "@s/core", kind: "dependencies", manifest: "apps/web/package.json" }], deps_removed: [],
14
+ new_edges: [{ from: "@s/web", to: "@s/db", file: "apps/web/a.ts", files: 2 }], removed_edges: [], top_growth: [{ file: "packages/db/big.ts", lines: 40 }], decisions: [] };
15
+ const S2 = { deps_added: [], deps_removed: [], new_edges: [{ from: "@s/web", to: "@s/db", file: "apps/web/c.ts", files: 1 }],
16
+ removed_edges: [{ from: "@s/web", to: "@s/old", files: 1 }], top_growth: [{ file: "packages/db/big.ts", lines: 10 }], decisions: [] };
17
+ const REPORT = {
18
+ milestone: "m1", fork_point: "0123456789abcdef",
19
+ pairs: [{ from: "@s/web", to: "@s/db", growth: 6, files_added: 6, files_removed: 0, files: ["apps/web/a.ts", "apps/web/c.ts"], prs: [21, 22] }],
20
+ files: [{ path: "packages/db/big.ts", added: 12000, removed: 0, growth: 12000, package: "@s/db" }],
21
+ packages: [{ name: "@s/db", path: "packages/db", added: 12000, removed: 0, growth: 12000 }],
22
+ crossings: [
23
+ { kind: "pair_growth", key: "@s/web->@s/db", value: 6, threshold: 5, package_path: "apps/web" },
24
+ { kind: "package_lines", key: "@s/db", value: 12000, threshold: 10000, package_path: "packages/db" },
25
+ ],
26
+ };
27
+
28
+ /** gh fake: one milestone, open issues, and a log of every call. */
29
+ function fakeGh({ description = "Ship the MCP server.", open = [] } = {}) {
30
+ const st = { description, open: [...open], calls: [], created: [] };
31
+ st.gh = async (a) => {
32
+ st.calls.push(a);
33
+ if (a[0] === "api" && /milestones\?state=all/.test(a[1])) return JSON.stringify([{ number: 7, title: "Other", description: "" }, { number: 3, title: "M One", description: st.description }]);
34
+ if (a[0] === "api" && a[1] === "-X" && a[2] === "PATCH") { st.description = a[a.indexOf("-f") + 1].slice("description=".length); return "{}"; }
35
+ if (a[0] === "issue" && a[1] === "list") return JSON.stringify(st.open);
36
+ if (a[0] === "issue" && a[1] === "create") {
37
+ const n = 900 + st.created.length;
38
+ st.created.push({ n, title: a[a.indexOf("--title") + 1], body: a[a.indexOf("--body") + 1], milestone: a[a.indexOf("--milestone") + 1] });
39
+ st.open.push({ number: n, body: a[a.indexOf("--body") + 1] });
40
+ return `https://github.com/o/r/issues/${n}\n`;
41
+ }
42
+ throw new Error(`unexpected gh ${a.join(" ")}`);
43
+ };
44
+ return st;
45
+ }
46
+ const fakeGit = () => { const calls = []; return { calls, git: async (a) => { calls.push(a); return ""; } }; };
47
+ const merged = (pr, structure = S1, lane = "milestone/m1") => ({ terminalReason: "merged", pr, structure, milestone: "M One", lane });
48
+
49
+ describe("drift summary fold (forge#4161)", () => {
50
+ it("folds structure records keyed by PR; a re-fold is a no-op", () => {
51
+ const a = foldSummary(emptySummary("M One"), { issue: 1, pr: 21, milestone: "M One", structure: S1 });
52
+ const b = foldSummary(a, { issue: 2, pr: 22, milestone: "M One", structure: S2 });
53
+ assert.deepEqual(b.merged, [{ issue: 1, pr: 21 }, { issue: 2, pr: 22 }]);
54
+ assert.deepEqual(b.pairs, { "@s/web:@s/db": { files_added: 3, files_removed: 0, prs: [21, 22] }, "@s/web:@s/old": { files_added: 0, files_removed: 1, prs: [] } });
55
+ assert.deepEqual(b.deps_added, [{ from: "@s/web", to: "@s/core", kind: "dependencies", pr: 21 }]);
56
+ assert.deepEqual(b.growth, { "packages/db/big.ts": 50 });
57
+ assert.equal(foldSummary(b, { issue: 2, pr: 22, structure: S2 }), b, "same PR: unchanged");
58
+ });
59
+
60
+ it("the description block round-trips and keeps the text around it byte for byte", () => {
61
+ const desc = "Line one.\r\n<!-- FORGE:MILESTONE_INDEX: https://gist.github.com/x -->\ntrailing ";
62
+ const s1 = foldSummary(emptySummary("M One"), { issue: 1, pr: 21, structure: { ...S1, top_growth: [{ file: "a-->b.ts", lines: 3 }] } });
63
+ const d1 = renderSummary(desc, s1);
64
+ assert.ok(d1.startsWith(`${desc}\n\n<!-- FORGE:DRIFT {`));
65
+ assert.equal((d1.split("-->").length - 1), 2, "a `-->` in a path never ends the comment");
66
+ assert.deepEqual(parseSummary(d1), s1);
67
+ const s2 = foldSummary(s1, { issue: 2, pr: 22, structure: S2 });
68
+ const d2 = renderSummary(d1, s2);
69
+ assert.equal(d2.slice(0, desc.length + 2), `${desc}\n\n`);
70
+ assert.deepEqual(parseSummary(d2), s2);
71
+ assert.equal((d2.match(/FORGE:DRIFT \{/g) ?? []).length, 1, "replaced, not appended");
72
+ assert.equal(parseSummary("no block"), null);
73
+ assert.equal(parseSummary("<!-- FORGE:DRIFT {bad} -->"), null);
74
+ assert.equal(renderSummary("", s1).startsWith("<!-- FORGE:DRIFT "), true);
75
+ });
76
+ });
77
+
78
+ describe("drift crossings (forge#4161)", () => {
79
+ it("one templated issue per crossing with numbers, files and a marker; flagged packages and holds", () => {
80
+ const issues = crossingIssues(REPORT, { slug: "m1", milestone: "M One" });
81
+ assert.deepEqual(issues.map((i) => i.marker), ["FORGE:DRIFT:m1:pair_growth:@s/web->@s/db", "FORGE:DRIFT:m1:package_lines:@s/db"]);
82
+ assert.equal(issues[0].title, "drift(milestone/m1): pair_growth @s/web->@s/db 6 > 5");
83
+ assert.match(issues[0].body, /`@s\/web->@s\/db` has 6 value-import files \(threshold 5\)/);
84
+ assert.match(issues[0].body, /- `apps\/web\/a\.ts`\n- `apps\/web\/c\.ts`\n- PRs: #21, #22/);
85
+ assert.match(issues[1].body, /- `packages\/db\/big\.ts` \+12000 -0/);
86
+ assert.ok(issues[1].body.endsWith("<!-- FORGE:DRIFT:m1:package_lines:@s/db -->"));
87
+ assert.deepEqual(flaggedPackages(REPORT), ["apps/web", "packages/db"]);
88
+ assert.equal(heldBy(["packages/db/src/x.ts"], ["packages/db"]), "packages/db");
89
+ assert.equal(heldBy(["packages/dbx/a.ts", "apps/webapp/a.ts"], ["packages/db", "apps/web"]), null, "a dir prefix only");
90
+ assert.deepEqual(crossingIssues({ ...REPORT, crossings: [] }, { slug: "m1", milestone: "M One" }), []);
91
+ });
92
+
93
+ it("files exactly one issue per new crossing; open drift issues and earlier filings are deduplicated", async () => {
94
+ const st = fakeGh({ open: [{ number: 5, body: "old\n<!-- FORGE:DRIFT:m1:package_lines:@s/db -->" }] });
95
+ const issues = crossingIssues(REPORT, { slug: "m1", milestone: "M One" });
96
+ const seen = new Set();
97
+ const r = await fileCrossings({ gh: st.gh, repo: "o/r", milestone: "M One", issues, seen });
98
+ assert.deepEqual(r, { filed: [{ marker: "FORGE:DRIFT:m1:pair_growth:@s/web->@s/db", number: 900 }], skipped: ["FORGE:DRIFT:m1:package_lines:@s/db"] });
99
+ assert.equal(st.created[0].milestone, "M One");
100
+ const again = await fileCrossings({ gh: st.gh, repo: "o/r", milestone: "M One", issues, seen });
101
+ assert.deepEqual(again.filed, []);
102
+ assert.equal(st.created.length, 1);
103
+ const none = await fileCrossings({ gh: st.gh, repo: "o/r", milestone: "M One", issues: [] });
104
+ assert.deepEqual(none, { filed: [], skipped: [] });
105
+ assert.equal(st.calls.filter((a) => a[0] === "issue" && a[1] === "list").length, 2, "no read without a crossing");
106
+ });
107
+ });
108
+
109
+ describe("makeDrift (forge#4161)", () => {
110
+ it("no config: the summary only (milestone GET + PATCH), no script, no fetch, no issue, no holds", async () => {
111
+ const st = fakeGh(), g = fakeGit(); let ran = 0;
112
+ const d = makeDrift({ repo: "o/r", cwd: "/x", io: { gh: st.gh, git: g.git }, config: null, runScript: async () => { ran++; return "{}"; } });
113
+ assert.equal(d.holds, undefined);
114
+ const r = await d.onMerged(1, { issue: 1, outcome: "merged", pr: 21 }, merged(21));
115
+ assert.deepEqual(parseSummary(st.description).merged, [{ issue: 1, pr: 21 }]);
116
+ assert.ok(st.description.startsWith("Ship the MCP server.\n\n"));
117
+ assert.equal(r.summary.pairs["@s/web:@s/db"].files_added, 2);
118
+ assert.deepEqual(st.calls.map((a) => `${a[0]} ${a[1]}`), ["api repos/o/r/milestones?state=all&per_page=100", "api -X"]);
119
+ assert.deepEqual([ran, g.calls.length], [0, 0]);
120
+ // same PR again: read only, no PATCH
121
+ await d.onMerged(1, { issue: 1, outcome: "merged", pr: 21 }, merged(21));
122
+ assert.equal(st.calls.filter((a) => a[2] === "PATCH").length, 1);
123
+ // staging lanes, results without structure or milestone: nothing
124
+ for (const res of [merged(23, S1, "staging"), { ...merged(24), structure: undefined }, { ...merged(25), milestone: undefined }]) assert.equal(await d.onMerged(3, {}, res), null);
125
+ assert.equal(st.calls.length, 3);
126
+ });
127
+
128
+ it("thresholds: runs the script with the summary and thresholds, files one issue per crossing once, flags packages", async () => {
129
+ const st = fakeGh(), g = fakeGit(); const runs = [];
130
+ const runScript = async (args) => {
131
+ const sum = JSON.parse((await import("node:fs")).readFileSync(args[args.indexOf("--summary") + 1], "utf8"));
132
+ runs.push({ args, merged: sum.merged.length });
133
+ return JSON.stringify(runs.length === 1 ? { ...REPORT, crossings: [] } : REPORT);
134
+ };
135
+ const d = makeDrift({ repo: "o/r", cwd: "/x", io: { gh: st.gh, git: g.git }, config: { pairGrowth: 5, packageLines: 10000, fileLines: null, blockDispatch: true }, stagingBranch: "staging", affectedFiles, runScript });
136
+ const below = await d.onMerged(1, {}, merged(21));
137
+ assert.deepEqual([below.filed, st.created.length], [[], 0], "below the thresholds nothing is filed");
138
+ assert.equal(st.calls.filter((a) => a[0] === "issue").length, 0, "and no issue read");
139
+ assert.deepEqual(runs[0].args.filter((x) => !x.includes("/")), ["m1", "--summary", "--pair-growth", "5", "--package-lines", "10000"]);
140
+ assert.deepEqual(g.calls, [["fetch", "origin", "milestone/m1"], ["fetch", "origin", "staging"]]);
141
+ const body = (files) => `## Affected Files\n${files.map((f) => `- \`${f}\``).join("\n")}\n`;
142
+ assert.equal(d.holds(9, { body: body(["packages/db/x.ts"]), lane: "milestone/m1" }), null, "no flag yet");
143
+ const r = await d.onMerged(2, {}, merged(22, S2));
144
+ assert.equal(runs[1].merged, 2, "the script sees the folded summary");
145
+ assert.deepEqual(r.filed.map((f) => f.number), [900, 901]);
146
+ assert.deepEqual(st.created.map((c) => c.title), ["drift(milestone/m1): pair_growth @s/web->@s/db 6 > 5", "drift(milestone/m1): package_lines @s/db 12000 > 10000"]);
147
+ await d.onMerged(3, {}, merged(23, S2));
148
+ assert.equal(st.created.length, 2, "a later merge with the same crossings files nothing");
149
+ assert.equal(d.holds(9, { body: body(["packages/db/x.ts"]), lane: "milestone/m1" }), "packages/db");
150
+ assert.equal(d.holds(9, { body: body(["packages/core/x.ts"]), lane: "milestone/m1" }), null, "another package");
151
+ assert.equal(d.holds(9, { body: body(["packages/db/x.ts"]), lane: "milestone/m2" }), null, "another milestone");
152
+ });
153
+
154
+ it("calls are serialised: parallel merges never lose a fold", async () => {
155
+ const st = fakeGh(); const slow = st.gh;
156
+ st.gh = async (a) => { await new Promise((r) => setTimeout(r, 5)); return slow(a); };
157
+ const d = makeDrift({ repo: "o/r", cwd: "/x", io: { gh: st.gh, git: async () => "" } });
158
+ await Promise.all([d.onMerged(1, {}, merged(21)), d.onMerged(2, {}, merged(22, S2)), d.onMerged(3, {}, merged(23))]);
159
+ assert.deepEqual(parseSummary(st.description).merged.map((m) => m.pr), [21, 22, 23]);
160
+ });
161
+ });
162
+
163
+ describe("orchestrate drift seam (forge#4161)", () => {
164
+ const lanesOf = (m) => async (n) => ({ ok: true, lane: m[n] ?? "staging" });
165
+
166
+ it("a merged milestone lane awaits onMerged before the batch ends; staging lanes and other outcomes do not", async () => {
167
+ const calls = [];
168
+ const drift = { onMerged: async (n, row, r) => { await new Promise((res) => setTimeout(res, 10)); calls.push([n, row.lane, r.pr]); } };
169
+ const rows = await orchestrate({ issues: [1, 2, 3], parallel: 3, readBody: async () => "", resolveLane: lanesOf({ 1: "milestone/m1", 3: "milestone/m1" }), drift,
170
+ runOne: async (n) => (n === 3 ? { terminalReason: "needs-human" } : merged(20 + n, S1, n === 1 ? "milestone/m1" : "staging")) });
171
+ assert.deepEqual(calls, [[1, "milestone/m1", 21]]);
172
+ assert.deepEqual(rows.map((r) => r.outcome), ["merged", "merged", "needs-human"]);
173
+ });
174
+
175
+ it("a drift failure never changes the outcome or stops the batch", async () => {
176
+ const seen = [];
177
+ const rows = await orchestrate({ issues: [1, 2], parallel: 1, readBody: async () => "", resolveLane: lanesOf({ 1: "milestone/m1", 2: "milestone/m1" }), onUpdate: (r) => seen.push(r),
178
+ drift: { onMerged: async () => { throw new Error("gh down"); } }, runOne: async (n) => merged(20 + n) });
179
+ assert.deepEqual(rows.map((r) => [r.issue, r.outcome]), [[1, "merged"], [2, "merged"]]);
180
+ assert.deepEqual(seen.filter((r) => r.outcome === "drift").map((r) => r.detail), ["drift check failed: gh down", "drift check failed: gh down"]);
181
+ });
182
+
183
+ it("without drift: identical rows and store shape", async () => {
184
+ const saves = [];
185
+ const run = (extra) => orchestrate({ issues: [1, 2], parallel: 2, readBody: async (n) => (n === 2 ? "Depends on #1" : ""), resolveLane: lanesOf({ 1: "milestone/m1", 2: "milestone/m1" }),
186
+ store: { load: () => null, save: (s) => saves.push(Object.keys(s).sort().join(",")) }, now: () => 0, runOne: async (n) => merged(20 + n), ...extra });
187
+ const a = await run({}); const keysA = [...saves]; saves.length = 0;
188
+ const b = await run({ drift: { onMerged: async () => {} } });
189
+ assert.deepEqual(a, b);
190
+ assert.deepEqual([...new Set(keysA)], [...new Set(saves)]);
191
+ });
192
+
193
+ it("block_dispatch: holds only queued issues whose Affected Files touch the flagged package; they end held", async () => {
194
+ const bodies = { 1: "", 2: "Depends on #1\n\n## Affected Files\n- `packages/db/src/x.ts`\n", 3: "Depends on #1\n\n## Affected Files\n- `packages/core/src/y.ts`\n" };
195
+ const st = fakeGh();
196
+ const d = makeDrift({ repo: "o/r", cwd: "/x", io: { gh: st.gh, git: async () => "" }, config: { packageLines: 10000, blockDispatch: true }, affectedFiles,
197
+ runScript: async () => JSON.stringify({ ...REPORT, crossings: [REPORT.crossings[1]] }) });
198
+ const ran = [];
199
+ const rows = await orchestrate({ issues: [1, 2, 3], parallel: 1, readBody: async (n) => bodies[n], resolveLane: lanesOf({ 1: "milestone/m1", 2: "milestone/m1", 3: "milestone/m1" }), drift: d,
200
+ runOne: async (n) => { ran.push(n); return merged(20 + n); } });
201
+ assert.deepEqual(ran, [1, 3]);
202
+ assert.deepEqual(rows.map((r) => [r.issue, r.outcome, r.detail ?? null]), [[1, "merged", null], [2, "held", "drift: packages/db flagged"], [3, "merged", null]]);
203
+ assert.equal(st.created.length, 1);
204
+ });
205
+ });
206
+
207
+ describe("readDrift (forge#4161)", () => {
208
+ const withYaml = (yaml, f) => {
209
+ const dir = tmpDir("drift-yaml-");
210
+ if (yaml != null) writeFileSync(join(dir, "forge.yaml"), yaml);
211
+ return f(dir);
212
+ };
213
+ it("null without forge.yaml, without the block, or with nothing set", () => {
214
+ assert.equal(withYaml(null, readDrift), null);
215
+ assert.equal(withYaml("orchestration:\n max_deferrals: 3\n", readDrift), null);
216
+ assert.equal(withYaml("orchestrate:\n drift:\n pair_growth: 0\n block_dispatch: false\n", readDrift), null);
217
+ });
218
+ it("reads the orchestrate.drift thresholds, block_dispatch and generated globs", () => {
219
+ const y = "orchestrate:\n drift:\n pair_growth: 5\n package_lines: 10000\n file_lines: 600\n block_dispatch: true\n generated_globs: data/*, *.lock\n";
220
+ assert.deepEqual(withYaml(y, readDrift), { pairGrowth: 5, packageLines: 10000, fileLines: 600, blockDispatch: true, generatedGlobs: ["data/*", "*.lock"] });
221
+ assert.deepEqual(withYaml("orchestrate:\n drift:\n file_lines: 600\n", readDrift), { pairGrowth: null, packageLines: null, fileLines: 600, blockDispatch: false, generatedGlobs: [] });
222
+ });
223
+ });