forgedock 1.27.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 (127) hide show
  1. package/README.md +4 -1
  2. package/bin/bootstrap.mjs +55 -0
  3. package/bin/engine/outcome-codes.mjs +10 -0
  4. package/bin/engine/telemetry-seam.mjs +37 -8
  5. package/bin/engine/telemetry-seam.test.mjs +31 -0
  6. package/bin/engine-cli.mjs +542 -38
  7. package/bin/engine.mjs +8 -4
  8. package/bin/forgedock.mjs +75 -9
  9. package/bin/gc.mjs +580 -0
  10. package/bin/lean/admission.mjs +33 -3
  11. package/bin/lean/batch.mjs +22 -4
  12. package/bin/lean/batch.test.mjs +5 -3
  13. package/bin/lean/checks.mjs +199 -0
  14. package/bin/lean/checks.test.mjs +183 -0
  15. package/bin/lean/conformance.mjs +190 -58
  16. package/bin/lean/conformance.test.mjs +116 -7
  17. package/bin/lean/credentials.mjs +29 -0
  18. package/bin/lean/depcache.mjs +312 -0
  19. package/bin/lean/depcache.test.mjs +298 -0
  20. package/bin/lean/drift.mjs +198 -0
  21. package/bin/lean/drift.test.mjs +223 -0
  22. package/bin/lean/effects.mjs +85 -8
  23. package/bin/lean/execution.e2e.test.mjs +81 -0
  24. package/bin/lean/execution.mjs +431 -0
  25. package/bin/lean/execution.test.mjs +583 -0
  26. package/bin/lean/heavy.mjs +289 -0
  27. package/bin/lean/heavy.test.mjs +207 -0
  28. package/bin/lean/lean.mjs +572 -139
  29. package/bin/lean/lean.test.mjs +1193 -31
  30. package/bin/lean/ledger.mjs +27 -2
  31. package/bin/lean/ledger.test.mjs +36 -1
  32. package/bin/lean/limits.mjs +58 -0
  33. package/bin/lean/limits.test.mjs +150 -0
  34. package/bin/lean/orchestrate.mjs +312 -19
  35. package/bin/lean/prompts.mjs +89 -15
  36. package/bin/lean/refactor.mjs +276 -0
  37. package/bin/lean/refactor.test.mjs +359 -0
  38. package/bin/lean/render.mjs +24 -5
  39. package/bin/lean/review-trigger.mjs +134 -0
  40. package/bin/lean/review-trigger.test.mjs +104 -0
  41. package/bin/lean/sandbox-provider.contract.test.mjs +257 -0
  42. package/bin/lean/sandbox-remote.mjs +142 -0
  43. package/bin/lean/sched.mjs +221 -0
  44. package/bin/lean/sched.test.mjs +310 -0
  45. package/bin/lean/schedd.mjs +247 -0
  46. package/bin/lean/schedd.test.mjs +180 -0
  47. package/bin/lean/toolchain.mjs +228 -0
  48. package/bin/lean/toolchain.test.mjs +154 -0
  49. package/bin/lean/verify.mjs +20 -9
  50. package/bin/lean/verify.test.mjs +26 -3
  51. package/bin/lean/worktree.mjs +69 -0
  52. package/bin/lean/worktree.test.mjs +141 -0
  53. package/bin/runner.mjs +53 -10
  54. package/bin/stats/github-source.mjs +1 -1
  55. package/bin/stats/index.mjs +14 -2
  56. package/bin/stats/query.mjs +72 -0
  57. package/bin/stats/stats.test.mjs +54 -0
  58. package/commands/audit.md +2 -0
  59. package/commands/autopilot.md +2 -0
  60. package/commands/bootstrap.md +35 -0
  61. package/commands/cleanup.md +7 -22
  62. package/commands/forge-stats.md +2 -1
  63. package/commands/forgedock-init.md +7 -0
  64. package/commands/issue.md +89 -830
  65. package/commands/milestone.md +55 -6
  66. package/commands/orchestrate.md +10 -2
  67. package/commands/upgrade-deps.md +3 -3
  68. package/commands/work-on/build/validate.md +4 -0
  69. package/commands/work-on/close.md +2 -0
  70. package/commands/work-on/decompose.md +24 -6
  71. package/docs/CI.md +3 -3
  72. package/docs/CONFIG.md +177 -2
  73. package/docs/OUTCOME-CODES.md +8 -0
  74. package/docs/TELEMETRY.md +1 -1
  75. package/docs/site/command-reference.md +22 -0
  76. package/docs/site/getting-started.md +2 -0
  77. package/docs/site/start-a-new-project.md +327 -0
  78. package/package.json +1 -1
  79. package/scripts/affected-files-check.sh +91 -0
  80. package/scripts/affected-files-check.test.sh +80 -0
  81. package/scripts/bootstrap-create.mjs +393 -0
  82. package/scripts/bootstrap-validate.mjs +36 -2
  83. package/scripts/calibration.mjs +116 -0
  84. package/scripts/check-devdocs-freshness.sh +9 -4
  85. package/scripts/check-devdocs-freshness.test.sh +11 -1
  86. package/scripts/check-spec-markers.sh +3 -0
  87. package/scripts/check-spec-size.sh +29 -11
  88. package/scripts/check-spec-size.test.sh +27 -4
  89. package/scripts/ci-local/lib.mjs +8 -1
  90. package/scripts/ci-local/parallelism.test.mjs +27 -0
  91. package/scripts/ci-local/run.mjs +3 -3
  92. package/scripts/ci-local/scoping.mjs +82 -0
  93. package/scripts/ci-local.test.sh +1 -1
  94. package/scripts/fixtures/symbol-index/other/sample.rb +3 -0
  95. package/scripts/fixtures/symbol-index/py/pkg/__init__.py +1 -0
  96. package/scripts/fixtures/symbol-index/py/pkg/a.py +5 -0
  97. package/scripts/fixtures/symbol-index/py/pkg/b.py +14 -0
  98. package/scripts/fixtures/symbol-index/py/pkg/c.py +1 -0
  99. package/scripts/fixtures/symbol-index/py/sample.py +41 -0
  100. package/scripts/fixtures/symbol-index/ts/sample.ts +13 -0
  101. package/scripts/fixtures/symbol-move/gen_big.py +201 -0
  102. package/scripts/fixtures/symbol-move-ts/gen_big.mjs +56 -0
  103. package/scripts/forge-root.test.sh +1 -1
  104. package/scripts/hotspots.sh +100 -0
  105. package/scripts/hotspots.test.sh +101 -0
  106. package/scripts/issue-create.sh +197 -0
  107. package/scripts/issue-create.test.sh +166 -0
  108. package/scripts/maintenance-scan.sh +185 -0
  109. package/scripts/maintenance-scan.test.sh +89 -0
  110. package/scripts/milestone-drift.sh +206 -0
  111. package/scripts/milestone-drift.test.sh +120 -0
  112. package/scripts/refactor-verify.sh +368 -0
  113. package/scripts/refactor-verify.test.sh +202 -0
  114. package/scripts/structure-delta.sh +10 -5
  115. package/scripts/structure-delta.test.sh +3 -2
  116. package/scripts/symbol-index.mjs +253 -0
  117. package/scripts/symbol-index.py +354 -0
  118. package/scripts/symbol-index.test.sh +119 -0
  119. package/scripts/symbol-move-ts.test.sh +161 -0
  120. package/scripts/symbol-move.mjs +379 -0
  121. package/scripts/symbol-move.py +383 -0
  122. package/scripts/symbol-move.test.sh +168 -0
  123. package/templates/bootstrap/interview.md +34 -0
  124. package/templates/bootstrap/issue-body.md +42 -0
  125. package/templates/bootstrap/needs-human-issue.md +45 -0
  126. package/templates/bootstrap/stack-profiles.md +34 -0
  127. package/templates/bootstrap/technical-spike.md +75 -0
