forgedock 1.1.7 → 1.1.9

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 (97) hide show
  1. package/README.md +10 -33
  2. package/bin/cinema.mjs +23 -5
  3. package/bin/engine/invariants.mjs +361 -0
  4. package/bin/engine-cli.mjs +137 -11
  5. package/bin/env-detect.mjs +249 -0
  6. package/bin/forge-utils.mjs +364 -4
  7. package/bin/forgedock.mjs +1064 -135
  8. package/bin/hooks/pre-tool-use.mjs +182 -0
  9. package/bin/hooks/session-start.mjs +68 -3
  10. package/bin/init-detect.mjs +101 -12
  11. package/bin/journey.mjs +917 -16
  12. package/bin/labels.json +10 -0
  13. package/bin/recall.mjs +557 -0
  14. package/bin/registry.mjs +91 -3
  15. package/bin/runner.mjs +38 -2
  16. package/bin/tui.mjs +49 -3
  17. package/commands/adopt.md +1 -1
  18. package/commands/analytics.md +8 -8
  19. package/commands/audit.md +1 -1
  20. package/commands/autopilot.md +958 -77
  21. package/commands/ci-audit.md +1 -1
  22. package/commands/cleanup.md +139 -55
  23. package/commands/compat-audit.md +1 -1
  24. package/commands/deploy-info.md +1 -1
  25. package/commands/deploy-pr.md +1 -1
  26. package/commands/diagnose.md +1 -1
  27. package/commands/explain.md +1 -1
  28. package/commands/fix-ci.md +1 -1
  29. package/commands/forgedock-init.md +1 -1
  30. package/commands/incident-response.md +3 -3
  31. package/commands/issue.md +1 -1
  32. package/commands/optimize.md +586 -1
  33. package/commands/orchestrate/config.md +13 -3
  34. package/commands/orchestrate/phase-1-resolve.md +36 -26
  35. package/commands/orchestrate/phase-2-triage.md +1 -1
  36. package/commands/orchestrate/phase-2.5-synthesis.md +1 -1
  37. package/commands/orchestrate/phase-3-dependency.md +587 -48
  38. package/commands/orchestrate/phase-4-execution.md +1031 -57
  39. package/commands/orchestrate/phase-5-cleanup.md +1 -1
  40. package/commands/orchestrate/phase-6-report.md +248 -11
  41. package/commands/orchestrate/safety.md +2 -2
  42. package/commands/pipeline-health/phase-2-metrics.md +305 -1
  43. package/commands/pipeline-health/phase-4-proposals.md +127 -0
  44. package/commands/qa-sweep.md +1 -1
  45. package/commands/quality-gate.md +418 -1
  46. package/commands/recover-orphans.md +5 -5
  47. package/commands/replay.md +1 -1
  48. package/commands/review-pr-agents/api.md +2 -2
  49. package/commands/review-pr-agents/auth.md +2 -2
  50. package/commands/review-pr-agents/billing.md +2 -2
  51. package/commands/review-pr-agents/concurrency.md +2 -2
  52. package/commands/review-pr-agents/database.md +2 -2
  53. package/commands/review-pr-agents/frontend.md +2 -2
  54. package/commands/review-pr-agents/infra.md +2 -2
  55. package/commands/review-pr-agents/protocols.md +1 -1
  56. package/commands/review-pr-agents/scraper.md +2 -2
  57. package/commands/review-pr-agents/security.md +2 -2
  58. package/commands/review-pr-staging.md +41 -3
  59. package/commands/review-pr.md +462 -19
  60. package/commands/rollback.md +1 -1
  61. package/commands/scope.md +1 -1
  62. package/commands/security-audit.md +1 -1
  63. package/commands/spec-doctor.md +429 -0
  64. package/commands/test-gate.md +1 -1
  65. package/commands/validate.md +53 -1
  66. package/commands/work-on/build/architect.md +78 -1
  67. package/commands/work-on/build/context.md +360 -22
  68. package/commands/work-on/build/implement.md +3 -1
  69. package/commands/work-on/build/validate.md +29 -1
  70. package/commands/work-on/build.md +75 -6
  71. package/commands/work-on/close.md +624 -10
  72. package/commands/work-on/decompose.md +1 -1
  73. package/commands/work-on/investigate.md +235 -5
  74. package/commands/work-on/remediate.md +331 -0
  75. package/commands/work-on/review.md +1 -1
  76. package/commands/work-on-monolithic.md +1 -1
  77. package/commands/work-on.md +205 -66
  78. package/docs/CI.md +2 -2
  79. package/docs/CODEX.md +1 -1
  80. package/docs/CONFIG.md +186 -0
  81. package/docs/FORGE-PROTOCOL.md +101 -0
  82. package/docs/site/command-reference.md +31 -1
  83. package/docs/site/getting-started.md +9 -34
  84. package/docs/site/index.md +1 -1
  85. package/docs/spec/forge-protocol-v1.md +97 -5
  86. package/docs/spec/label-state-machine.md +6 -1
  87. package/package.json +3 -2
  88. package/packages/protocol/src/cli.js +374 -84
  89. package/packages/protocol/src/types.js +75 -1
  90. package/scripts/build-knowledge-index.mjs +1761 -0
  91. package/scripts/calibration.mjs +996 -0
  92. package/scripts/check-command-docs-drift.sh +4 -2
  93. package/scripts/danger-zones.mjs +789 -0
  94. package/scripts/doctor-pipeline-state.sh +88 -2
  95. package/scripts/select-fix-targets.sh +234 -0
  96. package/scripts/transition-label.sh +245 -6
  97. package/scripts/refresh-bot-token.sh +0 -97
