forgedock 1.27.0 → 1.29.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.
- package/README.md +4 -1
- package/bin/bootstrap.mjs +55 -0
- package/bin/engine/outcome-codes.mjs +39 -0
- package/bin/engine/telemetry-seam.mjs +37 -8
- package/bin/engine/telemetry-seam.test.mjs +31 -0
- package/bin/engine-cli.mjs +601 -42
- package/bin/engine.mjs +8 -4
- package/bin/forgedock.mjs +75 -9
- package/bin/gc.mjs +580 -0
- package/bin/lean/admission.mjs +33 -3
- package/bin/lean/batch.mjs +22 -4
- package/bin/lean/batch.test.mjs +5 -3
- package/bin/lean/checks.mjs +199 -0
- package/bin/lean/checks.test.mjs +183 -0
- package/bin/lean/conformance.mjs +234 -57
- package/bin/lean/conformance.test.mjs +168 -7
- package/bin/lean/context.mjs +333 -0
- package/bin/lean/context.test.mjs +279 -0
- package/bin/lean/credentials.mjs +29 -0
- package/bin/lean/depcache.mjs +312 -0
- package/bin/lean/depcache.test.mjs +298 -0
- package/bin/lean/drift.mjs +198 -0
- package/bin/lean/drift.test.mjs +223 -0
- package/bin/lean/effects.mjs +104 -9
- package/bin/lean/execution.e2e.test.mjs +81 -0
- package/bin/lean/execution.mjs +431 -0
- package/bin/lean/execution.test.mjs +583 -0
- package/bin/lean/heavy.mjs +289 -0
- package/bin/lean/heavy.test.mjs +207 -0
- package/bin/lean/lean.mjs +723 -154
- package/bin/lean/lean.test.mjs +1603 -35
- package/bin/lean/ledger.mjs +27 -2
- package/bin/lean/ledger.test.mjs +36 -1
- package/bin/lean/limits.mjs +58 -0
- package/bin/lean/limits.test.mjs +150 -0
- package/bin/lean/orchestrate.mjs +389 -19
- package/bin/lean/prefilter.mjs +2 -2
- package/bin/lean/prefilter.test.mjs +3 -0
- package/bin/lean/prompts.mjs +106 -17
- package/bin/lean/recovery.test.mjs +230 -0
- package/bin/lean/refactor.mjs +276 -0
- package/bin/lean/refactor.test.mjs +359 -0
- package/bin/lean/render.mjs +49 -10
- package/bin/lean/review-trigger.mjs +134 -0
- package/bin/lean/review-trigger.test.mjs +104 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +257 -0
- package/bin/lean/sandbox-remote.mjs +142 -0
- package/bin/lean/sched.mjs +221 -0
- package/bin/lean/sched.test.mjs +310 -0
- package/bin/lean/schedd.mjs +247 -0
- package/bin/lean/schedd.test.mjs +180 -0
- package/bin/lean/testmap.mjs +84 -0
- package/bin/lean/testmap.test.mjs +58 -0
- package/bin/lean/toolchain.mjs +228 -0
- package/bin/lean/toolchain.test.mjs +154 -0
- package/bin/lean/verify.mjs +66 -10
- package/bin/lean/verify.test.mjs +104 -3
- package/bin/lean/worktree.mjs +69 -0
- package/bin/lean/worktree.test.mjs +141 -0
- package/bin/runner.mjs +53 -10
- package/bin/stats/github-source.mjs +1 -1
- package/bin/stats/index.mjs +24 -2
- package/bin/stats/local-source.mjs +13 -0
- package/bin/stats/query.mjs +99 -0
- package/bin/stats/stats.test.mjs +94 -1
- package/commands/audit.md +2 -0
- package/commands/autopilot.md +2 -0
- package/commands/bootstrap.md +35 -0
- package/commands/cleanup.md +7 -22
- package/commands/forge-stats.md +2 -1
- package/commands/forgedock-init.md +7 -0
- package/commands/issue.md +89 -830
- package/commands/milestone.md +55 -6
- package/commands/orchestrate.md +62 -8
- package/commands/upgrade-deps.md +3 -3
- package/commands/work-on/build/validate.md +4 -0
- package/commands/work-on/close.md +2 -0
- package/commands/work-on/decompose.md +24 -6
- package/docs/CI.md +3 -3
- package/docs/CONFIG.md +192 -2
- package/docs/OUTCOME-CODES.md +22 -0
- package/docs/RELEASE-NOTES.md +9 -0
- package/docs/TELEMETRY.md +1 -1
- package/docs/site/command-reference.md +22 -0
- package/docs/site/getting-started.md +2 -0
- package/docs/site/start-a-new-project.md +327 -0
- package/package.json +1 -1
- package/scripts/affected-files-check.sh +91 -0
- package/scripts/affected-files-check.test.sh +80 -0
- package/scripts/bootstrap-create.mjs +393 -0
- package/scripts/bootstrap-validate.mjs +36 -2
- package/scripts/calibration.mjs +116 -0
- package/scripts/check-devdocs-freshness.sh +9 -4
- package/scripts/check-devdocs-freshness.test.sh +11 -1
- package/scripts/check-spec-markers.sh +4 -0
- package/scripts/check-spec-size.sh +29 -11
- package/scripts/check-spec-size.test.sh +27 -4
- package/scripts/ci-local/lib.mjs +8 -1
- package/scripts/ci-local/parallelism.test.mjs +27 -0
- package/scripts/ci-local/run.mjs +3 -3
- package/scripts/ci-local/scoping.mjs +82 -0
- package/scripts/ci-local.test.sh +1 -1
- package/scripts/fixtures/symbol-index/other/sample.rb +3 -0
- package/scripts/fixtures/symbol-index/py/pkg/__init__.py +1 -0
- package/scripts/fixtures/symbol-index/py/pkg/a.py +5 -0
- package/scripts/fixtures/symbol-index/py/pkg/b.py +14 -0
- package/scripts/fixtures/symbol-index/py/pkg/c.py +1 -0
- package/scripts/fixtures/symbol-index/py/sample.py +41 -0
- package/scripts/fixtures/symbol-index/ts/sample.ts +13 -0
- package/scripts/fixtures/symbol-move/gen_big.py +201 -0
- package/scripts/fixtures/symbol-move-ts/gen_big.mjs +56 -0
- package/scripts/fixtures/test-map/go/.github/workflows/ci.yml +23 -0
- package/scripts/fixtures/test-map/go/Makefile +5 -0
- package/scripts/fixtures/test-map/go/README.md +1 -0
- package/scripts/fixtures/test-map/go/go.mod +3 -0
- package/scripts/fixtures/test-map/go/internal/util/util.go +3 -0
- package/scripts/fixtures/test-map/go/main.go +3 -0
- package/scripts/fixtures/test-map/node/package.json +10 -0
- package/scripts/fixtures/test-map/node/packages/ui/package.json +4 -0
- package/scripts/fixtures/test-map/node/packages/ui/src/button.tsx +1 -0
- package/scripts/fixtures/test-map/node/pnpm-lock.yaml +1 -0
- package/scripts/fixtures/test-map/node/src/a.ts +1 -0
- package/scripts/fixtures/test-map/node/vitest.config.ts +1 -0
- package/scripts/fixtures/test-map/python/pkg/__init__.py +0 -0
- package/scripts/fixtures/test-map/python/pkg/mod.py +2 -0
- package/scripts/fixtures/test-map/python/pyproject.toml +9 -0
- package/scripts/fixtures/test-map/python/tests/test_mod.py +5 -0
- package/scripts/fixtures/test-map/python/uv.lock +1 -0
- package/scripts/forge-root.test.sh +1 -1
- package/scripts/hotspots.sh +100 -0
- package/scripts/hotspots.test.sh +101 -0
- package/scripts/issue-create.sh +197 -0
- package/scripts/issue-create.test.sh +166 -0
- package/scripts/maintenance-scan.sh +185 -0
- package/scripts/maintenance-scan.test.sh +89 -0
- package/scripts/milestone-drift.sh +206 -0
- package/scripts/milestone-drift.test.sh +120 -0
- package/scripts/refactor-verify.sh +368 -0
- package/scripts/refactor-verify.test.sh +202 -0
- package/scripts/repo-facts.sh +146 -0
- package/scripts/repo-facts.test.sh +106 -0
- package/scripts/structure-delta.sh +10 -5
- package/scripts/structure-delta.test.sh +3 -2
- package/scripts/symbol-index.mjs +253 -0
- package/scripts/symbol-index.py +354 -0
- package/scripts/symbol-index.test.sh +119 -0
- package/scripts/symbol-move-ts.test.sh +161 -0
- package/scripts/symbol-move.mjs +379 -0
- package/scripts/symbol-move.py +383 -0
- package/scripts/symbol-move.test.sh +168 -0
- package/scripts/test-map.sh +211 -0
- package/scripts/test-map.test.sh +165 -0
- package/scripts/wait-ci-green.sh +10 -4
- package/scripts/wait-ci-green.test.sh +9 -0
- package/templates/bootstrap/interview.md +34 -0
- package/templates/bootstrap/issue-body.md +42 -0
- package/templates/bootstrap/needs-human-issue.md +45 -0
- package/templates/bootstrap/stack-profiles.md +34 -0
- package/templates/bootstrap/technical-spike.md +75 -0
|
@@ -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
|
+
}
|