@mmerterden/multi-agent-pipeline 20.24.0 → 20.25.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 (86) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +6 -5
  3. package/README.tr.md +6 -5
  4. package/docs/architecture.md +3 -3
  5. package/docs/ecosystem.md +5 -5
  6. package/docs/facts.json +7 -6
  7. package/docs/features.md +9 -4
  8. package/install/catalog-history.json +1 -1
  9. package/install/templates/claude-hooks.json +1 -1
  10. package/install/templates/codex-hooks.json +18 -0
  11. package/manifest.json +89 -82
  12. package/package.json +1 -1
  13. package/pipeline/agents/task-clarifier.md +1 -1
  14. package/pipeline/commands/multi-agent/deps/SKILL.md +61 -0
  15. package/pipeline/commands/multi-agent/model/SKILL.md +2 -2
  16. package/pipeline/commands/multi-agent/postmortem/SKILL.md +18 -6
  17. package/pipeline/commands/multi-agent/review/SKILL.md +5 -5
  18. package/pipeline/commands/multi-agent/route-on/SKILL.md +1 -1
  19. package/pipeline/commands/multi-agent/setup/SKILL.md +7 -5
  20. package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
  21. package/pipeline/commands/multi-agent/testflight-validation/SKILL.md +2 -3
  22. package/pipeline/contract/fixtures/runs-failed.json +1 -1
  23. package/pipeline/contract/fixtures/runs-old-schema.json +1 -1
  24. package/pipeline/contract/fixtures/runs-pr-opened-redacted.json +1 -1
  25. package/pipeline/contract/fixtures/runs-pr-opened.json +1 -1
  26. package/pipeline/contract/fixtures/runs-running.json +1 -1
  27. package/pipeline/lib/credential-inventory.sh +5 -5
  28. package/pipeline/lib/fetch-crashlytics.sh +6 -8
  29. package/pipeline/lib/model-rung.sh +1 -1
  30. package/pipeline/lib/plan-todos.sh +21 -4
  31. package/pipeline/lib/post-pr-review.sh +4 -6
  32. package/pipeline/lib/route-state.sh +1 -1
  33. package/pipeline/lib/shadow-git.sh +4 -5
  34. package/pipeline/multi-agent-refs/cross-cli-contract.md +8 -9
  35. package/pipeline/multi-agent-refs/features/cost-analysis.md +4 -0
  36. package/pipeline/multi-agent-refs/features/dep-bump.md +176 -0
  37. package/pipeline/multi-agent-refs/features/model-fallback.md +17 -12
  38. package/pipeline/multi-agent-refs/help/en.md +1 -0
  39. package/pipeline/multi-agent-refs/help/tr.md +1 -0
  40. package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -1
  41. package/pipeline/multi-agent-refs/phases/phase-2-dev.md +2 -0
  42. package/pipeline/multi-agent-refs/phases/phase-3-review.md +5 -5
  43. package/pipeline/multi-agent-refs/picker-contract.md +1 -1
  44. package/pipeline/multi-agent-refs/tracker-contract.md +6 -6
  45. package/pipeline/schemas/agent-state.schema.json +5 -0
  46. package/pipeline/schemas/phases.json +4 -0
  47. package/pipeline/schemas/plan-todos.schema.json +4 -4
  48. package/pipeline/schemas/prefs.schema.json +27 -4
  49. package/pipeline/scripts/_code-graph.mjs +2 -2
  50. package/pipeline/scripts/_transcript-hosts.mjs +406 -0
  51. package/pipeline/scripts/build-stack-plugins.mjs +3 -3
  52. package/pipeline/scripts/capture-flush.sh +7 -7
  53. package/pipeline/scripts/code-graph-rules/android.json +1 -1
  54. package/pipeline/scripts/cost-analyze.mjs +26 -19
  55. package/pipeline/scripts/cost-table.json +35 -19
  56. package/pipeline/scripts/dep-bump.mjs +1556 -0
  57. package/pipeline/scripts/edit-syntax-gate.mjs +151 -29
  58. package/pipeline/scripts/edit-syntax-gate.sh +9 -6
  59. package/pipeline/scripts/gc-abandoned.sh +2 -2
  60. package/pipeline/scripts/gen-mode-dispatch.mjs +3 -3
  61. package/pipeline/scripts/git-path.mjs +1 -2
  62. package/pipeline/scripts/host-hooks.mjs +265 -0
  63. package/pipeline/scripts/jira-attach.sh +1 -1
  64. package/pipeline/scripts/keychain.py +3 -3
  65. package/pipeline/scripts/output-quality-check.sh +1 -1
  66. package/pipeline/scripts/phase-tracker.sh +5 -6
  67. package/pipeline/scripts/phase0-exit-gate.mjs +3 -3
  68. package/pipeline/scripts/pre-push-check.sh +3 -6
  69. package/pipeline/scripts/transcript-index.mjs +423 -242
  70. package/pipeline/scripts/uninstall.mjs +90 -4
  71. package/pipeline/scripts/update-issue-progress.sh +4 -6
  72. package/pipeline/scripts/usage-report.mjs +2 -2
  73. package/pipeline/skills/.skill-manifest.json +14 -10
  74. package/pipeline/skills/.skills-index.json +12 -1
  75. package/pipeline/skills/shared/README.md +4 -3
  76. package/pipeline/skills/shared/core/multi-agent/SKILL.md +6 -6
  77. package/pipeline/skills/shared/core/multi-agent-deps/SKILL.md +60 -0
  78. package/pipeline/skills/shared/core/multi-agent-model/SKILL.md +2 -2
  79. package/pipeline/skills/shared/core/multi-agent-postmortem/SKILL.md +10 -7
  80. package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +5 -5
  81. package/pipeline/skills/shared/core/multi-agent-route-on/SKILL.md +1 -1
  82. package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +7 -5
  83. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -3
  84. package/pipeline/skills/shared/core/multi-agent-testflight-validation/SKILL.md +2 -3
  85. package/pipeline/skills/shared/external/ios-coding-standard/references/rules.yml +2 -2
  86. package/pipeline/skills/skills-index.md +2 -1