@@ -0,0 +1,359 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ // refactor:split lane step (forge#4211): bin/lean/refactor.mjs against real fixture repos and the real scripts.
4
+ // The libcst cases resolve an interpreter like scripts/symbol-move.test.sh ($SYMBOL_MOVE_PYTHON, python3 with libcst,
5
+ // `uv run --no-project --with libcst python`) and skip without one; SYMBOL_MOVE_REQUIRE_LIBCST=1 makes that a failure.
6
+ import { describe, it } from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import { execFileSync, spawnSync } from "node:child_process";
9
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { tmpDir } from "../tests/_tmp.mjs";
13
+ import { OUTCOME_CODES } from "../engine/outcome-codes.mjs";
14
+ import { groupingPrompt, GROUPING_SYSTEM } from "./prompts.mjs";
15
+ import { noToolFlags } from "./effects.mjs";
16
+ import {
17
+ runSplitStep, defaultRun, compactIndex, slicePlan, parseSplitTarget, parseTestCmd, parseSplitPlan, parseGroupingReply,
18
+ renderChainIssue, renderSplitPrBody, SYMBOL_INDEX, SYMBOL_MOVE, REFACTOR_VERIFY, SPLIT_LABEL, SYMBOL_INDEX_MJS, SYMBOL_MOVE_TS,
19
+ } from "./refactor.mjs";
20
+
21
+ const GEN = fileURLToPath(new URL("../../scripts/fixtures/symbol-move/gen_big.py", import.meta.url));
22
+ const TEST_CMD = "python3 -m unittest discover -s tests -t .";
23
+ const ENV = { ...process.env, PYTHONDONTWRITEBYTECODE: "1" };
24
+
25
+ function libcstPython() {
26
+ const ok = (argv) => spawnSync(argv[0], [...argv.slice(1), "-c", "import libcst"], { stdio: "ignore", env: ENV }).status === 0;
27
+ if (process.env.SYMBOL_MOVE_PYTHON) return process.env.SYMBOL_MOVE_PYTHON.trim().split(/\s+/);
28
+ if (ok(["python3"])) return ["python3"];
29
+ const uv = ["uv", "run", "--no-project", "--with", "libcst", "python"];
30
+ return ok(uv) ? uv : null;
31
+ }
32
+ const PY = libcstPython();
33
+ const SKIP = PY ? false : "no python with libcst (set SYMBOL_MOVE_PYTHON)";
34
+ if (!PY && process.env.SYMBOL_MOVE_REQUIRE_LIBCST === "1") throw new Error("libcst required (SYMBOL_MOVE_REQUIRE_LIBCST=1) but not available");
35
+
36
+ const git = (dir, ...a) => execFileSync("git", ["-C", dir, "-c", "user.name=t", "-c", "user.email=t@example.invalid", ...a], { encoding: "utf8", env: ENV }).trim();
37
+ /** A git repo holding the ~3k-line fixture module pkg/big.py and its unittest suite, committed and tagged `base`. */
38
+ function fixtureRepo() {
39
+ const d = join(tmpDir("split-"), "repo");
40
+ mkdirSync(d, { recursive: true });
41
+ git(d, "init", "-q");
42
+ writeFileSync(join(d, ".gitignore"), "__pycache__/\n.forge-lean/\n");
43
+ execFileSync("python3", [GEN, d], { env: ENV });
44
+ git(d, "add", "-A"); git(d, "commit", "-qm", "base"); git(d, "tag", "base");
45
+ return d;
46
+ }
47
+ const gitFx = (d) => async (args) => git(d, ...args);
48
+ const clean = (d) => git(d, "status", "--porcelain");
49
+ const seq = (n, f) => Array.from({ length: n }, (_, i) => f(String(i).padStart(2, "0")));
50
+ const FULL = {
51
+ consts: [...seq(90, (i) => `LIMIT_${i}`), "_SEPARATOR"],
52
+ helpers: [...seq(90, (i) => [`_scale_${i}`, `compute_${i}`]).flat(), "join_parts"],
53
+ aio: seq(30, (i) => `fetch_${i}`),
54
+ models: seq(30, (i) => `Item_${i}`),
55
+ };
56
+ const BODY = "`pkg/big.py` has 3000 lines.\n\n<!-- FORGE:MAINT:refactor:split:pkg/big.py -->\n";
57
+
58
+ /** Records every grouping call; replies with `plan`. */
59
+ function grouper(plan) {
60
+ const calls = [];
61
+ return { calls, fn: async (prompt, opts) => { calls.push({ prompt, opts }); return `Here is the plan:\n${JSON.stringify(plan)}`; } };
62
+ }
63
+ /** defaultRun, except that calls whose argv contains `script` are answered by `fake(args, cwd)`. */
64
+ const intercept = (script, fake) => async (cmd, args, o) => (args.includes(script) ? fake(args, o.cwd) : defaultRun(cmd, args, o));
65
+
66
+ describe("refactor:split parsers and planning (forge#4211)", () => {
67
+ it("target, test command and chain plan come from the issue body", () => {
68
+ assert.equal(parseSplitTarget(BODY), "pkg/big.py");
69
+ assert.equal(parseSplitTarget("**Target**: `src/a.py`"), "src/a.py");
70
+ assert.equal(parseSplitTarget("nothing"), null);
71
+ assert.equal(parseTestCmd("**Test command**: `pytest -q tests`", ["npm test"]), "pytest -q tests");
72
+ assert.equal(parseTestCmd("x", ["ruff check .", "pytest"]), "ruff check . && pytest");
73
+ assert.equal(parseTestCmd("x", []), null);
74
+ const c = renderChainIssue({ parent: 9, target: "pkg/big.py", packageName: "big_parts", rest: { models: ["Item_00"] }, testCmd: TEST_CMD });
75
+ assert.deepEqual(c.labels, ["maintenance", SPLIT_LABEL]);
76
+ assert.match(c.body, /<!-- FORGE:MAINT:refactor:split:pkg\/big.py -->/);
77
+ assert.deepEqual(parseSplitPlan(c.body), { target: "pkg/big.py", package: "big_parts", plan: { models: ["Item_00"] } });
78
+ assert.equal(parseSplitTarget(c.body), "pkg/big.py");
79
+ assert.equal(parseTestCmd(c.body, []), TEST_CMD);
80
+ assert.equal(parseSplitPlan("<!-- FORGE:SPLIT-PLAN -->\n```json\n{\"package\":\"bad name\",\"plan\":{}}\n```"), null);
81
+ });
82
+
83
+ it("the grouping reply must be one {module:[symbols]} object over known symbols, each once", () => {
84
+ const names = ["a", "b", "c"];
85
+ assert.deepEqual(parseGroupingReply('ok ```json\n{"m1":["a"],"m2":["b","c"]}\n```', names), { m1: ["a"], m2: ["b", "c"] });
86
+ assert.equal(parseGroupingReply('{"m1":["a","a"]}', names), null);
87
+ assert.equal(parseGroupingReply('{"m1":["zzz"]}', names), null);
88
+ assert.equal(parseGroupingReply('{"a":["b"]}', names), null, "module name colliding with a symbol");
89
+ assert.equal(parseGroupingReply('{"bad-name":["a"]}', names), null);
90
+ assert.equal(parseGroupingReply("no json", names), null);
91
+ });
92
+
93
+ it("R4: slicePlan keeps whole modules under maxLines, dependencies first, and returns the rest", () => {
94
+ const index = { symbols: [{ name: "A", kind: "const", lines: 10, public: true }, { name: "f", kind: "function", lines: 500, public: true }, { name: "g", kind: "function", lines: 600, public: true }],
95
+ edges: [{ from: "f", to: "A" }, { from: "g", to: "f" }] };
96
+ const plan = { late: ["g"], mid: ["f"], base: ["A"] };
97
+ assert.deepEqual(slicePlan(plan, index, 600), { now: { base: ["A"], mid: ["f"] }, rest: { late: ["g"] }, lines: 510 });
98
+ assert.deepEqual(slicePlan(plan, index, 5).now, { base: ["A"] }, "the first module is always taken");
99
+ assert.equal(slicePlan(plan, index, 5000).rest, null);
100
+ });
101
+
102
+ it("R2/R5: the grouping input is the compact index only; its size does not follow the file size", () => {
103
+ const d = tmpDir("split-size-");
104
+ const mk = (n) => ["X = 1", "", "def f():", ...Array.from({ length: n }, (_, i) => ` v${i} = X + ${i} # padding line ${"x".repeat(40)}`), " return X", "", "class K:", " def m(self):", " return f()", ""].join("\n");
105
+ writeFileSync(join(d, "small.py"), mk(5)); writeFileSync(join(d, "big.py"), mk(500));
106
+ const idx = (f) => JSON.parse(execFileSync("python3", [SYMBOL_INDEX, join(d, f)], { encoding: "utf8" }));
107
+ const [a, b] = [compactIndex(idx("small.py")), compactIndex(idx("big.py"))];
108
+ assert.deepEqual(a.symbols.map((s) => s.name), ["X", "f", "K"], "top-level symbols only (no K.m)");
109
+ assert.deepEqual(a.edges, ["K->f", "f->X"]);
110
+ for (const s of a.symbols) assert.deepEqual(Object.keys(s).sort(), ["kind", "lines", "name", "public"]);
111
+ const [pa, pb] = [groupingPrompt(a), groupingPrompt(b)];
112
+ assert.ok(readFileSync(join(d, "big.py"), "utf8").length > 50 * readFileSync(join(d, "small.py"), "utf8").length);
113
+ assert.ok(Math.abs(pb.length - pa.length) <= 2 * a.symbols.length, `prompt grew ${pa.length} -> ${pb.length}`);
114
+ assert.doesNotMatch(pb, /padding line/);
115
+ assert.match(GROUPING_SYSTEM, /no tools/);
116
+ assert.deepEqual(noToolFlags({ strictMcp: true }), ["--tools", ""]);
117
+ assert.ok(noToolFlags({}).includes("Read") && noToolFlags({}).includes("Grep") && noToolFlags({}).includes("Glob"));
118
+ });
119
+ });
120
+
121
+ describe("refactor:split step on a fixture repo (forge#4211)", () => {
122
+ it("R2: the ~3k-line module becomes a package plus shim with identical test results; the LLM sees the index only", { skip: SKIP }, async () => {
123
+ const d = fixtureRepo();
124
+ const src = readFileSync(join(d, "pkg/big.py"), "utf8");
125
+ assert.ok(src.split("\n").length >= 2500);
126
+ const g = grouper(FULL);
127
+ const r = await runSplitStep({ wt: d, base: "base", issue: 42, issueBody: BODY, precheckCommands: [TEST_CMD], runTmp: tmpDir("split-run-"), python: PY, git: gitFx(d), callGrouper: g.fn });
128
+ assert.equal(r.ok, true, r.reason);
129
+ assert.equal(r.package, "pkg.big_parts");
130
+ assert.deepEqual(Object.keys(r.modules).sort(), ["aio", "consts", "helpers", "models"]);
131
+ assert.equal(r.rest, null);
132
+ assert.equal(r.grouped, true);
133
+ assert.deepEqual(r.verify, { failures: [], ok: true });
134
+ for (const f of ["__init__", "consts", "helpers", "aio", "models"]) assert.ok(existsSync(join(d, `pkg/big_parts/${f}.py`)), f);
135
+ assert.equal(clean(d), "", "the move is committed");
136
+ assert.match(git(d, "log", "-1", "--format=%s"), /^refactor\(split\): move 4 module\(s\) of pkg\/big.py into pkg.big_parts \(#42\)$/);
137
+ const after = spawnSync("bash", ["-c", TEST_CMD], { cwd: d, encoding: "utf8", env: ENV });
138
+ assert.equal(after.status, 0);
139
+ assert.match(after.stderr, /OK \(skipped=1\)/);
140
+ // the grouping call: exactly one, an empty cwd, and no line of the module body in its prompt
141
+ assert.equal(g.calls.length, 1);
142
+ const { prompt, opts } = g.calls[0];
143
+ assert.deepEqual(readdirSync(opts.cwd), [], "grouping cwd is an empty scratch dir");
144
+ for (const line of src.split("\n").map((l) => l.trim()).filter((l) => l.length > 20)) assert.ok(!prompt.includes(line), `prompt leaks a body line: ${line}`);
145
+ assert.doesNotMatch(prompt, /"start"|"end"|import functools/);
146
+ assert.match(renderSplitPrBody({ issue: 42, ...r }), /Closes #42$/);
147
+ });
148
+
149
+ it("R4: a large split is a chain; each PR moves one package slice, the next applies the carried plan with --extend and no LLM call", { skip: SKIP }, async () => {
150
+ const d = fixtureRepo();
151
+ const g = grouper(FULL);
152
+ const r1 = await runSplitStep({ wt: d, base: "base", issue: 1, issueBody: BODY, precheckCommands: [TEST_CMD], runTmp: tmpDir("split-run-"), python: PY, git: gitFx(d), callGrouper: g.fn, maxLines: 1200 });
153
+ assert.equal(r1.ok, true, r1.reason);
154
+ assert.ok(r1.rest, "a remainder is carried");
155
+ assert.ok(r1.lines <= 1200 || Object.keys(r1.modules).length === 1);
156
+ git(d, "tag", "pr1");
157
+ const chain = renderChainIssue({ parent: 1, target: r1.target, packageName: r1.packageName, rest: r1.rest, testCmd: TEST_CMD });
158
+ const g2 = grouper({});
159
+ const r2 = await runSplitStep({ wt: d, base: "pr1", issue: 2, issueBody: chain.body, precheckCommands: [], runTmp: tmpDir("split-run-"), python: PY, git: gitFx(d), callGrouper: g2.fn, maxLines: 100000 });
160
+ assert.equal(r2.ok, true, r2.reason);
161
+ assert.equal(g2.calls.length, 0, "a chained step makes no grouping call");
162
+ assert.equal(r2.package, r1.package, "the same target package");
163
+ assert.deepEqual(Object.keys(r2.modules).sort(), Object.keys(r1.rest).sort());
164
+ assert.equal(r2.rest, null);
165
+ assert.equal(g.calls.length, 1, "one grouping call for the whole split");
166
+ assert.equal(spawnSync("bash", ["-c", TEST_CMD], { cwd: d, env: ENV }).status, 0);
167
+ });
168
+
169
+ for (const [kind, mutate] of [
170
+ ["logic-diff", (d) => { const p = join(d, "pkg/big.py"); writeFileSync(p, readFileSync(p, "utf8").replace('VERSION = "1.0"', 'VERSION = "2.0"')); }],
171
+ ["missing-symbol", (d) => { const p = join(d, "pkg/big.py"); const s = readFileSync(p, "utf8"); const m = /\ndef describe\(\)[^\n]*\n(?: {4}[^\n]*\n)+/.exec(s); writeFileSync(p, s.replace(m[0], "\n")); mkdirSync(join(d, "pkg/big_parts")); writeFileSync(join(d, "pkg/big_parts/__init__.py"), ""); writeFileSync(join(d, "pkg/big_parts/misc.py"), m[0].slice(1)); }],
172
+ ["new-cycle", (d) => { writeFileSync(join(d, "pkg/cyc_a.py"), "import pkg.cyc_b\n"); writeFileSync(join(d, "pkg/cyc_b.py"), "import pkg.cyc_a\n"); }],
173
+ ]) {
174
+ it(`R3: verification fails the lane on a ${kind} and resets the worktree`, async () => {
175
+ const d = fixtureRepo();
176
+ const run = async (cmd, args, o) => {
177
+ if (args.includes("-c") && args.includes("import libcst")) return { code: 0, stdout: "", stderr: "" };
178
+ if (args.includes(SYMBOL_MOVE)) { mutate(d); return { code: 0, stdout: JSON.stringify({ status: "ok", package: "pkg.big_parts", modules: { misc: ["describe"] } }), stderr: "" }; }
179
+ return defaultRun(cmd, args, o);
180
+ };
181
+ const r = await runSplitStep({ wt: d, base: "base", issue: 3, issueBody: BODY, precheckCommands: [TEST_CMD], runTmp: tmpDir("split-run-"), git: gitFx(d), run, callGrouper: grouper(FULL).fn });
182
+ assert.equal(r.ok, false);
183
+ assert.equal(r.code, OUTCOME_CODES.SPLIT_VERIFY_FAILED);
184
+ assert.match(r.reason, new RegExp(`- ${kind}`));
185
+ assert.equal(clean(d), "", "no partial edit survives");
186
+ assert.equal(git(d, "rev-parse", "HEAD"), git(d, "rev-parse", "base"));
187
+ });
188
+ }
189
+
190
+ it("R6: a target with no mover (Go) is refused as needs-human material before anything runs", async () => {
191
+ const d = fixtureRepo();
192
+ mkdirSync(join(d, "src")); writeFileSync(join(d, "src/big.go"), "package big\n");
193
+ git(d, "add", "-A"); git(d, "commit", "-qm", "go");
194
+ const calls = [];
195
+ const r = await runSplitStep({ wt: d, base: "base", issueBody: "<!-- FORGE:MAINT:refactor:split:src/big.go -->", precheckCommands: [TEST_CMD], git: gitFx(d),
196
+ run: async (...a) => { calls.push(a); return defaultRun(...a); }, callGrouper: grouper(FULL).fn });
197
+ assert.deepEqual(r, { ok: false, code: OUTCOME_CODES.SPLIT_UNSUPPORTED, reason: "no codemod tool for go: `src/big.go` (the refactor:split movers support Python and TypeScript)" });
198
+ assert.equal(calls.length, 0);
199
+ assert.equal(clean(d), "");
200
+ });
201
+
202
+ it("R6: no libcst (probe or mover refusal) is split.unsupported with the reason and no partial edit", async () => {
203
+ const d = fixtureRepo();
204
+ const g = grouper(FULL);
205
+ const r = await runSplitStep({ wt: d, base: "base", issueBody: BODY, precheckCommands: [TEST_CMD], git: gitFx(d), python: ["python3", "-I", "-S"], callGrouper: g.fn });
206
+ assert.equal(r.code, OUTCOME_CODES.SPLIT_UNSUPPORTED);
207
+ assert.match(r.reason, /libcst is not importable/);
208
+ assert.equal(g.calls.length, 0, "refused before the grouping call");
209
+ // the mover itself refusing for want of libcst (after baseline and grouping) also leaves nothing behind
210
+ const run = async (cmd, args, o) => {
211
+ if (args.includes("-c") && args.includes("import libcst")) return { code: 0, stdout: "", stderr: "" };
212
+ if (args.includes(SYMBOL_MOVE)) return defaultRun("python3", ["-I", "-S", ...args.slice(args.indexOf(SYMBOL_MOVE))], o);
213
+ return defaultRun(cmd, args, o);
214
+ };
215
+ const r2 = await runSplitStep({ wt: d, base: "base", issueBody: BODY, precheckCommands: [TEST_CMD], runTmp: tmpDir("split-run-"), git: gitFx(d), run, callGrouper: grouper(FULL).fn });
216
+ assert.equal(r2.code, OUTCOME_CODES.SPLIT_UNSUPPORTED);
217
+ assert.match(r2.reason, /no codemod tool: libcst/);
218
+ assert.equal(clean(d), "");
219
+ assert.equal(git(d, "rev-parse", "HEAD"), git(d, "rev-parse", "base"));
220
+ });
221
+
222
+ it("R4/R5: a chained plan passes --package and --extend to the mover and makes no grouping call", async () => {
223
+ const d = fixtureRepo();
224
+ const body = renderChainIssue({ parent: 1, target: "pkg/big.py", packageName: "big_parts", rest: { models: FULL.models }, testCmd: TEST_CMD }).body;
225
+ let moverArgs = null;
226
+ const mover = intercept(SYMBOL_MOVE, (args) => { moverArgs = args; return { code: 3, stdout: JSON.stringify({ status: "refused", reason: "moved symbol Item_00 needs 'compute_00'" }), stderr: "" }; });
227
+ const run = async (cmd, args, o) => (args.includes("-c") && args.includes("import libcst") ? { code: 0, stdout: "", stderr: "" } : mover(cmd, args, o));
228
+ const g = grouper(FULL);
229
+ const r = await runSplitStep({ wt: d, base: "base", issueBody: body, precheckCommands: [], runTmp: tmpDir("split-run-"), git: gitFx(d), run, python: ["python3"], callGrouper: g.fn });
230
+ assert.equal(g.calls.length, 0);
231
+ assert.ok(moverArgs.includes("--extend"));
232
+ assert.equal(moverArgs[moverArgs.indexOf("--package") + 1], "big_parts");
233
+ assert.equal(r.code, OUTCOME_CODES.SPLIT_PLAN_INVALID, "a plan the mover refuses (not for libcst) is plan_invalid");
234
+ assert.match(r.reason, /symbol-move refused: moved symbol Item_00/);
235
+ assert.equal(clean(d), "");
236
+ });
237
+
238
+ it("an unparseable grouping reply is split.plan_invalid with no edit", async () => {
239
+ const d = fixtureRepo();
240
+ const run = async (cmd, args, o) => (args.includes("-c") && args.includes("import libcst") ? { code: 0, stdout: "", stderr: "" } : defaultRun(cmd, args, o));
241
+ const r = await runSplitStep({ wt: d, base: "base", issueBody: BODY, precheckCommands: [TEST_CMD], runTmp: tmpDir("split-run-"), git: gitFx(d), run, callGrouper: async () => "I cannot do that" });
242
+ assert.equal(r.code, OUTCOME_CODES.SPLIT_PLAN_INVALID);
243
+ assert.equal(clean(d), "");
244
+ });
245
+
246
+ it("the scripts the step calls exist", () => {
247
+ for (const p of [SYMBOL_INDEX, SYMBOL_MOVE, REFACTOR_VERIFY, SYMBOL_INDEX_MJS, SYMBOL_MOVE_TS]) assert.ok(existsSync(p), p);
248
+ });
249
+ });
250
+
251
+ // ---- forge#4212: TypeScript targets go through scripts/symbol-move.mjs (ts-morph) ----
252
+ // The end-to-end case needs SYMBOL_MOVE_TS_NODE_MODULES=<node_modules holding ts-morph> and skips without it.
253
+ const TS_GEN = fileURLToPath(new URL("../../scripts/fixtures/symbol-move-ts/gen_big.mjs", import.meta.url));
254
+ const TS_TEST_CMD = "env -u NODE_TEST_CONTEXT node --test test/big.test.ts"; // not a child of this node:test run
255
+ const TS_BODY = "`src/big.ts` is a hotspot.\n\n<!-- FORGE:MAINT:refactor:split:src/big.ts -->\n";
256
+ const TSM = process.env.SYMBOL_MOVE_TS_NODE_MODULES && existsSync(join(process.env.SYMBOL_MOVE_TS_NODE_MODULES, "ts-morph")) ? process.env.SYMBOL_MOVE_TS_NODE_MODULES : null;
257
+ const TS_PLAN = {
258
+ consts: seq(20, (i) => `LIMIT_${i}`),
259
+ helpers: seq(20, (i) => [`_scale_${i}`, `compute_${i}`]).flat(),
260
+ models: [...seq(20, (i) => `Item_${i}`), ...seq(20, (i) => `Store_${i}`)],
261
+ aio: seq(20, (i) => `fetch_${i}`),
262
+ };
263
+ /** A git repo holding the TS fixture src/big.ts and its node:test suite, committed and tagged `base`. */
264
+ function tsFixtureRepo() {
265
+ const d = join(tmpDir("split-ts-"), "repo");
266
+ mkdirSync(d, { recursive: true });
267
+ git(d, "init", "-q");
268
+ writeFileSync(join(d, ".gitignore"), "node_modules/\n.forge-lean/\n");
269
+ execFileSync(process.execPath, [TS_GEN, d], { env: ENV });
270
+ git(d, "add", "-A"); git(d, "commit", "-qm", "base"); git(d, "tag", "base");
271
+ return d;
272
+ }
273
+
274
+ describe("refactor:split TypeScript targets (forge#4212)", () => {
275
+ it("R3: ts-morph unavailable is split.unsupported `no codemod tool for ts`, before the grouping call, with no partial edit", async () => {
276
+ const d = tsFixtureRepo();
277
+ const g = grouper(TS_PLAN);
278
+ const home = tmpDir("split-ts-home-");
279
+ const r = await runSplitStep({ wt: d, base: "base", issueBody: TS_BODY, precheckCommands: [TS_TEST_CMD], runTmp: tmpDir("split-run-"), git: gitFx(d),
280
+ env: { NODE_PATH: "", HOME: home }, callGrouper: g.fn });
281
+ assert.equal(r.ok, false);
282
+ assert.equal(r.code, OUTCOME_CODES.SPLIT_UNSUPPORTED);
283
+ assert.match(r.reason, /^no codemod tool for ts: ts-morph is not resolvable/);
284
+ assert.equal(g.calls.length, 0, "refused before the grouping call");
285
+ assert.equal(clean(d), "");
286
+ assert.equal(git(d, "rev-parse", "HEAD"), git(d, "rev-parse", "base"));
287
+ assert.equal(existsSync(join(d, "src/big_parts")), false);
288
+ });
289
+
290
+ it("R1: a .ts target dispatches to node symbol-index.mjs and symbol-move.mjs; a ts-morph refusal from the mover resets the worktree", async () => {
291
+ const d = tsFixtureRepo();
292
+ const calls = [];
293
+ const index = { symbols: [{ name: "LIMIT_00", kind: "const", lines: 1, public: true }, { name: "compute_00", kind: "function", lines: 4, public: true }], edges: [], imports: [], cycles: [] };
294
+ const run = async (cmd, args, o) => {
295
+ calls.push({ cmd, args });
296
+ if (args.includes(SYMBOL_MOVE_TS) && args.includes("--probe")) return { code: 0, stdout: JSON.stringify({ status: "ok", tool: "ts-morph" }), stderr: "" };
297
+ if (args.includes(SYMBOL_INDEX_MJS)) return { code: 0, stdout: JSON.stringify(index), stderr: "" };
298
+ if (args.includes(REFACTOR_VERIFY)) return { code: 0, stdout: "{}", stderr: "" };
299
+ if (args.includes(SYMBOL_MOVE_TS)) {
300
+ writeFileSync(join(o.cwd, "src/partial.ts"), "export {};\n"); // whatever a mover leaves behind is reset
301
+ return { code: 3, stdout: JSON.stringify({ status: "refused", reason: "no codemod tool for ts: ts-morph is not resolvable from src" }), stderr: "" };
302
+ }
303
+ return defaultRun(cmd, args, o);
304
+ };
305
+ const r = await runSplitStep({ wt: d, base: "base", issueBody: TS_BODY, precheckCommands: [TS_TEST_CMD], runTmp: tmpDir("split-run-"), git: gitFx(d), run,
306
+ callGrouper: grouper({ consts: ["LIMIT_00"], helpers: ["compute_00"] }).fn });
307
+ assert.equal(r.code, OUTCOME_CODES.SPLIT_UNSUPPORTED);
308
+ assert.match(r.reason, /symbol-move refused: no codemod tool for ts/);
309
+ const idx = calls.find((c) => c.args.includes(SYMBOL_INDEX_MJS)), mv = calls.find((c) => c.args.includes(SYMBOL_MOVE_TS) && !c.args.includes("--probe"));
310
+ assert.equal(idx.cmd, process.execPath);
311
+ assert.deepEqual(idx.args, [SYMBOL_INDEX_MJS, "src/big.ts"]);
312
+ assert.equal(mv.cmd, process.execPath);
313
+ assert.deepEqual(mv.args.slice(0, 4), [SYMBOL_MOVE_TS, "--src", "src/big.ts", "--plan"]);
314
+ assert.ok(!calls.some((c) => c.args.includes(SYMBOL_INDEX) || c.args.includes(SYMBOL_MOVE) || c.args.includes("import libcst")), "no Python tool for a .ts target");
315
+ assert.equal(clean(d), "");
316
+ assert.equal(git(d, "rev-parse", "HEAD"), git(d, "rev-parse", "base"));
317
+ });
318
+
319
+ it("a plan the TS mover refuses is split.plan_invalid", async () => {
320
+ const d = tsFixtureRepo();
321
+ const run = async (cmd, args, o) => {
322
+ if (args.includes(SYMBOL_MOVE_TS)) return args.includes("--probe") ? { code: 0, stdout: "{}", stderr: "" } : { code: 3, stdout: JSON.stringify({ status: "refused", reason: "plan creates an import cycle: a -> b -> a" }), stderr: "" };
323
+ if (args.includes(SYMBOL_INDEX_MJS)) return { code: 0, stdout: JSON.stringify({ symbols: [{ name: "ping", kind: "function", lines: 3, public: true }, { name: "pong", kind: "function", lines: 3, public: true }], edges: [] }), stderr: "" };
324
+ if (args.includes(REFACTOR_VERIFY)) return { code: 0, stdout: "{}", stderr: "" };
325
+ return defaultRun(cmd, args, o);
326
+ };
327
+ const r = await runSplitStep({ wt: d, base: "base", issueBody: TS_BODY, precheckCommands: [TS_TEST_CMD], runTmp: tmpDir("split-run-"), git: gitFx(d), run, callGrouper: grouper({ a: ["ping"], b: ["pong"] }).fn });
328
+ assert.equal(r.code, OUTCOME_CODES.SPLIT_PLAN_INVALID);
329
+ assert.match(r.reason, /import cycle/);
330
+ assert.equal(clean(d), "");
331
+ });
332
+
333
+ it("R2: the TS module becomes modules plus a re-export barrel, refactor-verify ok, one grouping call", { skip: TSM ? false : "set SYMBOL_MOVE_TS_NODE_MODULES to a node_modules holding ts-morph" }, async () => {
334
+ const d = tsFixtureRepo();
335
+ const g = grouper(TS_PLAN);
336
+ const r = await runSplitStep({ wt: d, base: "base", issue: 7, issueBody: TS_BODY, precheckCommands: [TS_TEST_CMD], runTmp: tmpDir("split-run-"), git: gitFx(d),
337
+ env: { NODE_PATH: TSM }, callGrouper: g.fn });
338
+ assert.equal(r.ok, true, r.reason);
339
+ assert.equal(r.package, "src/big_parts");
340
+ assert.equal(r.packageName, "big_parts");
341
+ assert.equal(r.tool, "ts-morph");
342
+ assert.deepEqual(Object.keys(r.modules).sort(), ["aio", "consts", "helpers", "models"]);
343
+ assert.deepEqual(r.verify, { failures: [], ok: true });
344
+ assert.equal(g.calls.length, 1);
345
+ for (const m of ["consts", "helpers", "models", "aio"]) assert.ok(existsSync(join(d, `src/big_parts/${m}.ts`)), m);
346
+ assert.match(readFileSync(join(d, "src/big.ts"), "utf8"), /^export \{ .*\} from "\.\/big_parts\/helpers\.ts";$/m);
347
+ assert.equal(clean(d), "", "the move is committed");
348
+ assert.match(git(d, "log", "-1", "--format=%s"), /^refactor\(split\): move 4 module\(s\) of src\/big.ts into src\/big_parts \(#7\)$/);
349
+ const body = renderSplitPrBody({ issue: 7, ...r });
350
+ assert.match(body, /scripts\/symbol-move\.mjs` \(ts-morph\)/);
351
+ // a chained remainder carries the bare package name, which the TS mover takes as --package
352
+ assert.equal(parseSplitPlan(renderChainIssue({ parent: 7, target: r.target, packageName: r.packageName, rest: { more: ["x"] } }).body).package, "big_parts");
353
+ });
354
+
355
+ it("renderSplitPrBody names the mover by the target's language when the result carries no tool (a rerun)", () => {
356
+ assert.match(renderSplitPrBody({ issue: 1, target: "src/big.ts", package: null, modules: {}, verify: { ok: true }, rest: null, grouped: false }), /symbol-move\.mjs` \(ts-morph\)/);
357
+ assert.match(renderSplitPrBody({ issue: 1, target: "pkg/big.py", package: null, modules: {}, verify: { ok: true }, rest: null, grouped: false }), /symbol-move\.py` \(libcst\)/);
358
+ });
359
+ });
@@ -95,7 +95,9 @@ export function renderContract(c, hash, { compact = false } = {}) {
95
95
  const disp = (r) => `${r.disposition}${r.tracked ? ` → ${r.tracked}` : ["defer", "post-deploy"].includes(r.disposition) ? " (filed after review accepts it)" : ""}${r.why ? ` (${escl(r.why)})` : ""}`;
96
96
  const req = c.requirements.map((r) => `| ${esc(r.id)} | ${esc(r.quote)} | ${esc(disp(r))} |`).join("\n");
97
97
  const del = (c.deliverables ?? []).map((d) => `| ${esc(d.id)} | \`${esc(d.path)}\` | ${esc(d.change)} | ${esc((d.requirements ?? []).join(", "))} |`).join("\n");
98
- return `## Builder Contract (frozen \`${hash}\`)\n\n### Requirements\n| Id | From the issue | Disposition |\n| --- | --- | --- |\n${req}\n\n### Deliverables\n${del ? `| Id | Path | Change | Requirements |\n| --- | --- | --- | --- |\n${del}` : "None."}\n\n### Preserve\n${(c.preserve ?? []).map((p) => `- ${escl(p.id)}: ${escl(p.what)}`).join("\n") || "None."}\n\n### Validate\n${(c.validate ?? []).map((v) => `- ${escl(v.id)}: \`${escl(v.command).replace(/`/g, "'")}\``).join("\n") || NR}\n\n${json}`;
98
+ const dec = Array.isArray(c.decisions) && c.decisions.length // forge#4165: decision provenance
99
+ ? `\n\n### Decisions\n| Id | Choice | Justification | Source |\n| --- | --- | --- | --- |\n${c.decisions.map((k) => `| ${esc(k?.id)} | ${esc(k?.choice)} | ${esc(k?.justification)} | ${esc(k?.source)} |`).join("\n")}` : "";
100
+ return `## Builder Contract (frozen \`${hash}\`)\n\n### Requirements\n| Id | From the issue | Disposition |\n| --- | --- | --- |\n${req}\n\n### Deliverables\n${del ? `| Id | Path | Change | Requirements |\n| --- | --- | --- | --- |\n${del}` : "None."}\n\n### Preserve\n${(c.preserve ?? []).map((p) => `- ${escl(p.id)}: ${escl(p.what)}`).join("\n") || "None."}\n\n### Validate\n${(c.validate ?? []).map((v) => `- ${escl(v.id)}: \`${escl(v.command).replace(/`/g, "'")}\``).join("\n") || NR}${dec}\n\n${json}`;
99
101
  }
100
102
  /** {contract, hash, text} from a FORGE:CONTRACT comment body, or null. */
101
103
  export function parseContractComment(body) {
@@ -120,12 +122,12 @@ export function renderReview(rv, contract) {
120
122
  }
121
123
 
122
124
  /** FORGE:REVIEW in shadow mode: what the enforced gate would have done, without enforcing it. */
123
- export function renderShadowReview({ would, failures = [], review, contract, unfiled = [] }) {
125
+ export function renderShadowReview({ would, failures = [], review, contract, degraded = null }) {
124
126
  const head = would === "conforms" ? "would PASS" : `would BLOCK (${escl(would)})`;
125
127
  const rows = review ? `\n\n${renderReview(review, contract).split("\n").slice(2).join("\n")}` : "";
126
128
  const fl = failures.length ? `\n\n**What the enforced gate would have flagged:**\n${failures.slice(0, 30).map((f) => `- \`${escl(f.code)}\`: ${escl(f.detail)}`).join("\n")}` : "";
127
- const uf = unfiled.length ? `\n\n**Deferrals not filed in shadow mode:** ${unfiled.map((r) => `${escl(r.id)} (${escl(r.disposition)})`).join(", ")}` : "";
128
- return `## Conformance Review (shadow mode: not enforced)\n**Gate verdict:** ${head}. Only a requirement found not implemented, or a regression, blocks in shadow mode.${fl}${uf}${rows}`;
129
+ const dg = degraded ? `\n\n**Review degraded:** the review stayed incomplete after the targeted re-ask; merged with label \`review-degraded\`, follow-up ${escl(degraded)}.` : "";
130
+ return `## Conformance Review (shadow mode: not enforced)\n**Gate verdict:** ${head}. In shadow mode a requirement not implemented (found by the review or the precheck), a regression, or a review without complete rows blocks; accepted deferrals are filed as follow-ups.${fl}${dg}${rows}`;
129
131
  }