@@ -0,0 +1,249 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * env-detect.mjs — Pure deterministic environment detection for ForgeDock.
4
+ *
5
+ * Exports a single function: detectEnvironment(opts)
6
+ *
7
+ * Returns a flat, structured description of the host environment the
8
+ * installer is running in — platform, WSL status, shell, and a best-guess at
9
+ * symlink support. Mirrors the conventions established by init-detect.mjs:
10
+ *
11
+ * Contract guarantees:
12
+ * - Pure: no prompts, no writes; reads only injectable inputs (or their
13
+ * process defaults) — process.platform, os.release(), process.env, and
14
+ * (best-effort) /proc/version.
15
+ * - Safe: every risky read is wrapped in try/catch and degrades to a
16
+ * best-guess default. detectEnvironment() never throws.
17
+ * - Testable: inject `platform`, `env`, `release`, and `readFileSync` to
18
+ * simulate any OS/shell/WSL combination without mocking global process
19
+ * state.
20
+ */
21
+
22
+ import { readFileSync as fsReadFileSync } from "fs";
23
+ import os from "os";
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Detection helpers — each is pure and never throws
27
+ // ---------------------------------------------------------------------------
28
+
29
+ /**
30
+ * Detect whether the process is running inside WSL, and which distro.
31
+ *
32
+ * Strategy (first success wins):
33
+ * 1. `WSL_DISTRO_NAME` env var — set by WSL itself, authoritative when present.
34
+ * 2. On `linux`, best-effort read `/proc/version` and test for "microsoft"
35
+ * (case-insensitive) — covers WSL setups that don't export the env var
36
+ * (e.g. processes launched without inheriting the interactive shell's
37
+ * environment).
38
+ *
39
+ * Never throws: a missing/unreadable /proc/version degrades to "not WSL".
40
+ *
41
+ * @param {string} platform
42
+ * @param {NodeJS.ProcessEnv} env
43
+ * @param {(path: string, encoding: string) => string} readFile
44
+ * @returns {{ isWSL: boolean, wslDistro: string | null }}
45
+ */
46
+ function detectWSL(platform, env, readFile) {
47
+ if (env.WSL_DISTRO_NAME) {
48
+ return { isWSL: true, wslDistro: env.WSL_DISTRO_NAME };
49
+ }
50
+
51
+ if (platform === "linux") {
52
+ try {
53
+ const version = readFile("/proc/version", "utf-8");
54
+ if (/microsoft/i.test(version)) {
55
+ return { isWSL: true, wslDistro: null };
56
+ }
57
+ } catch {
58
+ // /proc/version missing or unreadable — not WSL, or undetectable; degrade quietly.
59
+ }
60
+ }
61
+
62
+ return { isWSL: false, wslDistro: null };
63
+ }
64
+
65
+ /**
66
+ * Derive a human-readable platform label.
67
+ *
68
+ * Windows build numbers >= 22000 report as "Windows 11" (Node's os.release()
69
+ * on win32 returns the kernel version, e.g. "10.0.22631" — Windows 11 never
70
+ * bumped the major.minor past 10.0, only the build number). Anything that
71
+ * doesn't match a recognized pattern falls back to a generic label rather
72
+ * than throwing or guessing wildly.
73
+ *
74
+ * @param {string} platform
75
+ * @param {string} release
76
+ * @returns {string}
77
+ */
78
+ function detectPlatformLabel(platform, release) {
79
+ switch (platform) {
80
+ case "win32": {
81
+ const buildMatch = String(release).match(/^10\.0\.(\d+)/);
82
+ if (buildMatch && Number(buildMatch[1]) >= 22000) return "Windows 11";
83
+ if (String(release).startsWith("10.")) return "Windows 10";
84
+ return "Windows";
85
+ }
86
+ case "darwin":
87
+ return "macOS";
88
+ case "linux":
89
+ return "Linux";
90
+ default:
91
+ return platform;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Best-effort shell name detection.
97
+ *
98
+ * Strategy (first success wins):
99
+ * 1. `$SHELL` — set on POSIX (bash/zsh/fish/...) and also by Git-Bash/MSYS
100
+ * environments on Windows. Basename it (strip any path).
101
+ * 2. On `win32` without `$SHELL`: `$PSModulePath` presence indicates
102
+ * PowerShell; otherwise `$ComSpec` presence indicates cmd.exe.
103
+ * 3. "unknown" — no shell signal found.
104
+ *
105
+ * Never throws.
106
+ *
107
+ * @param {string} platform
108
+ * @param {NodeJS.ProcessEnv} env
109
+ * @returns {string}
110
+ */
111
+ function detectShell(platform, env) {
112
+ if (env.SHELL) {
113
+ const parts = String(env.SHELL).split(/[\\/]/);
114
+ const name = parts[parts.length - 1];
115
+ return name || env.SHELL;
116
+ }
117
+
118
+ if (platform === "win32") {
119
+ if (env.PSModulePath) return "PowerShell";
120
+ if (env.ComSpec) return "cmd";
121
+ }
122
+
123
+ return "unknown";
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Public API
128
+ // ---------------------------------------------------------------------------
129
+
130
+ /**
131
+ * @typedef {{
132
+ * platform: string,
133
+ * platformLabel: string,
134
+ * isWSL: boolean,
135
+ * wslDistro: string | null,
136
+ * shell: string,
137
+ * symlinkSupport: boolean,
138
+ * }} EnvironmentInfo
139
+ */
140
+
141
+ /**
142
+ * Detect the current host environment: platform, WSL status, shell, and a
143
+ * best-guess at symlink support. Never throws — every risky read degrades to
144
+ * a best-guess default.
145
+ *
146
+ * @param {object} [opts]
147
+ * @param {string} [opts.platform] - Defaults to `process.platform`. Inject to test other OSes.
148
+ * @param {NodeJS.ProcessEnv} [opts.env] - Defaults to `process.env`. Inject to test WSL/shell scenarios.
149
+ * @param {string} [opts.release] - Defaults to `os.release()`. Inject to force a specific Windows
150
+ * build number (e.g. "10.0.22631") so Windows 10 vs 11 labeling is deterministically testable.
151
+ * @param {(path: string, encoding: string) => string} [opts.readFileSync] - Defaults to `fs.readFileSync`.
152
+ * Inject to simulate /proc/version contents (or failures) without touching the real filesystem.
153
+ * @returns {EnvironmentInfo}
154
+ */
155
+ export function detectEnvironment(opts = {}) {
156
+ const platform = opts.platform ?? process.platform;
157
+ const env = opts.env ?? process.env;
158
+ let release = opts.release;
159
+ if (release === undefined) {
160
+ try {
161
+ release = os.release();
162
+ } catch {
163
+ release = "";
164
+ }
165
+ }
166
+ const readFile = opts.readFileSync ?? fsReadFileSync;
167
+
168
+ let isWSL = false;
169
+ let wslDistro = null;
170
+ try {
171
+ ({ isWSL, wslDistro } = detectWSL(platform, env, readFile));
172
+ } catch {
173
+ // Best-effort — degrade to "not WSL" on any unexpected failure.
174
+ }
175
+
176
+ let platformLabel = platform;
177
+ try {
178
+ platformLabel = detectPlatformLabel(platform, release);
179
+ } catch {
180
+ platformLabel = platform;
181
+ }
182
+
183
+ let shell = "unknown";
184
+ try {
185
+ shell = detectShell(platform, env);
186
+ } catch {
187
+ shell = "unknown";
188
+ }
189
+
190
+ // Best-guess only — POSIX filesystems support symlinks unconditionally;
191
+ // Windows requires Developer Mode or elevation, which isn't reliably
192
+ // detectable without a live filesystem probe (out of scope: this module is
193
+ // pure/read-only). journey.mjs's forge() already handles the real-world
194
+ // EPERM/EACCES case reactively via a copy fallback.
195
+ const symlinkSupport = platform !== "win32";
196
+
197
+ return { platform, platformLabel, isWSL, wslDistro, shell, symlinkSupport };
198
+ }
199
+
200
+ // ---------------------------------------------------------------------------
201
+ // Cross-environment path translation (forge#1893)
202
+ // ---------------------------------------------------------------------------
203
+
204
+ /**
205
+ * Translate a WSL-mounted-drive path to its native Windows equivalent.
206
+ *
207
+ * WSL exposes Windows drives under `/mnt/<drive>/...` (lowercase drive
208
+ * letter, single character). This is a live bind mount — `/mnt/c/Users/foo`
209
+ * and `C:\Users\foo` refer to the exact same files on disk. Only that shape
210
+ * is recognized; a plain WSL-native path (`/home/foo/...`) has no Windows
211
+ * equivalent and returns `null` rather than guessing.
212
+ *
213
+ * Pure, never throws.
214
+ *
215
+ * @param {string} p - Absolute POSIX path, e.g. `/mnt/c/Users/foo/repo`.
216
+ * @returns {string | null} e.g. `C:\Users\foo\repo`, or `null` if `p` isn't
217
+ * a `/mnt/<drive>/...` path.
218
+ */
219
+ export function wslPathToWindows(p) {
220
+ if (!p || typeof p !== "string") return null;
221
+ const m = p.match(/^\/mnt\/([a-z])(\/.*)?$/i);
222
+ if (!m) return null;
223
+ const drive = m[1].toUpperCase();
224
+ const rest = (m[2] || "").replace(/\//g, "\\");
225
+ return `${drive}:${rest}`;
226
+ }
227
+
228
+ /**
229
+ * Translate a native Windows path to its WSL-mounted-drive equivalent.
230
+ *
231
+ * Mirrors `wslPathToWindows()`: `C:\Users\foo\repo` (or the forward-slash
232
+ * variant `C:/Users/foo/repo`, which Node also accepts on win32) becomes
233
+ * `/mnt/c/Users/foo/repo`. Only a `<drive>:` prefix is recognized; anything
234
+ * else (UNC paths, relative paths) returns `null` rather than guessing.
235
+ *
236
+ * Pure, never throws.
237
+ *
238
+ * @param {string} p - Absolute Windows path, e.g. `C:\Users\foo\repo`.
239
+ * @returns {string | null} e.g. `/mnt/c/Users/foo/repo`, or `null` if `p`
240
+ * isn't a `<drive>:...` path.
241
+ */
242
+ export function windowsPathToWsl(p) {
243
+ if (!p || typeof p !== "string") return null;
244
+ const m = p.match(/^([a-z]):[\\/]?(.*)$/i);
245
+ if (!m) return null;
246
+ const drive = m[1].toLowerCase();
247
+ const rest = m[2].replace(/\\/g, "/");
248
+ return `/mnt/${drive}${rest ? `/${rest}` : ""}`;
249
+ }
@@ -6,13 +6,22 @@
6
6
  * top-level as part of its fail-open contract).
7
7
  *
8
8
  * Exports:
9
- * parseForgeYaml(raw) → object (parse a forge.yaml string)
10
- * sanitizeContextValue(value, max) → string|null (sanitize before injection)
9
+ * parseForgeYaml(raw) → object (parse a forge.yaml string)
10
+ * sanitizeContextValue(value, max) → string|null (sanitize before injection)
11
+ * resolveModelAlias(value) → string|null (short alias → full model ID)
12
+ * parseNameStatusDiff(diffText) → array (parse `git diff --name-status` output)
13
+ * classifyCommandChanges(entries, opts) → object (added/updated/removed/engine counts)
14
+ * countBreakingCommits(subjects) → number (breaking-change commit count)
15
+ * parseGitHubOwnerRepo(remoteUrl) → object|null ({owner, repo} from a git remote URL)
16
+ * classifyConventionalCommitLines(body) → object (feat/fix/... counts from release notes)
17
+ * formatUpdateChangelogSummary(opts) → string (git-clone mode changelog summary)
18
+ * formatVersionAvailableSummary(opts) → string (npm mode changelog summary)
11
19
  *
12
20
  * Contract guarantees:
13
21
  * - Pure: no I/O, no side effects, no global state
14
- * - Safe: both functions are try/catch-free at the module level; individual
15
- * helpers never throw
22
+ * - Safe: every exported function is internally try/catch-free where it
23
+ * cannot throw on any input shape (string coercion + defensive defaults);
24
+ * callers doing I/O (execFileSync, fetch) still wrap those calls
16
25
  * - Isolated: imports no external modules
17
26
  */
18
27
 
@@ -162,3 +171,354 @@ export function sanitizeContextValue(value, maxLen) {
162
171
  return null;
163
172
  }
164
173
  }
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // Model alias resolution
177
+ // ---------------------------------------------------------------------------
178
+
179
+ /**
180
+ * Short model aliases mapped to their current full Anthropic model IDs.
181
+ *
182
+ * These are the same aliases accepted by the Agent/Task tool's `model`
183
+ * parameter in Claude Code (enum: "sonnet" | "opus" | "haiku" | "fable").
184
+ * forge.yaml's `agents.default_model` field is documented to use one of
185
+ * these aliases so the same configured value drives both interactive
186
+ * (Agent/Task tool) and headless (bin/runner.mjs, raw Anthropic SDK) use.
187
+ *
188
+ * Keep in sync with the other hardcoded model IDs in this codebase (e.g.
189
+ * bin/runner.mjs's DEFAULT_MODEL, bin/init-enrich-api.mjs's ENRICH_MODEL)
190
+ * if those are ever bumped.
191
+ */
192
+ const MODEL_ALIASES = {
193
+ sonnet: "claude-sonnet-5",
194
+ opus: "claude-opus-4-6",
195
+ haiku: "claude-haiku-4-5",
196
+ };
197
+
198
+ /**
199
+ * Resolve a forge.yaml `agents.default_model` value to a full Anthropic
200
+ * model ID, for use with the raw Anthropic SDK (bin/runner.mjs).
201
+ *
202
+ * Recognized short aliases ("sonnet", "opus", "haiku" — case-insensitive)
203
+ * are mapped to their current full model ID via MODEL_ALIASES. Any other
204
+ * non-empty string is returned unchanged (pass-through), so a full model ID
205
+ * configured directly still works for the headless runner — though it will
206
+ * NOT work for interactive Agent/Task tool calls in command specs, which
207
+ * only accept the short-alias enum. This constraint is documented in
208
+ * docs/CONFIG.md and forge.yaml.example.
209
+ *
210
+ * Never throws. Returns null for null/undefined/empty input.
211
+ *
212
+ * @param {string|null|undefined} value - Raw `agents.default_model` value.
213
+ * @returns {string|null}
214
+ */
215
+ export function resolveModelAlias(value) {
216
+ try {
217
+ if (value == null) return null;
218
+ const trimmed = String(value).trim();
219
+ if (!trimmed) return null;
220
+ const alias = MODEL_ALIASES[trimmed.toLowerCase()];
221
+ return alias ?? trimmed;
222
+ } catch {
223
+ return null;
224
+ }
225
+ }
226
+
227
+ // ---------------------------------------------------------------------------
228
+ // Diff-aware changelog summary (forge#1947) — parsing & formatting helpers
229
+ // ---------------------------------------------------------------------------
230
+ //
231
+ // These functions turn raw `git diff --name-status`, `git log --pretty=%s`,
232
+ // a git remote URL, and a GitHub release body into the condensed changelog
233
+ // summary printed by `update()` in bin/forgedock.mjs. Kept pure/I/O-free here
234
+ // so they're unit-testable without spawning git or hitting the network — the
235
+ // caller (forgedock.mjs) is responsible for running the actual git/fetch
236
+ // calls and wrapping them in try/catch so a failure never blocks `update()`.
237
+
238
+ /**
239
+ * Parse the output of `git diff --name-status <a> <b>` into structured
240
+ * entries.
241
+ *
242
+ * Handles:
243
+ * - Plain status lines: `A\tpath`, `M\tpath`, `D\tpath`
244
+ * - Rename/copy lines: `R100\told\tnew` / `C100\told\tnew` — the new path
245
+ * is used, status is normalized to the bare letter (`R`/`C`)
246
+ * - Both LF and CRLF line endings
247
+ * - Blank lines and malformed lines are skipped rather than throwing
248
+ *
249
+ * @param {string} diffText - Raw stdout from `git diff --name-status`.
250
+ * @returns {Array<{status: string, path: string}>}
251
+ */
252
+ export function parseNameStatusDiff(diffText) {
253
+ const entries = [];
254
+ if (typeof diffText !== "string" || !diffText.trim()) return entries;
255
+
256
+ for (const line of diffText.split(/\r?\n/)) {
257
+ if (!line.trim()) continue;
258
+ const parts = line.split("\t").filter((p) => p.length > 0);
259
+ if (parts.length < 2) continue;
260
+
261
+ const rawStatus = parts[0];
262
+ const status = rawStatus.charAt(0).toUpperCase();
263
+ // Rename/copy lines carry two paths (old, new) — the working path after
264
+ // the change is the last field.
265
+ const path = parts[parts.length - 1];
266
+ if (!path) continue;
267
+
268
+ entries.push({ status, path });
269
+ }
270
+
271
+ return entries;
272
+ }
273
+
274
+ /**
275
+ * Classify parsed diff entries into command/engine change counts.
276
+ *
277
+ * `commands/` files are ForgeDock's slash-command specs — Added counts as
278
+ * "commands added", Deleted as "removed", everything else (Modified,
279
+ * Renamed, Copied) as "updated". `bin/engine/` files are counted as a single
280
+ * "engine changed" bucket regardless of status, since the changelog summary
281
+ * only needs to flag that the durable-run engine moved, not the granularity
282
+ * of how.
283
+ *
284
+ * @param {Array<{status: string, path: string}>} entries - From parseNameStatusDiff.
285
+ * @param {{commandsPrefix?: string, enginePrefix?: string}} [opts]
286
+ * @returns {{commandsAdded: number, commandsUpdated: number, commandsRemoved: number, engineChanged: number}}
287
+ */
288
+ export function classifyCommandChanges(entries, opts = {}) {
289
+ const commandsPrefix = opts.commandsPrefix ?? "commands/";
290
+ const enginePrefix = opts.enginePrefix ?? "bin/engine/";
291
+
292
+ const result = {
293
+ commandsAdded: 0,
294
+ commandsUpdated: 0,
295
+ commandsRemoved: 0,
296
+ engineChanged: 0,
297
+ };
298
+
299
+ if (!Array.isArray(entries)) return result;
300
+
301
+ for (const entry of entries) {
302
+ if (!entry || typeof entry.path !== "string") continue;
303
+ const normalizedPath = entry.path.replace(/\\/g, "/");
304
+
305
+ if (normalizedPath.startsWith(enginePrefix)) {
306
+ result.engineChanged++;
307
+ continue;
308
+ }
309
+
310
+ if (normalizedPath.startsWith(commandsPrefix)) {
311
+ if (entry.status === "A") result.commandsAdded++;
312
+ else if (entry.status === "D") result.commandsRemoved++;
313
+ else result.commandsUpdated++;
314
+ }
315
+ }
316
+
317
+ return result;
318
+ }
319
+
320
+ /**
321
+ * Count commit subject lines that signal a breaking change, using the
322
+ * Conventional Commits convention: a `!` immediately before the `:` in the
323
+ * type/scope prefix (e.g. `feat(cli)!: drop legacy flag`), or a
324
+ * `BREAKING CHANGE` marker anywhere in the subject.
325
+ *
326
+ * @param {string[]} subjects - Commit subject lines (e.g. from `git log --pretty=%s`).
327
+ * @returns {number}
328
+ */
329
+ export function countBreakingCommits(subjects) {
330
+ if (!Array.isArray(subjects)) return 0;
331
+ const breakingPrefix = /^\w+(\([^)]*\))?!:/;
332
+ let count = 0;
333
+ for (const subject of subjects) {
334
+ if (typeof subject !== "string") continue;
335
+ if (breakingPrefix.test(subject) || subject.includes("BREAKING CHANGE")) {
336
+ count++;
337
+ }
338
+ }
339
+ return count;
340
+ }
341
+
342
+ /**
343
+ * Extract `{owner, repo}` from a git remote URL, supporting the common forms
344
+ * GitHub remotes take:
345
+ * - `https://github.com/owner/repo.git`
346
+ * - `https://github.com/owner/repo`
347
+ * - `git@github.com:owner/repo.git`
348
+ * - `ssh://git@github.com/owner/repo.git`
349
+ *
350
+ * Returns null (never throws) for non-GitHub remotes or unparseable input —
351
+ * callers should treat null as "omit the link" rather than failing.
352
+ *
353
+ * @param {string} remoteUrl - Output of `git remote get-url origin`.
354
+ * @returns {{owner: string, repo: string}|null}
355
+ */
356
+ export function parseGitHubOwnerRepo(remoteUrl) {
357
+ if (typeof remoteUrl !== "string" || !remoteUrl.trim()) return null;
358
+ const trimmed = remoteUrl.trim();
359
+
360
+ const patterns = [
361
+ /^(?:https?:\/\/)(?:[^@/]+@)?github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/,
362
+ /^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?\/?$/,
363
+ /^ssh:\/\/git@github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/,
364
+ ];
365
+
366
+ for (const pattern of patterns) {
367
+ const match = trimmed.match(pattern);
368
+ if (match) {
369
+ return { owner: match[1], repo: match[2] };
370
+ }
371
+ }
372
+
373
+ return null;
374
+ }
375
+
376
+ /**
377
+ * Classify a GitHub auto-generated release body ("What's Changed" style —
378
+ * a bullet list of `* type(scope): description by @user in .../pull/N`
379
+ * lines) into conventional-commit-type counts, plus a breaking-change count.
380
+ *
381
+ * Unrecognized/non-conventional bullet lines are counted under "other" so
382
+ * the total always reflects every listed entry.
383
+ *
384
+ * @param {string} body - Raw release body (markdown).
385
+ * @returns {{counts: Record<string, number>, breakingCount: number}}
386
+ */
387
+ export function classifyConventionalCommitLines(body) {
388
+ const counts = {};
389
+ let breakingCount = 0;
390
+ if (typeof body !== "string" || !body.trim()) {
391
+ return { counts, breakingCount };
392
+ }
393
+
394
+ const bulletPattern = /^\s*[*-]\s+(.+)$/;
395
+ const typePattern = /^(\w+)(\([^)]*\))?(!)?:\s*/;
396
+
397
+ for (const line of body.split(/\r?\n/)) {
398
+ const bulletMatch = line.match(bulletPattern);
399
+ if (!bulletMatch) continue;
400
+ const rest = bulletMatch[1];
401
+
402
+ const typeMatch = rest.match(typePattern);
403
+ const type = typeMatch ? typeMatch[1].toLowerCase() : "other";
404
+ counts[type] = (counts[type] || 0) + 1;
405
+
406
+ if ((typeMatch && typeMatch[3] === "!") || rest.includes("BREAKING CHANGE")) {
407
+ breakingCount++;
408
+ }
409
+ }
410
+
411
+ return { counts, breakingCount };
412
+ }
413
+
414
+ /**
415
+ * Build the condensed changelog summary printed after a successful
416
+ * git-clone-mode update (`update()`'s fast-forward-merge branch in
417
+ * bin/forgedock.mjs). Always returns at least a headline — never an empty
418
+ * string — so the caller can print unconditionally when it has data to pass.
419
+ *
420
+ * @param {object} opts
421
+ * @param {string} [opts.fromVersion] - package.json version before the merge.
422
+ * @param {string} [opts.toVersion] - package.json version after the merge.
423
+ * @param {number} [opts.commandsAdded]
424
+ * @param {number} [opts.commandsUpdated]
425
+ * @param {number} [opts.commandsRemoved]
426
+ * @param {number} [opts.engineChanged]
427
+ * @param {number} [opts.breakingCount]
428
+ * @param {string} [opts.compareUrl] - Link to the full diff/changelog.
429
+ * @returns {string}
430
+ */
431
+ export function formatUpdateChangelogSummary(opts = {}) {
432
+ const {
433
+ fromVersion,
434
+ toVersion,
435
+ commandsAdded = 0,
436
+ commandsUpdated = 0,
437
+ commandsRemoved = 0,
438
+ engineChanged = 0,
439
+ breakingCount = 0,
440
+ compareUrl,
441
+ } = opts;
442
+
443
+ const versionLabel =
444
+ fromVersion && toVersion && fromVersion !== toVersion
445
+ ? `v${fromVersion} -> v${toVersion}`
446
+ : toVersion
447
+ ? `v${toVersion}`
448
+ : null;
449
+
450
+ const bullets = [];
451
+ if (commandsAdded) {
452
+ bullets.push(`${commandsAdded} command${commandsAdded === 1 ? "" : "s"} added`);
453
+ }
454
+ if (commandsRemoved) {
455
+ bullets.push(`${commandsRemoved} removed`);
456
+ }
457
+ if (commandsUpdated) {
458
+ bullets.push(`${commandsUpdated} updated`);
459
+ }
460
+ if (engineChanged) {
461
+ bullets.push(`engine: ${engineChanged} file${engineChanged === 1 ? "" : "s"} changed`);
462
+ }
463
+
464
+ const headline = versionLabel ? `Updated ${versionLabel}` : "Updated";
465
+ const lines = [bullets.length ? `${headline}: ${bullets.join(", ")}` : `${headline}.`];
466
+
467
+ if (breakingCount > 0) {
468
+ lines.push(
469
+ ` ${breakingCount} breaking change${breakingCount === 1 ? "" : "s"} — review before continuing.`,
470
+ );
471
+ }
472
+ if (compareUrl) {
473
+ lines.push(` See full changelog: ${compareUrl}`);
474
+ }
475
+
476
+ return lines.join("\n");
477
+ }
478
+
479
+ /**
480
+ * Build the condensed changelog summary printed after `update()` detects a
481
+ * newer version is available in npm/npx mode (where the tool never runs the
482
+ * update itself, so no local before/after diff exists — the summary is
483
+ * sourced from the newest published GitHub release's notes instead).
484
+ *
485
+ * @param {object} opts
486
+ * @param {string} opts.currentVersion - Installed package.json version.
487
+ * @param {string} opts.latestVersion - Latest published version.
488
+ * @param {Record<string, number>} [opts.typeCounts] - From classifyConventionalCommitLines.
489
+ * @param {number} [opts.breakingCount]
490
+ * @param {string} [opts.releaseUrl] - Link to the release notes.
491
+ * @returns {string}
492
+ */
493
+ export function formatVersionAvailableSummary(opts = {}) {
494
+ const {
495
+ currentVersion,
496
+ latestVersion,
497
+ typeCounts = {},
498
+ breakingCount = 0,
499
+ releaseUrl,
500
+ } = opts;
501
+
502
+ const parts = Object.entries(typeCounts)
503
+ .filter(([, count]) => count > 0)
504
+ .sort((a, b) => b[1] - a[1])
505
+ .map(([type, count]) => `${count} ${type}`);
506
+
507
+ const headline =
508
+ currentVersion && latestVersion
509
+ ? `Changelog v${currentVersion} -> v${latestVersion}`
510
+ : "Changelog";
511
+
512
+ const lines = [parts.length ? `${headline}: ${parts.join(", ")}` : `${headline}.`];
513
+
514
+ if (breakingCount > 0) {
515
+ lines.push(
516
+ ` ${breakingCount} breaking change${breakingCount === 1 ? "" : "s"} — review before updating.`,
517
+ );
518
+ }
519
+ if (releaseUrl) {
520
+ lines.push(` See full changelog: ${releaseUrl}`);
521
+ }
522
+
523
+ return lines.join("\n");
524
+ }