forgedock 1.16.2 → 1.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/engine/outcome-codes.mjs +114 -0
- package/bin/engine/phases.mjs +211 -100
- package/bin/engine/projector.mjs +48 -1
- package/bin/engine/runlog.mjs +20 -5
- package/bin/engine/telemetry-seam.mjs +62 -0
- package/bin/engine/telemetry-seam.test.mjs +60 -0
- package/bin/engine/telemetry.mjs +148 -0
- package/bin/engine/telemetry.test.mjs +120 -0
- package/bin/engine-cli.mjs +17 -1
- package/bin/engine.mjs +227 -48
- package/bin/forgedock.mjs +8 -2
- package/bin/hooks/interactive-engine.mjs +10 -0
- package/bin/hooks/pre-tool-use.mjs +220 -0
- package/bin/runner.mjs +70 -4
- package/bin/stats/aggregate.mjs +231 -0
- package/bin/stats/cache.mjs +40 -0
- package/bin/stats/github-source.mjs +211 -0
- package/bin/stats/index.mjs +154 -0
- package/bin/stats/local-source.mjs +79 -0
- package/bin/stats/package.json +6 -0
- package/bin/stats/render.mjs +82 -0
- package/bin/stats/stats.test.mjs +312 -0
- package/commands/cleanup.md +90 -2
- package/commands/deploy-info.md +8 -4
- package/commands/deploy-pr.md +64 -0
- package/commands/fix-ci.md +20 -6
- package/commands/milestone.md +34 -17
- package/commands/orchestrate/phase-1-resolve.md +4 -1
- package/commands/orchestrate/phase-4-execution.md +28 -6
- package/commands/orchestrate/phase-5-cleanup.md +18 -2
- package/commands/pipeline-health/phase-1-context.md +2 -0
- package/commands/pipeline-health/phase-2-metrics.md +12 -5
- package/commands/pipeline-status.md +21 -1
- package/commands/review-pr-agents/protocols.md +1 -0
- package/commands/review-pr-agents.md +10 -2
- package/commands/review-pr-staging.md +196 -496
- package/commands/review-pr.md +512 -3134
- package/commands/test-gate.md +25 -10
- package/commands/work-on/build/architect.md +13 -1
- package/commands/work-on/build/implement.md +28 -6
- package/commands/work-on/build/validate.md +5 -2
- package/commands/work-on/build.md +197 -145
- package/commands/work-on/close.md +66 -20
- package/commands/work-on/decompose.md +113 -34
- package/commands/work-on/investigate.md +15 -14
- package/commands/work-on/remediate.md +49 -19
- package/commands/work-on/review.md +7 -5
- package/commands/work-on.md +1 -2
- package/docs/CONFIG.md +76 -24
- package/docs/OUTCOME-CODES.md +53 -0
- package/docs/RELEASE-NOTES.md +24 -0
- package/docs/STATS.md +63 -0
- package/docs/TELEMETRY.md +65 -0
- package/docs/WORK-ON-RUNTIME.md +19 -0
- package/docs/site/command-reference.md +1 -1
- package/docs/spec/forge-protocol-v1.md +2 -2
- package/docs/spec/label-state-machine.md +1 -1
- package/package.json +3 -2
- package/packages/protocol/src/phases.js +5 -5
- package/scripts/builder-resume-match.test.sh +58 -0
- package/scripts/cap-findings.sh +110 -0
- package/scripts/cap-findings.test.sh +130 -0
- package/scripts/check-spec-bash.sh +5 -1
- package/scripts/check-spec-bash.test.sh +20 -0
- package/scripts/check-spec-markers.sh +2 -0
- package/scripts/classify-finding.sh +65 -16
- package/scripts/classify-finding.test.sh +43 -1
- package/scripts/classify-grep-exit.sh +20 -0
- package/scripts/classify-grep-exit.test.sh +46 -0
- package/scripts/close-parent-c3.test.sh +123 -0
- package/scripts/cost-report.sh +146 -0
- package/scripts/cost-report.test.sh +80 -0
- package/scripts/diff-size.sh +97 -3
- package/scripts/diff-size.test.sh +59 -2
- package/scripts/doctor-pipeline-state.sh +43 -0
- package/scripts/file-deferred-followups.sh +159 -0
- package/scripts/file-deferred-followups.test.sh +106 -0
- package/scripts/fixtures/cost-report/a-merged1.jsonl +6 -0
- package/scripts/fixtures/cost-report/a-merged2-remediated.jsonl +7 -0
- package/scripts/fixtures/cost-report/a-needs-human.jsonl +4 -0
- package/scripts/fixtures/cost-report/b-merged.jsonl +3 -0
- package/scripts/fixtures/cost-report/c-merged-attempt-usage.jsonl +8 -0
- package/scripts/fixtures/cost-report/hydrated-no-usage.jsonl +3 -0
- package/scripts/fixtures/cost-report/old-log-no-version.jsonl +3 -0
- package/scripts/fixtures/cost-report/unfinished-no-terminal.jsonl +2 -0
- package/scripts/fixtures/cost-report/unfinished-reopened.jsonl +5 -0
- package/scripts/forge-root.test.sh +18 -11
- package/scripts/lib/fence-bootstrap.sh +4 -3
- package/scripts/lib/pr-issue-number.sh +18 -0
- package/scripts/lib/review-template-resolve.sh +41 -0
- package/scripts/lib/telemetry.sh +168 -0
- package/scripts/publish-staging-sync.test.sh +48 -0
- package/scripts/remediate-liveness.sh +42 -6
- package/scripts/resolve-deploy-gate.sh +32 -6
- package/scripts/resolve-deploy-gate.test.sh +42 -1
- package/scripts/review-acceptance-gate.sh +16 -7
- package/scripts/review-acceptance-gate.test.sh +13 -0
- package/scripts/review-finalize.sh +1427 -0
- package/scripts/review-finalize.test.sh +833 -0
- package/scripts/review-pr-delta-wiring.test.sh +19 -72
- package/scripts/review-pr-domain-map.test.sh +23 -13
- package/scripts/review-pr-gate2.test.sh +24 -65
- package/scripts/review-pr-staging-finding-filter.test.sh +44 -0
- package/scripts/review-pr-staging-scope.test.sh +56 -80
- package/scripts/review-prepare.sh +553 -0
- package/scripts/review-prepare.test.sh +288 -0
- package/scripts/review-roster.sh +186 -0
- package/scripts/review-roster.test.sh +185 -0
- package/scripts/run-shell-tests.sh +3 -0
- package/scripts/telemetry.test.sh +106 -0
- package/scripts/trusted-comments.test.sh +9 -2
- package/scripts/wait-ci-green.sh +8 -1
- package/scripts/wait-ci-green.test.sh +3 -3
- 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,21 @@ 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) — enforced by default
|
|
120
|
+
* Enforced (exit 2) unless FORGE_ENFORCE_MAIN_CHECKOUT_GIT=0, which downgrades it
|
|
121
|
+
* to a stderr warning (#3873). Ref-only verbs are narrowed: `git branch -d/-D/-m/-f`
|
|
122
|
+
* is denied only for the branch checked out in the target dir, and
|
|
123
|
+
* `git restore --staged` (index only) is allowed.
|
|
124
|
+
* It denies `git checkout|switch|stash|merge`, `git reset --hard`, and
|
|
125
|
+
* `gh pr checkout` when the shell's cwd is the main (non-worktree) checkout
|
|
126
|
+
* (`git rev-parse --git-dir` equals `--git-common-dir`). Pipeline workers and
|
|
127
|
+
* reviewers share that tree with the operator; mutating it races with them.
|
|
128
|
+
* Read-only git, `git checkout -b`, `git worktree ...`, and anything in a
|
|
129
|
+
* linked worktree stay allowed. Fails open when the repo cannot be resolved,
|
|
130
|
+
* and treats `git -C <dir>`, `--git-dir`, `--work-tree` and a leading `cd` as
|
|
131
|
+
* indeterminate (allowed). Override: FORGE_ALLOW_MAIN_CHECKOUT_GIT=1 in the
|
|
132
|
+
* shell environment (operator-set only).
|
|
133
|
+
*
|
|
118
134
|
* Rules 1-4 and 6 only apply inside a ForgeDock-managed directory (a directory
|
|
119
135
|
* with a `forge.yaml` or `.forgedock` marker) — see `isForgeDockManagedCwd()`
|
|
120
136
|
* (issue #1591). The hook installs into the user's global
|
|
@@ -566,6 +582,20 @@ async function main() {
|
|
|
566
582
|
return;
|
|
567
583
|
}
|
|
568
584
|
|
|
585
|
+
// --- Rule 9: Main-checkout mutation guard (issue #3650) ---
|
|
586
|
+
// Deliberately BEFORE the managed-cwd gate: pipeline workers run with cwd = a linked worktree (no
|
|
587
|
+
// forge.yaml), and `git -C <main> ...` from there is the case this rule exists for.
|
|
588
|
+
const mainCheckoutViolation = checkMainCheckoutMutation(command);
|
|
589
|
+
if (mainCheckoutViolation) {
|
|
590
|
+
if (process.env.FORGE_ENFORCE_MAIN_CHECKOUT_GIT !== "0") {
|
|
591
|
+
process.stderr.write(mainCheckoutViolation);
|
|
592
|
+
process.exit(2);
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
// Advisory only when the operator opts out with FORGE_ENFORCE_MAIN_CHECKOUT_GIT=0 (#3873).
|
|
596
|
+
process.stderr.write("WARNING (advisory, not blocked): " + mainCheckoutViolation.replace(/^BLOCKED: /, ""));
|
|
597
|
+
}
|
|
598
|
+
|
|
569
599
|
// Only enforce Rules 1-4 and 6 inside a ForgeDock-managed project — this hook
|
|
570
600
|
// installs globally (~/.claude/settings.json), so without this guard those
|
|
571
601
|
// rules would fire in every unrelated repo on the machine (issue #1591).
|
|
@@ -1233,6 +1263,196 @@ function checkTempPathStaging(command) {
|
|
|
1233
1263
|
return null;
|
|
1234
1264
|
}
|
|
1235
1265
|
|
|
1266
|
+
// ---------------------------------------------------------------------------
|
|
1267
|
+
// Rule 9: Main-checkout mutation guard (issue #3650)
|
|
1268
|
+
// ---------------------------------------------------------------------------
|
|
1269
|
+
|
|
1270
|
+
/**
|
|
1271
|
+
* Classify one command segment as a main-checkout mutation.
|
|
1272
|
+
*
|
|
1273
|
+
* The option tables live INSIDE the function: `main()` runs at module top via
|
|
1274
|
+
* `await`, before module-level `const`s declared further down are initialised,
|
|
1275
|
+
* so a module-level table throws (TDZ) and the hook fails open.
|
|
1276
|
+
*
|
|
1277
|
+
* @param {string[]} tokens
|
|
1278
|
+
* @param {number} [depth] wrapper-unwrap depth (bounded)
|
|
1279
|
+
* @returns {{name: string, dir: string|null}|null} offender and the `-C` dir it targets, or null
|
|
1280
|
+
*/
|
|
1281
|
+
function mutatingGitCommand(tokens, depth = 0) {
|
|
1282
|
+
// Strip grouping/compound prefixes: `(git x)`, `{ git x; }`, `then git x`, `do git x`, `! git x`.
|
|
1283
|
+
tokens = tokens.slice();
|
|
1284
|
+
for (;;) {
|
|
1285
|
+
if (/^[({]+$/.test(tokens[0] || "") || ["then", "do", "else", "elif", "!", "if", "while", "until"].includes(tokens[0])) tokens.shift();
|
|
1286
|
+
else if (/^[({]+[^({]/.test(tokens[0] || "")) tokens[0] = tokens[0].replace(/^[({]+/, "");
|
|
1287
|
+
else break;
|
|
1288
|
+
}
|
|
1289
|
+
const valueOpts = new Set(["-c", "--exec-path", "--namespace", "--super-prefix", "--config-env"]);
|
|
1290
|
+
let i = 0;
|
|
1291
|
+
// Skip leading env assignments (`FOO=1 git ...`) and transparent wrappers.
|
|
1292
|
+
for (;;) {
|
|
1293
|
+
while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i])) i++;
|
|
1294
|
+
const w = String(tokens[i] || "").replace(/^.*\//, "");
|
|
1295
|
+
if (["sudo", "env", "command", "exec", "nice", "nohup", "time", "xargs", "builtin"].includes(w)) {
|
|
1296
|
+
i++;
|
|
1297
|
+
while (i < tokens.length && (tokens[i].startsWith("-") || /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i]))) i++;
|
|
1298
|
+
continue;
|
|
1299
|
+
}
|
|
1300
|
+
if (["bash", "sh", "zsh", "dash"].includes(w) || w === "eval") {
|
|
1301
|
+
if (depth >= 3) return null;
|
|
1302
|
+
const k = w === "eval" ? i + 1 : tokens.findIndex((t, n) => n > i && /^-[a-z]*c$/.test(t)) + 1;
|
|
1303
|
+
if (k <= i || k >= tokens.length) return null;
|
|
1304
|
+
const inner = w === "eval" ? tokens.slice(k).join(" ") : tokens[k];
|
|
1305
|
+
// Every hit of the payload, not just the first (#3920): an allowed ref-only segment (`git branch -d other`)
|
|
1306
|
+
// must not mask a later mutating one. The caller decides per hit.
|
|
1307
|
+
const hits = [];
|
|
1308
|
+
for (const seg of extractCommandSegments(inner)) hits.push(...mutatingGitHits(seg, depth + 1));
|
|
1309
|
+
return hits.length ? { name: hits[0].name, dir: hits[0].dir, hits } : null;
|
|
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":
|
|
1342
|
+
return hit(`git ${sub}`);
|
|
1343
|
+
case "restore": {
|
|
1344
|
+
// `restore --staged` only rewrites the index (not the working tree); anything else (or --worktree/-W) does.
|
|
1345
|
+
const flag = (long, ch) => opts.some((t) => t === long || (/^-[a-zA-Z]+$/.test(t) && t.includes(ch)));
|
|
1346
|
+
return flag("--worktree", "W") || !flag("--staged", "S") ? hit("git restore") : null;
|
|
1347
|
+
}
|
|
1348
|
+
case "clean":
|
|
1349
|
+
return opts.some((t) => t === "-n" || t === "--dry-run") ? null : hit("git clean");
|
|
1350
|
+
case "branch": {
|
|
1351
|
+
// Ref-only verbs: a hit only when the delete/move/force target is the branch checked out at the
|
|
1352
|
+
// target dir (resolved by the caller). `-m <new>` with one positional renames the current branch.
|
|
1353
|
+
if (!opts.some((t) => /^(?:-[a-zA-Z]*[fFmMdD][a-zA-Z]*|--force|--move|--delete)$/.test(t))) return null;
|
|
1354
|
+
// Positionals after `--` count too (`git branch -m -- renamed` renames the checked-out branch, #3920).
|
|
1355
|
+
const pos = [...opts.filter((t) => !t.startsWith("-")), ...(dd >= 0 ? rest.slice(dd + 1) : [])];
|
|
1356
|
+
if (!pos.length) return null;
|
|
1357
|
+
const moves = opts.some((t) => t === "--move" || /^-[a-zA-Z]*[mM][a-zA-Z]*$/.test(t));
|
|
1358
|
+
const deletes = opts.some((t) => t === "--delete" || /^-[a-zA-Z]*[dD][a-zA-Z]*$/.test(t));
|
|
1359
|
+
const branches = moves && !deletes ? (pos.length === 1 ? ["@current"] : [pos[0]]) : deletes ? pos : [pos[0]];
|
|
1360
|
+
return { name: "git branch", dir, branches };
|
|
1361
|
+
}
|
|
1362
|
+
case "reset":
|
|
1363
|
+
return opts.some((t) => t === "--hard" || t === "--merge" || t === "--keep") ? hit("git reset") : null;
|
|
1364
|
+
case "stash": {
|
|
1365
|
+
const action = opts.find((t) => !t.startsWith("-"));
|
|
1366
|
+
return action === "list" || action === "show" ? null : hit("git stash");
|
|
1367
|
+
}
|
|
1368
|
+
default:
|
|
1369
|
+
return null;
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
/**
|
|
1374
|
+
* All main-checkout mutation hits in one command segment, flattening `bash -c` / `eval` payloads (#3920).
|
|
1375
|
+
* @param {string[]} tokens
|
|
1376
|
+
* @param {number} [depth]
|
|
1377
|
+
* @returns {Array<{name: string, dir: string|null, branches?: string[]}>}
|
|
1378
|
+
*/
|
|
1379
|
+
function mutatingGitHits(tokens, depth = 0) {
|
|
1380
|
+
const h = mutatingGitCommand(tokens, depth);
|
|
1381
|
+
if (!h) return [];
|
|
1382
|
+
return h.hits ? h.hits : [h];
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
/**
|
|
1386
|
+
* Is `cwd` the main (non-linked-worktree) checkout of a git repo?
|
|
1387
|
+
* Fails closed-to-allow: any error or non-git dir returns false.
|
|
1388
|
+
* @param {string} cwd
|
|
1389
|
+
* @returns {boolean}
|
|
1390
|
+
*/
|
|
1391
|
+
function isMainCheckout(cwd) {
|
|
1392
|
+
try {
|
|
1393
|
+
const r = spawnSync(
|
|
1394
|
+
"git",
|
|
1395
|
+
["rev-parse", "--path-format=absolute", "--git-dir", "--git-common-dir"],
|
|
1396
|
+
{ cwd, encoding: "utf-8", timeout: 5000 },
|
|
1397
|
+
);
|
|
1398
|
+
if (r.status !== 0) return false;
|
|
1399
|
+
const [gitDir, commonDir] = String(r.stdout).trim().split(/\r?\n/);
|
|
1400
|
+
if (!gitDir || !commonDir) return false;
|
|
1401
|
+
return resolvePath(cwd, gitDir) === resolvePath(cwd, commonDir);
|
|
1402
|
+
} catch {
|
|
1403
|
+
return false;
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
/**
|
|
1408
|
+
* Rule 9: deny mutating git/gh commands aimed at the main checkout.
|
|
1409
|
+
* @param {string} command
|
|
1410
|
+
* @returns {string|null} stderr message when denied, else null
|
|
1411
|
+
*/
|
|
1412
|
+
function checkMainCheckoutMutation(command) {
|
|
1413
|
+
if (process.env.FORGE_ALLOW_MAIN_CHECKOUT_GIT === "1") return null;
|
|
1414
|
+
if (!/\b(?:git|gh)\b/.test(command)) return null;
|
|
1415
|
+
try {
|
|
1416
|
+
let baseDir = process.cwd();
|
|
1417
|
+
// Decide per segment: an allowed ref-only verb (`git branch -d other`) must not mask a later mutating segment.
|
|
1418
|
+
for (const tokens of extractCommandSegments(command)) {
|
|
1419
|
+
const head = tokens.slice();
|
|
1420
|
+
while (head.length && (/^[({]+$/.test(head[0]) || ["then", "do", "else", "!"].includes(head[0]))) head.shift();
|
|
1421
|
+
if (head.length && /^[({]+[^({]/.test(head[0])) head[0] = head[0].replace(/^[({]+/, "");
|
|
1422
|
+
if (head[0] === "cd" || head[0] === "pushd") {
|
|
1423
|
+
// Follow a literal target; a variable/substitution/`-` makes later segments' cwd indeterminate: allow those.
|
|
1424
|
+
const t = head[1];
|
|
1425
|
+
if (!t || /[$`~]/.test(t) || t === "-") break;
|
|
1426
|
+
baseDir = resolvePath(baseDir, t);
|
|
1427
|
+
continue;
|
|
1428
|
+
}
|
|
1429
|
+
for (const hit of mutatingGitHits(tokens)) {
|
|
1430
|
+
const targetDir = hit.dir === null ? baseDir : resolvePath(baseDir, hit.dir);
|
|
1431
|
+
if (!isMainCheckout(targetDir)) continue;
|
|
1432
|
+
if (hit.branches) {
|
|
1433
|
+
// Ref-only verb: only the checked-out branch matters; unresolvable (detached HEAD, error) = allow.
|
|
1434
|
+
const cur = spawnSync("git", ["-C", targetDir, "branch", "--show-current"], { encoding: "utf-8", timeout: 5000 });
|
|
1435
|
+
const current = cur.status === 0 ? String(cur.stdout).trim() : "";
|
|
1436
|
+
if (!current || !hit.branches.some((b) => b === "@current" || b === current)) continue;
|
|
1437
|
+
}
|
|
1438
|
+
// Hook is global: only protect ForgeDock-managed main checkouts (issue #1591 scoping), never unrelated repos.
|
|
1439
|
+
if (!existsSync(join(targetDir, "forge.yaml")) && !existsSync(join(targetDir, ".forgedock"))) continue;
|
|
1440
|
+
return (
|
|
1441
|
+
`BLOCKED: \`${hit.name}\` would mutate the main checkout (${targetDir}).\n` +
|
|
1442
|
+
"The main checkout is shared with the operator and other agents. Work in a git worktree " +
|
|
1443
|
+
"(`git worktree list`, `git worktree add`) and use `git -C <worktree> ...`, or read code with " +
|
|
1444
|
+
"`git show <sha>:<path>`, `gh pr diff`, or `gh api`.\n" +
|
|
1445
|
+
"Operator override: set FORGE_ALLOW_MAIN_CHECKOUT_GIT=1 in the shell before starting Claude Code " +
|
|
1446
|
+
"(set FORGE_ENFORCE_MAIN_CHECKOUT_GIT=0 to make this advisory only).\n"
|
|
1447
|
+
);
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
return null;
|
|
1451
|
+
} catch {
|
|
1452
|
+
return null; // fail open
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1236
1456
|
// ---------------------------------------------------------------------------
|
|
1237
1457
|
// Rule 8: Dangerous `rm` target guard (issue #2856)
|
|
1238
1458
|
//
|
package/bin/runner.mjs
CHANGED
|
@@ -74,6 +74,64 @@ export function withForgedockHome(env) {
|
|
|
74
74
|
return env;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/** Expand a leading `~` and require an absolute result; anything else -> null. */
|
|
78
|
+
function absoluteTmpRoot(value) {
|
|
79
|
+
const v = typeof value === "string" ? value.trim() : "";
|
|
80
|
+
if (!v) return null;
|
|
81
|
+
const expanded = v === "~" || v.startsWith("~/") ? join(os.homedir(), v.slice(1)) : v;
|
|
82
|
+
return isAbsolute(expanded) ? expanded : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Root of the per-run scratch dirs (forge#3572). Precedence: forge.yaml `engine.tmp_root`
|
|
87
|
+
* (absolute or `~`-prefixed), else null when the operator exported their own
|
|
88
|
+
* CLAUDE_CODE_TMPDIR/TMPDIR (their choice is respected and never cleaned), else `~/.forge/tmp`.
|
|
89
|
+
* A null return means "operator-managed: leave the child env untouched".
|
|
90
|
+
*
|
|
91
|
+
* @param {string} cwd
|
|
92
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
93
|
+
* @returns {string|null}
|
|
94
|
+
*/
|
|
95
|
+
export function resolveRunTmpRoot(cwd, env = process.env) {
|
|
96
|
+
try {
|
|
97
|
+
const forgeYamlPath = join(cwd, "forge.yaml");
|
|
98
|
+
if (existsSync(forgeYamlPath)) {
|
|
99
|
+
const configured = absoluteTmpRoot(parseForgeYaml(readFileSync(forgeYamlPath, "utf-8"))?.engine?.tmp_root);
|
|
100
|
+
if (configured) return configured;
|
|
101
|
+
}
|
|
102
|
+
} catch { /* unreadable config: fall through to the defaults */ }
|
|
103
|
+
if (env.CLAUDE_CODE_TMPDIR || env.TMPDIR) return null;
|
|
104
|
+
return join(os.homedir(), ".forge", "tmp");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** `<root>/<issue>` for a numeric issue; null for an empty root or non-numeric issue (no path-escape, never the root). */
|
|
108
|
+
export function runTmpDir(root, issue) {
|
|
109
|
+
const id = String(issue ?? "").trim();
|
|
110
|
+
return root && /^[0-9]+$/.test(id) ? join(root, id) : null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Point TMPDIR and CLAUDE_CODE_TMPDIR at a per-run, disk-backed dir so venvs and Claude session data
|
|
115
|
+
* stay off a size-limited /tmp. No-op when the issue is unknown or the operator owns the tmp root.
|
|
116
|
+
* Best-effort: a failed mkdir leaves the env unchanged.
|
|
117
|
+
*/
|
|
118
|
+
export function withRunTmpdir(env, { issue, cwd = process.cwd() } = {}) {
|
|
119
|
+
const dir = runTmpDir(resolveRunTmpRoot(cwd, env), issue);
|
|
120
|
+
if (!dir) return env;
|
|
121
|
+
try {
|
|
122
|
+
mkdirSync(dir, { recursive: true });
|
|
123
|
+
env.TMPDIR = dir;
|
|
124
|
+
env.CLAUDE_CODE_TMPDIR = dir;
|
|
125
|
+
} catch { /* scratch stays on the inherited tmp */ }
|
|
126
|
+
return env;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** First positional arg when it is an issue number (the phase arg convention), else null. */
|
|
130
|
+
function issueFromArgs(args) {
|
|
131
|
+
const first = Array.isArray(args) ? args[0] : String(args ?? "").trim().split(/\s+/)[0];
|
|
132
|
+
return /^[0-9]+$/.test(String(first ?? "")) ? String(first) : null;
|
|
133
|
+
}
|
|
134
|
+
|
|
77
135
|
const DEFAULT_MODEL = "claude-sonnet-5";
|
|
78
136
|
const DEFAULT_MAX_ITERATIONS = 50;
|
|
79
137
|
const DEFAULT_MAX_TOKENS = 16384;
|
|
@@ -1410,7 +1468,7 @@ export function runCliBackend({
|
|
|
1410
1468
|
// Mirrors the existing scrub in the `run_bash` tool handler below.
|
|
1411
1469
|
// GH_TOKEN/GITHUB_TOKEN and all other env vars are intentionally left
|
|
1412
1470
|
// intact — only the Anthropic key is removed.
|
|
1413
|
-
const childEnv = withForgedockHome({ ...process.env });
|
|
1471
|
+
const childEnv = withRunTmpdir(withForgedockHome({ ...process.env }), { issue: issueFromArgs(args), cwd });
|
|
1414
1472
|
delete childEnv.ANTHROPIC_API_KEY;
|
|
1415
1473
|
|
|
1416
1474
|
// No `shell` option (defaults to false): argv is passed as discrete,
|
|
@@ -1609,10 +1667,15 @@ export function runCliBackend({
|
|
|
1609
1667
|
let parsedResult = null;
|
|
1610
1668
|
let usage = null;
|
|
1611
1669
|
let turns = null;
|
|
1670
|
+
// forge#3696: envelope cost/duration per attempt (null when absent or non-finite).
|
|
1671
|
+
let costUsd = null;
|
|
1672
|
+
let durationMs = null;
|
|
1612
1673
|
try {
|
|
1613
1674
|
const parsed = JSON.parse(stdoutTrimmed);
|
|
1614
1675
|
if (parsed && typeof parsed === "object") {
|
|
1615
1676
|
if (Number.isFinite(parsed.num_turns)) turns = parsed.num_turns;
|
|
1677
|
+
if (Number.isFinite(parsed.total_cost_usd)) costUsd = parsed.total_cost_usd;
|
|
1678
|
+
if (Number.isFinite(parsed.duration_ms)) durationMs = parsed.duration_ms;
|
|
1616
1679
|
parsedResult = typeof parsed.result === "string" ? parsed.result : null;
|
|
1617
1680
|
if (parsed.usage && typeof parsed.usage === "object") {
|
|
1618
1681
|
usage = {
|
|
@@ -1697,6 +1760,8 @@ export function runCliBackend({
|
|
|
1697
1760
|
// and the envelope's turn count (null when absent), for runlog recording.
|
|
1698
1761
|
cliModel: modelFlag,
|
|
1699
1762
|
turns,
|
|
1763
|
+
costUsd,
|
|
1764
|
+
durationMs,
|
|
1700
1765
|
backend: "cli",
|
|
1701
1766
|
// forge#3521: the phase's final reply (the CLI envelope's `.result`), so
|
|
1702
1767
|
// callers can read its structured `*_RESULT` block. Additive; "" when absent.
|
|
@@ -2179,9 +2244,10 @@ export function resolveBashShell() {
|
|
|
2179
2244
|
* the loop catches and reports the error back to the model as an error result.
|
|
2180
2245
|
*
|
|
2181
2246
|
* @param {string} cwd
|
|
2247
|
+
* @param {string|null} [runIssueNumber] - issue number scoping the per-run TMPDIR (forge#3572)
|
|
2182
2248
|
* @returns {Record<string, (input: object) => string>}
|
|
2183
2249
|
*/
|
|
2184
|
-
export function getToolHandlers(cwd) {
|
|
2250
|
+
export function getToolHandlers(cwd, runIssueNumber = null) {
|
|
2185
2251
|
return {
|
|
2186
2252
|
read_file: ({ path }) => {
|
|
2187
2253
|
if (!path) throw new Error("read_file requires a 'path'");
|
|
@@ -2201,7 +2267,7 @@ export function getToolHandlers(cwd) {
|
|
|
2201
2267
|
// into the model loop, so a prompt-injection payload could otherwise issue
|
|
2202
2268
|
// a run_bash command that exfiltrates the key. gh/git auth tokens
|
|
2203
2269
|
// (GH_TOKEN/GITHUB_TOKEN) are intentionally left intact.
|
|
2204
|
-
const childEnv = withForgedockHome({ ...process.env });
|
|
2270
|
+
const childEnv = withRunTmpdir(withForgedockHome({ ...process.env }), { issue: runIssueNumber, cwd });
|
|
2205
2271
|
delete childEnv.ANTHROPIC_API_KEY;
|
|
2206
2272
|
// Run under bash when available so bash-style commands the model emits
|
|
2207
2273
|
// (heredocs, scripts/*.sh, single-quote semantics) behave consistently
|
|
@@ -2641,7 +2707,7 @@ export async function runCommand(opts = {}) {
|
|
|
2641
2707
|
// is a known Linux limitation at the OS level; closing it would require
|
|
2642
2708
|
// a native module (e.g. prctl PR_SET_DUMPABLE) or process isolation.
|
|
2643
2709
|
// See: issue #1370 for tracking this known limitation.
|
|
2644
|
-
const handlers = getToolHandlers(cwd);
|
|
2710
|
+
const handlers = getToolHandlers(cwd, issueFromArgs(args));
|
|
2645
2711
|
const messages = [{ role: "user", content: userMessage }];
|
|
2646
2712
|
|
|
2647
2713
|
// Accumulate token usage across all messages.create() calls in this run.
|
|
@@ -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
|
+
}
|