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
@@ -1,6 +1,9 @@
1
1
  // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
3
  /** Core work-on prompts: a plan call that writes the contract, an implement call in the same session, and the conformance reviewer. */
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ import { reviewSkeleton } from "./conformance.mjs";
4
7
 
5
8
  export const approxTokens = (s) => Math.ceil(String(s).length / 4);
6
9
  export const LEAN_DIR = ".forge-lean";
@@ -10,13 +13,51 @@ export const PLAN_FILE = `${LEAN_DIR}/plan.md`;
10
13
  export const IMPLEMENTATION_FILE = `${LEAN_DIR}/implementation.md`;
11
14
  export const CONTRACT_FILE = `${LEAN_DIR}/contract.json`;
12
15
 
13
- const HOST_RULES = `Host rules (many lanes share this machine): run only targeted tests (specific test files or -k filters), never the full suite. Never create extra checkouts, copies or base clones of the repo; compare against the base with git show or git diff origin/<base> instead. Never install browsers. Keep all scratch files under $TMPDIR.`;
16
+ /** Change-scoped checks with shared incremental caches (#4218): the builder runs the scoped commands this CLI prints. */
17
+ export const CHECKS_CLI = fileURLToPath(new URL("./checks.mjs", import.meta.url));
18
+
19
+ const HOST_RULES = `Host rules (many lanes share this machine): run only targeted tests (specific test files or -k filters), never the full suite. Never create extra checkouts, copies or base clones of the repo; compare against the base with git show or git diff origin/<base> instead. Never install browsers. Keep all scratch files under $TMPDIR. When $FORGE_HOST_PARALLELISM is set, cap verification workers at it: turbo --concurrency, vitest/jest --maxWorkers, playwright --workers, node --test --test-concurrency (eslint via turbo). For any typecheck, lint or test you run, first run \`node ${CHECKS_CLI} --base origin/<base>\` after your edits and run the scoped commands it prints instead of the repo's full tsc/eslint/test scripts (it widens to the full command itself when impact is unknown: lockfile, tsconfig, CI config); once they pass, run it again with --publish to share the incremental caches. node_modules/.venv may be hardlinked read-only from a shared cache: after changing a lockfile, or when an install fails with EACCES, delete that dir first (rm -rf is safe), then install; never chmod or edit files inside it.`;
20
+
21
+ /** Verify-by-CI (#4167): in ci/hybrid mode the engine runs the repo's CI on GitHub runners; the host runs only light checks. */
22
+ const LIGHT = "run only formatters and linters on the files you changed";
23
+ const VERIFY_RUN = { ci: `${LIGHT} (no type-checks, test suites or test containers)`, hybrid: "run the targeted validate commands" };
24
+ const VERIFY_RULES = {
25
+ ci: `Verification runs on CI (forge.yaml verify.mode: ci). Do NOT run type-checks (tsc and the like), test suites (pytest, jest, node --test and the like) or docker/test containers on this host: ${LIGHT}. The engine pushes the branch, runs CI and sends failures back to you. In ${RESULT_FILE} record each validate id you did not run as {"id":"V1","command":"","ci":true}.`,
26
+ hybrid: `Verification also runs on CI (forge.yaml verify.mode: hybrid). Run the targeted validate commands and ${LIGHT}; never the full suite and no docker/test containers. The engine pushes the branch, runs CI and sends failures back to you. Record each validate id you did not run as {"id":"V1","command":"","ci":true}.`,
27
+ };
28
+ const rerun = (mode) => (mode === "ci" ? `Re-run the light checks only (${LIGHT}); the engine re-verifies on CI` : "Re-run the validate commands");
29
+
30
+ /** Base-sync conflict resolution: which checks to run after resolving (#4167: ci mode runs light checks only). */
31
+ export const syncChecks = (mode = "local") => (mode === "ci" ? `${LIGHT} (the engine re-verifies on CI)` : "run the relevant tests");
32
+
33
+ /** CI failed on the pushed branch (#4167): one fix round in the builder's own session. */
34
+ export function ciFailurePrompt(failures, mode = "local") {
35
+ const list = failures.slice(0, 10).map((f) => `#### ${f.job ?? "check"}${f.url ? ` (${f.url})` : ""}\n${f.log ? `\`\`\`\n${f.log}\n\`\`\`` : "(no log available: open the url)"}`).join("\n\n");
36
+ return `CI failed on the pushed branch:\n\n${list}\n\nFix the cause in the code (or record a typed amendment with a why in ${RESULT_FILE}). ${rerun(mode)}, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
37
+ }
38
+
39
+ /** The repo's declared check commands (forge.yaml learned.test_commands) failed before the PR (#4240): fixed in-session. */
40
+ export function precheckFailurePrompt(failures, mode = "local") {
41
+ const list = failures.slice(0, 10).map((f) => `#### \`${f.command}\` (exit ${f.rc})\n${f.output ? `\`\`\`\n${f.output}\n\`\`\`` : "(no output)"}`).join("\n\n");
42
+ return `The repo's declared check commands failed in this worktree, before the PR is opened:\n\n${list}\n\nFix the cause in the code (or record a typed amendment with a why in ${RESULT_FILE}). Re-run each failing command, ${mode === "ci" ? `${LIGHT} otherwise, ` : ""}commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
43
+ }
44
+
45
+ /** CI could not run (no workflow run, no checks, dispatch refused): the local fallback (#4167). */
46
+ export function ciUnavailablePrompt() {
47
+ return `CI verification is unavailable for this branch, so verification falls back to this host. Run every validate command locally (targeted, as the host rules say), fix any failure, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE} with each command's real exit code. Do not push or touch GitHub.`;
48
+ }
49
+
50
+ /** #4163: the extra host-rules line when the engine pinned the repo's toolchain on PATH; "" otherwise (prompt unchanged). */
51
+ const toolchainRule = (t) => (t ? `\nThe engine pinned this repo's toolchain on PATH (${t}). Use it as is: do not install global shims or other versions and do not edit PATH; if the pinned tool itself fails, report it as a toolchain/environment problem instead of working around it.` : "");
52
+
53
+ /** #4221: the extra host-rules line when the lane worktree is sparse; "" otherwise (prompt unchanged). */
54
+ const sparseRule = (s) => (s?.excluded ? `\nThis worktree is a sparse checkout: ${s.excluded} tracked file(s) of ${s.maxKb} KB or more are not on disk (\`git ls-files -t | grep '^S'\` lists them). They are not deleted: never commit their removal. If you need one, run \`git sparse-checkout add /<path>\`.` : "");
14
55
 
