forgedock 1.16.2 → 1.17.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 (95) hide show
  1. package/bin/engine/outcome-codes.mjs +92 -0
  2. package/bin/engine/phases.mjs +86 -26
  3. package/bin/engine/projector.mjs +48 -1
  4. package/bin/engine/runlog.mjs +13 -5
  5. package/bin/engine/telemetry-seam.mjs +62 -0
  6. package/bin/engine/telemetry-seam.test.mjs +60 -0
  7. package/bin/engine/telemetry.mjs +148 -0
  8. package/bin/engine/telemetry.test.mjs +120 -0
  9. package/bin/engine-cli.mjs +12 -1
  10. package/bin/engine.mjs +145 -40
  11. package/bin/forgedock.mjs +7 -1
  12. package/bin/hooks/interactive-engine.mjs +10 -0
  13. package/bin/hooks/pre-tool-use.mjs +187 -0
  14. package/bin/runner.mjs +7 -0
  15. package/bin/stats/aggregate.mjs +231 -0
  16. package/bin/stats/cache.mjs +40 -0
  17. package/bin/stats/github-source.mjs +140 -0
  18. package/bin/stats/index.mjs +154 -0
  19. package/bin/stats/local-source.mjs +79 -0
  20. package/bin/stats/package.json +6 -0
  21. package/bin/stats/render.mjs +82 -0
  22. package/bin/stats/stats.test.mjs +224 -0
  23. package/commands/cleanup.md +83 -2
  24. package/commands/deploy-info.md +8 -4
  25. package/commands/orchestrate/phase-1-resolve.md +4 -1
  26. package/commands/orchestrate/phase-4-execution.md +17 -2
  27. package/commands/orchestrate/phase-5-cleanup.md +18 -2
  28. package/commands/pipeline-health/phase-1-context.md +2 -0
  29. package/commands/pipeline-health/phase-2-metrics.md +12 -5
  30. package/commands/pipeline-status.md +21 -1
  31. package/commands/review-pr-agents/protocols.md +1 -0
  32. package/commands/review-pr-agents.md +10 -2
  33. package/commands/review-pr-staging.md +102 -25
  34. package/commands/review-pr.md +119 -103
  35. package/commands/work-on/build/implement.md +26 -5
  36. package/commands/work-on/build/validate.md +3 -2
  37. package/commands/work-on/build.md +95 -64
  38. package/commands/work-on/close.md +17 -6
  39. package/commands/work-on/decompose.md +45 -4
  40. package/commands/work-on/investigate.md +6 -2
  41. package/commands/work-on/remediate.md +22 -6
  42. package/commands/work-on/review.md +2 -2
  43. package/docs/CONFIG.md +33 -0
  44. package/docs/OUTCOME-CODES.md +48 -0
  45. package/docs/RELEASE-NOTES.md +12 -0
  46. package/docs/STATS.md +62 -0
  47. package/docs/TELEMETRY.md +65 -0
  48. package/docs/WORK-ON-RUNTIME.md +13 -0
  49. package/docs/site/command-reference.md +1 -1
  50. package/docs/spec/label-state-machine.md +1 -1
  51. package/package.json +3 -2
  52. package/scripts/builder-resume-match.test.sh +58 -0
  53. package/scripts/cap-findings.sh +110 -0
  54. package/scripts/cap-findings.test.sh +130 -0
  55. package/scripts/check-spec-markers.sh +2 -0
  56. package/scripts/classify-finding.sh +65 -16
  57. package/scripts/classify-finding.test.sh +43 -1
  58. package/scripts/classify-grep-exit.sh +20 -0
  59. package/scripts/classify-grep-exit.test.sh +46 -0
  60. package/scripts/close-parent-c3.test.sh +54 -0
  61. package/scripts/cost-report.sh +146 -0
  62. package/scripts/cost-report.test.sh +80 -0
  63. package/scripts/diff-size.sh +5 -0
  64. package/scripts/doctor-pipeline-state.sh +43 -0
  65. package/scripts/file-deferred-followups.sh +159 -0
  66. package/scripts/file-deferred-followups.test.sh +106 -0
  67. package/scripts/fixtures/cost-report/a-merged1.jsonl +6 -0
  68. package/scripts/fixtures/cost-report/a-merged2-remediated.jsonl +7 -0
  69. package/scripts/fixtures/cost-report/a-needs-human.jsonl +4 -0
  70. package/scripts/fixtures/cost-report/b-merged.jsonl +3 -0
  71. package/scripts/fixtures/cost-report/c-merged-attempt-usage.jsonl +8 -0
  72. package/scripts/fixtures/cost-report/hydrated-no-usage.jsonl +3 -0
  73. package/scripts/fixtures/cost-report/old-log-no-version.jsonl +3 -0
  74. package/scripts/fixtures/cost-report/unfinished-no-terminal.jsonl +2 -0
  75. package/scripts/fixtures/cost-report/unfinished-reopened.jsonl +5 -0
  76. package/scripts/forge-root.test.sh +8 -3
  77. package/scripts/lib/fence-bootstrap.sh +4 -3
  78. package/scripts/lib/pr-issue-number.sh +18 -0
  79. package/scripts/lib/review-template-resolve.sh +41 -0
  80. package/scripts/lib/telemetry.sh +168 -0
  81. package/scripts/remediate-liveness.sh +42 -6
  82. package/scripts/review-acceptance-gate.sh +16 -7
  83. package/scripts/review-acceptance-gate.test.sh +13 -0
  84. package/scripts/review-pr-domain-map.test.sh +8 -3
  85. package/scripts/review-pr-gate2.test.sh +15 -0
  86. package/scripts/review-pr-staging-finding-filter.test.sh +44 -0
  87. package/scripts/review-pr-staging-scope.test.sh +65 -5
  88. package/scripts/review-prepare.sh +517 -0
  89. package/scripts/review-prepare.test.sh +270 -0
  90. package/scripts/review-roster.sh +172 -0
  91. package/scripts/review-roster.test.sh +163 -0
  92. package/scripts/run-shell-tests.sh +3 -0
  93. package/scripts/telemetry.test.sh +106 -0
  94. package/scripts/wait-ci-green.sh +8 -1
  95. package/templates/forge.yaml.minimal +9 -0
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from "module";
3
3
  import { existsSync } from "fs";