@@ -0,0 +1,1556 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dep-bump.mjs - the deterministic half of /multi-agent:deps.
4
+ *
5
+ * Lists dependency candidates per ecosystem from the manifests, classifies the
6
+ * semver risk of a bump, applies ONE named bump to the manifest and its
7
+ * lockfile, and reports the bump's API surface so a fix round can be held to
8
+ * the breakage the bump caused. The model never edits a manifest version by
9
+ * hand: this script does, and it either applies the whole bump or nothing.
10
+ *
11
+ * Ecosystems: SwiftPM (Package.swift, Package.resolved), Gradle version
12
+ * catalogs (gradle/*.versions.toml), npm (package.json plus the lockfile of the
13
+ * resolved package manager) and pip (requirements*.txt, pyproject.toml).
14
+ *
15
+ * Listing works offline from the manifests and from what is already on disk:
16
+ * the lockfile for the current version, SwiftPM checkout tags and the Gradle
17
+ * module cache for newer ones. The registry is queried only with --registry,
18
+ * through the ecosystem's own tool (npm view, git ls-remote, curl for Maven
19
+ * and PyPI metadata); a query that fails is noted per package, never fatal.
20
+ *
21
+ * Usage:
22
+ * dep-bump.mjs detect --dir <repo>
23
+ * dep-bump.mjs list --dir <repo> [--ecosystem <e>] [--name <pkg>] [--registry]
24
+ * dep-bump.mjs apply --dir <repo> --ecosystem <e> --name <pkg> --to <ver>
25
+ * [--manifest <rel>] [--allow-major] [--no-lock]
26
+ * dep-bump.mjs surface --dir <repo> --ecosystem <e> --name <pkg>
27
+ * dep-bump.mjs scope --dir <repo> [--log <build-or-test-log>]
28
+ * dep-bump.mjs notes --dir <repo> --ecosystem <e> --name <pkg> --from <v> --to <v>
29
+ * dep-bump.mjs rollback --dir <repo>
30
+ * dep-bump.mjs config
31
+ * dep-bump.mjs help
32
+ *
33
+ * Output is JSON on stdout (help prints plain usage). Exit codes: 0 ok, 1
34
+ * failure (not found, dirty tree, lockfile tool failed, files changed outside
35
+ * the bump's surface), 2 usage or a --to that is not a version, 3 a major,
36
+ * unclassifiable or prerelease bump without --allow-major.
37
+ *
38
+ * @module pipeline/scripts/dep-bump
39
+ */
40
+
41
+ import { spawnSync } from "node:child_process";
42
+ import {
43
+ existsSync,
44
+ mkdirSync,
45
+ readdirSync,
46
+ readFileSync,
47
+ rmSync,
48
+ statSync,
49
+ writeFileSync,
50
+ } from "node:fs";
51
+ import { homedir } from "node:os";
52
+ import { basename, dirname, extname, join, relative } from "node:path";
53
+
54
+ import { detect as detectPackageManager } from "./package-manager.mjs";
55
+ import { invokedDirectly } from "../lib/invoked-directly.mjs";
56
+
57
+ export const ECOSYSTEMS = ["swiftpm", "gradle", "npm", "pip"];
58
+ export const RECORD = join(".pipeline", "dep-bump.json");
59
+ const PREF_MAX_FIX_ROUNDS = "depBump.maxFixRounds";
60
+ const PREF_QUERY_REGISTRY = "depBump.queryRegistry";
61
+ const DEFAULT_MAX_FIX_ROUNDS = 2;
62
+ const TOOL_TIMEOUT_MS = 10 * 60 * 1000;
63
+ const QUERY_TIMEOUT_MS = 30 * 1000;
64
+ const OUTPUT_TAIL = 4000;
65
+ const PRUNE = new Set([
66
+ "node_modules",
67
+ ".build",
68
+ "build",
69
+ ".git",
70
+ "Pods",
71
+ ".gradle",
72
+ ".pipeline",
73
+ "venv",
74
+ ".venv",
75
+ "dist",
76
+ "DerivedData",
77
+ "Carthage",
78
+ ".worktrees",
79
+ "__pycache__",
80
+ ]);
81
+ const MAX_DEPTH = 4;
82
+
83
+ class UsageError extends Error {}
84
+
85
+ export const USAGE = `Usage:
86
+ dep-bump.mjs detect --dir <repo>
87
+ dep-bump.mjs list --dir <repo> [--ecosystem <e>] [--name <pkg>] [--registry]
88
+ dep-bump.mjs apply --dir <repo> --ecosystem <e> --name <pkg> --to <ver>
89
+ [--manifest <rel>] [--allow-major] [--no-lock]
90
+ dep-bump.mjs surface --dir <repo> --ecosystem <e> --name <pkg>
91
+ dep-bump.mjs scope --dir <repo> [--log <build-or-test-log>]
92
+ dep-bump.mjs notes --dir <repo> --ecosystem <e> --name <pkg> --from <v> --to <v>
93
+ dep-bump.mjs rollback --dir <repo>
94
+ dep-bump.mjs config
95
+ dep-bump.mjs help
96
+
97
+ Ecosystems: ${ECOSYSTEMS.join(", ")}.
98
+ JSON on stdout. Exit: 0 ok, 1 failure, 2 usage, 3 a major, unknown or
99
+ prerelease bump without --allow-major.
100
+ `;
101
+
102
+ // ─── semver ────────────────────────────────────────────────────────────────
103
+
104
+ // Maven marks a release with a qualifier (-jre, -android, .Final, -RELEASE,
105
+ // .GA, .SP1): for gradle that suffix is part of a stable version, not a
106
+ // prerelease tag.
107
+ const MAVEN_STABLE = /[.-](?:final|release|ga|jre|android|sp\d*)$/i;
108
+
109
+ /**
110
+ * Loose semver: `v1.2`, `1.2.3`, `1.2.3-rc.1`; `+build` metadata is dropped.
111
+ * Under gradle a Maven release qualifier is stripped and a dotted qualifier
112
+ * (`1.0.0.Beta2`) reads as a prerelease tag. Anything else is null.
113
+ */
114
+ export function parseVersion(v, ecosystem) {
115
+ let text = String(v ?? "").trim();
116
+ if (ecosystem === "gradle") {
117
+ text = text.replace(MAVEN_STABLE, "").replace(/^(\d+(?:\.\d+){0,2})\.(?=[A-Za-z])/, "$1-");
118
+ }
119
+ const m = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(
120
+ text,
121
+ );
122
+ if (!m) return null;
123
+ return { major: +m[1], minor: +(m[2] ?? 0), patch: +(m[3] ?? 0), pre: m[4] ?? "" };
124
+ }
125
+
126
+ function comparePre(a, b) {
127
+ if (a === b) return 0;
128
+ if (!a) return 1;
129
+ if (!b) return -1;
130
+ const pa = a.split(".");
131
+ const pb = b.split(".");
132
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
133
+ if (pa[i] === undefined) return -1;
134
+ if (pb[i] === undefined) return 1;
135
+ const na = /^\d+$/.test(pa[i]);
136
+ const nb = /^\d+$/.test(pb[i]);
137
+ if (na && nb && +pa[i] !== +pb[i]) return +pa[i] - +pb[i];
138
+ if (na !== nb) return na ? -1 : 1;
139
+ if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1;
140
+ }
141
+ return 0;
142
+ }
143
+
144
+ export function compareVersions(a, b, ecosystem) {
145
+ const x = parseVersion(a, ecosystem);
146
+ const y = parseVersion(b, ecosystem);
147
+ if (!x || !y) return NaN;
148
+ return x.major - y.major || x.minor - y.minor || x.patch - y.patch || comparePre(x.pre, y.pre);
149
+ }
150
+
151
+ /**
152
+ * Risk of moving from `from` to `to`. Under 0.x a minor step is breaking and
153
+ * under 0.0.x every step is, which is what semver says those ranges mean.
154
+ */
155
+ export function classifyRisk(from, to, ecosystem) {
156
+ const a = parseVersion(from, ecosystem);
157
+ const b = parseVersion(to, ecosystem);
158
+ if (!a || !b) return { risk: "unknown", prerelease: false };
159
+ const prerelease = Boolean(b.pre);
160
+ const cmp = compareVersions(from, to, ecosystem);
161
+ if (cmp === 0) return { risk: "none", prerelease };
162
+ if (cmp > 0) return { risk: "downgrade", prerelease };
163
+ if (a.major !== b.major) return { risk: "major", prerelease };
164
+ if (a.major === 0 && a.minor !== b.minor) return { risk: "major", prerelease };
165
+ if (a.major === 0 && a.minor === 0) return { risk: "major", prerelease };
166
+ if (a.minor !== b.minor) return { risk: "minor", prerelease };
167
+ return { risk: "patch", prerelease };
168
+ }
169
+
170
+ /** Highest stable version above `current`, and the highest that is not breaking. */
171
+ export function pickTargets(current, versions, ecosystem) {
172
+ const parsed = (v) => parseVersion(v, ecosystem);
173
+ const newer = versions
174
+ .filter((v) => parsed(v) && !parsed(v).pre)
175
+ .map((v) => v.replace(/^v/, ""))
176
+ .filter((v) => !current || !parsed(current) || compareVersions(v, current, ecosystem) > 0)
177
+ .sort((x, y) => compareVersions(x, y, ecosystem));
178
+ if (newer.length === 0) return { latest: null, latestInMajor: null };
179
+ const latest = newer[newer.length - 1];
180
+ const compatible = current
181
+ ? newer.filter((v) => classifyRisk(current, v, ecosystem).risk !== "major")
182
+ : [];
183
+ return { latest, latestInMajor: compatible.length ? compatible[compatible.length - 1] : null };
184
+ }
185
+
186
+ // --to is written into a manifest as text, so it must be a version in the
187
+ // ecosystem's own grammar: no quote, comma, semicolon, bracket or whitespace can
188
+ // pass, whatever the risk of the bump.
189
+ const SEMVER_TAIL =
190
+ "(?:-[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?";
191
+ const TARGET_GRAMMAR = {
192
+ swiftpm: new RegExp(`^\\d+\\.\\d+\\.\\d+${SEMVER_TAIL}$`),
193
+ npm: new RegExp(`^\\d+(?:\\.\\d+){0,2}${SEMVER_TAIL}$`),
194
+ gradle: /^\d[0-9A-Za-z._-]{0,63}$/,
195
+ pip: /^(?:\d+!)?\d+(?:\.\d+)*(?:(?:a|b|c|rc|alpha|beta|pre|preview)\.?\d*)?(?:\.?post\d*)?(?:\.?dev\d*)?(?:\+[a-z0-9]+(?:\.[a-z0-9]+)*)?$/i,
196
+ };
197
+
198
+ export function validTarget(ecosystem, to) {
199
+ return typeof to === "string" && Boolean(TARGET_GRAMMAR[ecosystem]?.test(to));
200
+ }
201
+
202
+ // ─── manifest readers ──────────────────────────────────────────────────────
203
+
204
+ function lineOf(text, index) {
205
+ return text.slice(0, index).split("\n").length;
206
+ }
207
+
208
+ /**
209
+ * `text` with every Swift comment replaced by spaces, newlines kept, so offsets
210
+ * still index the original. String literals are skipped whole.
211
+ */
212
+ export function blankSwiftComments(text) {
213
+ const out = text.split("");
214
+ for (let i = 0; i < text.length; i++) {
215
+ if (text[i] === '"') {
216
+ for (i++; i < text.length && text[i] !== '"' && text[i] !== "\n"; i++)
217
+ if (text[i] === "\\") i++;
218
+ } else if (text.startsWith("//", i)) {
219
+ for (; i < text.length && text[i] !== "\n"; i++) out[i] = " ";
220
+ } else if (text.startsWith("/*", i)) {
221
+ const end = text.indexOf("*/", i + 2);
222
+ const stop = end === -1 ? text.length : end + 2;
223
+ for (; i < stop; i++) if (text[i] !== "\n") out[i] = " ";
224
+ i--;
225
+ }
226
+ }
227
+ return out.join("");
228
+ }
229
+
230
+ /** Balanced-paren segments of every `.package(` call outside comments. */
231
+ function packageCalls(text) {
232
+ const code = blankSwiftComments(text);
233
+ const calls = [];
234
+ let i = code.indexOf(".package(");
235
+ while (i !== -1) {
236
+ let depth = 0;
237
+ let j = i + ".package".length;
238
+ for (; j < code.length; j++) {
239
+ if (code[j] === "(") depth++;
240
+ else if (code[j] === ")" && --depth === 0) break;
241
+ }
242
+ calls.push({ start: i, end: j + 1, body: text.slice(i, j + 1) });
243
+ i = code.indexOf(".package(", j);
244
+ }
245
+ return calls;
246
+ }
247
+
248
+ export function swiftIdentity(url) {
249
+ return basename(String(url).replace(/\/+$/, ""))
250
+ .replace(/\.git$/, "")
251
+ .toLowerCase();
252
+ }
253
+
254
+ const SWIFT_REQUIREMENTS = [
255
+ ["upToNextMajor", /upToNextMajor\(\s*from:\s*"([^"]+)"/],
256
+ ["upToNextMinor", /upToNextMinor\(\s*from:\s*"([^"]+)"/],
257
+ ["exact", /\bexact:\s*"([^"]+)"/],
258
+ ["from", /\bfrom:\s*"([^"]+)"/],
259
+ ["range", /"([^"]+)"\s*\.\.[<.]\s*"([^"]+)"/],
260
+ ];
261
+
262
+ export function readPackageSwift(text) {
263
+ const deps = [];
264
+ for (const call of packageCalls(text)) {
265
+ const url = /url:\s*"([^"]+)"/.exec(call.body)?.[1];
266
+ if (!url) continue;
267
+ for (const [kind, re] of SWIFT_REQUIREMENTS) {
268
+ const m = re.exec(call.body);
269
+ if (!m) continue;
270
+ deps.push({
271
+ name: swiftIdentity(url),
272
+ url,
273
+ kind,
274
+ declared: m[1],
275
+ line: lineOf(text, call.start),
276
+ });
277
+ break;
278
+ }
279
+ }
280
+ return deps;
281
+ }
282
+
283
+ function swiftProducts(text, identity) {
284
+ const out = new Set();
285
+ const re = /\.product\(\s*name:\s*"([^"]+)"\s*,\s*package:\s*"([^"]+)"/g;
286
+ for (const m of text.matchAll(re)) if (m[2].toLowerCase() === identity) out.add(m[1]);
287
+ return [...out];
288
+ }
289
+
290
+ function readResolvedPins(text) {
291
+ let json;
292
+ try {
293
+ json = JSON.parse(text);
294
+ } catch {
295
+ return [];
296
+ }
297
+ const out = [];
298
+ for (const p of json.pins ?? json.object?.pins ?? []) {
299
+ const url = p.location ?? p.repositoryURL ?? "";
300
+ const id = (p.identity ?? swiftIdentity(url || p.package || "")).toLowerCase();
301
+ if (p.state?.version)
302
+ out.push({ name: id, url, declared: p.state.version, current: p.state.version });
303
+ }
304
+ return out;
305
+ }
306
+
307
+ function readResolved(text) {
308
+ const pins = new Map();
309
+ let json;
310
+ try {
311
+ json = JSON.parse(text);
312
+ } catch {
313
+ return pins;
314
+ }
315
+ for (const p of json.pins ?? json.object?.pins ?? []) {
316
+ const id = p.identity ?? swiftIdentity(p.location ?? p.repositoryURL ?? p.package ?? "");
317
+ if (p.state?.version) pins.set(id.toLowerCase(), p.state.version);
318
+ }
319
+ return pins;
320
+ }
321
+
322
+ function tomlSections(text) {
323
+ const out = [];
324
+ let section = "";
325
+ const lines = text.split("\n");
326
+ lines.forEach((raw, idx) => {
327
+ const line = raw.replace(/\s+#.*$/, "");
328
+ const head = /^\s*\[([^\]]+)\]\s*$/.exec(line);
329
+ if (head) {
330
+ section = head[1].trim();
331
+ return;
332
+ }
333
+ out.push({ section, line, idx });
334
+ });
335
+ return out;
336
+ }
337
+
338
+ export function readVersionCatalog(text) {
339
+ const rows = tomlSections(text);
340
+ const versions = new Map();
341
+ for (const { section, line } of rows) {
342
+ if (section !== "versions") continue;
343
+ const m = /^\s*([\w.-]+)\s*=\s*(?:"([^"]+)"|\{[^}]*?"([^"]+)")/.exec(line);
344
+ if (m) versions.set(m[1], m[2] ?? m[3]);
345
+ }
346
+ const deps = [];
347
+ for (const { section, line, idx } of rows) {
348
+ if (section !== "libraries" && section !== "plugins") continue;
349
+ const m = /^\s*([\w.-]+)\s*=\s*(.+)$/.exec(line);
350
+ if (!m) continue;
351
+ const [, alias, rhs] = m;
352
+ const kind = section === "plugins" ? "plugin" : "library";
353
+ let name;
354
+ let declared;
355
+ let versionRef;
356
+ const str = /^"([^"]+)"/.exec(rhs);
357
+ if (str) {
358
+ const parts = str[1].split(":");
359
+ if (kind === "library" && parts.length === 3)
360
+ [name, declared] = [`${parts[0]}:${parts[1]}`, parts[2]];
361
+ else if (kind === "plugin" && parts.length === 2) [name, declared] = parts;
362
+ else continue;
363
+ } else {
364
+ const get = (k) => new RegExp(`\\b${k.replace(".", "\\.")}\\s*=\\s*"([^"]+)"`).exec(rhs)?.[1];
365
+ name =
366
+ kind === "plugin"
367
+ ? get("id")
368
+ : (get("module") ??
369
+ (get("group") && get("name") ? `${get("group")}:${get("name")}` : null));
370
+ versionRef = get("version.ref") ?? /version\s*=\s*\{\s*ref\s*=\s*"([^"]+)"/.exec(rhs)?.[1];
371
+ declared = versionRef ? versions.get(versionRef) : get("version");
372
+ }
373
+ if (!name || !declared) continue;
374
+ deps.push({
375
+ name,
376
+ alias,
377
+ kind,
378
+ declared,
379
+ ...(versionRef ? { versionRef } : {}),
380
+ line: idx + 1,
381
+ });
382
+ }
383
+ return deps;
384
+ }
385
+
386
+ const PEP508_HEAD = /^([A-Za-z0-9][A-Za-z0-9._-]*)\s*(\[[^\]]*\])?\s*\(?([^;#]*)/;
387
+ const PEP440_CLAUSE = /^\s*(===|==|~=|>=|<=|!=|>|<)\s*([^\s,;#()]+)\s*$/;
388
+ const PRIMARY_OPS = new Set(["===", "==", "~=", ">=", ">"]);
389
+
390
+ /**
391
+ * A PEP 508 requirement split into its version clauses. The primary clause is
392
+ * the first exact or lower bound; `<`, `<=` and `!=` only constrain it, so a
393
+ * requirement made of those alone has nothing to bump and reads as null.
394
+ */
395
+ export function parsePep508(text) {
396
+ const m = PEP508_HEAD.exec(String(text).trim());
397
+ if (!m) return null;
398
+ const spec = m[3].replace(/\)\s*$/, "").trim();
399
+ if (!spec) return null;
400
+ const clauses = [];
401
+ for (const part of spec.split(",")) {
402
+ const c = PEP440_CLAUSE.exec(part);
403
+ if (!c) return null;
404
+ clauses.push({ op: c[1], version: c[2] });
405
+ }
406
+ const primary = clauses.find((c) => PRIMARY_OPS.has(c.op));
407
+ if (!primary) return null;
408
+ return { name: m[1], op: primary.op, declared: primary.version, clauses };
409
+ }
410
+
411
+ export function readRequirements(text) {
412
+ const deps = [];
413
+ text.split("\n").forEach((raw, idx) => {
414
+ const line = raw.replace(/\s#.*$/, "").trim();
415
+ if (!line || line.startsWith("#") || line.startsWith("-")) return;
416
+ const d = parsePep508(line);
417
+ if (d) deps.push({ ...d, line: idx + 1 });
418
+ });
419
+ return deps;
420
+ }
421
+
422
+ /** The clause of a pip requirement that `to` would violate, or null. */
423
+ export function pipExclusion(dep, to) {
424
+ for (const c of dep.clauses ?? []) {
425
+ if (c.op === dep.op && c.version === dep.declared) continue;
426
+ const label = `${c.op}${c.version}`;
427
+ if (c.version.endsWith(".*")) {
428
+ const prefix = c.version.slice(0, -2);
429
+ const inside = to === prefix || to.startsWith(`${prefix}.`);
430
+ if ((c.op === "!=" && inside) || (c.op === "==" && !inside)) return label;
431
+ continue;
432
+ }
433
+ const cmp = compareVersions(to, c.version);
434
+ if (Number.isNaN(cmp)) return label;
435
+ const ok = {
436
+ "<": cmp < 0,
437
+ "<=": cmp <= 0,
438
+ "!=": cmp !== 0,
439
+ "==": cmp === 0,
440
+ "===": cmp === 0,
441
+ ">": cmp > 0,
442
+ ">=": cmp >= 0,
443
+ "~=": cmp >= 0,
444
+ }[c.op];
445
+ if (!ok) return label;
446
+ }
447
+ return null;
448
+ }
449
+
450
+ export function readPyproject(text) {
451
+ const deps = [];
452
+ for (const { section, line, idx } of tomlSections(text)) {
453
+ if (/^tool\.poetry\.(group\.[^.]+\.)?(dev-)?dependencies$/.test(section)) {
454
+ const m = /^\s*([A-Za-z0-9][\w.-]*)\s*=\s*(?:"([^"]+)"|\{[^}]*version\s*=\s*"([^"]+)")/.exec(
455
+ line,
456
+ );
457
+ if (!m || m[1] === "python") continue;
458
+ const spec = m[2] ?? m[3];
459
+ const v = /^(\^|~=|~|>=|==)?\s*(\d[\w.-]*)$/.exec(spec);
460
+ if (v)
461
+ deps.push({ name: m[1], op: v[1] ?? "", declared: v[2], line: idx + 1, style: "poetry" });
462
+ continue;
463
+ }
464
+ if (
465
+ section === "project" ||
466
+ section === "project.optional-dependencies" ||
467
+ section === "dependency-groups"
468
+ ) {
469
+ for (const q of line.matchAll(/"([^"]+)"/g)) {
470
+ const d = parsePep508(q[1]);
471
+ if (d) deps.push({ ...d, line: idx + 1, style: "pep621" });
472
+ }
473
+ }
474
+ }
475
+ return deps;
476
+ }
477
+
478
+ const NPM_SECTIONS = ["dependencies", "devDependencies", "optionalDependencies"];
479
+ const NPM_SPEC = /^(\^|~|>=|=)?v?(\d+(?:\.\d+){0,2}(?:-[\w.]+)?)$/;
480
+
481
+ export function readPackageJson(text) {
482
+ const pkg = JSON.parse(text);
483
+ const deps = [];
484
+ for (const section of NPM_SECTIONS) {
485
+ for (const [name, spec] of Object.entries(pkg[section] ?? {})) {
486
+ const m = NPM_SPEC.exec(String(spec).trim());
487
+ if (m) deps.push({ name, section, spec, op: m[1] ?? "", declared: m[2] });
488
+ }
489
+ }
490
+ return deps;
491
+ }
492
+
493
+ function readNpmLock(dir) {
494
+ const p = join(dir, "package-lock.json");
495
+ if (!existsSync(p)) return new Map();
496
+ try {
497
+ const lock = JSON.parse(readFileSync(p, "utf8"));
498
+ const out = new Map();
499
+ for (const [k, v] of Object.entries(lock.packages ?? {})) {
500
+ if (k.startsWith("node_modules/") && !k.slice(13).includes("node_modules/") && v.version) {
501
+ out.set(k.slice(13), v.version);
502
+ }
503
+ }
504
+ for (const [k, v] of Object.entries(lock.dependencies ?? {}))
505
+ if (!out.has(k) && v.version) out.set(k, v.version);
506
+ return out;
507
+ } catch {
508
+ return new Map();
509
+ }
510
+ }
511
+
512
+ function readPyLock(dir) {
513
+ const out = new Map();
514
+ for (const f of ["poetry.lock", "uv.lock"]) {
515
+ const p = join(dir, f);
516
+ if (!existsSync(p)) continue;
517
+ const text = readFileSync(p, "utf8");
518
+ for (const m of text.matchAll(
519
+ /\[\[package\]\]\s*\nname\s*=\s*"([^"]+)"\s*\nversion\s*=\s*"([^"]+)"/g,
520
+ )) {
521
+ out.set(normalizePy(m[1]), m[2]);
522
+ }
523
+ }
524
+ return out;
525
+ }
526
+
527
+ const normalizePy = (n) => n.toLowerCase().replace(/[-_.]+/g, "-");
528
+
529
+ // ─── discovery ─────────────────────────────────────────────────────────────
530
+
531
+ // An Xcode project keeps its own SwiftPM pins inside the bundle; the bundle is
532
+ // probed at that one path and never walked.
533
+ const XCODE_RESOLVED_IN = {
534
+ ".xcodeproj": "project.xcworkspace/xcshareddata/swiftpm/Package.resolved",
535
+ ".xcworkspace": "xcshareddata/swiftpm/Package.resolved",
536
+ };
537
+ const XCODE_RESOLVED =
538
+ /\.(xcodeproj\/project\.xcworkspace|xcworkspace)\/xcshareddata\/swiftpm\/Package\.resolved$/;
539
+ const XCODE_NOTE =
540
+ "pinned by an Xcode project: the requirement lives in project.pbxproj, which dep-bump does not edit; bump it in Xcode (File > Packages) instead";
541
+
542
+ const isXcodeResolved = (manifest) => XCODE_RESOLVED.test(manifest);
543
+
544
+ function walk(root, visit, rel = "", depth = 0) {
545
+ let entries;
546
+ try {
547
+ entries = readdirSync(join(root, rel), { withFileTypes: true });
548
+ } catch {
549
+ return;
550
+ }
551
+ for (const e of entries) {
552
+ const r = rel ? `${rel}/${e.name}` : e.name;
553
+ if (e.isDirectory()) {
554
+ const xcode = XCODE_RESOLVED_IN[extname(e.name)];
555
+ if (xcode) {
556
+ if (existsSync(join(root, r, xcode))) visit(`${r}/${xcode}`, "Package.resolved");
557
+ } else if (!PRUNE.has(e.name) && depth < MAX_DEPTH) walk(root, visit, r, depth + 1);
558
+ } else if (e.isFile()) visit(r, e.name);
559
+ }
560
+ }
561
+
562
+ function manifestEcosystem(rel, name) {
563
+ if (name === "Package.swift" || isXcodeResolved(rel)) return "swiftpm";
564
+ if (name === "package.json") return "npm";
565
+ if (name.endsWith(".versions.toml") && /(^|\/)gradle\/[^/]+$/.test(rel)) return "gradle";
566
+ if (/^requirements[\w.-]*\.txt$/.test(name) || name === "pyproject.toml") return "pip";
567
+ return null;
568
+ }
569
+
570
+ export function detect(dir) {
571
+ const found = [];
572
+ walk(dir, (rel, name) => {
573
+ const ecosystem = manifestEcosystem(rel, name);
574
+ if (!ecosystem) return;
575
+ if (isXcodeResolved(rel))
576
+ found.push({ ecosystem, manifest: rel, lockfile: null, readOnly: true });
577
+ else {
578
+ const lockError = ecosystem === "npm" ? npmLockRoot(dir, rel).error : undefined;
579
+ found.push({
580
+ ecosystem,
581
+ manifest: rel,
582
+ lockfile: lockfileFor(dir, ecosystem, rel),
583
+ ...(lockError ? { lockError } : {}),
584
+ });
585
+ }
586
+ });
587
+ found.sort(
588
+ (a, b) => a.ecosystem.localeCompare(b.ecosystem) || a.manifest.localeCompare(b.manifest),
589
+ );
590
+ return found;
591
+ }
592
+
593
+ /** The directory a Gradle catalog belongs to is the one holding `gradle/`. */
594
+ function manifestRoot(dir, ecosystem, manifest) {
595
+ const d = dirname(join(dir, manifest));
596
+ return ecosystem === "gradle" ? dirname(d) : d;
597
+ }
598
+
599
+ const NPM_LOCKFILES = [
600
+ "package-lock.json",
601
+ "npm-shrinkwrap.json",
602
+ "pnpm-lock.yaml",
603
+ "yarn.lock",
604
+ "bun.lock",
605
+ "bun.lockb",
606
+ ];
607
+
608
+ function isWorkspaceRoot(d) {
609
+ if (existsSync(join(d, "pnpm-workspace.yaml"))) return true;
610
+ try {
611
+ return Boolean(JSON.parse(readFileSync(join(d, "package.json"), "utf8")).workspaces);
612
+ } catch {
613
+ return false;
614
+ }
615
+ }
616
+
617
+ /**
618
+ * Where an npm manifest's lockfile lives. A workspace member has none of its
619
+ * own: the nearest lockfile above it, up to the repo root, belongs to the
620
+ * workspace root, and the lockfile tool runs there. A lockfile above a manifest
621
+ * in a directory that declares no workspaces is reported as `error`, so the
622
+ * bump fails instead of going ahead with no lockfile.
623
+ */
624
+ function npmLockRoot(dir, manifest) {
625
+ const own = dirname(join(dir, manifest));
626
+ const top = join(dir);
627
+ for (let d = own; ; d = dirname(d)) {
628
+ const hit = NPM_LOCKFILES.find((f) => existsSync(join(d, f)));
629
+ if (hit) {
630
+ const lockfile = relative(dir, join(d, hit));
631
+ if (d === own || isWorkspaceRoot(d)) return { root: d, lockfile };
632
+ return {
633
+ root: own,
634
+ lockfile,
635
+ error: `${lockfile} sits above ${manifest} in a directory that declares no workspaces`,
636
+ };
637
+ }
638
+ if (d === top || d === dirname(d)) return { root: own, lockfile: null };
639
+ }
640
+ }
641
+
642
+ function lockfileFor(dir, ecosystem, manifest) {
643
+ if (ecosystem === "npm") return npmLockRoot(dir, manifest).lockfile;
644
+ const root = manifestRoot(dir, ecosystem, manifest);
645
+ const candidates = {
646
+ swiftpm: ["Package.resolved"],
647
+ gradle: ["gradle.lockfile"],
648
+ pip: basename(manifest) === "pyproject.toml" ? ["poetry.lock", "uv.lock"] : [],
649
+ }[ecosystem];
650
+ const hit = candidates.find((f) => existsSync(join(root, f)));
651
+ return hit ? relative(dir, join(root, hit)) : null;
652
+ }
653
+
654
+ function readDeps(dir, ecosystem, manifest) {
655
+ const text = readFileSync(join(dir, manifest), "utf8");
656
+ const root = manifestRoot(dir, ecosystem, manifest);
657
+ if (ecosystem === "swiftpm" && isXcodeResolved(manifest)) {
658
+ return readResolvedPins(text).map((p) => ({ ...p, kind: "xcode-pin", readOnly: true }));
659
+ }
660
+ if (ecosystem === "swiftpm") {
661
+ const pins = existsSync(join(root, "Package.resolved"))
662
+ ? readResolved(readFileSync(join(root, "Package.resolved"), "utf8"))
663
+ : new Map();
664
+ return readPackageSwift(text).map((d) => ({ ...d, current: pins.get(d.name) ?? d.declared }));
665
+ }
666
+ if (ecosystem === "gradle")
667
+ return readVersionCatalog(text).map((d) => ({ ...d, current: d.declared }));
668
+ if (ecosystem === "npm") {
669
+ const lock = readNpmLock(npmLockRoot(dir, manifest).root);
670
+ return readPackageJson(text).map((d) => ({ ...d, current: lock.get(d.name) ?? d.declared }));
671
+ }
672
+ const lock = readPyLock(root);
673
+ const deps =
674
+ basename(manifest) === "pyproject.toml" ? readPyproject(text) : readRequirements(text);
675
+ return deps.map((d) => ({ ...d, current: lock.get(normalizePy(d.name)) ?? d.declared }));
676
+ }
677
+
678
+ // ─── version sources ───────────────────────────────────────────────────────
679
+
680
+ function sh(cmd, args, opts = {}) {
681
+ const res = spawnSync(cmd, args, {
682
+ encoding: "utf8",
683
+ timeout: opts.timeout ?? QUERY_TIMEOUT_MS,
684
+ cwd: opts.cwd,
685
+ maxBuffer: 32 * 1024 * 1024,
686
+ });
687
+ const output = `${res.stdout ?? ""}${res.stderr ?? ""}`;
688
+ return {
689
+ ok: res.status === 0 && !res.error,
690
+ stdout: res.stdout ?? "",
691
+ output: res.error ? `${res.error.code ?? res.error.message}` : output,
692
+ };
693
+ }
694
+
695
+ function gradleCacheVersions(dep) {
696
+ const home = process.env.GRADLE_USER_HOME || join(homedir(), ".gradle");
697
+ const [group, artifact] =
698
+ dep.kind === "plugin" ? [dep.name, `${dep.name}.gradle.plugin`] : dep.name.split(":");
699
+ const d = join(home, "caches", "modules-2", "files-2.1", group, artifact ?? "");
700
+ try {
701
+ return readdirSync(d).filter((v) => statSync(join(d, v)).isDirectory());
702
+ } catch {
703
+ return [];
704
+ }
705
+ }
706
+
707
+ function checkoutTags(root, dep) {
708
+ const co = join(root, ".build", "checkouts", dep.name);
709
+ if (!existsSync(co)) return [];
710
+ const r = sh("git", ["-C", co, "tag", "--list"]);
711
+ return r.ok ? r.stdout.split("\n").filter(Boolean) : [];
712
+ }
713
+
714
+ function offlineVersions(dir, ecosystem, manifest, dep) {
715
+ if (ecosystem === "gradle") return { versions: gradleCacheVersions(dep), source: "gradle-cache" };
716
+ if (ecosystem === "swiftpm") {
717
+ return {
718
+ versions: checkoutTags(manifestRoot(dir, ecosystem, manifest), dep),
719
+ source: "checkout-tags",
720
+ };
721
+ }
722
+ return { versions: [], source: null };
723
+ }
724
+
725
+ function mavenMetadataUrl(dep) {
726
+ const [group, artifact] =
727
+ dep.kind === "plugin" ? [dep.name, `${dep.name}.gradle.plugin`] : dep.name.split(":");
728
+ const google = /^(androidx\.|com\.android\.|com\.google\.android\.)/.test(group);
729
+ const base = google
730
+ ? "https://dl.google.com/android/maven2"
731
+ : "https://repo.maven.apache.org/maven2";
732
+ return `${base}/${group.replace(/\./g, "/")}/${artifact}/maven-metadata.xml`;
733
+ }
734
+
735
+ // Names and urls come from the repo's manifests, so each one is checked against
736
+ // its ecosystem's grammar before it reaches a tool's argv: a value that starts
737
+ // with "-" would otherwise be parsed as an option (git --upload-pack runs a
738
+ // command, npm --registry redirects the query).
739
+ const REMOTE_URL = /^(?:(?:https?|ssh|git|file):\/\/[^\s]+|git@[^\s]+:[^\s]+)$/;
740
+ const NAME_GRAMMAR = {
741
+ npm: /^(?:@[a-z0-9~][a-z0-9._~-]*\/)?[a-z0-9~][a-z0-9._~-]*$/i,
742
+ gradle: /^[A-Za-z0-9_][A-Za-z0-9._-]*(?::[A-Za-z0-9_][A-Za-z0-9._-]*)?$/,
743
+ pip: /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/,
744
+ };
745
+
746
+ /** Why `dep` may not be queried, or null when its name or url is well formed. */
747
+ export function queryRefusal(ecosystem, dep) {
748
+ if (ecosystem === "swiftpm") {
749
+ return REMOTE_URL.test(String(dep.url ?? ""))
750
+ ? null
751
+ : `url ${JSON.stringify(dep.url)} is not an http(s), ssh, git, file or git@host: url`;
752
+ }
753
+ return NAME_GRAMMAR[ecosystem]?.test(String(dep.name ?? ""))
754
+ ? null
755
+ : `name ${JSON.stringify(dep.name)} is not a valid ${ecosystem} package name`;
756
+ }
757
+
758
+ // Every tool runs in the manifest's directory, so the repo's own configuration
759
+ // (.npmrc registries and scopes, git and curl settings) applies to the query.
760
+ function registryVersions(ecosystem, dep, cwd) {
761
+ const refusal = queryRefusal(ecosystem, dep);
762
+ if (refusal) return { skipped: refusal };
763
+ let r;
764
+ if (ecosystem === "npm") {
765
+ r = sh("npm", ["view", "--json", "--", dep.name, "versions"], { cwd });
766
+ if (!r.ok) return { error: r.output.trim().split("\n").pop() };
767
+ try {
768
+ const parsed = JSON.parse(r.stdout);
769
+ const list = Array.isArray(parsed) ? parsed : (parsed.versions ?? [parsed]);
770
+ return { versions: list.map(String) };
771
+ } catch {
772
+ return { error: "unparseable npm view output" };
773
+ }
774
+ }
775
+ if (ecosystem === "swiftpm") {
776
+ r = sh("git", ["ls-remote", "--tags", "--refs", "--", dep.url], { cwd });
777
+ if (!r.ok) return { error: r.output.trim().split("\n").pop() };
778
+ return { versions: [...r.stdout.matchAll(/refs\/tags\/(\S+)/g)].map((m) => m[1]) };
779
+ }
780
+ if (ecosystem === "gradle") {
781
+ r = sh("curl", ["-fsSL", "--max-time", "20", mavenMetadataUrl(dep)], { cwd });
782
+ if (!r.ok) return { error: r.output.trim().split("\n").pop() };
783
+ return { versions: [...r.stdout.matchAll(/<version>([^<]+)<\/version>/g)].map((m) => m[1]) };
784
+ }
785
+ r = sh("curl", ["-fsSL", "--max-time", "20", `https://pypi.org/pypi/${dep.name}/json`], {
786
+ cwd,
787
+ });
788
+ if (!r.ok) return { error: r.output.trim().split("\n").pop() };
789
+ try {
790
+ return { versions: Object.keys(JSON.parse(r.stdout).releases ?? {}) };
791
+ } catch {
792
+ return { error: "unparseable PyPI response" };
793
+ }
794
+ }
795
+
796
+ export function list(dir, { ecosystem, name, registry = false } = {}) {
797
+ const candidates = [];
798
+ for (const m of detect(dir)) {
799
+ if (ecosystem && m.ecosystem !== ecosystem) continue;
800
+ let deps;
801
+ try {
802
+ deps = readDeps(dir, m.ecosystem, m.manifest);
803
+ } catch (err) {
804
+ candidates.push({ ecosystem: m.ecosystem, manifest: m.manifest, error: String(err.message) });
805
+ continue;
806
+ }
807
+ for (const dep of deps) {
808
+ if (name && dep.name !== name) continue;
809
+ let versions = [];
810
+ let source = null;
811
+ let note;
812
+ if (registry) {
813
+ const r = registryVersions(m.ecosystem, dep, dirname(join(dir, m.manifest)));
814
+ if (r.skipped) note = `registry query skipped: ${r.skipped}`;
815
+ else if (r.error) note = `registry query failed: ${r.error}`;
816
+ else [versions, source] = [r.versions, "registry"];
817
+ }
818
+ if (!source) {
819
+ const off = offlineVersions(dir, m.ecosystem, m.manifest, dep);
820
+ if (off.versions.length) [versions, source] = [off.versions, off.source];
821
+ }
822
+ const t = pickTargets(dep.current, versions, m.ecosystem);
823
+ candidates.push({
824
+ ecosystem: m.ecosystem,
825
+ manifest: m.manifest,
826
+ name: dep.name,
827
+ declared: dep.declared,
828
+ current: dep.current,
829
+ latest: t.latest,
830
+ latestInMajor: t.latestInMajor,
831
+ risk: t.latest ? classifyRisk(dep.current, t.latest, m.ecosystem).risk : null,
832
+ riskInMajor: t.latestInMajor
833
+ ? classifyRisk(dep.current, t.latestInMajor, m.ecosystem).risk
834
+ : null,
835
+ source,
836
+ ...(dep.readOnly ? { readOnly: true } : {}),
837
+ ...(note || dep.readOnly
838
+ ? { note: [dep.readOnly ? XCODE_NOTE : null, note].filter(Boolean).join("; ") }
839
+ : {}),
840
+ });
841
+ }
842
+ }
843
+ return {
844
+ dir,
845
+ registry: registry
846
+ ? {
847
+ queried: true,
848
+ note: "registry queried through the ecosystem tool; failures are noted per package",
849
+ }
850
+ : {
851
+ queried: false,
852
+ note: "registry query skipped (offline listing from manifests, lockfiles and local caches; pass --registry to query)",
853
+ },
854
+ candidates,
855
+ };
856
+ }
857
+
858
+ const esc = (s) => s.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
859
+
860
+ // ─── apply ─────────────────────────────────────────────────────────────────
861
+
862
+ function replaceAt(text, start, end, from, to) {
863
+ const seg = text.slice(start, end);
864
+ const i = seg.indexOf(from);
865
+ if (i === -1) return null;
866
+ return text.slice(0, start) + seg.slice(0, i) + to + seg.slice(i + from.length) + text.slice(end);
867
+ }
868
+
869
+ function lineSpan(text, line) {
870
+ let start = 0;
871
+ for (let i = 1; i < line; i++) start = text.indexOf("\n", start) + 1;
872
+ const nl = text.indexOf("\n", start);
873
+ return [start, nl === -1 ? text.length : nl];
874
+ }
875
+
876
+ function editSwift(text, dep, to) {
877
+ const call = packageCalls(text).find(
878
+ (c) => swiftIdentity(/url:\s*"([^"]+)"/.exec(c.body)?.[1] ?? "") === dep.name,
879
+ );
880
+ if (!call) return null;
881
+ if (dep.kind === "range") {
882
+ const m = /"([^"]+)"(\s*)(\.\.[<.])(\s*)"([^"]+)"/.exec(call.body);
883
+ if (!m) return null;
884
+ const [, , gapA, op, gapB, upper] = m;
885
+ const cmp = compareVersions(to, upper);
886
+ const inside = op === "..." ? cmp <= 0 : cmp < 0;
887
+ const range = inside
888
+ ? `"${to}"${gapA}${op}${gapB}"${upper}"`
889
+ : `"${to}"${gapA}..<${gapB}"${parseVersion(to).major + 1}.0.0"`;
890
+ const body = call.body.slice(0, m.index) + range + call.body.slice(m.index + m[0].length);
891
+ return text.slice(0, call.start) + body + text.slice(call.end);
892
+ }
893
+ return replaceAt(text, call.start, call.end, `"${dep.declared}"`, `"${to}"`);
894
+ }
895
+
896
+ function editCatalog(text, dep, to) {
897
+ if (dep.versionRef) {
898
+ const rows = tomlSections(text).filter(
899
+ (r) =>
900
+ r.section === "versions" &&
901
+ new RegExp(`^\\s*${dep.versionRef.replace(/[.-]/g, "\\$&")}\\s*=`).test(r.line),
902
+ );
903
+ if (!rows.length) return null;
904
+ const [s, e] = lineSpan(text, rows[0].idx + 1);
905
+ return replaceAt(text, s, e, `"${dep.declared}"`, `"${to}"`);
906
+ }
907
+ const [s, e] = lineSpan(text, dep.line);
908
+ return replaceAt(text, s, e, `${dep.declared}"`, `${to}"`);
909
+ }
910
+
911
+ /** [start, end) of the object value of top-level key `key` in a JSON text. */
912
+ export function jsonObjectSpan(text, key) {
913
+ let depth = 0;
914
+ let pendingKey = null;
915
+ for (let i = 0; i < text.length; i++) {
916
+ const ch = text[i];
917
+ if (ch === '"') {
918
+ let j = i + 1;
919
+ while (j < text.length && text[j] !== '"') j += text[j] === "\\" ? 2 : 1;
920
+ const str = text.slice(i + 1, j);
921
+ if (depth === 1 && /^\s*:/.test(text.slice(j + 1))) pendingKey = str;
922
+ i = j;
923
+ continue;
924
+ }
925
+ if (ch === "{" || ch === "[") {
926
+ if (depth === 1 && ch === "{" && pendingKey === key) {
927
+ let d = 0;
928
+ for (let k = i; k < text.length; k++) {
929
+ const c = text[k];
930
+ if (c === '"') {
931
+ k++;
932
+ while (k < text.length && text[k] !== '"') k += text[k] === "\\" ? 2 : 1;
933
+ } else if (c === "{") d++;
934
+ else if (c === "}" && --d === 0) return [i, k + 1];
935
+ }
936
+ return null;
937
+ }
938
+ depth++;
939
+ pendingKey = null;
940
+ } else if (ch === "}" || ch === "]") depth--;
941
+ else if (ch === ",") pendingKey = null;
942
+ }
943
+ return null;
944
+ }
945
+
946
+ function editNpm(text, dep, to) {
947
+ const span = jsonObjectSpan(text, dep.section);
948
+ if (!span) return null;
949
+ const seg = text.slice(span[0], span[1]);
950
+ const re = new RegExp(`("${esc(dep.name)}"\\s*:\\s*")${esc(dep.spec)}"`);
951
+ const next = seg.replace(re, `$1${dep.op}${to}"`);
952
+ return next === seg ? null : text.slice(0, span[0]) + next + text.slice(span[1]);
953
+ }
954
+
955
+ function editPip(text, dep, to) {
956
+ const [s, e] = lineSpan(text, dep.line);
957
+ const seg = text.slice(s, e);
958
+ if (dep.style === "poetry") {
959
+ const re = new RegExp(`"${esc(dep.op || "")}\\s*${esc(dep.declared)}"`);
960
+ const replaced = seg.replace(re, `"${dep.op}${to}"`);
961
+ return replaced === seg ? null : text.slice(0, s) + replaced + text.slice(e);
962
+ }
963
+ const at = new RegExp(`(^|["'\\s])${esc(dep.name)}(?=\\s*[\\[(<>=!~])`).exec(seg);
964
+ if (!at) return null;
965
+ const from = at.index + at[0].length;
966
+ const stop = seg.slice(from).search(/["';#]/);
967
+ const to_ = stop === -1 ? seg.length : from + stop;
968
+ const spec = seg.slice(from, to_);
969
+ const clause = new RegExp(`(^|[\\s,(\\]])${esc(dep.op)}(\\s*)${esc(dep.declared)}(?![\\w.*])`);
970
+ const next = spec.replace(clause, (_, lead, gap) => `${lead}${dep.op}${gap}${to}`);
971
+ if (next === spec) return null;
972
+ return text.slice(0, s) + seg.slice(0, from) + next + seg.slice(to_) + text.slice(e);
973
+ }
974
+
975
+ const EDITORS = { swiftpm: editSwift, gradle: editCatalog, npm: editNpm, pip: editPip };
976
+
977
+ /** Major version a tool reports for --version, or null when it cannot say. */
978
+ function toolMajor(cmd, cwd) {
979
+ const r = sh(cmd, ["--version"], { cwd });
980
+ const m = r.ok ? /(\d+)\.\d+/.exec(r.stdout) : null;
981
+ return m ? +m[1] : null;
982
+ }
983
+
984
+ function lockCommand(dir, ecosystem, manifest, lockfile, name) {
985
+ const cwd =
986
+ ecosystem === "npm" ? npmLockRoot(dir, manifest).root : manifestRoot(dir, ecosystem, manifest);
987
+ if (ecosystem === "swiftpm") return { cwd, cmd: "swift", args: ["package", "resolve"] };
988
+ if (ecosystem === "gradle") {
989
+ const wrapper = existsSync(join(cwd, "gradlew"));
990
+ return { cwd, cmd: wrapper ? "./gradlew" : "gradle", args: ["dependencies", "--write-locks"] };
991
+ }
992
+ if (ecosystem === "npm") {
993
+ const pm = detectPackageManager(cwd).pm;
994
+ const yarnBerry = pm === "yarn" && (toolMajor("yarn", cwd) ?? 1) >= 2;
995
+ const args = {
996
+ npm: ["install", "--package-lock-only", "--ignore-scripts", "--no-audit", "--no-fund"],
997
+ pnpm: ["install", "--lockfile-only", "--ignore-scripts"],
998
+ yarn: yarnBerry ? ["install", "--mode=skip-build"] : ["install", "--ignore-scripts"],
999
+ bun: ["install", "--ignore-scripts"],
1000
+ }[pm];
1001
+ return args ? { cwd, cmd: pm, args } : null;
1002
+ }
1003
+ if (basename(lockfile) === "poetry.lock") {
1004
+ const modern = (toolMajor("poetry", cwd) ?? 1) >= 2;
1005
+ return { cwd, cmd: "poetry", args: modern ? ["lock"] : ["lock", "--no-update"] };
1006
+ }
1007
+ return { cwd, cmd: "uv", args: ["lock", "--upgrade-package", name] };
1008
+ }
1009
+
1010
+ function git(dir, args) {
1011
+ return sh("git", ["-C", dir, ...args], { timeout: 60000 });
1012
+ }
1013
+
1014
+ function untracked(dir) {
1015
+ const r = git(dir, ["ls-files", "--others", "--exclude-standard"]);
1016
+ return r.ok ? r.stdout.split("\n").filter((f) => f && !f.startsWith(".pipeline/")) : [];
1017
+ }
1018
+
1019
+ /** Tracked files with uncommitted changes; empty outside a git checkout. */
1020
+ function dirtyTracked(dir) {
1021
+ const r = git(dir, ["status", "--porcelain", "--untracked-files=no"]);
1022
+ if (!r.ok) return [];
1023
+ return r.stdout
1024
+ .split("\n")
1025
+ .filter(Boolean)
1026
+ .map((l) => l.slice(3).replace(/^.* -> /, ""))
1027
+ .filter((f) => !f.startsWith(".pipeline/"));
1028
+ }
1029
+
1030
+ function writeRecord(dir, rec) {
1031
+ mkdirSync(join(dir, ".pipeline"), { recursive: true });
1032
+ writeFileSync(join(dir, RECORD), `${JSON.stringify(rec, null, 2)}\n`);
1033
+ }
1034
+
1035
+ function resolveTarget(dir, ecosystem, name, manifest) {
1036
+ const hits = [];
1037
+ for (const m of detect(dir)) {
1038
+ if (m.ecosystem !== ecosystem || (manifest && m.manifest !== manifest)) continue;
1039
+ for (const d of readDeps(dir, ecosystem, m.manifest)) {
1040
+ const same = ecosystem === "swiftpm" ? d.name === name.toLowerCase() : d.name === name;
1041
+ if (same || d.alias === name) hits.push({ ...m, dep: d });
1042
+ }
1043
+ }
1044
+ return hits;
1045
+ }
1046
+
1047
+ export function apply(dir, opts) {
1048
+ const { ecosystem, name, to, manifest, allowMajor = false, lock = true } = opts;
1049
+ if (!validTarget(ecosystem, to))
1050
+ return { code: 2, out: { error: `${JSON.stringify(to)} is not a valid ${ecosystem} version` } };
1051
+ const found = resolveTarget(dir, ecosystem, name, manifest);
1052
+ const hits = found.filter((h) => !h.readOnly);
1053
+ if (hits.length === 0 && found.length) {
1054
+ return {
1055
+ code: 1,
1056
+ out: {
1057
+ error: `${name} is ${XCODE_NOTE.replace(/^pinned/, "only pinned")}`,
1058
+ manifests: found.map((h) => h.manifest),
1059
+ },
1060
+ };
1061
+ }
1062
+ if (hits.length === 0)
1063
+ return { code: 1, out: { error: `${name} is not declared in any ${ecosystem} manifest` } };
1064
+ if (hits.length > 1) {
1065
+ return {
1066
+ code: 1,
1067
+ out: {
1068
+ error: `${name} is declared in several manifests; pass --manifest`,
1069
+ manifests: hits.map((h) => h.manifest),
1070
+ },
1071
+ };
1072
+ }
1073
+ const hit = hits[0];
1074
+ const dep = hit.dep;
1075
+ if (hit.lockError)
1076
+ return {
1077
+ code: 1,
1078
+ out: { error: `${hit.lockError}; add it to the workspaces or bump it from there` },
1079
+ };
1080
+ const from = dep.current;
1081
+ const { risk, prerelease } = classifyRisk(from, to, ecosystem);
1082
+ const base = { ecosystem, name: dep.name, manifest: hit.manifest, from, to, risk, prerelease };
1083
+ if (risk === "none" || risk === "downgrade")
1084
+ return { code: 1, out: { ...base, error: `${to} is not newer than ${from}` } };
1085
+ if ((risk === "major" || risk === "unknown" || prerelease) && !allowMajor) {
1086
+ const what = prerelease ? `prerelease ${risk}` : risk;
1087
+ return {
1088
+ code: 3,
1089
+ out: { ...base, error: `${what} bump needs --allow-major (or explicit confirmation)` },
1090
+ };
1091
+ }
1092
+
1093
+ const dirty = dirtyTracked(dir);
1094
+ if (dirty.length) {
1095
+ return {
1096
+ code: 1,
1097
+ out: {
1098
+ ...base,
1099
+ error: "the worktree has uncommitted changes to tracked files; commit or stash them first",
1100
+ dirty,
1101
+ },
1102
+ };
1103
+ }
1104
+
1105
+ if (ecosystem === "pip") {
1106
+ const excluded = pipExclusion(dep, to);
1107
+ if (excluded)
1108
+ return {
1109
+ code: 1,
1110
+ out: {
1111
+ ...base,
1112
+ error: `${excluded} in ${hit.manifest} excludes ${to}; dep-bump does not rewrite other bounds, so this bump needs a decision on that bound first`,
1113
+ },
1114
+ };
1115
+ }
1116
+
1117
+ const manifestPath = join(dir, hit.manifest);
1118
+ const before = readFileSync(manifestPath, "utf8");
1119
+ const after = EDITORS[ecosystem](before, dep, to);
1120
+ if (!after)
1121
+ return {
1122
+ code: 1,
1123
+ out: { ...base, error: `could not locate the version text for ${name} in ${hit.manifest}` },
1124
+ };
1125
+
1126
+ const alsoMoves =
1127
+ ecosystem === "gradle" && dep.versionRef
1128
+ ? readVersionCatalog(before)
1129
+ .filter((d) => d.versionRef === dep.versionRef && d.name !== dep.name)
1130
+ .map((d) => d.name)
1131
+ : [];
1132
+
1133
+ const head = git(dir, ["rev-parse", "HEAD"]);
1134
+ const record = {
1135
+ ...base,
1136
+ baseSha: head.ok ? head.stdout.trim() : null,
1137
+ untrackedAtStart: untracked(dir),
1138
+ lockfile: hit.lockfile,
1139
+ surface: [],
1140
+ appliedAt: new Date().toISOString(),
1141
+ };
1142
+
1143
+ const lockBefore = hit.lockfile ? readFileSync(join(dir, hit.lockfile)) : null;
1144
+ writeFileSync(manifestPath, after);
1145
+ let lockResult = { file: hit.lockfile, status: hit.lockfile ? "skipped" : "absent" };
1146
+ if (hit.lockfile && lock) {
1147
+ const c = lockCommand(dir, ecosystem, hit.manifest, hit.lockfile, dep.name);
1148
+ if (!c) {
1149
+ lockResult = {
1150
+ file: hit.lockfile,
1151
+ status: "failed",
1152
+ output: "no lockfile tool for this package manager",
1153
+ };
1154
+ } else {
1155
+ const r = sh(c.cmd, c.args, { cwd: c.cwd, timeout: TOOL_TIMEOUT_MS });
1156
+ lockResult = {
1157
+ file: hit.lockfile,
1158
+ command: [c.cmd, ...c.args].join(" "),
1159
+ status: r.ok ? "updated" : "failed",
1160
+ output: r.output.slice(-OUTPUT_TAIL),
1161
+ };
1162
+ }
1163
+ if (lockResult.status === "failed") {
1164
+ writeFileSync(manifestPath, before);
1165
+ writeFileSync(join(dir, hit.lockfile), lockBefore);
1166
+ return {
1167
+ code: 1,
1168
+ out: {
1169
+ ...base,
1170
+ lock: lockResult,
1171
+ error: "lockfile update failed; manifest and lockfile restored",
1172
+ },
1173
+ };
1174
+ }
1175
+ }
1176
+
1177
+ record.surface = surface(dir, ecosystem, dep.name, alsoMoves).files;
1178
+ writeRecord(dir, { ...record, alsoMoves, lock: lockResult });
1179
+
1180
+ return {
1181
+ code: 0,
1182
+ out: {
1183
+ ...base,
1184
+ edits: [{ file: hit.manifest, before: dep.declared, after: to }],
1185
+ alsoMoves,
1186
+ lock: lockResult,
1187
+ record: RECORD,
1188
+ commitMessage: `chore(deps): bump ${dep.name} from ${from} to ${to}`,
1189
+ },
1190
+ };
1191
+ }
1192
+
1193
+ // ─── surface, scope, rollback ──────────────────────────────────────────────
1194
+
1195
+ function sourceFiles(dir) {
1196
+ const r = git(dir, ["ls-files", "--cached", "--others", "--exclude-standard"]);
1197
+ if (r.ok) return r.stdout.split("\n").filter((f) => f && !f.startsWith(".pipeline/"));
1198
+ const out = [];
1199
+ walk(dir, (rel) => out.push(rel));
1200
+ return out;
1201
+ }
1202
+
1203
+ /**
1204
+ * Module names a SwiftPM package can be imported under: its identity, the
1205
+ * repository name as written in the url, the products other targets name with
1206
+ * `.product(name:package:)`, and by-name target dependencies that match the
1207
+ * identity. Identities compare case-insensitively, module names do not.
1208
+ */
1209
+ function swiftModules(dir, name) {
1210
+ const identity = name.toLowerCase();
1211
+ const modules = new Set([name, identity]);
1212
+ for (const m of detect(dir).filter((x) => x.ecosystem === "swiftpm")) {
1213
+ const raw = readFileSync(join(dir, m.manifest), "utf8");
1214
+ if (m.readOnly) {
1215
+ for (const p of readResolvedPins(raw))
1216
+ if (p.name === identity) modules.add(basename(p.url).replace(/\.git$/, ""));
1217
+ continue;
1218
+ }
1219
+ for (const d of readPackageSwift(raw))
1220
+ if (d.name === identity)
1221
+ modules.add(basename(d.url.replace(/\/+$/, "")).replace(/\.git$/, ""));
1222
+ const code = blankSwiftComments(raw);
1223
+ for (const p of swiftProducts(code, identity)) modules.add(p);
1224
+ for (const q of code.matchAll(/"([A-Za-z_]\w*)"/g))
1225
+ if (q[1].toLowerCase() === identity) modules.add(q[1]);
1226
+ }
1227
+ return modules;
1228
+ }
1229
+
1230
+ function surfaceMatchers(dir, ecosystem, name, alsoMoves) {
1231
+ if (ecosystem === "npm") {
1232
+ const n = esc(name);
1233
+ return [
1234
+ {
1235
+ ext: /\.(c|m)?(j|t)sx?$|\.(vue|svelte)$/,
1236
+ re: new RegExp(
1237
+ `(from\\s*|require\\(\\s*|import\\(\\s*|import\\s+)["'\`]${n}(/[^"'\`]*)?["'\`]`,
1238
+ ),
1239
+ },
1240
+ ];
1241
+ }
1242
+ if (ecosystem === "swiftpm") {
1243
+ const alt = [...swiftModules(dir, name)].map(esc).join("|");
1244
+ return [
1245
+ {
1246
+ ext: /\.swift$/,
1247
+ re: new RegExp(`^\\s*(@testable\\s+)?import\\s+(\\w+\\s+)?(${alt})\\b`, "m"),
1248
+ },
1249
+ ];
1250
+ }
1251
+ if (ecosystem === "gradle") {
1252
+ const names = [name, ...alsoMoves];
1253
+ const aliases = [];
1254
+ const stems = new Set();
1255
+ const stemOf = (s) => /^[A-Za-z_]\w*/.exec(s)?.[0];
1256
+ for (const m of detect(dir).filter((x) => x.ecosystem === "gradle")) {
1257
+ for (const d of readVersionCatalog(readFileSync(join(dir, m.manifest), "utf8"))) {
1258
+ if (!names.includes(d.name)) continue;
1259
+ aliases.push(d.alias.replace(/[-_]/g, "."));
1260
+ stems.add(stemOf(d.alias));
1261
+ }
1262
+ }
1263
+ for (const n of names.filter((x) => x.includes(":"))) stems.add(stemOf(n.split(":")[1]));
1264
+ stems.delete(undefined);
1265
+ // A JVM package root is often the group (com.google.code.gson), but just as
1266
+ // often the artifact or alias stem with a major suffix (okhttp3, retrofit2).
1267
+ const groups = [
1268
+ ...names.filter((n) => n.includes(":")).map((n) => esc(n.split(":")[0])),
1269
+ ...[...stems].map((st) => `${esc(st)}\\d*`),
1270
+ ];
1271
+ const out = [];
1272
+ if (aliases.length) {
1273
+ out.push({
1274
+ ext: /\.gradle(\.kts)?$/,
1275
+ re: new RegExp(`libs\\.(plugins\\.)?(${aliases.map(esc).join("|")})\\b`),
1276
+ });
1277
+ }
1278
+ if (groups.length)
1279
+ out.push({
1280
+ ext: /\.(kt|kts|java)$/,
1281
+ re: new RegExp(`^\\s*import\\s+(${groups.join("|")})\\.`, "m"),
1282
+ });
1283
+ return out;
1284
+ }
1285
+ const mod = esc(name.replace(/-/g, "_"));
1286
+ return [{ ext: /\.py$/, re: new RegExp(`^\\s*(import|from)\\s+${mod}\\b`, "mi") }];
1287
+ }
1288
+
1289
+ export function surface(dir, ecosystem, name, alsoMoves = []) {
1290
+ const matchers = surfaceMatchers(dir, ecosystem, name, alsoMoves);
1291
+ const files = [];
1292
+ for (const rel of sourceFiles(dir)) {
1293
+ const ms = matchers.filter((m) => m.ext.test(rel));
1294
+ if (!ms.length) continue;
1295
+ let text;
1296
+ try {
1297
+ text = readFileSync(join(dir, rel), "utf8");
1298
+ } catch {
1299
+ continue;
1300
+ }
1301
+ if (ms.some((m) => m.re.test(text))) files.push(rel);
1302
+ }
1303
+ return { ecosystem, name, files: files.sort() };
1304
+ }
1305
+
1306
+ function readRecord(dir) {
1307
+ const p = join(dir, RECORD);
1308
+ if (!existsSync(p)) throw new UsageError(`no bump recorded in ${RECORD}; run apply first`);
1309
+ return JSON.parse(readFileSync(p, "utf8"));
1310
+ }
1311
+
1312
+ const ERROR_LINE = /\b(error|errors|fail|failed|failure|failing|exception)\b/i;
1313
+
1314
+ /** The first build or test error line in `logText` that names `file`. */
1315
+ function citation(logText, file) {
1316
+ return (
1317
+ logText
1318
+ .split("\n")
1319
+ .find((l) => l.includes(file) && ERROR_LINE.test(l))
1320
+ ?.trim() ?? null
1321
+ );
1322
+ }
1323
+
1324
+ /**
1325
+ * Splits the files changed since apply. Only the surface recorded at apply time
1326
+ * is in scope, with the manifest and lockfile: a file that imports the
1327
+ * dependency only after the bump is a new importer, which passes only when the
1328
+ * build or test log (--log) has an error line naming it.
1329
+ */
1330
+ export function scope(dir, { log } = {}) {
1331
+ const rec = readRecord(dir);
1332
+ if (!rec.baseSha)
1333
+ throw new UsageError("the recorded bump has no base commit; scope needs a git checkout");
1334
+ const logText = log ? readFileSync(log, "utf8") : "";
1335
+ const changed = changedSince(dir, rec);
1336
+ const allowed = new Set([rec.manifest, rec.lockfile, ...rec.surface].filter(Boolean));
1337
+ if (rec.ecosystem === "swiftpm")
1338
+ allowed.add(join(dirname(rec.manifest), "Package.resolved").replace(/^\.\//, ""));
1339
+ const importers = new Set(surface(dir, rec.ecosystem, rec.name, rec.alsoMoves ?? []).files);
1340
+ const inScope = changed.filter((f) => allowed.has(f));
1341
+ const rest = changed.filter((f) => !allowed.has(f));
1342
+ const newImporters = rest
1343
+ .filter((f) => importers.has(f))
1344
+ .map((file) => ({ file, cited: citation(logText, file) }));
1345
+ const outOfScope = rest.filter((f) => !importers.has(f));
1346
+ const uncited = newImporters.some((n) => !n.cited);
1347
+ const touched = new Set([...(rec.touched ?? []), ...inScope, ...newImporters.map((n) => n.file)]);
1348
+ writeRecord(dir, { ...rec, touched: [...touched].sort() });
1349
+ return {
1350
+ code: outOfScope.length || uncited ? 1 : 0,
1351
+ out: { name: rec.name, from: rec.from, to: rec.to, inScope, newImporters, outOfScope },
1352
+ };
1353
+ }
1354
+
1355
+ function changedSince(dir, rec) {
1356
+ const diff = git(dir, ["diff", "--name-only", rec.baseSha]);
1357
+ return [
1358
+ ...new Set([...(diff.ok ? diff.stdout.split("\n") : []), ...untracked(dir)].filter(Boolean)),
1359
+ ].filter((f) => !f.startsWith(".pipeline/") && !rec.untrackedAtStart.includes(f));
1360
+ }
1361
+
1362
+ /**
1363
+ * Puts back only what the bump owns: the manifest, the lockfile and the files
1364
+ * scope saw the fix rounds touch inside the bump's reach. A file the base
1365
+ * commit has is checked out from it, one it lacks is removed; every other
1366
+ * change in the worktree is left in place and listed under `kept`.
1367
+ */
1368
+ export function rollback(dir) {
1369
+ const rec = readRecord(dir);
1370
+ if (!rec.baseSha)
1371
+ throw new UsageError("the recorded bump has no base commit; rollback needs a git checkout");
1372
+ const owned = new Set([rec.manifest, rec.lockfile, ...(rec.touched ?? [])].filter(Boolean));
1373
+ if (rec.ecosystem === "swiftpm")
1374
+ owned.add(join(dirname(rec.manifest), "Package.resolved").replace(/^\.\//, ""));
1375
+ const changed = changedSince(dir, rec);
1376
+ const targets = changed.filter((f) => owned.has(f));
1377
+ const atBase = new Set();
1378
+ if (targets.length) {
1379
+ const ls = git(dir, ["ls-tree", "-r", "--name-only", rec.baseSha, "--", ...targets]);
1380
+ if (!ls.ok) return { code: 1, out: { error: `git ls-tree failed: ${ls.output.trim()}` } };
1381
+ for (const f of ls.stdout.split("\n")) if (f) atBase.add(f);
1382
+ }
1383
+ const restored = targets.filter((f) => atBase.has(f));
1384
+ if (restored.length) {
1385
+ const r = git(dir, ["checkout", rec.baseSha, "--", ...restored]);
1386
+ if (!r.ok) return { code: 1, out: { error: `git checkout failed: ${r.output.trim()}` } };
1387
+ }
1388
+ const removed = targets.filter((f) => !atBase.has(f));
1389
+ for (const f of removed) rmSync(join(dir, f), { force: true });
1390
+ const kept = changed.filter((f) => !owned.has(f)).sort();
1391
+ writeRecord(dir, { ...rec, rolledBackAt: new Date().toISOString() });
1392
+ return {
1393
+ code: 0,
1394
+ out: {
1395
+ rolledBackTo: rec.baseSha,
1396
+ restored: restored.sort(),
1397
+ removed: removed.sort(),
1398
+ kept,
1399
+ name: rec.name,
1400
+ from: rec.from,
1401
+ to: rec.to,
1402
+ },
1403
+ };
1404
+ }
1405
+
1406
+ // ─── release notes ─────────────────────────────────────────────────────────
1407
+
1408
+ const CHANGELOG = /^(changelog|changes|history|releases|news)(\.md|\.markdown|\.txt)?$/i;
1409
+
1410
+ function packageDir(dir, ecosystem, name) {
1411
+ if (ecosystem === "npm") return join(dir, "node_modules", name);
1412
+ if (ecosystem === "swiftpm") return join(dir, ".build", "checkouts", name);
1413
+ return null;
1414
+ }
1415
+
1416
+ export function changelogExcerpt(text, from, to) {
1417
+ const out = [];
1418
+ let keep = false;
1419
+ for (const line of text.split("\n")) {
1420
+ const h = /^#{1,3}\s*\[?v?(\d+\.\d+(?:\.\d+)?(?:-[\w.]+)?)\]?/.exec(line);
1421
+ if (h) keep = compareVersions(h[1], from) > 0 && compareVersions(h[1], to) <= 0;
1422
+ if (keep) out.push(line);
1423
+ }
1424
+ return out.join("\n").trim();
1425
+ }
1426
+
1427
+ export function notes(dir, ecosystem, name, from, to) {
1428
+ const pd = packageDir(dir, ecosystem, name);
1429
+ const hint = {};
1430
+ if (pd && existsSync(join(pd, "package.json"))) {
1431
+ try {
1432
+ const repo = JSON.parse(readFileSync(join(pd, "package.json"), "utf8")).repository;
1433
+ hint.repository = typeof repo === "string" ? repo : repo?.url;
1434
+ } catch {
1435
+ hint.repository = undefined;
1436
+ }
1437
+ }
1438
+ if (ecosystem === "swiftpm") {
1439
+ const dep = resolveTarget(dir, ecosystem, name)[0]?.dep;
1440
+ if (dep) hint.repository = dep.url;
1441
+ }
1442
+ if (pd && existsSync(pd)) {
1443
+ const file = readdirSync(pd).find((f) => CHANGELOG.test(f));
1444
+ if (file) {
1445
+ const excerpt = changelogExcerpt(readFileSync(join(pd, file), "utf8"), from, to);
1446
+ return { source: relative(dir, join(pd, file)), excerpt: excerpt || null, ...hint };
1447
+ }
1448
+ }
1449
+ return {
1450
+ source: null,
1451
+ excerpt: null,
1452
+ note: "no local changelog; read the release notes from the repository (gh release list / view) when reachable",
1453
+ ...hint,
1454
+ };
1455
+ }
1456
+
1457
+ // ─── config ────────────────────────────────────────────────────────────────
1458
+
1459
+ function readPrefs() {
1460
+ const p =
1461
+ process.env.MULTI_AGENT_PREFS ||
1462
+ join(process.env.HOME || homedir(), ".claude", "multi-agent-preferences.json");
1463
+ try {
1464
+ return JSON.parse(readFileSync(p, "utf8"))?.global ?? {};
1465
+ } catch {
1466
+ return {};
1467
+ }
1468
+ }
1469
+
1470
+ const atPath = (obj, path) => path.split(".").reduce((o, k) => (o == null ? undefined : o[k]), obj);
1471
+
1472
+ export function config(prefs = readPrefs()) {
1473
+ const rounds = atPath(prefs, PREF_MAX_FIX_ROUNDS);
1474
+ const registry = atPath(prefs, PREF_QUERY_REGISTRY);
1475
+ return {
1476
+ maxFixRounds:
1477
+ Number.isInteger(rounds) && rounds >= 0 && rounds <= 5 ? rounds : DEFAULT_MAX_FIX_ROUNDS,
1478
+ queryRegistry: typeof registry === "boolean" ? registry : true,
1479
+ };
1480
+ }
1481
+
1482
+ // ─── cli ───────────────────────────────────────────────────────────────────
1483
+
1484
+ function parseArgs(argv) {
1485
+ const [cmd, ...rest] = argv;
1486
+ const opts = { cmd };
1487
+ const flags = { "--registry": "registry", "--allow-major": "allowMajor", "--no-lock": "noLock" };
1488
+ const values = {
1489
+ "--dir": "dir",
1490
+ "--ecosystem": "ecosystem",
1491
+ "--name": "name",
1492
+ "--to": "to",
1493
+ "--from": "from",
1494
+ "--manifest": "manifest",
1495
+ "--log": "log",
1496
+ };
1497
+ for (let i = 0; i < rest.length; i++) {
1498
+ const a = rest[i];
1499
+ if (flags[a]) opts[flags[a]] = true;
1500
+ else if (values[a]) {
1501
+ if (rest[i + 1] === undefined) throw new UsageError(`${a} needs a value`);
1502
+ opts[values[a]] = rest[++i];
1503
+ } else throw new UsageError(`unexpected argument ${a}`);
1504
+ }
1505
+ if (opts.ecosystem && !ECOSYSTEMS.includes(opts.ecosystem)) {
1506
+ throw new UsageError(`--ecosystem must be one of ${ECOSYSTEMS.join(", ")}`);
1507
+ }
1508
+ return opts;
1509
+ }
1510
+
1511
+ function need(opts, ...keys) {
1512
+ for (const k of keys) if (!opts[k]) throw new UsageError(`${opts.cmd} needs --${k}`);
1513
+ }
1514
+
1515
+ export function main(argv) {
1516
+ if (["help", "--help", "-h"].includes(argv[0])) return { code: 0, text: USAGE };
1517
+ const opts = parseArgs(argv);
1518
+ const dir = opts.dir ?? process.cwd();
1519
+ switch (opts.cmd) {
1520
+ case "detect":
1521
+ return { code: 0, out: { dir, ecosystems: detect(dir) } };
1522
+ case "list":
1523
+ return { code: 0, out: list(dir, opts) };
1524
+ case "apply":
1525
+ need(opts, "ecosystem", "name", "to");
1526
+ return apply(dir, { ...opts, lock: !opts.noLock });
1527
+ case "surface":
1528
+ need(opts, "ecosystem", "name");
1529
+ return { code: 0, out: surface(dir, opts.ecosystem, opts.name) };
1530
+ case "scope":
1531
+ return scope(dir, opts);
1532
+ case "notes":
1533
+ need(opts, "ecosystem", "name", "from", "to");
1534
+ return { code: 0, out: notes(dir, opts.ecosystem, opts.name, opts.from, opts.to) };
1535
+ case "rollback":
1536
+ return rollback(dir);
1537
+ case "config":
1538
+ return { code: 0, out: config() };
1539
+ default:
1540
+ throw new UsageError(
1541
+ "command must be detect, list, apply, surface, scope, notes, rollback, config or help",
1542
+ );
1543
+ }
1544
+ }
1545
+
1546
+ if (invokedDirectly(import.meta.url)) {
1547
+ let result;
1548
+ try {
1549
+ result = main(process.argv.slice(2));
1550
+ } catch (err) {
1551
+ const usage = err instanceof UsageError;
1552
+ result = { code: usage ? 2 : 1, out: { error: `dep-bump: ${err.message}` } };
1553
+ }
1554
+ process.stdout.write(result.text ?? `${JSON.stringify(result.out, null, 2)}\n`);
1555
+ process.exitCode = result.code;
1556
+ }