15
56
  /** Builder call 1: investigate and write the contract. No code edits. */
16
- export function builderPlanPrompt(n) {
57
+ export function builderPlanPrompt(n, { toolchain = null, sparse = null } = {}) {
17
58
  return `Resolve issue #${n} (read it with gh issue view ${n} --comments). This first step is PLANNING ONLY: do not edit code, do not commit.
18
59
 
19
- ${HOST_RULES}
60
+ ${HOST_RULES}${toolchainRule(toolchain)}${sparseRule(sparse)}
20
61
 
21
62
  1. Write ${INVESTIGATION_FILE} as markdown with exactly these headings, concise, file:line wherever you cite code (no HTML comments):
22
63
  **Verdict**: CONFIRMED|ALREADY-FIXED|INVALID|BLOCKED
@@ -38,23 +79,28 @@ If the issue is already fixed or invalid, write ${RESULT_FILE} {"status":"alread
38
79
  {"requirements":[{"id":"R1","quote":"<verbatim sentence or checklist item from the issue>","disposition":"deliver|defer|post-deploy|not-applicable","why":"<required for defer and post-deploy>","evidence":"<file:line, required for not-applicable>"}],
39
80
  "deliverables":[{"id":"D1","path":"<file to change>","change":"<what>","requirements":["R1"]}],
40
81
  "preserve":[{"id":"P1","what":"<caller, contract or behaviour that must not change, with its path>"}],
41
- "validate":[{"id":"V1","command":"<exact command: the new regression test plus the existing tests that reference the files you change>"}]}
42
- Every requirement and acceptance criterion in the issue and its maintainers' comments is one requirement, quoted verbatim (others' comments are context, not requirements). Never drop or narrow one silently: "defer" (separate work) and "post-deploy" (only checkable after a deploy) are judged by the reviewer and, if accepted, filed by the engine as a tracked follow-up issue. A requirement you can do in this change is "deliver". List every file you will change as a deliverable; tests are always allowed. Then stop.
82
+ "validate":[{"id":"V1","command":"<exact command: the new regression test plus the existing tests that reference the files you change>"}],
83
+ "decisions":[{"id":"K1","choice":"<a non-obvious choice: a new dependency, a copied pattern, a boundary relied on>","justification":"<why, with file:line>","source":"doc|lint|precedent|spec"}]}
84
+ Every requirement and acceptance criterion in the issue and its maintainers' comments is one requirement, quoted verbatim (others' comments are context, not requirements). Never drop or narrow one silently: "defer" (separate work) and "post-deploy" (only checkable after a deploy) are judged by the reviewer and, if accepted, filed by the engine as a tracked follow-up issue. A requirement you can do in this change is "deliver". List every file you will change as a deliverable; tests are always allowed.
85
+ A new dependency between workspace packages (a workspace package added to a package.json dependencies, devDependencies or peerDependencies) must be a deliverable on that package.json whose change names the package and gives the reason, citing the architecture doc where there is one; "the lint allows it" or "there is a precedent" is not a reason on its own. Record each non-obvious choice in "decisions" with where its justification comes from (doc, lint, precedent or spec); omit the field when there is none. Then stop.
43
86
 
44
87
  ${BLOCK_RULES}`;
45
88
  }
46
89
 
47
90
  /** Builder call 2, same session: implement the frozen contract. */
48
- export function builderImplementPrompt() {
49
- return `The engine froze ${CONTRACT_FILE}; it is now the authority. Implement it exactly: change only deliverable paths (tests are always allowed), keep every preserve item intact, add a regression test, run every validate command, and commit. Do not push or touch GitHub.
91
+ export function builderImplementPrompt(mode = "local", { toolchain = null, sparse = null } = {}) {
92
+ const remote = mode === "ci" || mode === "hybrid";
93
+ return `The engine froze ${CONTRACT_FILE}; it is now the authority. Implement it exactly: change only deliverable paths (tests are always allowed), keep every preserve item intact, add a regression test, ${remote ? VERIFY_RUN[mode] : "run every validate command"}, and commit. Do not push or touch GitHub.
50
94
 
51
- ${HOST_RULES}
95
+ ${HOST_RULES}${toolchainRule(toolchain)}${sparseRule(sparse)}${remote ? `\n\n${VERIFY_RULES[mode]}` : ""}
52
96
 
53
97
  The contract changes only through typed amendments in ${RESULT_FILE}, each with a why, which the engine posts on the issue:
54
98
  - {"kind":"add-deliverable","path":"<file>","why":""}: a file you must change that the contract did not list.
55
99
  - {"kind":"add-requirement","requirement":{"id":"R9","quote":"<verbatim from the issue>","disposition":"deliver|defer|post-deploy"},"why":""}: an issue requirement the contract missed.
56
100
  - {"kind":"defer|post-deploy","requirement":"R#","why":""}: a contracted requirement that becomes separate work or a post-deploy check. The engine files it as a tracked follow-up.
57
101
  Never report a deliver requirement as "deferred" or "follow-up" without an amendment.
102
+ A new dependency between workspace packages in a package.json that no deliverable names (that package.json, with the package in its change) is blocked by the engine: add it through add-deliverable with a why naming the package, or do not add it.
103
+ If the repository has scripts/check-devdocs-freshness.sh, its docs must move with the code: a behaviour change under the paths it checks needs the matching module dossier (devdocs/modules/*.md, per devdocs/index.yaml) updated in the same change. Run it against the base before writing the result; the engine runs it too and resumes you once if it fails.
58
104
 
59
105
  When done and committed, write ${IMPLEMENTATION_FILE} (markdown, no HTML comments):
60
106
  ### Approach
@@ -64,16 +110,48 @@ When done and committed, write ${IMPLEMENTATION_FILE} (markdown, no HTML comment
64
110
  ### Notes (behavior changes, env vars)
65
111
 
66
112
  Finally write ${RESULT_FILE}: {"status":"fixed","summary":"one line","requirements":[{"id":"R1","status":"implemented|partial|missing","evidence":"file:line"}],"validation":[{"id":"V1","command":"","exit":0}],"tests":[{"command":"","result":"pass|fail: ...","fails_without_fix":true|false|"unknown"}],"amendments":[]}
113
+ Docker: start containers with docker run --rm; pass --label for each entry of $FORGE_DOCKER_LABELS on every docker run, volume create, network create and build (compose: on services, volumes, networks); tear down via trap/finally.
114
+ If you started Docker containers or volumes that are still there, list their names in ${RESULT_FILE} as "resources":{"containers":[],"volumes":[]} so the engine removes them when the lane ends.
67
115
  If you find you are blocked, write "status":"blocked" with a typed "block" instead.`;
68
116
  }
69
117
 
118
+ /** #4271: the bug-lane plan addition (lean.repro_test prefer on a bug/type:bug issue); "" when off, so the prompt is unchanged. */
119
+ export function reproPlanNote(on) {
120
+ if (!on) return "";
121
+ return `\n\nThis issue is a bug. When it is testable, ${CONTRACT_FILE} must also carry "repro_test":{"path":"<test file>","id":"<test name>","command":"<exact command that runs only that test>"}: a test that fails on the base and passes after the fix. The engine proves it with scripts/acceptance-red-green.sh (head's changed test files are overlaid on the base) and fails the run if it is not red on base and green on head. When it is not testable (specs, docs, workflows, environment), write "repro_test":"none","repro_test_reason":"<one line why>" instead.`;
122
+ }
123
+
124
+ /** forge#4195: hotspot facts for the plan prompt: one sentence plus one line per over-threshold file; "" when none. */
125
+ export function hotspotPlanNote(facts) {
126
+ if (!facts || facts.enabled === false || !Array.isArray(facts.files)) return "";
127
+ const hot = facts.files.filter((f) => f?.over && !f.excluded);
128
+ if (!hot.length) return "";
129
+ return `\n\nHotspots (scripts/hotspots.sh): an Affected File below is over ${facts.file_lines} lines, so the contract must put new logic in a new or neighbouring module and give the hotspot only wiring (an import and a call); growing it by more than ${facts.growth_lines} lines needs "hotspot-exempt: <reason>" in that deliverable's change.\n${hot.map((f) => `- ${f.path}: ${f.lines} lines, ${f.commits_90d} commits/90d`).join("\n")}`;
130
+ }
131
+
132
+ /** forge#4195: the builder's growth rule with the effective thresholds; "" when the check is off. */
133
+ export function hotspotBuildRule(facts) {
134
+ if (!facts || facts.enabled === false || !(facts.file_lines > 0) || !(facts.growth_lines > 0)) return "";
135
+ return `\n\nDon't grow a file over ${facts.file_lines} lines by more than ${facts.growth_lines} lines. Put new code next to it.`;
136
+ }
137
+
70
138
  export function contractCorrectionPrompt(problems) {
71
139
  return `Your ${CONTRACT_FILE} is not valid:\n${problems.slice(0, 20).map((p) => `- ${p}`).join("\n")}\n\nFix the contract (planning only, no code, no commit) and stop. Do not push or touch GitHub.`;
72
140
  }
73
141
 
74
142
  /** The one bounded correction round, in the builder's own session. */
75
- export function conformanceResumePrompt(failures) {
76
- return `The change does not conform to the frozen contract and the issue:\n${failures.slice(0, 30).map((f) => `- ${f.code}: ${String(f.detail).slice(0, 500)}`).join("\n")}\n\nFix each item in the code, or record a typed amendment with a why in ${RESULT_FILE}. Re-run the validate commands, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
143
+ export function conformanceResumePrompt(failures, mode = "local") {
144
+ return `The change does not conform to the frozen contract and the issue:\n${failures.slice(0, 30).map((f) => `- ${f.code}: ${String(f.detail).slice(0, 500)}`).join("\n")}\n\nFix each item in the code, or record a typed amendment with a why in ${RESULT_FILE}. ${rerun(mode)}, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
145
+ }
146
+
147
+ /** forge#4159: the one resume for package.json edges between workspace packages the contract does not name. */
148
+ export function structurePrompt(edges, mode = "local") {
149
+ return `This change adds dependencies between workspace packages that the frozen contract does not name:\n${edges.slice(0, 30).map((e) => `- ${e.from} -> ${e.to} (${e.kind}, ${e.manifest})`).join("\n")}\n\nFor each one, add it to the contract with a reason, or remove it: either record {"kind":"add-deliverable","path":"<that package.json>","why":"<names the package and why this dependency is right>"} in ${RESULT_FILE}, or drop the dependency from the package.json and the code that needs it. ${rerun(mode)}, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
150
+ }
151
+
152
+ /** The repo's devdocs freshness rule failed on the committed change: update the named dossier(s) and commit. */
153
+ export function docsPrompt(output, mode = "local") {
154
+ return `The repository's docs freshness check (scripts/check-devdocs-freshness.sh) fails on this change:\n${String(output ?? "").slice(0, 4000)}\n\nUpdate the module dossier it names (devdocs/modules/*.md; devdocs/index.yaml maps files to dossiers) to describe the behaviour you changed, in the dossier's existing style and in a few lines. ${rerun(mode)}, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
77
155
  }
78
156
 
79
157
  /** Human-only block reasons (#4124). Everything else agents resolve: a dependency, a split, prod data monitoring can read. */
@@ -89,13 +167,34 @@ export function blockCorrectionPrompt(problem) {
89
167
  return `Your ${RESULT_FILE} says "blocked", but ${String(problem).slice(0, 1000)}. That is not a block that needs a human.\n\n${BLOCK_RULES}\n\nEither do the work (fix, commit, update the stage notes and ${RESULT_FILE}) or write a valid typed "block". Do not push or touch GitHub.`;
90
168
  }
91
169
 
170
+ /** refactor:split grouping call (#4211): no tools, no files; the only input is the compact symbol index. */
171
+ export const GROUPING_SYSTEM = "You plan how to split one oversized source module into a package of smaller modules. You see only a symbol index (names, kinds, sizes, references); you have no tools and never see code. Reply with exactly one JSON object and nothing else.";
172
+
173
+ /** The grouping prompt: fixed rules plus JSON of bin/lean/refactor.mjs compactIndex(). Its size depends on the index only, never on file length. */
174
+ export function groupingPrompt(index) {
175
+ return `Group the top-level symbols of one module into cohesive new modules of a package. A codemod moves them verbatim and leaves the old module re-exporting every moved name.
176
+
177
+ Index: "symbols" are top-level symbols {name, kind, lines, public}; "edges" are "from->to" references between them.
178
+
179
+ Rules:
180
+ - Module names are snake_case Python identifiers, unique, not equal to any symbol name.
181
+ - Each symbol appears at most once. Symbols you leave out stay in the old module.
182
+ - A moved symbol must not reference a symbol that stays: when you move a symbol, move everything it references (follow the edges).
183
+ - Modules must not reference each other in a cycle; keep heavily cross-referenced symbols in one module.
184
+ - Prefer a few modules of similar size grouped by what the symbols do.
185
+
186
+ Reply with only {"<module>": ["<symbol>", ...], ...}.
187
+
188
+ ${JSON.stringify(index)}`;
189
+ }
190
+
92
191
  export const REVIEWER_SYSTEM = `You are an independent conformance reviewer of one change. You did not write it. Read-only: use Read, Grep and Glob in the worktree.
93
192
 
94
- You receive the GitHub issue with its comments, the frozen contract (requirements quoted from the issue, deliverables, preserve constraints, validate commands) with typed amendments, the builder's result with validation output, the changed files, and the diff. The engine computes the verdict from your rows; a reply without rows is rejected, so fill EVERY row:
95
- - issue_coverage: one row per requirement or acceptance criterion in the issue, quoted verbatim, saying whether the contract covers it, narrows it, or misses it.
96
- - requirements: one row per contract requirement id: implemented|partial|missing|deferred|post-deploy|not-applicable, with path:line evidence and the validate id or test that proves it (validated_by). For a defer or post-deploy requirement add deferral_ok: true only if it is genuinely separate work or only checkable after a deploy, false if it belongs in this change.
97
- - scope: one row per changed file: the deliverable id that authorizes it (deliverables may be directories or globs), "test", "incidental" (changelog, lockfile), or "none" with what it changes.
98
- - preserve: one row per preserve id: intact|violated|unverified, with path:line.
193
+ You receive the GitHub issue with its comments, the frozen contract (requirements quoted from the issue, deliverables, preserve constraints, validate commands) with typed amendments, the builder's result with validation output, the changed files, the diff, and a row skeleton with one row per contract id and changed file. The engine computes the verdict from your rows; a reply without rows is rejected, so fill the skeleton rather than composing rows: fill EVERY row (set each empty status, evidence and validated_by, and each null deferral_ok; keep the ids and files as given):
194
+ - issue_coverage: one row per requirement or acceptance criterion in the issue, quoted verbatim, saying whether the contract covers it, narrows it, or misses it. The skeleton lists the contract's quotes; add a row for any issue requirement the contract does not quote.
195
+ - requirements: one row per contract requirement id: implemented|partial|missing|deferred|post-deploy|not-applicable, with repo-relative path:line evidence (e.g. src/app/route.ts:12, not route.ts:12) and the validate id or test that proves it (validated_by). For a defer or post-deploy requirement add deferral_ok: true only if it is genuinely separate work or only checkable after a deploy, false if it belongs in this change.
196
+ - scope: one row per changed file, pre-filled with the engine's authorization: the deliverable id that authorizes it (deliverables may be directories or globs), "test", "incidental" (changelog, lockfile), or "none" with what it changes. Change it only if the diff shows the file does something its deliverable does not cover.
197
+ - preserve: one row per preserve id: intact|violated|unverified, with path:line. The skeleton starts each at "unverified"; set intact only after checking it.
99
198
  - regressions: concrete breakages only, each with path:line.
100
199
 
101
200
  Output ONLY a JSON object in a json code fence:
@@ -132,9 +231,18 @@ export function boundDiff(diff, { stat = "", base = "", maxBytes = REVIEW_DIFF_M
132
231
  + (list.length ? `\n<omitted_files>\n${list.join("\n")}\n</omitted_files>` : "");
133
232
  }
134
233
 
135
- export function reviewerPrompt({ issue, comments = [], contract, result, files = [], diff, stat = "", base = "" }) {
234
+ export function reviewerPrompt({ issue, comments = [], contract, result, files = [], diff, stat = "", base = "", structure = "", ignore = [`${LEAN_DIR}/`], skeleton = null }) {
136
235
  const notes = comments.map((c) => String(c?.body ?? "")).filter((b) => !/^<!-- FORGE:/.test(b.trim()));
137
- return `Issue #${issue.number}: ${issue.title}\n\n<issue_body>\n${cap(issue.body ?? "", 20000)}\n</issue_body>\n\n<issue_comments>\n${cap(notes.join("\n---\n"), 8000)}\n</issue_comments>\n\n<contract>\n${cap(JSON.stringify(contract ?? {}, null, 1), 12000)}\n</contract>\n\n<builder_result>\n${cap(JSON.stringify(result ?? {}, null, 1), 6000)}\n</builder_result>\n\n<changed_files>\n${files.join("\n")}\n</changed_files>\n\n<diff>\n${boundDiff(diff, { stat, base })}\n</diff>`;
236
+ const sk = JSON.stringify(skeleton ?? reviewSkeleton(contract ?? {}, files, ignore), null, 1);
237
+ return `Issue #${issue.number}: ${issue.title}\n\n<issue_body>\n${cap(issue.body ?? "", 20000)}\n</issue_body>\n\n<issue_comments>\n${cap(notes.join("\n---\n"), 8000)}\n</issue_comments>\n\n<contract>\n${cap(JSON.stringify(contract ?? {}, null, 1), 12000)}\n</contract>\n\n<builder_result>\n${cap(JSON.stringify(result ?? {}, null, 1), 6000)}\n</builder_result>\n\n<changed_files>\n${files.join("\n")}\n</changed_files>\n\n<row_skeleton>\n${cap(sk, 30000)}\n</row_skeleton>\n\n<diff>\n${boundDiff(diff, { stat, base })}\n</diff>${structure ? `\n<structure>${String(structure).replace(/[\r\n]+/g, " ")}</structure>` : ""}`;
238
+ }
239
+
240
+ /**
241
+ * #4160: the targeted re-ask after an incomplete or inconsistent reply. Only the missing or invalid ids are asked
242
+ * for, with the exact problems and the reviewer's previous reply; no diff is re-sent (the worktree still holds the change).
243
+ */
244
+ export function reviewerRepairPrompt({ missing = [], problems = [], rows = null }) {
245
+ return `Your conformance review is incomplete. The engine keeps your other rows; answer ONLY for these ids or sections: ${missing.join(", ") || "(see the problems)"}.\n\n<previous_reply_problems>\nYour previous reply was rejected as incomplete. Fix exactly these:\n${problems.slice(0, 60).map((p) => `- ${String(p).slice(0, 300)}`).join("\n")}\n</previous_reply_problems>\n\n<previous_reply>\n${cap(JSON.stringify(rows ?? {}), 20000)}\n</previous_reply>\n\nRead the changed files in your worktree as needed. Output ONLY a JSON object in a json code fence with just the rows for those ids (requirements rows by id, preserve rows by id, scope rows by file, issue_coverage in full if it is listed) plus "verdict":"CONFORMS|NONCONFORMING" consistent with all rows. Evidence is repo-relative path:line.`;
138
246
  }
139
247
 
140
248
  /** Pre-filter (#4123): one read-only, single-turn check of an issue a cheap signal flagged as possibly already fixed. */
@@ -0,0 +1,276 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * refactor:split lane step (forge#4211, #4196): replaces the builder for an issue labelled `refactor:split`.
5
+ * Deterministic except one grouping call: scripts/symbol-index.py (Python) or scripts/symbol-index.mjs (TypeScript)
6
+ * indexes the target, one tool-less LLM call groups the top-level symbols from that index only (never a file body),
7
+ * the language's codemod mover (scripts/symbol-move.py with libcst, scripts/symbol-move.mjs with ts-morph, #4212)
8
+ * moves them into one package and leaves a re-export shim/barrel, and scripts/refactor-verify.sh proves the move
9
+ * (same test counts, every public symbol resolves from its old path, no new cycle, moved-only diff). Other languages
10
+ * are refused before anything runs. Every refusal leaves the worktree at its start commit.
11
+ * A plan over `maxLines` moved lines is cut into whole modules: this PR moves the first slice, the rest is carried in
12
+ * a FORGE:SPLIT-PLAN block of a chained issue that the next run applies with `symbol-move.py --extend`, no new call.
13
+ * runCore (lean.mjs) owns lease, worktree, PR and merge; this module only produces the verified commit.
14
+ */
15
+ import { execFile } from "node:child_process";
16
+ import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
17
+ import { extname, isAbsolute, join, normalize } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+ import { OUTCOME_CODES } from "../engine/outcome-codes.mjs";
20
+ import { groupingPrompt } from "./prompts.mjs";
21
+
22
+ export const SPLIT_LABEL = "refactor:split";
23
+ export const DEFAULT_SPLIT_MAX_LINES = 4000;
24
+ const SCRIPTS = fileURLToPath(new URL("../../scripts/", import.meta.url));
25
+ export const SYMBOL_INDEX = join(SCRIPTS, "symbol-index.py");
26
+ export const SYMBOL_MOVE = join(SCRIPTS, "symbol-move.py");
27
+ export const REFACTOR_VERIFY = join(SCRIPTS, "refactor-verify.sh");
28
+ export const SYMBOL_INDEX_MJS = join(SCRIPTS, "symbol-index.mjs");
29
+ export const SYMBOL_MOVE_TS = join(SCRIPTS, "symbol-move.mjs");
30
+ const LANGS = { ".py": "python", ".ts": "ts", ".tsx": "ts", ".mts": "ts", ".cts": "ts", ".js": "js", ".jsx": "js", ".mjs": "js", ".cjs": "js", ".go": "go", ".rs": "rust", ".rb": "ruby", ".java": "java" };
31
+ const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
32
+
33
+ /** argv runner without a shell; never throws for a non-zero exit. */
34
+ export function defaultRun(cmd, args, { cwd, env } = {}) {
35
+ return new Promise((resolve) => {
36
+ execFile(cmd, args, { cwd, env, maxBuffer: 64 * 1024 * 1024 }, (err, stdout, stderr) => {
37
+ const code = err ? (Number.isInteger(err.code) ? err.code : 127) : 0;
38
+ resolve({ code, stdout: String(stdout ?? ""), stderr: String(stderr ?? (err && !Number.isInteger(err.code) ? err.message : "")) });
39
+ });
40
+ });
41
+ }
42
+
43
+ /** `<!-- FORGE:MAINT:refactor:split:<path> -->` (scripts/maintenance-scan.sh), else a `**Target**: <path>` line; null otherwise. */
44
+ export function parseSplitTarget(body) {
45
+ const s = String(body ?? "");
46
+ const m = /<!--\s*FORGE:MAINT:refactor:split:(.+?)\s*-->/.exec(s) ?? /^\s*\*\*Target\*\*:\s*`?([^`\s]+)`?/m.exec(s);
47
+ return m ? m[1].trim() : null;
48
+ }
49
+
50
+ /** `**Test command**: <cmd>` (backticks optional), else forge.yaml learned.test_commands joined with ` && `; null otherwise. */
51
+ export function parseTestCmd(body, precheckCommands = []) {
52
+ const m = /^\s*\*\*Test command\*\*:\s*(?:`([^`]+)`|(.+))$/m.exec(String(body ?? ""));
53
+ const own = (m?.[1] ?? m?.[2] ?? "").trim();
54
+ if (own) return own;
55
+ const cmds = (Array.isArray(precheckCommands) ? precheckCommands : []).map((c) => String(c ?? "").trim()).filter(Boolean);
56
+ return cmds.length ? cmds.join(" && ") : null;
57
+ }
58
+
59
+ const validPlan = (p) => p && typeof p === "object" && !Array.isArray(p) && Object.keys(p).length > 0
60
+ && Object.entries(p).every(([k, v]) => IDENT.test(k) && Array.isArray(v) && v.length > 0 && v.every((n) => typeof n === "string" && n));
61
+
62
+ /** The chain plan `<!-- FORGE:SPLIT-PLAN -->` + fenced json {target?, package, plan:{module:[symbols]}}; null when absent or invalid. */
63
+ export function parseSplitPlan(body) {
64
+ const m = /<!--\s*FORGE:SPLIT-PLAN\s*-->\s*```(?:json)?\s*([\s\S]*?)```/.exec(String(body ?? ""));
65
+ if (!m) return null;
66
+ let j; try { j = JSON.parse(m[1]); } catch { return null; }
67
+ if (!j || typeof j !== "object" || !IDENT.test(String(j.package ?? "")) || !validPlan(j.plan)) return null;
68
+ return { target: typeof j.target === "string" && j.target ? j.target : null, package: j.package, plan: j.plan };
69
+ }
70
+
71
+ const top = (n) => String(n).split(".")[0];
72
+
73
+ /** What the grouping call sees: top-level symbols {name,kind,lines,public} and deduped `from->to` edges. No spans, bodies or imports. */
74
+ export function compactIndex(index) {
75
+ const symbols = (index?.symbols ?? []).filter((s) => !String(s.name).includes(".")).map((s) => ({ name: s.name, kind: s.kind, lines: s.lines, public: Boolean(s.public) }));
76
+ const names = new Set(symbols.map((s) => s.name));
77
+ const edges = new Set();
78
+ for (const e of index?.edges ?? []) {
79
+ const f = top(e.from), t = top(e.to);
80
+ if (f !== t && names.has(f) && names.has(t)) edges.add(`${f}->${t}`);
81
+ }
82
+ return { symbols, edges: [...edges].sort() };
83
+ }
84
+
85
+ /** The grouping reply as {module:[symbols]}: one JSON object, identifiers, known top-level symbols, none twice; null otherwise. */
86
+ export function parseGroupingReply(text, names) {
87
+ const s = String(text ?? ""), a = s.indexOf("{"), b = s.lastIndexOf("}");
88
+ if (a < 0 || b <= a) return null;
89
+ let p; try { p = JSON.parse(s.slice(a, b + 1)); } catch { return null; }
90
+ if (!validPlan(p)) return null;
91
+ const known = new Set(names), seen = new Set();
92
+ for (const list of Object.values(p)) for (const n of list) { if (!known.has(n) || seen.has(n)) return null; seen.add(n); }
93
+ if (Object.keys(p).some((k) => known.has(k))) return null;
94
+ return p;
95
+ }
96
+
97
+ /**
98
+ * One PR's slice of a plan: modules ordered so a module comes after the modules it references (plan order otherwise),
99
+ * then whole modules until the moved lines would exceed maxLines; the first module is always taken.
100
+ * Returns {now, rest, lines}; rest is null when everything fits.
101
+ */
102
+ export function slicePlan(plan, index, maxLines = DEFAULT_SPLIT_MAX_LINES) {
103
+ const ci = compactIndex(index);
104
+ const size = new Map(ci.symbols.map((s) => [s.name, Number(s.lines) || 0]));
105
+ const modOf = new Map();
106
+ for (const [m, list] of Object.entries(plan)) for (const n of list) modOf.set(n, m);
107
+ const deps = new Map(Object.keys(plan).map((m) => [m, new Set()]));
108
+ for (const e of ci.edges) {
109
+ const [f, t] = e.split("->"), mf = modOf.get(f), mt = modOf.get(t);
110
+ if (mf && mt && mf !== mt) deps.get(mf).add(mt);
111
+ }
112
+ const order = [], state = new Map();
113
+ const visit = (m) => {
114
+ if (state.get(m)) return; // visiting (a cycle: symbol-move refuses it) or done
115
+ state.set(m, 1);
116
+ for (const d of Object.keys(plan)) if (deps.get(m).has(d)) visit(d);
117
+ state.set(m, 2); order.push(m);
118
+ };
119
+ for (const m of Object.keys(plan)) visit(m);
120
+ const now = {}, rest = {};
121
+ let lines = 0, full = false;
122
+ for (const m of order) {
123
+ const l = plan[m].reduce((n, s) => n + (size.get(s) ?? 0), 0);
124
+ if (!full && (!Object.keys(now).length || lines + l <= maxLines)) { now[m] = plan[m]; lines += l; } else { full = true; rest[m] = plan[m]; }
125
+ }
126
+ return { now, rest: Object.keys(rest).length ? rest : null, lines };
127
+ }
128
+
129
+ const pythonCmd = (py) => {
130
+ const v = Array.isArray(py) ? py : String(py ?? process.env.SYMBOL_MOVE_PYTHON ?? "").trim().split(/\s+/).filter(Boolean);
131
+ return v.length ? v : ["python3"];
132
+ };
133
+ /** A script's JSON reply: the whole stdout (refactor-verify pretty-prints), else its last line. */
134
+ const parseJson = (t) => {
135
+ for (const c of [String(t ?? "").trim(), String(t ?? "").trim().split("\n").filter(Boolean).at(-1) ?? ""]) {
136
+ try { const j = JSON.parse(c); if (j && typeof j === "object") return j; } catch { /* next candidate */ }
137
+ }
138
+ return null;
139
+ };
140
+ const tail = (r) => String(r?.stderr || r?.stdout || "").trim().slice(-600);
141
+ const failureLine = (f) => (f.kind === "logic-diff" ? `logic-diff ${f.file}:${f.line} \`${f.text}\`` : f.kind === "missing-symbol" ? `missing-symbol ${f.module}.${f.name}`
142
+ : f.kind === "count-changed" ? `count-changed ${JSON.stringify(f.before)} -> ${JSON.stringify(f.after)}` : f.kind === "new-cycle" ? `new-cycle ${(f.cycle ?? []).join(" -> ")}` : JSON.stringify(f));
143
+
144
+ function effects(ctx) {
145
+ const run = ctx.run ?? defaultRun;
146
+ const env = { ...process.env, PYTHONDONTWRITEBYTECODE: "1", ...(ctx.env ?? {}) };
147
+ const sh = (cmd, args) => run(cmd, args, { cwd: ctx.wt, env });
148
+ const git = ctx.git ?? (async (args) => { const r = await sh("git", args); if (r.code !== 0) throw new Error(`git ${args.join(" ")} failed: ${tail(r)}`); return r.stdout; });
149
+ const scratch = () => { const d = join(ctx.runTmp ?? join(ctx.wt, ".forge-lean"), "split"); mkdirSync(d, { recursive: true }); return d; };
150
+ return { sh, git, scratch };
151
+ }
152
+
153
+ /** Puts the worktree back at `head` and drops untracked files (ignored ones, such as .forge-lean/, stay). */
154
+ async function restore(git, head) {
155
+ try { await git(["reset", "-q", "--hard", head]); } catch { /* best effort */ }
156
+ try { await git(["clean", "-fdq"]); } catch { /* best effort */ }
157
+ }
158
+
159
+ /** baseline at `ref`'s state (the worktree checked out there), back to the current branch, then verify `ref..HEAD`. */
160
+ export async function reverifySplit(ctx) {
161
+ const { sh, git, scratch } = effects(ctx);
162
+ const head = String(await git(["rev-parse", "HEAD"])).trim();
163
+ const branch = String(await git(["rev-parse", "--abbrev-ref", "HEAD"])).trim();
164
+ const out = join(scratch(), "split-rebaseline.json");
165
+ await git(["checkout", "-q", "--detach", ctx.base]);
166
+ const b = await sh("bash", [REFACTOR_VERIFY, "baseline", "--root", ctx.wt, "--target", ctx.target, "--test-cmd", ctx.testCmd, "--out", out]);
167
+ await restore(git, ctx.base);
168
+ await git(["checkout", "-q", branch === "HEAD" ? head : branch]);
169
+ if (b.code !== 0) return { ok: false, code: OUTCOME_CODES.SPLIT_PLAN_INVALID, reason: `refactor-verify baseline at ${ctx.base} failed (exit ${b.code}): ${tail(b)}` };
170
+ const v = await sh("bash", [REFACTOR_VERIFY, "verify", "--baseline", out, "--base", ctx.base]);
171
+ const j = parseJson(v.stdout);
172
+ if (v.code === 0 && j?.ok) return { ok: true, verify: j };
173
+ if (v.code === 1) return { ok: false, code: OUTCOME_CODES.SPLIT_VERIFY_FAILED, reason: `refactor-verify failed:\n${(j?.failures ?? []).slice(0, 20).map((f) => `- ${failureLine(f)}`).join("\n")}`, verify: j };
174
+ return { ok: false, code: OUTCOME_CODES.SPLIT_UNSUPPORTED, reason: `refactor-verify could not verify (exit ${v.code}): ${tail(v)}` };
175
+ }
176
+
177
+ /**
178
+ * The split step. ctx: {wt, base, issue, issueBody, precheckCommands, runTmp, maxLines, run(cmd,args,{cwd,env}),
179
+ * callGrouper(prompt, {cwd: an empty dir}) -> reply text, git(args) -> stdout, python (argv or string; default $SYMBOL_MOVE_PYTHON, else python3)}.
180
+ * Returns {ok:true, target, testCmd, package, packageName, modules, rest, verify, grouped, lines} or {ok:false, code, reason}.
181
+ */
182
+ export async function runSplitStep(ctx) {
183
+ const { sh, git, scratch } = effects(ctx);
184
+ const py = pythonCmd(ctx.python);
185
+ const fail = (code, reason) => ({ ok: false, code, reason });
186
+ const chain = parseSplitPlan(ctx.issueBody);
187
+ // ---- 1. target, language, test command, codemod tool: nothing touched yet ----
188
+ const raw = parseSplitTarget(ctx.issueBody) ?? chain?.target;
189
+ if (!raw) return fail(OUTCOME_CODES.SPLIT_PLAN_INVALID, "no split target: the issue needs a `<!-- FORGE:MAINT:refactor:split:<path> -->` marker or a `**Target**: <path>` line");
190
+ const target = normalize(raw.replace(/^\.\//, ""));
191
+ if (isAbsolute(target) || target.startsWith("..")) return fail(OUTCOME_CODES.SPLIT_PLAN_INVALID, `split target \`${raw}\` must be a path inside the repository`);
192
+ const ext = extname(target).toLowerCase();
193
+ const lang = LANGS[ext] ?? (ext.slice(1) || "unknown");
194
+ if (lang !== "python" && lang !== "ts") return fail(OUTCOME_CODES.SPLIT_UNSUPPORTED, `no codemod tool for ${lang}: \`${target}\` (the refactor:split movers support Python and TypeScript)`);
195
+ if (!existsSync(join(ctx.wt, target))) return fail(OUTCOME_CODES.SPLIT_PLAN_INVALID, `split target \`${target}\` does not exist on the base`);
196
+ const testCmd = parseTestCmd(ctx.issueBody, ctx.precheckCommands);
197
+ if (!testCmd) return fail(OUTCOME_CODES.SPLIT_PLAN_INVALID, "no test command: add a `**Test command**: <cmd>` line to the issue or forge.yaml learned.test_commands");
198
+ if (lang === "python") {
199
+ const probe = await sh(py[0], [...py.slice(1), "-c", "import libcst"]);
200
+ if (probe.code !== 0) return fail(OUTCOME_CODES.SPLIT_UNSUPPORTED, `no codemod tool for python: libcst is not importable by \`${py.join(" ")}\` (install it or set SYMBOL_MOVE_PYTHON)`);
201
+ } else {
202
+ // #4212: ts-morph is resolved from the target repo (or NODE_PATH); the probe reads and writes nothing
203
+ const probe = await sh(process.execPath, [SYMBOL_MOVE_TS, "--probe", "--src", target]);
204
+ if (probe.code !== 0) {
205
+ const why = String(parseJson(probe.stdout)?.reason ?? "");
206
+ return fail(OUTCOME_CODES.SPLIT_UNSUPPORTED, /^no codemod tool for ts:/.test(why) ? why : `no codemod tool for ts: the ts-morph probe failed (exit ${probe.code}): ${why || tail(probe)}`);
207
+ }
208
+ }
209
+ const startHead = String(await git(["rev-parse", "HEAD"])).trim();
210
+ const refuse = async (code, reason) => { await restore(git, startHead); return fail(code, reason); };
211
+ // ---- 2. symbol index ----
212
+ const ir = lang === "python" ? await sh("python3", [SYMBOL_INDEX, target]) : await sh(process.execPath, [SYMBOL_INDEX_MJS, target]);
213
+ const index = parseJson(ir.stdout);
214
+ if (index?.unsupported) return await refuse(OUTCOME_CODES.SPLIT_UNSUPPORTED, `no codemod tool for ${index.unsupported}: ${index.reason ?? "unsupported"}`);
215
+ if (ir.code !== 0 || !Array.isArray(index?.symbols)) return await refuse(OUTCOME_CODES.SPLIT_PLAN_INVALID, `symbol index failed (exit ${ir.code}): ${tail(ir)}`);
216
+ // ---- 3. baseline ----
217
+ const dir = scratch(), baseline = join(dir, "split-baseline.json");
218
+ const br = await sh("bash", [REFACTOR_VERIFY, "baseline", "--root", ctx.wt, "--target", target, "--test-cmd", testCmd, "--out", baseline]);
219
+ if (br.code !== 0) return await refuse(OUTCOME_CODES.SPLIT_PLAN_INVALID, `refactor-verify baseline failed (exit ${br.code}): ${tail(br)}`);
220
+ // ---- 4. plan: the chained remainder, else exactly one grouping call over the compact index ----
221
+ let plan, grouped = false;
222
+ if (chain) plan = chain.plan;
223
+ else {
224
+ const ci = compactIndex(index);
225
+ const reply = await ctx.callGrouper(groupingPrompt(ci), { cwd: mkdtempSync(join(dir, "group-")) }); // empty cwd: nothing to read even with a tool
226
+ grouped = true;
227
+ plan = parseGroupingReply(reply, ci.symbols.map((s) => s.name));
228
+ if (!plan) return await refuse(OUTCOME_CODES.SPLIT_PLAN_INVALID, `the grouping reply is not a valid {module:[symbols]} plan over the index: ${String(reply ?? "").slice(0, 300)}`);
229
+ }
230
+ // ---- 5. one PR's slice ----
231
+ const { now, rest, lines } = slicePlan(plan, index, ctx.maxLines ?? DEFAULT_SPLIT_MAX_LINES);
232
+ // ---- 6. move ----
233
+ const planFile = join(mkdtempSync(join(dir, "plan-")), "plan.json");
234
+ writeFileSync(planFile, `${JSON.stringify(now)}\n`);
235
+ const moveArgs = ["--src", target, "--plan", planFile, ...(chain ? ["--package", chain.package, "--extend"] : [])];
236
+ const mr = lang === "python" ? await sh(py[0], [...py.slice(1), SYMBOL_MOVE, ...moveArgs]) : await sh(process.execPath, [SYMBOL_MOVE_TS, ...moveArgs]);
237
+ const moved = parseJson(mr.stdout);
238
+ if (mr.code === 3 || moved?.status === "refused") {
239
+ const why = String(moved?.reason ?? tail(mr));
240
+ return await refuse(/libcst|no codemod tool/i.test(why) ? OUTCOME_CODES.SPLIT_UNSUPPORTED : OUTCOME_CODES.SPLIT_PLAN_INVALID, `symbol-move refused: ${why}`);
241
+ }
242
+ if (mr.code !== 0 || moved?.status !== "ok") return await refuse(OUTCOME_CODES.SPLIT_PLAN_INVALID, `symbol-move failed (exit ${mr.code}): ${tail(mr)}`);
243
+ // ---- 7. commit ----
244
+ const pkg = String(moved.package);
245
+ const packageName = lang === "python" ? pkg.split(".").at(-1) : pkg.split("/").at(-1); // dotted package | package dir
246
+ const tool = lang === "python" ? "libcst" : "ts-morph";
247
+ try {
248
+ await git(["add", "-A"]);
249
+ await git(["commit", "--no-verify", "-q", "-m", `refactor(split): move ${Object.keys(now).length} module(s) of ${target} into ${pkg}${ctx.issue ? ` (#${ctx.issue})` : ""}`]);
250
+ } catch (e) { return await refuse(OUTCOME_CODES.SPLIT_PLAN_INVALID, `committing the move failed: ${String(e?.message ?? e).slice(0, 300)}`); }
251
+ // ---- 8. verify ----
252
+ const vr = await sh("bash", [REFACTOR_VERIFY, "verify", "--baseline", baseline, "--base", ctx.base]);
253
+ const verify = parseJson(vr.stdout);
254
+ if (vr.code === 1) return await refuse(OUTCOME_CODES.SPLIT_VERIFY_FAILED, `refactor-verify failed; the move was reset:\n${(verify?.failures ?? []).slice(0, 20).map((f) => `- ${failureLine(f)}`).join("\n")}`);
255
+ if (vr.code !== 0 || !verify?.ok) return await refuse(OUTCOME_CODES.SPLIT_UNSUPPORTED, `refactor-verify could not verify (exit ${vr.code}): ${tail(vr)}`);
256
+ return { ok: true, target, testCmd, package: pkg, packageName, modules: moved.modules ?? now, rest, verify, grouped, lines, lang, tool };
257
+ }
258
+
259
+ const table = (modules) => ["| Module | Symbols |", "| --- | --- |", ...Object.entries(modules ?? {}).map(([m, s]) => `| \`${m}\` | ${s.length} |`)].join("\n");
260
+
261
+ /** The split PR body: what moved where, the deterministic proof, the chained remainder. */
262
+ export function renderSplitPrBody({ issue, target, package: pkg, modules, verify, rest, lines, grouped, tool }) {
263
+ tool ??= /\.[cm]?tsx?$/i.test(String(target ?? "")) ? "ts-morph" : "libcst"; // a rerun result carries no tool
264
+ const left = rest ? `\n\n## Remaining work\n${Object.keys(rest).length} module(s) (${Object.keys(rest).map((m) => `\`${m}\``).join(", ")}) move in a chained \`refactor:split\` PR filed after this one merges; each PR is mergeable on its own.` : "";
265
+ return `## Split\n\`${target}\` → package \`${pkg}\`, ${Object.keys(modules ?? {}).length} module(s), ${lines ?? "?"} moved line(s). Moved verbatim by ${tool === "ts-morph" ? "\`scripts/symbol-move.mjs\` (ts-morph)" : "\`scripts/symbol-move.py\` (libcst)"}; \`${target}\` keeps every unmoved statement and re-exports each moved name. Plan: ${grouped ? "one grouping call over the symbol index (no file bodies)" : "carried from the chained issue (no LLM call)"}.\n\n${table(modules)}\n\n## Verification\n\`scripts/refactor-verify.sh\`: ${verify?.ok ? "ok" : "not run"} (identical pass/skip counts, every baseline public symbol resolves from its old path, no new import cycle, diff is moved blocks and import lines only). Location-only change: no conformance review.${left}\n\nCloses #${issue}`;
266
+ }
267
+
268
+ /** The chained issue for the rest of a large split: same maintenance marker and labels, and the plan, so the next run makes no grouping call. */
269
+ export function renderChainIssue({ parent, target, packageName, rest, testCmd = null }) {
270
+ const plan = JSON.stringify({ target, package: packageName, plan: rest }, null, 2);
271
+ return {
272
+ title: `split ${target} (continued)`,
273
+ labels: ["maintenance", SPLIT_LABEL],
274
+ body: `Continues the split of \`${target}\` from #${parent}: ${Object.keys(rest).length} module(s) left, moved into the existing package \`${packageName}\`.\n\n**Target**: \`${target}\`\n${testCmd ? `**Test command**: \`${testCmd}\`\n` : ""}\n<!-- FORGE:MAINT:refactor:split:${target} -->\n<!-- FORGE:SPLIT-PLAN -->\n\`\`\`json\n${plan}\n\`\`\`\n`,
275
+ };
276
+ }