130
132
 
131
133
  // ---- PR body ----
@@ -140,11 +142,28 @@ export function renderPrBody({ issue, investigation, contract, implementation, r
140
142
  }
141
143
 
142
144
  // ---- trajectory ----
145
+ /**
146
+ * forge#4165: the merged change's structure delta as a compact table, or "" without one. `s` is the STRUCTURE_DELTA
147
+ * record built at merge: deps_added [{from,to,manifest}], new_edges [{from,to,file}], removed_edges [{from,to}],
148
+ * top_growth [{file,lines}], decisions [{id,source}].
149
+ */
150
+ export function renderStructure(s) {
151
+ if (!s || typeof s !== "object") return "";
152
+ const list = (v, fn) => (Array.isArray(v) && v.length ? v.slice(0, 10).map(fn).join(", ") + (v.length > 10 ? `, +${v.length - 10} more` : "") : "none");
153
+ const rows = [
154
+ ["Package deps added", list(s.deps_added, (e) => `${e.from} → ${e.to}${e.manifest ? ` (${e.manifest})` : ""}`)],
155
+ ["Import edges added", list(s.new_edges, (e) => `${e.from} → ${e.to}${e.file ? ` (${e.file})` : ""}`)],
156
+ ["Import edges removed", list(s.removed_edges, (e) => `${e.from} → ${e.to}`)],
157
+ ["Top growth", list(s.top_growth, (g) => `${g.file} +${g.lines}`)],
158
+ ["Decision sources", list(s.decisions, (k) => `${k.id} ${k.source}`)],
159
+ ];
160
+ return `**Structure**\n| Delta | Value |\n| --- | --- |\n${rows.map(([k, v]) => `| ${k} | ${esc(v)} |`).join("\n")}\n\n`;
161
+ }
143
162
  export function renderTrajectory(T) {
144
163
  const rows = [["Validate", T.validate], ["Plan", T.plan], ["Implement", T.implement], ...(T.verify ? [["Verify", T.verify]] : []), ["Review", T.review], ["PR", T.pr], ["Base Sync", T.baseSync ?? "not needed"], ["Merge", T.merge], ["Close", T.close]];
145
164
  const cellPair = (v) => {
146
165
  const [res, notes] = Array.isArray(v) ? v : [v, ""];
147
166
  return `${cell(res ?? "not reached")} | ${notes ? cell(notes) : "-"}`;
148
167
  };
149
- return `## Pipeline Trajectory — #${T.issue}\n\n| Phase | Result | Notes |\n| --- | --- | --- |\n${rows.map(([p, v]) => `| ${p} | ${cellPair(v)} |`).join("\n")}\n\n**Decisions**\n${(T.decisions ?? []).length ? T.decisions.map((d) => `- ${line(d)}`).join("\n") : "- none"}\n\n**Anomalies**\n${(T.anomalies ?? []).length ? T.anomalies.map((d) => `- ${line(d)}`).join("\n") : "- none"}\n\n${T.usage ? `_${T.usage}_` : ""}`;
168
+ return `## Pipeline Trajectory — #${T.issue}\n\n| Phase | Result | Notes |\n| --- | --- | --- |\n${rows.map(([p, v]) => `| ${p} | ${cellPair(v)} |`).join("\n")}\n\n${renderStructure(T.structure)}**Decisions**\n${(T.decisions ?? []).length ? T.decisions.map((d) => `- ${line(d)}`).join("\n") : "- none"}\n\n**Anomalies**\n${(T.anomalies ?? []).length ? T.anomalies.map((d) => `- ${line(d)}`).join("\n") : "- none"}\n\n${T.usage ? `_${T.usage}_` : ""}`;
150
169
  }
@@ -0,0 +1,134 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Deterministic review trigger (#4273, #4199 "Judge only on a risk trigger"). The conformance reviewer is needed when
5
+ * the diff touches a risk path, adds a package edge (#4159), changes an over-threshold file (#4195), or a deterministic
6
+ * gate failed or could not answer. Otherwise the review would be skipped. forge.yaml `review.trigger`: shadow (default:
7
+ * log REVIEW_TRIGGER, always review), enforce (skip the reviewer when not triggered), off (today's behaviour).
8
+ * Run directly, it reports per repo how many would-skip lanes the reviewer flagged as nonconforming.
9
+ */
10
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import { matchesGlob } from "./verify.mjs";
15
+
16
+ /** The engine run-log dir (same rule as bin/stats/local-source.mjs runsDir). */
17
+ const runsDir = (env = process.env) => env.FORGE_RUNS_DIR || join(homedir(), ".forge", "runs");
18
+
19
+ export const TRIGGER_MODES = Object.freeze(["shadow", "enforce", "off"]);
20
+
21
+ /** Built-in risk words, matched against a path's word tokens (camelCase, "/", ".", "-", "_" split them). */
22
+ export const DEFAULT_RISK_PATTERNS = Object.freeze({
23
+ auth: ["auth", "authn", "authz", "oauth", "login", "logout", "session", "sessions", "permission", "permissions", "rbac", "acl"],
24
+ payments: ["payment", "payments", "billing", "checkout", "stripe", "invoice", "invoices", "subscription", "subscriptions"],
25
+ security: ["security", "crypto", "secret", "secrets", "credential", "credentials", "token", "tokens"],
26
+ deletion: ["delete", "deletion", "purge", "gdpr", "erase"],
27
+ migrations: ["migration", "migrations", "migrate", "alembic"],
28
+ });
29
+ const RISK_WORDS = new Map(Object.entries(DEFAULT_RISK_PATTERNS).flatMap(([k, ws]) => ws.map((w) => [w, k])));
30
+
31
+ /** Word tokens of a path: "src/AuthService/user_delete.ts" -> src, auth, service, user, delete, ts. */
32
+ const tokens = (p) => String(p).replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
33
+
34
+ /** The built-in risk class a path hits, or null. */
35
+ export function riskClassOf(path) {
36
+ for (const t of tokens(path)) if (RISK_WORDS.has(t)) return RISK_WORDS.get(t);
37
+ return null;
38
+ }
39
+
40
+ /** forge.yaml review.risk_paths: a comma-separated string or a list -> trimmed non-empty globs. */
41
+ export function parseRiskPaths(v) {
42
+ const xs = Array.isArray(v) ? v : typeof v === "string" ? v.split(",") : [];
43
+ return xs.map((x) => String(x ?? "").trim()).filter(Boolean);
44
+ }
45
+
46
+ /**
47
+ * {would_skip, reasons:[{code, detail}]} from facts only. Codes: risk_path, package_edge, hotspot, gate.failed,
48
+ * gate.ambiguous. `hotspots` null/undefined means the facts were unavailable (ambiguous); {enabled:false} is an answer.
49
+ * gates: {precheckFailures[], checkCommandsFailing, ciFellBack}. Reasons are sorted, so equal input gives equal output.
50
+ */
51
+ export function computeTrigger({ files = [], riskPaths = [], structure = null, hotspots, gates = {}, contract = null } = {}) {
52
+ const reasons = [];
53
+ const add = (code, detail) => reasons.push({ code, detail });
54
+ const globs = parseRiskPaths(riskPaths);
55
+ for (const f of [...new Set(files.map(String))].sort()) {
56
+ const cls = riskClassOf(f);
57
+ if (cls) add("risk_path", `${f} (${cls})`);
58
+ else { const g = globs.find((x) => matchesGlob(f, x)); if (g) add("risk_path", `${f} (review.risk_paths ${g})`); }
59
+ }
60
+ for (const e of Array.isArray(structure?.package_edges_added) ? structure.package_edges_added : []) add("package_edge", `${e?.from} → ${e?.to}`);
61
+ if (hotspots == null) add("gate.ambiguous", "hotspot facts unavailable");
62
+ else if (hotspots.enabled !== false && Array.isArray(hotspots.files)) {
63
+ const changed = new Set(files.map(String));
64
+ for (const h of hotspots.files) if (h?.over === true && h.excluded !== true && changed.has(String(h.path))) add("hotspot", `${h.path} (${h.lines} lines)`);
65
+ }
66
+ const pre = Array.isArray(gates.precheckFailures) ? gates.precheckFailures : [];
67
+ for (const c of [...new Set(pre.map((f) => String(f?.code ?? f)))].sort()) add("gate.failed", `precheck ${c}`);
68
+ if (gates.checkCommandsFailing) add("gate.failed", "declared check commands still failing");
69
+ if (gates.ciFellBack) add("gate.failed", "CI verification unavailable (local fallback)");
70
+ const tracked = (contract?.requirements ?? []).filter((r) => r?.disposition === "defer" || r?.disposition === "post-deploy").map((r) => r.id);
71
+ if (tracked.length) add("gate.ambiguous", `contract defers ${tracked.sort().join(", ")} (only the reviewer judges deferrals)`);
72
+ reasons.sort((a, b) => a.code.localeCompare(b.code) || a.detail.localeCompare(b.detail));
73
+ return { would_skip: reasons.length === 0, reasons };
74
+ }
75
+
76
+ /**
77
+ * Per repo, over run logs (one event array per log; the slice from the last RUN_START counts): runs with a
78
+ * REVIEW_TRIGGER, would-skip runs (their last REVIEW_TRIGGER), how many of those the reviewer flagged nonconforming
79
+ * (a CONFORMANCE event with state "nonconforming"), and enforce skips.
80
+ */
81
+ export function triggerReport(logs = []) {
82
+ const out = {};
83
+ for (const events of logs) {
84
+ if (!Array.isArray(events)) continue;
85
+ let s = -1;
86
+ events.forEach((e, i) => { if (e?.event === "RUN_START") s = i; });
87
+ if (s < 0) continue;
88
+ const ev = events.slice(s);
89
+ const trig = ev.filter((e) => e?.event === "REVIEW_TRIGGER");
90
+ if (!trig.length) continue;
91
+ const repo = typeof ev[0].repo === "string" && ev[0].repo ? ev[0].repo : "unknown";
92
+ const r = (out[repo] ??= { runs: 0, would_skip: 0, would_skip_flagged: 0, skipped: 0 });
93
+ r.runs++;
94
+ if (trig.some((e) => e.skipped === true)) r.skipped++;
95
+ if (trig.at(-1).would_skip !== true) continue;
96
+ r.would_skip++;
97
+ if (ev.some((e) => e?.event === "CONFORMANCE" && e.state === "nonconforming")) r.would_skip_flagged++;
98
+ }
99
+ return out;
100
+ }
101
+
102
+ function parseLog(text) {
103
+ const out = [];
104
+ for (const line of text.split("\n")) {
105
+ if (!line.trim()) continue;
106
+ try { const e = JSON.parse(line); if (e && typeof e === "object") out.push(e); } catch { /* truncated or corrupt line */ }
107
+ }
108
+ return out;
109
+ }
110
+
111
+ /** Every *.jsonl run log in `dir`; a missing dir is no runs. */
112
+ export function readRunLogs(dir = runsDir()) {
113
+ if (!existsSync(dir)) return [];
114
+ const logs = [];
115
+ for (const f of readdirSync(dir).sort()) {
116
+ if (!f.endsWith(".jsonl")) continue;
117
+ try { logs.push(parseLog(readFileSync(join(dir, f), "utf8"))); } catch { /* unreadable log */ }
118
+ }
119
+ return logs;
120
+ }
121
+
122
+ export function formatReport(report) {
123
+ const rows = Object.entries(report).sort(([a], [b]) => a.localeCompare(b));
124
+ if (!rows.length) return "No REVIEW_TRIGGER runs found.";
125
+ return ["| Repo | Runs | Would skip | Would skip, reviewer flagged | Skipped (enforce) |", "| --- | --- | --- | --- | --- |",
126
+ ...rows.map(([repo, r]) => `| ${repo} | ${r.runs} | ${r.would_skip} | ${r.would_skip_flagged} | ${r.skipped} |`)].join("\n");
127
+ }
128
+
129
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
130
+ const argv = process.argv.slice(2);
131
+ const i = argv.indexOf("--dir");
132
+ const report = triggerReport(readRunLogs(i >= 0 && argv[i + 1] ? argv[i + 1] : runsDir()));
133
+ process.stdout.write((argv.includes("--json") ? JSON.stringify(report, null, 2) : formatReport(report)) + "\n");
134
+ }