4
+ import { spawnSync } from "child_process";
4
5
  import { dirname, join, resolve as resolvePath } from "path";
5
6
  import { fileURLToPath, pathToFileURL } from "url";
6
7
  const _require = createRequire(import.meta.url);
@@ -115,6 +116,23 @@ async function getInvariants() {
115
116
  * starting Claude Code (operator-set only — same process.env semantics as
116
117
  * the gist guard above).
117
118
  *
119
+ * 9. Main-checkout mutation guard (issue #3650) — ADVISORY by default
120
+ * Enforced (exit 2) only when FORGE_ENFORCE_MAIN_CHECKOUT_GIT=1. Otherwise it
121
+ * writes a warning to stderr and allows the call: shipped specs (fix-ci,
122
+ * milestone, test-gate, cleanup, rollback, recover-orphans, spec-doctor,
123
+ * orchestrate phase-4) still run these verbs in the main checkout on purpose,
124
+ * and a denial blocks the whole Bash call (their `|| true` fallbacks never run).
125
+ * Enforcement becomes the default once those specs use worktrees (#3873).
126
+ * When enforced it denies `git checkout|switch|stash|merge`, `git reset --hard`, and
127
+ * `gh pr checkout` when the shell's cwd is the main (non-worktree) checkout
128
+ * (`git rev-parse --git-dir` equals `--git-common-dir`). Pipeline workers and
129
+ * reviewers share that tree with the operator; mutating it races with them.
130
+ * Read-only git, `git checkout -b`, `git worktree ...`, and anything in a
131
+ * linked worktree stay allowed. Fails open when the repo cannot be resolved,
132
+ * and treats `git -C <dir>`, `--git-dir`, `--work-tree` and a leading `cd` as
133
+ * indeterminate (allowed). Override: FORGE_ALLOW_MAIN_CHECKOUT_GIT=1 in the
134
+ * shell environment (operator-set only).
135
+ *
118
136
  * Rules 1-4 and 6 only apply inside a ForgeDock-managed directory (a directory
119
137
  * with a `forge.yaml` or `.forgedock` marker) — see `isForgeDockManagedCwd()`
120
138
  * (issue #1591). The hook installs into the user's global
@@ -566,6 +584,20 @@ async function main() {
566
584
  return;
567
585
  }
568
586
 
587
+ // --- Rule 9: Main-checkout mutation guard (issue #3650) ---
588
+ // Deliberately BEFORE the managed-cwd gate: pipeline workers run with cwd = a linked worktree (no
589
+ // forge.yaml), and `git -C <main> ...` from there is the case this rule exists for.
590
+ const mainCheckoutViolation = checkMainCheckoutMutation(command);
591
+ if (mainCheckoutViolation) {
592
+ if (process.env.FORGE_ENFORCE_MAIN_CHECKOUT_GIT === "1") {
593
+ process.stderr.write(mainCheckoutViolation);
594
+ process.exit(2);
595
+ return;
596
+ }
597
+ // Advisory (default): several shipped specs still mutate the main checkout on purpose (#3873).
598
+ process.stderr.write("WARNING (advisory, not blocked): " + mainCheckoutViolation.replace(/^BLOCKED: /, ""));
599
+ }
600
+
569
601
  // Only enforce Rules 1-4 and 6 inside a ForgeDock-managed project — this hook
570
602
  // installs globally (~/.claude/settings.json), so without this guard those
571
603
  // rules would fire in every unrelated repo on the machine (issue #1591).
@@ -1233,6 +1265,161 @@ function checkTempPathStaging(command) {
1233
1265
  return null;
1234
1266
  }
1235
1267
 
1268
+ // ---------------------------------------------------------------------------
1269
+ // Rule 9: Main-checkout mutation guard (issue #3650)
1270
+ // ---------------------------------------------------------------------------
1271
+
1272
+ /**
1273
+ * Classify one command segment as a main-checkout mutation.
1274
+ *
1275
+ * The option tables live INSIDE the function: `main()` runs at module top via
1276
+ * `await`, before module-level `const`s declared further down are initialised,
1277
+ * so a module-level table throws (TDZ) and the hook fails open.
1278
+ *
1279
+ * @param {string[]} tokens
1280
+ * @param {number} [depth] wrapper-unwrap depth (bounded)
1281
+ * @returns {{name: string, dir: string|null}|null} offender and the `-C` dir it targets, or null
1282
+ */
1283
+ function mutatingGitCommand(tokens, depth = 0) {
1284
+ // Strip grouping/compound prefixes: `(git x)`, `{ git x; }`, `then git x`, `do git x`, `! git x`.
1285
+ tokens = tokens.slice();
1286
+ for (;;) {
1287
+ if (/^[({]+$/.test(tokens[0] || "") || ["then", "do", "else", "elif", "!", "if", "while", "until"].includes(tokens[0])) tokens.shift();
1288
+ else if (/^[({]+[^({]/.test(tokens[0] || "")) tokens[0] = tokens[0].replace(/^[({]+/, "");
1289
+ else break;
1290
+ }
1291
+ const valueOpts = new Set(["-c", "--exec-path", "--namespace", "--super-prefix", "--config-env"]);
1292
+ let i = 0;
1293
+ // Skip leading env assignments (`FOO=1 git ...`) and transparent wrappers.
1294
+ for (;;) {
1295
+ while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i])) i++;
1296
+ const w = String(tokens[i] || "").replace(/^.*\//, "");
1297
+ if (["sudo", "env", "command", "exec", "nice", "nohup", "time", "xargs", "builtin"].includes(w)) {
1298
+ i++;
1299
+ while (i < tokens.length && (tokens[i].startsWith("-") || /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i]))) i++;
1300
+ continue;
1301
+ }
1302
+ if (["bash", "sh", "zsh", "dash"].includes(w) || w === "eval") {
1303
+ if (depth >= 3) return null;
1304
+ const k = w === "eval" ? i + 1 : tokens.findIndex((t, n) => n > i && /^-[a-z]*c$/.test(t)) + 1;
1305
+ if (k <= i || k >= tokens.length) return null;
1306
+ const inner = w === "eval" ? tokens.slice(k).join(" ") : tokens[k];
1307
+ let hit = null;
1308
+ for (const seg of extractCommandSegments(inner)) hit = hit || mutatingGitCommand(seg, depth + 1);
1309
+ return hit;
1310
+ }
1311
+ break;
1312
+ }
1313
+ const cmd = String(tokens[i] || "").replace(/^.*\//, "");
1314
+ if (cmd === "gh") {
1315
+ return tokens[i + 1] === "pr" && tokens[i + 2] === "checkout" ? { name: "gh pr checkout", dir: null } : null;
1316
+ }
1317
+ if (cmd !== "git") return null;
1318
+ i++;
1319
+ let dir = null;
1320
+ while (i < tokens.length && tokens[i].startsWith("-")) {
1321
+ if (tokens[i] === "-C") { dir = dir === null ? (tokens[i + 1] ?? ".") : resolvePath(dir, tokens[i + 1] ?? "."); i += 2; continue; }
1322
+ const wt = /^--work-tree(?:=(.*))?$/.exec(tokens[i]);
1323
+ if (wt) { const v = wt[1] ?? tokens[i + 1] ?? "."; dir = dir === null ? v : resolvePath(dir, v); i += wt[1] === undefined ? 2 : 1; continue; }
1324
+ if (/^--git-dir(?:=.*)?$/.test(tokens[i])) { i += tokens[i].includes("=") ? 1 : 2; continue; } // work tree stays the cwd/-C
1325
+ i += valueOpts.has(tokens[i]) ? 2 : 1;
1326
+ }
1327
+ const sub = tokens[i];
1328
+ // Arguments end at a shell comment; options end at `--`.
1329
+ let rest = tokens.slice(i + 1);
1330
+ const hash = rest.findIndex((t) => t.startsWith("#"));
1331
+ if (hash >= 0) rest = rest.slice(0, hash);
1332
+ const dd = rest.indexOf("--");
1333
+ const opts = dd >= 0 ? rest.slice(0, dd) : rest;
1334
+ const hit = (name) => ({ name, dir });
1335
+ switch (sub) {
1336
+ case "checkout":
1337
+ // Creating a new branch is the one sanctioned form (`git checkout -b`).
1338
+ return opts.some((t) => t === "-b") ? null : hit("git checkout");
1339
+ case "switch":
1340
+ return opts.some((t) => t === "-c" || t === "--create") ? null : hit("git switch");
1341
+ case "merge": case "pull": case "rebase": case "cherry-pick": case "revert": case "am": case "restore":
1342
+ return hit(`git ${sub}`);
1343
+ case "clean":
1344
+ return opts.some((t) => t === "-n" || t === "--dry-run") ? null : hit("git clean");
1345
+ case "branch":
1346
+ return opts.some((t) => /^(?:-[a-zA-Z]*[fFmMdD][a-zA-Z]*|--force|--move|--delete)$/.test(t)) ? hit("git branch") : null;
1347
+ case "reset":
1348
+ return opts.some((t) => t === "--hard" || t === "--merge" || t === "--keep") ? hit("git reset") : null;
1349
+ case "stash": {
1350
+ const action = opts.find((t) => !t.startsWith("-"));
1351
+ return action === "list" || action === "show" ? null : hit("git stash");
1352
+ }
1353
+ default:
1354
+ return null;
1355
+ }
1356
+ }
1357
+
1358
+ /**
1359
+ * Is `cwd` the main (non-linked-worktree) checkout of a git repo?
1360
+ * Fails closed-to-allow: any error or non-git dir returns false.
1361
+ * @param {string} cwd
1362
+ * @returns {boolean}
1363
+ */
1364
+ function isMainCheckout(cwd) {
1365
+ try {
1366
+ const r = spawnSync(
1367
+ "git",
1368
+ ["rev-parse", "--path-format=absolute", "--git-dir", "--git-common-dir"],
1369
+ { cwd, encoding: "utf-8", timeout: 5000 },
1370
+ );
1371
+ if (r.status !== 0) return false;
1372
+ const [gitDir, commonDir] = String(r.stdout).trim().split(/\r?\n/);
1373
+ if (!gitDir || !commonDir) return false;
1374
+ return resolvePath(cwd, gitDir) === resolvePath(cwd, commonDir);
1375
+ } catch {
1376
+ return false;
1377
+ }
1378
+ }
1379
+
1380
+ /**
1381
+ * Rule 9: deny mutating git/gh commands aimed at the main checkout.
1382
+ * @param {string} command
1383
+ * @returns {string|null} stderr message when denied, else null
1384
+ */
1385
+ function checkMainCheckoutMutation(command) {
1386
+ if (process.env.FORGE_ALLOW_MAIN_CHECKOUT_GIT === "1") return null;
1387
+ if (!/\b(?:git|gh)\b/.test(command)) return null;
1388
+ try {
1389
+ let offender = null;
1390
+ let baseDir = process.cwd();
1391
+ let targetDir = baseDir;
1392
+ for (const tokens of extractCommandSegments(command)) {
1393
+ const head = tokens.slice();
1394
+ while (head.length && (/^[({]+$/.test(head[0]) || ["then", "do", "else", "!"].includes(head[0]))) head.shift();
1395
+ if (head.length && /^[({]+[^({]/.test(head[0])) head[0] = head[0].replace(/^[({]+/, "");
1396
+ if (head[0] === "cd" || head[0] === "pushd") {
1397
+ // Follow a literal target; a variable/substitution/`-` makes later segments' cwd indeterminate: allow those.
1398
+ const t = head[1];
1399
+ if (!t || /[$`~]/.test(t) || t === "-") break;
1400
+ baseDir = resolvePath(baseDir, t);
1401
+ continue;
1402
+ }
1403
+ const hit = mutatingGitCommand(tokens);
1404
+ if (hit) { offender = hit.name; targetDir = hit.dir === null ? baseDir : resolvePath(baseDir, hit.dir); break; }
1405
+ }
1406
+ if (!offender) return null;
1407
+ if (!isMainCheckout(targetDir)) return null;
1408
+ // Hook is global: only protect ForgeDock-managed main checkouts (issue #1591 scoping), never unrelated repos.
1409
+ if (!existsSync(join(targetDir, "forge.yaml")) && !existsSync(join(targetDir, ".forgedock"))) return null;
1410
+ return (
1411
+ `BLOCKED: \`${offender}\` would mutate the main checkout (${targetDir}).\n` +
1412
+ "The main checkout is shared with the operator and other agents. Work in a git worktree " +
1413
+ "(`git worktree list`, `git worktree add`) and use `git -C <worktree> ...`, or read code with " +
1414
+ "`git show <sha>:<path>`, `gh pr diff`, or `gh api`.\n" +
1415
+ "Operator override: set FORGE_ALLOW_MAIN_CHECKOUT_GIT=1 in the shell before starting Claude Code " +
1416
+ "(enforcement itself is opt-in: FORGE_ENFORCE_MAIN_CHECKOUT_GIT=1).\n"
1417
+ );
1418
+ } catch {
1419
+ return null; // fail open
1420
+ }
1421
+ }
1422
+
1236
1423
  // ---------------------------------------------------------------------------
1237
1424
  // Rule 8: Dangerous `rm` target guard (issue #2856)
1238
1425
  //
package/bin/runner.mjs CHANGED
@@ -1609,10 +1609,15 @@ export function runCliBackend({
1609
1609
  let parsedResult = null;
1610
1610
  let usage = null;
1611
1611
  let turns = null;
1612
+ // forge#3696: envelope cost/duration per attempt (null when absent or non-finite).
1613
+ let costUsd = null;
1614
+ let durationMs = null;
1612
1615
  try {
1613
1616
  const parsed = JSON.parse(stdoutTrimmed);
1614
1617
  if (parsed && typeof parsed === "object") {
1615
1618
  if (Number.isFinite(parsed.num_turns)) turns = parsed.num_turns;
1619
+ if (Number.isFinite(parsed.total_cost_usd)) costUsd = parsed.total_cost_usd;
1620
+ if (Number.isFinite(parsed.duration_ms)) durationMs = parsed.duration_ms;
1616
1621
  parsedResult = typeof parsed.result === "string" ? parsed.result : null;
1617
1622
  if (parsed.usage && typeof parsed.usage === "object") {
1618
1623
  usage = {
@@ -1697,6 +1702,8 @@ export function runCliBackend({
1697
1702
  // and the envelope's turn count (null when absent), for runlog recording.
1698
1703
  cliModel: modelFlag,
1699
1704
  turns,
1705
+ costUsd,
1706
+ durationMs,
1700
1707
  backend: "cli",
1701
1708
  // forge#3521: the phase's final reply (the CLI envelope's `.result`), so
1702
1709
  // callers can read its structured `*_RESULT` block. Additive; "" when absent.
@@ -0,0 +1,231 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Pure, source-agnostic aggregation for `forgedock stats` (forge#3835). No I/O, no clock, no model.
5
+ *
6
+ * Input record model (every source maps onto it; a missing field is null and degrades to "n/a"):
7
+ * run { source, repo, version, issue, run, finished, hydrated, usageEvents, reason, code, remediated,
8
+ * startTs, endTs, wallMs, phases: { <id>: { cr, out, usd, ms, attempts } } }
9
+ * event { repo, version, issue, pr, ts, event, ...payload } (FORGE:TELEMETRY gate/review events)
10
+ * issue { repo, number, isPr, createdAt, closedAt, labels: [name], authorType }
11
+ *
12
+ * Counting rules reproduce scripts/cost-report.sh so both reports agree on the same logs: only finished runs
13
+ * count, hydrated runs and runs with no usage events are excluded, tokens are cache-read tokens, and the
14
+ * per-phase mean divides by n (a run that skipped a phase counts 0).
15
+ */
16
+
17
+ export const DEFAULT_MIN_N = 10;
18
+ export const UNKNOWN = "unknown";
19
+
20
+ const PHASES = ["investigate", "build", "review", "remediate", "close"];
21
+ const isNum = (x) => typeof x === "number" && Number.isFinite(x);
22
+ const sum = (a) => a.reduce((s, x) => s + x, 0);
23
+ const mean = (a) => (a.length ? sum(a) / a.length : null);
24
+
25
+ /** p=50 is the median (average of the middle pair, as cost-report.sh); any other p is nearest-rank. */
26
+ export function percentile(values, p) {
27
+ const v = values.filter(isNum).sort((a, b) => a - b);
28
+ if (!v.length) return null;
29
+ if (p === 50) return v.length % 2 ? v[(v.length - 1) / 2] : (v[v.length / 2 - 1] + v[v.length / 2]) / 2;
30
+ return v[Math.max(0, Math.ceil((v.length * p) / 100) - 1)];
31
+ }
32
+
33
+ /** Sort key for "1.16.1" / "1.16.1~": numeric parts, unparseable parts count as 0. */
34
+ export function versionKey(v) {
35
+ return String(v).replace(/~$/, "").split(".").map((x) => (/^\d+$/.test(x) ? Number(x) : 0));
36
+ }
37
+ export function compareVersions(a, b) {
38
+ const x = versionKey(a); const y = versionKey(b);
39
+ for (let i = 0; i < Math.max(x.length, y.length); i++) {
40
+ const d = (x[i] ?? 0) - (y[i] ?? 0);
41
+ if (d) return d;
42
+ }
43
+ return 0;
44
+ }
45
+
46
+ /** releaseTimes: [{ v, t }] with t in epoch seconds. Latest release at or before `ms`, marked "~" (estimated). */
47
+ export function estimateVersion(ms, releaseTimes) {
48
+ if (!isNum(ms)) return UNKNOWN;
49
+ let best = null;
50
+ for (const r of releaseTimes ?? []) if (r.t * 1000 <= ms && (!best || r.t > best.t)) best = r;
51
+ return best ? `${best.v}~` : UNKNOWN;
52
+ }
53
+
54
+ const toMs = (ts) => { const t = Date.parse(ts ?? ""); return Number.isNaN(t) ? null : t; };
55
+ const ratio = (num, den) => (den > 0 ? num / den : null);
56
+ const sub = (a, b) => (isNum(a) && isNum(b) ? a - b : null);
57
+
58
+ function issueSource(i) {
59
+ if (i.labels.includes("review-finding")) return "review-finding";
60
+ return i.authorType === "Bot" ? "pipeline" : "human";
61
+ }
62
+ function closedKind(i) {
63
+ if (i.labels.includes("workflow:merged")) return "merged";
64
+ if (i.labels.includes("workflow:invalid") || i.labels.includes("invalid")) return "invalid";
65
+ if (i.labels.includes("duplicate")) return "duplicate";
66
+ return "other";
67
+ }
68
+
69
+ function costSection(runs) {
70
+ const n = runs.length;
71
+ const cr = runs.map((r) => sum(Object.values(r.phases).map((p) => p.cr ?? 0)));
72
+ const merged = runs.filter((r) => r.reason === "merged").length;
73
+ const usd = runs.map((r) => {
74
+ const v = Object.values(r.phases).map((p) => p.usd).filter(isNum);
75
+ return v.length ? sum(v) : null;
76
+ }).filter(isNum);
77
+ const perPhase = {};
78
+ for (const id of [...PHASES, ...new Set(runs.flatMap((r) => Object.keys(r.phases)).filter((p) => !PHASES.includes(p)))]) {
79
+ perPhase[id] = n ? sum(runs.map((r) => r.phases[id]?.cr ?? 0)) / n : null;
80
+ }
81
+ return {
82
+ n, cacheReadMean: mean(cr), cacheReadMedian: percentile(cr, 50), perPhase,
83
+ outputMean: n ? sum(runs.map((r) => sum(Object.values(r.phases).map((p) => p.out ?? 0)))) / n : null,
84
+ perMergedIssue: merged ? sum(cr) / merged : null,
85
+ usdN: usd.length, usdMean: mean(usd), usdTotal: usd.length ? sum(usd) : null,
86
+ };
87
+ }
88
+
89
+ function timeSection(runs) {
90
+ const phases = {};
91
+ const ids = new Set(runs.flatMap((r) => Object.keys(r.phases)));
92
+ for (const id of ids) {
93
+ const ms = runs.map((r) => r.phases[id]?.ms).filter((x) => isNum(x) && x > 0);
94
+ if (ms.length) phases[id] = { n: ms.length, p50: percentile(ms, 50), p90: percentile(ms, 90) };
95
+ }
96
+ const wall = runs.map((r) => r.wallMs).filter((x) => isNum(x) && x >= 0);
97
+ return { phases, wall: { n: wall.length, p50: percentile(wall, 50), p90: percentile(wall, 90) } };
98
+ }
99
+
100
+ function flowSection(runs) {
101
+ const n = runs.length;
102
+ const codes = {};
103
+ let coded = 0;
104
+ for (const r of runs) if (r.code) { codes[r.code] = (codes[r.code] ?? 0) + 1; coded++; }
105
+ const top = Object.entries(codes).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
106
+ .map(([code, count]) => ({ code, count, pct: count / coded }));
107
+ return {
108
+ n,
109
+ mergedPct: ratio(runs.filter((r) => r.reason === "merged").length, n),
110
+ needsHumanPct: ratio(runs.filter((r) => r.reason === "needs-human").length, n),
111
+ remediationPct: ratio(runs.filter((r) => r.remediated).length, n),
112
+ codesN: coded, codes: top,
113
+ };
114
+ }
115
+
116
+ function funnelSection(events, issues) {
117
+ const findings = events.filter((e) => e.event === "finding_disposition");
118
+ const reviews = new Set([...events.filter((e) => e.event === "review_panel"), ...findings]
119
+ .map((e) => `${e.repo}#${e.pr ?? e.issue}`));
120
+ const bySeverity = {};
121
+ for (const f of findings) {
122
+ const s = String(f.severity ?? "unknown").toUpperCase();
123
+ const d = (bySeverity[s] ??= { total: 0, ISSUE: 0, NOTE: 0, INPR_FIX: 0, other: 0 });
124
+ d.total++;
125
+ if (f.disposition in d) d[f.disposition]++; else d.other++;
126
+ }
127
+ for (const d of Object.values(bySeverity)) d.perReview = reviews.size ? d.total / reviews.size : null;
128
+ const dispositions = { ISSUE: 0, NOTE: 0, INPR_FIX: 0 };
129
+ for (const f of findings) if (f.disposition in dispositions) dispositions[f.disposition]++;
130
+ return {
131
+ reviews: reviews.size, findings: findings.length, perReview: ratio(findings.length, reviews.size),
132
+ bySeverity, dispositions,
133
+ validated: issues.filter((i) => i.labels.includes("validated")).length,
134
+ falsePositive: issues.filter((i) => i.labels.includes("false-positive")).length,
135
+ };
136
+ }
137
+
138
+ function gatesSection(events) {
139
+ const of = (name) => events.filter((e) => e.event === name);
140
+ const acc = of("acceptance_gate"); const size = of("diff_size"); const ci = of("ci_gate"); const live = of("liveness");
141
+ const blocked = (list, fn) => ({ n: list.length, blocked: list.filter(fn).length, rate: ratio(list.filter(fn).length, list.length) });
142
+ const malformed = acc.filter((e) => Number(e.malformed) > 0);
143
+ const count = (list, key, val) => list.filter((e) => e[key] === val).length;
144
+ return {
145
+ acceptance: blocked(acc, (e) => Number(e.failed) > 0),
146
+ malformedRate: ratio(malformed.length, acc.length),
147
+ diffSize: { ...blocked(size, (e) => e.result === "OVER"), over: count(size, "result", "OVER"), cohesive: count(size, "result", "COHESIVE") },
148
+ ci: blocked(ci, (e) => e.result !== "PASS"),
149
+ liveness: blocked(live, (e) => e.decision === "DEFER" || e.decision === "EXHAUSTED"),
150
+ };
151
+ }
152
+
153
+ function issueFlowSection(issues) {
154
+ const real = issues.filter((i) => !i.isPr);
155
+ const created = {};
156
+ for (const i of real) { const s = issueSource(i); created[s] = (created[s] ?? 0) + 1; }
157
+ const closedIssues = real.filter((i) => i.closedAt);
158
+ const closed = {};
159
+ for (const i of closedIssues) { const k = closedKind(i); closed[k] = (closed[k] ?? 0) + 1; }
160
+ const merged = closed.merged ?? 0;
161
+ return {
162
+ n: real.length, created, createdTotal: real.length, closed, closedTotal: closedIssues.length,
163
+ net: real.length ? real.length - closedIssues.length : null,
164
+ findingsPerMerge: ratio(created["review-finding"] ?? 0, merged),
165
+ };
166
+ }
167
+
168
+ function deltaOf(cur, prev) {
169
+ if (!prev) return null;
170
+ return {
171
+ against: prev.version,
172
+ cacheReadMean: sub(cur.cost.cacheReadMean, prev.cost.cacheReadMean),
173
+ mergedPct: sub(cur.flow.mergedPct, prev.flow.mergedPct),
174
+ needsHumanPct: sub(cur.flow.needsHumanPct, prev.flow.needsHumanPct),
175
+ remediationPct: sub(cur.flow.remediationPct, prev.flow.remediationPct),
176
+ wallP50: sub(cur.time.wall.p50, prev.time.wall.p50),
177
+ };
178
+ }
179
+
180
+ /**
181
+ * Join runs, gate events and issues into per-(version, repo) groups with a delta against the previous version of
182
+ * the same repo and a low-n flag. opts: { minN, releaseTimes, sinceMs, sinceVersion }.
183
+ */
184
+ export function aggregate({ runs = [], events = [], issues = [] } = {}, opts = {}) {
185
+ const minN = isNum(opts.minN) ? opts.minN : DEFAULT_MIN_N;
186
+ const rel = opts.releaseTimes ?? [];
187
+ const sinceMs = opts.sinceMs ?? null;
188
+ const inWindow = (...ts) => sinceMs == null || ts.some((t) => (toMs(t) ?? -Infinity) >= sinceMs);
189
+
190
+ const excluded = { unfinished: 0, noUsage: 0 };
191
+ const counted = [];
192
+ for (const r of runs) {
193
+ if (!r.finished) { excluded.unfinished++; continue; }
194
+ if (r.hydrated || !(r.usageEvents > 0)) { excluded.noUsage++; continue; }
195
+ if (!inWindow(r.endTs, r.startTs)) continue;
196
+ counted.push({ ...r, version: r.version ?? estimateVersion(toMs(r.endTs ?? r.startTs ?? r.mtimeTs), rel), repo: r.repo ?? UNKNOWN });
197
+ }
198
+
199
+ const versionOfIssue = new Map();
200
+ for (const r of counted) if (r.issue != null && r.version !== UNKNOWN) versionOfIssue.set(`${r.repo}#${r.issue}`, r.version);
201
+ const evs = events.filter((e) => inWindow(e.ts)).map((e) => ({
202
+ ...e, repo: e.repo ?? UNKNOWN,
203
+ version: e.version ?? e.forge_version ?? estimateVersion(toMs(e.ts), rel),
204
+ }));
205
+ for (const e of evs) if (e.issue != null && e.version !== UNKNOWN && !versionOfIssue.has(`${e.repo}#${e.issue}`)) versionOfIssue.set(`${e.repo}#${e.issue}`, e.version);
206
+ const iss = issues.filter((i) => inWindow(i.createdAt, i.closedAt)).map((i) => ({
207
+ ...i, repo: i.repo ?? UNKNOWN, labels: i.labels ?? [],
208
+ version: versionOfIssue.get(`${i.repo}#${i.number}`) ?? estimateVersion(toMs(i.createdAt), rel),
209
+ }));
210
+
211
+ const keys = new Map();
212
+ for (const x of [...counted, ...evs, ...iss]) keys.set(`${x.version}\u0000${x.repo}`, { version: x.version, repo: x.repo });
213
+
214
+ let groups = [...keys.values()].map(({ version, repo }) => {
215
+ const sel = (list) => list.filter((x) => x.version === version && x.repo === repo);
216
+ const g = { version, repo, cost: costSection(sel(counted)), time: timeSection(sel(counted)), flow: flowSection(sel(counted)),
217
+ funnel: funnelSection(sel(evs), sel(iss)), gates: gatesSection(sel(evs)), issueFlow: issueFlowSection(sel(iss)) };
218
+ g.n = g.flow.n;
219
+ g.lowN = g.n < minN;
220
+ g.minN = minN;
221
+ return g;
222
+ });
223
+ groups.sort((a, b) => a.repo.localeCompare(b.repo) || (a.version === UNKNOWN) - (b.version === UNKNOWN)
224
+ || compareVersions(a.version, b.version));
225
+ for (let i = 0; i < groups.length; i++) {
226
+ const prev = [...groups.slice(0, i)].reverse().find((p) => p.repo === groups[i].repo && p.version !== UNKNOWN);
227
+ groups[i].delta = groups[i].version === UNKNOWN ? null : deltaOf(groups[i], prev);
228
+ }
229
+ if (opts.sinceVersion) groups = groups.filter((g) => g.version !== UNKNOWN && compareVersions(g.version, opts.sinceVersion) >= 0);
230
+ return { groups, excluded, minN };
231
+ }
@@ -0,0 +1,40 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Optional local cache for `forgedock stats` (forge#3835): ~/.forge/stats-cache/<owner>_<repo>/cache.json.
5
+ * GitHub is the source of truth; this only stores what was already fetched plus the `updated_at` watermark so a
6
+ * refresh is incremental. A missing, unreadable or wrong-version cache is treated as empty (a re-fetch), and
7
+ * deleting the directory costs nothing but time. Writes never throw.
8
+ */
9
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { join } from "node:path";
12
+
13
+ export const CACHE_VERSION = 1;
14
+
15
+ export function cacheDir(repo, env = process.env) {
16
+ const root = env.FORGE_STATS_CACHE_DIR || join(homedir(), ".forge", "stats-cache");
17
+ return join(root, repo.replace("/", "_"));
18
+ }
19
+
20
+ /** { watermark: ISO string | null, items: { "<number>": item } } */
21
+ export const emptyCache = () => ({ v: CACHE_VERSION, watermark: null, items: {} });
22
+
23
+ export function loadCache(repo, env = process.env) {
24
+ try {
25
+ const c = JSON.parse(readFileSync(join(cacheDir(repo, env), "cache.json"), "utf8"));
26
+ if (c && c.v === CACHE_VERSION && c.items && typeof c.items === "object") return { ...emptyCache(), ...c };
27
+ } catch { /* missing or corrupt: refetch */ }
28
+ return emptyCache();
29
+ }
30
+
31
+ export function saveCache(repo, cache, env = process.env) {
32
+ try {
33
+ const dir = cacheDir(repo, env);
34
+ mkdirSync(dir, { recursive: true });
35
+ const tmp = join(dir, `cache.json.${process.pid}.tmp`);
36
+ writeFileSync(tmp, JSON.stringify(cache));
37
+ renameSync(tmp, join(dir, "cache.json"));
38
+ return true;
39
+ } catch { return false; }
40
+ }
@@ -0,0 +1,140 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Primary source for `forgedock stats` (forge#3835): the target repo's issues and PRs on GitHub.
5
+ * Reads labels and ONLY trusted `<!-- FORGE:TELEMETRY v1 -->` comments (same predicate as
6
+ * scripts/trusted-comments.sh: an author_association in FORGE_TRAIL_TRUSTED_ASSOCIATIONS, a Bot, or a login in
7
+ * FORGE_TRAIL_TRUSTED_LOGINS). Every `gh` call is argv-based (no shell). The `gh` runner is injectable for tests.
8
+ * Fetching is incremental: only items updated after the cached watermark are re-read.
9
+ */
10
+ import { execFileSync } from "node:child_process";
11
+ import { emptyCache, loadCache, saveCache } from "./cache.mjs";
12
+
13
+ export const MARKER_RE = /^<!-- FORGE:TELEMETRY v1( part=(\d+))? -->/;
14
+ const OWNER_REPO_RE = /^[\w.-]+\/[\w.-]+$/;
15
+ export const isValidRepo = (r) => typeof r === "string" && OWNER_REPO_RE.test(r) && !r.split("/").some((p) => p === "." || p === "..");
16
+
17
+ export function defaultGh(args) {
18
+ return execFileSync("gh", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 120000, maxBuffer: 256 * 1024 * 1024 });
19
+ }
20
+
21
+ /** `gh api --paginate` prints one JSON array per page; join them into one flat array. */
22
+ export function parsePages(raw) {
23
+ const t = (raw ?? "").trim();
24
+ if (!t) return [];
25
+ return JSON.parse(`[${t.replace(/\]\s*\[/g, "],[")}]`).flat();
26
+ }
27
+
28
+ export function isTrusted(comment, env = process.env) {
29
+ const assoc = (env.FORGE_TRAIL_TRUSTED_ASSOCIATIONS ?? "OWNER,MEMBER,COLLABORATOR").split(",").map((s) => s.trim()).filter(Boolean);
30
+ const logins = (env.FORGE_TRAIL_TRUSTED_LOGINS ?? "").split(",").map((s) => s.trim()).filter(Boolean);
31
+ return assoc.includes(comment?.author_association ?? "") || (comment?.user?.type ?? "") === "Bot" || logins.includes(comment?.user?.login ?? "");
32
+ }
33
+
34
+ /**
35
+ * Trusted FORGE:TELEMETRY comments -> record objects. Parts are all read (order is irrelevant), the fenced `jsonl`
36
+ * block is parsed line by line (a torn or invalid line is skipped), and records are deduped on `eid` (never on the
37
+ * line text: identical same-second events carry different ids).
38
+ */
39
+ export function parseTelemetryComments(comments, env = process.env) {
40
+ const seen = new Set();
41
+ const out = [];
42
+ for (const c of comments) {
43
+ if (!MARKER_RE.test(c?.body ?? "") || !isTrusted(c, env)) continue;
44
+ const block = /```jsonl\n([\s\S]*?)(?:\n```|$)/.exec(c.body);
45
+ if (!block) continue;
46
+ for (const line of block[1].split("\n")) {
47
+ if (!line.trim()) continue;
48
+ let rec;
49
+ try { rec = JSON.parse(line); } catch { continue; }
50
+ if (!rec || typeof rec !== "object" || typeof rec.event !== "string" || typeof rec.eid !== "string" || seen.has(rec.eid)) continue;
51
+ seen.add(rec.eid);
52
+ out.push(rec);
53
+ }
54
+ }
55
+ return out;
56
+ }
57
+
58
+ const num = (x) => { const v = Number(x); return x != null && x !== "" && Number.isFinite(v) ? v : null; };
59
+
60
+ /**
61
+ * Turn `phase_attempt` / `run_terminal` records into run summaries (one per repo#issue#run, finished only when a
62
+ * `run_terminal` exists). Field names are the ones bin/engine/telemetry-seam.mjs writes (docs/TELEMETRY.md).
63
+ */
64
+ export function runsFromRecords(records) {
65
+ const byRun = new Map();
66
+ for (const r of records) {
67
+ if ((r.event !== "phase_attempt" && r.event !== "run_terminal") || r.issue == null) continue;
68
+ const key = `${r.repo}#${r.issue}#${r.run ?? ""}`;
69
+ const run = byRun.get(key) ?? { source: "github", repo: r.repo ?? null, version: r.forge_version ?? null, issue: r.issue, run: r.run ?? null,
70
+ finished: false, hydrated: false, usageEvents: 0, reason: null, code: null, remediated: false, startTs: null, endTs: null, wallMs: null,
71
+ phases: {}, _first: null };
72
+ byRun.set(key, run);
73
+ if (!run._first || r.ts < run._first) run._first = r.ts;
74
+ if (r.event === "run_terminal") {
75
+ run.finished = true; run.reason = r.reason ?? null; run.code = r.code ?? null; run.endTs = r.ts ?? null; run.wallMs = num(r.wall_ms);
76
+ continue;
77
+ }
78
+ const id = r.phase ?? "other";
79
+ const p = (run.phases[id] ??= { cr: 0, out: 0, usd: null, ms: null, attempts: 0 });
80
+ const hasUsage = [r.input_tokens, r.output_tokens, r.cache_read_tokens].some((x) => num(x) != null);
81
+ if (hasUsage) run.usageEvents++;
82
+ p.cr += num(r.cache_read_tokens) ?? 0;
83
+ p.out += num(r.output_tokens) ?? 0;
84
+ p.attempts++;
85
+ if (num(r.duration_ms) != null) p.ms = (p.ms ?? 0) + num(r.duration_ms);
86
+ if (num(r.cost_usd) != null) p.usd = (p.usd ?? 0) + num(r.cost_usd);
87
+ if (id === "remediate") run.remediated = true;
88
+ }
89
+ return [...byRun.values()].map(({ _first, ...run }) => ({ ...run, startTs: _first }));
90
+ }
91
+
92
+ /** Gate/review records (everything that is not a run record) become aggregate events. */
93
+ export const gateEvents = (records) => records.filter((r) => r.event !== "phase_attempt" && r.event !== "run_terminal")
94
+ .map((r) => ({ ...r, version: r.forge_version ?? null }));
95
+
96
+ const meta = (it) => ({ number: it.number, isPr: Boolean(it.pull_request), createdAt: it.created_at ?? null, closedAt: it.closed_at ?? null,
97
+ updatedAt: it.updated_at ?? null, labels: (it.labels ?? []).map((l) => (typeof l === "string" ? l : l.name)).filter(Boolean), authorType: it.user?.type ?? null });
98
+
99
+ /**
100
+ * Fetch (or incrementally refresh) the repo's issues, PRs and trusted telemetry records.
101
+ * Returns { items, records, fetched, fromCache }. `refresh` ignores the cache; `useCache:false` never touches disk.
102
+ */
103
+ export function fetchGithubRecords({ repo, refresh = false, useCache = true, gh = defaultGh, env = process.env } = {}) {
104
+ if (!isValidRepo(repo)) throw new Error(`invalid repo: ${repo}`);
105
+ const cache = useCache && !refresh ? loadCache(repo, env) : emptyCache();
106
+ const q = ["state=all", "sort=updated", "direction=asc", "per_page=100", ...(cache.watermark ? [`since=${cache.watermark}`] : [])];
107
+ const listed = parsePages(gh(["api", "--paginate", `repos/${repo}/issues?${q.join("&")}`]));
108
+ const items = { ...cache.items };
109
+ let watermark = cache.watermark;
110
+ let fetched = 0;
111
+ for (const it of listed) {
112
+ if (!Number.isInteger(it?.number)) continue;
113
+ const m = meta(it);
114
+ let records = items[it.number]?.records ?? [];
115
+ // A telemetry comment is edited in place (which may not bump the issue's updated_at), so every listed item with
116
+ // comments is re-read rather than trusting a cached copy.
117
+ if ((it.comments ?? 0) > 0) {
118
+ records = parseTelemetryComments(parsePages(gh(["api", "--paginate", `repos/${repo}/issues/${it.number}/comments`])), env);
119
+ fetched++;
120
+ }
121
+ items[it.number] = { ...m, records };
122
+ if (m.updatedAt && (!watermark || m.updatedAt > watermark)) watermark = m.updatedAt;
123
+ }
124
+ if (useCache) saveCache(repo, { ...emptyCache(), watermark, items }, env);
125
+ const seen = new Set();
126
+ const records = [];
127
+ for (const it of Object.values(items)) for (const r of it.records ?? []) {
128
+ if (!seen.has(r.eid)) { seen.add(r.eid); records.push(r); }
129
+ }
130
+ return { items: Object.values(items), records, fetched, fromCache: cache.watermark != null };
131
+ }
132
+
133
+ /** Aggregate-model inputs for one repo from a fetch result. */
134
+ export function toModel(repo, result) {
135
+ return {
136
+ runs: runsFromRecords(result.records.map((r) => ({ ...r, repo: r.repo ?? repo }))),
137
+ events: gateEvents(result.records.map((r) => ({ ...r, repo: r.repo ?? repo }))),
138
+ issues: result.items.map((i) => ({ repo, number: i.number, isPr: i.isPr, createdAt: i.createdAt, closedAt: i.closedAt, labels: i.labels, authorType: i.authorType })),
139
+ };
140
+ }