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.
- package/README.md +10 -33
- package/bin/cinema.mjs +23 -5
- package/bin/engine/invariants.mjs +361 -0
- package/bin/engine-cli.mjs +137 -11
- package/bin/env-detect.mjs +249 -0
- package/bin/forge-utils.mjs +364 -4
- package/bin/forgedock.mjs +1064 -135
- package/bin/hooks/pre-tool-use.mjs +182 -0
- package/bin/hooks/session-start.mjs +68 -3
- package/bin/init-detect.mjs +101 -12
- package/bin/journey.mjs +917 -16
- package/bin/labels.json +10 -0
- package/bin/recall.mjs +557 -0
- package/bin/registry.mjs +91 -3
- package/bin/runner.mjs +38 -2
- package/bin/tui.mjs +49 -3
- package/commands/adopt.md +1 -1
- package/commands/analytics.md +8 -8
- package/commands/audit.md +1 -1
- package/commands/autopilot.md +958 -77
- package/commands/ci-audit.md +1 -1
- package/commands/cleanup.md +139 -55
- package/commands/compat-audit.md +1 -1
- package/commands/deploy-info.md +1 -1
- package/commands/deploy-pr.md +1 -1
- package/commands/diagnose.md +1 -1
- package/commands/explain.md +1 -1
- package/commands/fix-ci.md +1 -1
- package/commands/forgedock-init.md +1 -1
- package/commands/incident-response.md +3 -3
- package/commands/issue.md +1 -1
- package/commands/optimize.md +586 -1
- package/commands/orchestrate/config.md +13 -3
- package/commands/orchestrate/phase-1-resolve.md +36 -26
- package/commands/orchestrate/phase-2-triage.md +1 -1
- package/commands/orchestrate/phase-2.5-synthesis.md +1 -1
- package/commands/orchestrate/phase-3-dependency.md +587 -48
- package/commands/orchestrate/phase-4-execution.md +1031 -57
- package/commands/orchestrate/phase-5-cleanup.md +1 -1
- package/commands/orchestrate/phase-6-report.md +248 -11
- package/commands/orchestrate/safety.md +2 -2
- package/commands/pipeline-health/phase-2-metrics.md +305 -1
- package/commands/pipeline-health/phase-4-proposals.md +127 -0
- package/commands/qa-sweep.md +1 -1
- package/commands/quality-gate.md +418 -1
- package/commands/recover-orphans.md +5 -5
- package/commands/replay.md +1 -1
- package/commands/review-pr-agents/api.md +2 -2
- package/commands/review-pr-agents/auth.md +2 -2
- package/commands/review-pr-agents/billing.md +2 -2
- package/commands/review-pr-agents/concurrency.md +2 -2
- package/commands/review-pr-agents/database.md +2 -2
- package/commands/review-pr-agents/frontend.md +2 -2
- package/commands/review-pr-agents/infra.md +2 -2
- package/commands/review-pr-agents/protocols.md +1 -1
- package/commands/review-pr-agents/scraper.md +2 -2
- package/commands/review-pr-agents/security.md +2 -2
- package/commands/review-pr-staging.md +41 -3
- package/commands/review-pr.md +462 -19
- package/commands/rollback.md +1 -1
- package/commands/scope.md +1 -1
- package/commands/security-audit.md +1 -1
- package/commands/spec-doctor.md +429 -0
- package/commands/test-gate.md +1 -1
- package/commands/validate.md +53 -1
- package/commands/work-on/build/architect.md +78 -1
- package/commands/work-on/build/context.md +360 -22
- package/commands/work-on/build/implement.md +3 -1
- package/commands/work-on/build/validate.md +29 -1
- package/commands/work-on/build.md +75 -6
- package/commands/work-on/close.md +624 -10
- package/commands/work-on/decompose.md +1 -1
- package/commands/work-on/investigate.md +235 -5
- package/commands/work-on/remediate.md +331 -0
- package/commands/work-on/review.md +1 -1
- package/commands/work-on-monolithic.md +1 -1
- package/commands/work-on.md +205 -66
- package/docs/CI.md +2 -2
- package/docs/CODEX.md +1 -1
- package/docs/CONFIG.md +186 -0
- package/docs/FORGE-PROTOCOL.md +101 -0
- package/docs/site/command-reference.md +31 -1
- package/docs/site/getting-started.md +9 -34
- package/docs/site/index.md +1 -1
- package/docs/spec/forge-protocol-v1.md +97 -5
- package/docs/spec/label-state-machine.md +6 -1
- package/package.json +3 -2
- package/packages/protocol/src/cli.js +374 -84
- package/packages/protocol/src/types.js +75 -1
- package/scripts/build-knowledge-index.mjs +1761 -0
- package/scripts/calibration.mjs +996 -0
- package/scripts/check-command-docs-drift.sh +4 -2
- package/scripts/danger-zones.mjs +789 -0
- package/scripts/doctor-pipeline-state.sh +88 -2
- package/scripts/select-fix-targets.sh +234 -0
- package/scripts/transition-label.sh +245 -6
- 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
|
+
}
|
package/bin/forge-utils.mjs
CHANGED
|
@@ -6,13 +6,22 @@
|
|
|
6
6
|
* top-level as part of its fail-open contract).
|
|
7
7
|
*
|
|
8
8
|
* Exports:
|
|
9
|
-
* parseForgeYaml(raw)
|
|
10
|
-
* sanitizeContextValue(value, max)
|
|
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:
|
|
15
|
-
*
|
|
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
|
+
}
|