forgedock 1.18.0 → 1.19.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 (116) hide show
  1. package/bin/engine/outcome-codes.mjs +13 -0
  2. package/bin/engine/phases.mjs +54 -4
  3. package/bin/engine/runlog.mjs +10 -2
  4. package/bin/engine/telemetry-seam.mjs +3 -0
  5. package/bin/engine/telemetry.test.mjs +3 -2
  6. package/bin/engine-cli.mjs +32 -2
  7. package/bin/engine.mjs +139 -7
  8. package/bin/runner.mjs +69 -9
  9. package/bin/stats/aggregate.mjs +7 -3
  10. package/bin/stats/cache.mjs +7 -3
  11. package/bin/stats/github-source.mjs +136 -28
  12. package/bin/stats/index.mjs +46 -9
  13. package/bin/stats/render.mjs +1 -1
  14. package/bin/stats/stats.test.mjs +201 -40
  15. package/commands/review-pr-staging.md +4 -4
  16. package/commands/review-pr.md +25 -15
  17. package/commands/work-on/build/architect.md +53 -0
  18. package/commands/work-on/build/implement.md +19 -0
  19. package/commands/work-on/build/validate.md +320 -28
  20. package/commands/work-on/build.md +66 -9
  21. package/commands/work-on/investigate.md +7 -0
  22. package/commands/work-on/remediate.md +32 -13
  23. package/commands/work-on/review.md +32 -19
  24. package/docs/CONFIG.md +89 -2
  25. package/docs/OUTCOME-CODES.md +5 -1
  26. package/docs/STATS.md +11 -4
  27. package/docs/TELEMETRY.md +5 -2
  28. package/docs/WORK-ON-RUNTIME.md +18 -0
  29. package/package.json +2 -2
  30. package/packages/protocol/src/types.js +1 -1
  31. package/scripts/acceptance-red-green.sh +90 -0
  32. package/scripts/acceptance-red-green.test.sh +74 -0
  33. package/scripts/cap-findings.test.sh +2 -0
  34. package/scripts/check-contract-scope.test.sh +2 -0
  35. package/scripts/check-edge-cases.sh +143 -0
  36. package/scripts/check-edge-cases.test.sh +175 -0
  37. package/scripts/check-spec-markers.sh +2 -0
  38. package/scripts/check-test-tmp-cleanup.sh +56 -0
  39. package/scripts/check-test-tmp-cleanup.test.sh +69 -0
  40. package/scripts/ci-local/adapters/azure-pipelines.mjs +113 -0
  41. package/scripts/ci-local/adapters/bitbucket.mjs +64 -0
  42. package/scripts/ci-local/adapters/buildkite.mjs +58 -0
  43. package/scripts/ci-local/adapters/circleci.mjs +101 -0
  44. package/scripts/ci-local/adapters/common.mjs +115 -0
  45. package/scripts/ci-local/adapters/explicit.mjs +21 -0
  46. package/scripts/ci-local/adapters/github-actions.mjs +349 -0
  47. package/scripts/ci-local/adapters/gitlab-ci.mjs +184 -0
  48. package/scripts/ci-local/adapters/portability.mjs +104 -0
  49. package/scripts/ci-local/adapters/task-runners.mjs +67 -0
  50. package/scripts/ci-local/cli.mjs +50 -0
  51. package/scripts/ci-local/lib.mjs +371 -0
  52. package/scripts/ci-local/plan.mjs +186 -0
  53. package/scripts/ci-local/run.mjs +345 -0
  54. package/scripts/ci-local/scoping.mjs +508 -0
  55. package/scripts/ci-local.sh +97 -0
  56. package/scripts/ci-local.test.sh +919 -0
  57. package/scripts/classify-finding.sh +32 -6
  58. package/scripts/classify-finding.test.sh +23 -0
  59. package/scripts/classify-test-run.sh +69 -0
  60. package/scripts/classify-test-run.test.sh +90 -0
  61. package/scripts/close-parent-c3.test.sh +2 -0
  62. package/scripts/consumer-sweep.sh +569 -0
  63. package/scripts/consumer-sweep.test.sh +357 -0
  64. package/scripts/detect-append-registries.sh +106 -0
  65. package/scripts/detect-append-registries.test.sh +64 -0
  66. package/scripts/fixtures/ci-local/azure/azure-pipelines.yml +11 -0
  67. package/scripts/fixtures/ci-local/bitbucket/bitbucket-pipelines.yml +12 -0
  68. package/scripts/fixtures/ci-local/buildkite/.buildkite/pipeline.yml +10 -0
  69. package/scripts/fixtures/ci-local/circleci/.circleci/config.yml +29 -0
  70. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/ci.yml +59 -0
  71. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/docs.yml +10 -0
  72. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/nightly.yml +10 -0
  73. package/scripts/fixtures/ci-local/gha-basic/docs/index.md +1 -0
  74. package/scripts/fixtures/ci-local/gha-basic/src/greet.sh +2 -0
  75. package/scripts/fixtures/ci-local/gha-basic/tests/greet.test.sh +3 -0
  76. package/scripts/fixtures/ci-local/gha-dorny/.github/workflows/ci.yml +51 -0
  77. package/scripts/fixtures/ci-local/gha-dorny/backend/app.py +1 -0
  78. package/scripts/fixtures/ci-local/gha-dorny/docs/a.md +1 -0
  79. package/scripts/fixtures/ci-local/gha-dorny/frontend/app.js +1 -0
  80. package/scripts/fixtures/ci-local/gha-run/.github/workflows/ci.yml +39 -0
  81. package/scripts/fixtures/ci-local/gha-run/src/x.txt +1 -0
  82. package/scripts/fixtures/ci-local/gitlab/.gitlab-ci.yml +43 -0
  83. package/scripts/fixtures/ci-local/gitlab/docs/d.md +1 -0
  84. package/scripts/fixtures/ci-local/gitlab/src/a.txt +1 -0
  85. package/scripts/fixtures/ci-local/monorepo/.github/workflows/ci.yml +13 -0
  86. package/scripts/fixtures/ci-local/monorepo/package-lock.json +1 -0
  87. package/scripts/fixtures/ci-local/monorepo/packages/a/src/a.sh +2 -0
  88. package/scripts/fixtures/ci-local/monorepo/packages/a/tests/a.test.sh +3 -0
  89. package/scripts/fixtures/ci-local/monorepo/packages/b/src/b.sh +2 -0
  90. package/scripts/fixtures/ci-local/monorepo/packages/b/tests/b.test.sh +3 -0
  91. package/scripts/fixtures/ci-local/no-ci/Makefile +6 -0
  92. package/scripts/fixtures/ci-local/no-ci/package.json +1 -0
  93. package/scripts/fixtures/ci-local/no-ci/src/a.js +1 -0
  94. package/scripts/fixtures/ci-local/no-ci/src/b.js +1 -0
  95. package/scripts/fixtures/ci-local/no-ci/tests/a.test.js +2 -0
  96. package/scripts/fixtures/ci-local/no-ci/tests/b.test.js +2 -0
  97. package/scripts/forge-root.test.sh +1 -1
  98. package/scripts/lib/telemetry.sh +1 -0
  99. package/scripts/remediate-approved-head.test.sh +2 -0
  100. package/scripts/remediate-basesync.test.sh +1 -1
  101. package/scripts/review-acceptance-gate.sh +2 -2
  102. package/scripts/review-acceptance-gate.test.sh +20 -0
  103. package/scripts/review-delta.sh +8 -1
  104. package/scripts/review-delta.test.sh +19 -0
  105. package/scripts/review-finalize.sh +193 -41
  106. package/scripts/review-finalize.test.sh +211 -4
  107. package/scripts/review-pr-delta-wiring.test.sh +13 -0
  108. package/scripts/review-prepare.sh +5 -3
  109. package/scripts/review-prepare.test.sh +18 -2
  110. package/scripts/run-shell-tests.sh +5 -0
  111. package/scripts/telemetry.test.sh +2 -0
  112. package/scripts/trusted-comments.test.sh +2 -1
  113. package/scripts/verify-phase-trail.test.sh +8 -0
  114. package/scripts/wait-ci-green.sh +61 -9
  115. package/scripts/wait-ci-green.test.sh +41 -3
  116. package/scripts/with-run-tmpdir.sh +20 -0
package/bin/runner.mjs CHANGED
@@ -47,8 +47,11 @@ import {
47
47
  mkdtempSync,
48
48
  rmSync,
49
49
  realpathSync,
50
+ lstatSync,
51
+ chmodSync,
50
52
  } from "fs";
51
- import { join, dirname, basename, relative, isAbsolute, extname, win32 as win32Path } from "path";
53
+ import { createHash } from "crypto";
54
+ import { join, dirname, basename, resolve, relative, isAbsolute, extname, win32 as win32Path } from "path";
52
55
  import os from "os";
53
56
  import { execSync, spawnSync } from "child_process";
54
57
  import { createRequire } from "module";
@@ -104,22 +107,47 @@ export function resolveRunTmpRoot(cwd, env = process.env) {
104
107
  return join(os.homedir(), ".forge", "tmp");
105
108
  }
106
109
 
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) {
110
+ /**
111
+ * Stable, path-safe identity of the repo checkout: `<sanitized basename>-<8 hex of sha1(realpath)>`.
112
+ * Local only (no network), identical across a `--retry` from the same checkout, distinct per checkout.
113
+ * Never empty, never `.`/`..`, never contains a path separator.
114
+ */
115
+ export function repoKey(cwd = process.cwd()) {
116
+ let real;
117
+ try { real = realpathSync(cwd); } catch { real = resolve(String(cwd)); }
118
+ const name = basename(real).replace(/[^A-Za-z0-9._-]/g, "_").replace(/^\.+/, "") || "repo";
119
+ return `${name}-${createHash("sha1").update(real).digest("hex").slice(0, 8)}`;
120
+ }
121
+
122
+ /** `<root>/<repoKey>/<issue>` for a numeric issue; null for an empty root or non-numeric issue (no path-escape, never the root). */
123
+ export function runTmpDir(root, issue, cwd = process.cwd()) {
109
124
  const id = String(issue ?? "").trim();
110
- return root && /^[0-9]+$/.test(id) ? join(root, id) : null;
125
+ return root && /^[0-9]+$/.test(id) ? join(root, repoKey(cwd), id) : null;
126
+ }
127
+
128
+ /** True when `p` is a real directory (not a symlink) owned by the current uid (uid check skipped where unavailable). */
129
+ function isOwnedRealDir(p) {
130
+ const st = lstatSync(p);
131
+ if (!st.isDirectory() || st.isSymbolicLink()) return false;
132
+ return typeof process.getuid !== "function" || st.uid === process.getuid();
111
133
  }
112
134
 
113
135
  /**
114
136
  * 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.
137
+ * stay off a size-limited /tmp. The dir is repo-scoped and created 0700; an existing dir that is a symlink
138
+ * or owned by another uid is not used. No-op when the issue is unknown or the operator owns the tmp root.
139
+ * Best-effort: any failure leaves the env unchanged.
117
140
  */
118
141
  export function withRunTmpdir(env, { issue, cwd = process.cwd() } = {}) {
119
- const dir = runTmpDir(resolveRunTmpRoot(cwd, env), issue);
142
+ const dir = runTmpDir(resolveRunTmpRoot(cwd, env), issue, cwd);
120
143
  if (!dir) return env;
121
144
  try {
122
- mkdirSync(dir, { recursive: true });
145
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
146
+ const parent = dirname(dir);
147
+ for (const d of [parent, dir]) {
148
+ if (!isOwnedRealDir(d)) return env;
149
+ chmodSync(d, 0o700);
150
+ }
123
151
  env.TMPDIR = dir;
124
152
  env.CLAUDE_CODE_TMPDIR = dir;
125
153
  } catch { /* scratch stays on the inherited tmp */ }
@@ -1493,6 +1521,9 @@ export function runCliBackend({
1493
1521
  // intentionally omitted here since spawnSync's own `timeout` + ETIMEDOUT is
1494
1522
  // reliable for this single, non-looped invocation.
1495
1523
  const timedOut = result.status === null && result.error?.code === "ETIMEDOUT";
1524
+ // forge#4037: only a spawn-stage failure is transient infra. A wall-clock kill leaves evidence that the child ran
1525
+ // (a kill signal, a pid, or captured output); an ETIMEDOUT with none of those never started the child.
1526
+ const childRan = !!result.signal || (Number.isInteger(result.pid) && result.pid > 0) || (stdout + stderr).trim().length > 0;
1496
1527
  if (timedOut) {
1497
1528
  const timeoutSecs = Math.round(timeoutMs / 1000);
1498
1529
  // forge#2360: spawnSync's `timeout` option kills the child outright on
@@ -1510,15 +1541,44 @@ export function runCliBackend({
1510
1541
  // review findings — #2277, #2292, #2293, #2355 — for that defect
1511
1542
  // class).
1512
1543
  const partial = (stdout + stderr).trim();
1513
- throw new Error(
1544
+ const timeoutError = new Error(
1514
1545
  `claude CLI invocation timed out after ${timeoutSecs}s and was killed. ` +
1515
1546
  `Set FORGEDOCK_CLI_TIMEOUT_MS (ms) to adjust, or use --backend api.` +
1516
1547
  (partial ? ` Partial output: ${sanitizeOutputExcerptForLog(partial)}` : ""),
1517
1548
  );
1549
+ if (!childRan) {
1550
+ // forge#4021: stable transient-infra class; the engine retries it on a
1551
+ // separate bounded counter without consuming a phase attempt.
1552
+ timeoutError.code = "CLI_SPAWN_TRANSIENT";
1553
+ timeoutError.spawnErrno = "ETIMEDOUT";
1554
+ throw timeoutError;
1555
+ }
1556
+ // forge#4037: the child ran and was killed at the wall-clock limit: a normal, attempt-consuming failure
1557
+ // (`cli.timeout`), with usage attached when the CLI had already emitted a parseable envelope.
1558
+ timeoutError.code = "CLI_TIMEOUT";
1559
+ timeoutError.usage = null;
1560
+ try {
1561
+ const u = JSON.parse(stdout.trim())?.usage;
1562
+ if (u && typeof u === "object") {
1563
+ timeoutError.usage = {
1564
+ input_tokens: toFiniteUsageNumber(u.input_tokens),
1565
+ output_tokens: toFiniteUsageNumber(u.output_tokens),
1566
+ cache_creation_input_tokens: toFiniteUsageNumber(u.cache_creation_input_tokens),
1567
+ cache_read_input_tokens: toFiniteUsageNumber(u.cache_read_input_tokens),
1568
+ };
1569
+ }
1570
+ } catch {
1571
+ // No parseable envelope from a killed child: usage stays null (never fabricated).
1572
+ }
1573
+ throw timeoutError;
1518
1574
  }
1519
1575
  if (result.error) {
1520
1576
  const error = new Error(`Failed to invoke claude CLI: ${result.error.message}`);
1521
1577
  error.code = result.error.code;
1578
+ if (["EAGAIN", "ENOMEM", "EMFILE"].includes(result.error.code)) {
1579
+ error.spawnErrno = result.error.code;
1580
+ error.code = "CLI_SPAWN_TRANSIENT";
1581
+ }
1522
1582
  throw error;
1523
1583
  }
1524
1584
 
@@ -127,8 +127,10 @@ function funnelSection(events, issues) {
127
127
  for (const d of Object.values(bySeverity)) d.perReview = reviews.size ? d.total / reviews.size : null;
128
128
  const dispositions = { ISSUE: 0, NOTE: 0, INPR_FIX: 0 };
129
129
  for (const f of findings) if (f.disposition in dispositions) dispositions[f.disposition]++;
130
+ // Events written before the scope field existed carry none and are not counted.
131
+ const introduced = findings.filter((f) => f.scope === "introduced").length;
130
132
  return {
131
- reviews: reviews.size, findings: findings.length, perReview: ratio(findings.length, reviews.size),
133
+ reviews: reviews.size, findings: findings.length, introduced, perReview: ratio(findings.length, reviews.size),
132
134
  bySeverity, dispositions,
133
135
  validated: issues.filter((i) => i.labels.includes("validated")).length,
134
136
  falsePositive: issues.filter((i) => i.labels.includes("false-positive")).length,
@@ -150,7 +152,7 @@ function gatesSection(events) {
150
152
  };
151
153
  }
152
154
 
153
- function issueFlowSection(issues) {
155
+ function issueFlowSection(issues, introduced = 0) {
154
156
  const real = issues.filter((i) => !i.isPr);
155
157
  const created = {};
156
158
  for (const i of real) { const s = issueSource(i); created[s] = (created[s] ?? 0) + 1; }
@@ -162,6 +164,7 @@ function issueFlowSection(issues) {
162
164
  n: real.length, created, createdTotal: real.length, closed, closedTotal: closedIssues.length,
163
165
  net: real.length ? real.length - closedIssues.length : null,
164
166
  findingsPerMerge: ratio(created["review-finding"] ?? 0, merged),
167
+ introducedPerMerge: ratio(introduced, merged),
165
168
  };
166
169
  }
167
170
 
@@ -214,7 +217,8 @@ export function aggregate({ runs = [], events = [], issues = [] } = {}, opts = {
214
217
  let groups = [...keys.values()].map(({ version, repo }) => {
215
218
  const sel = (list) => list.filter((x) => x.version === version && x.repo === repo);
216
219
  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)) };
220
+ funnel: funnelSection(sel(evs), sel(iss)), gates: gatesSection(sel(evs)), issueFlow: null };
221
+ g.issueFlow = issueFlowSection(sel(iss), g.funnel.introduced);
218
222
  g.n = g.flow.n;
219
223
  g.lowN = g.n < minN;
220
224
  g.minN = minN;
@@ -17,13 +17,17 @@ export function cacheDir(repo, env = process.env) {
17
17
  return join(root, repo.replace("/", "_"));
18
18
  }
19
19
 
20
- /** { watermark: ISO string | null, items: { "<number>": item } } */
21
- export const emptyCache = () => ({ v: CACHE_VERSION, watermark: null, items: {} });
20
+ /**
21
+ * { watermark: ISO string | null, coveredFrom: ISO string | null, items: { "<number>": item } }
22
+ * The cache covers activity in [coveredFrom, watermark]; coveredFrom null means "from the beginning". A cache written
23
+ * before the field existed loads with coveredFrom undefined (floor unknown), never as full coverage.
24
+ */
25
+ export const emptyCache = () => ({ v: CACHE_VERSION, watermark: null, coveredFrom: null, items: {} });
22
26
 
23
27
  export function loadCache(repo, env = process.env) {
24
28
  try {
25
29
  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 };
30
+ if (c && c.v === CACHE_VERSION && c.items && typeof c.items === "object") return { ...emptyCache(), ...c, coveredFrom: "coveredFrom" in c ? c.coveredFrom : undefined };
27
31
  } catch { /* missing or corrupt: refetch */ }
28
32
  return emptyCache();
29
33
  }
@@ -5,17 +5,24 @@
5
5
  * Reads labels and ONLY trusted `<!-- FORGE:TELEMETRY v1 -->` comments (same predicate as
6
6
  * scripts/trusted-comments.sh: an author_association in FORGE_TRAIL_TRUSTED_ASSOCIATIONS, a Bot, or a login in
7
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 (minus a small overlap window) are re-read.
8
+ * Fetching is windowed and incremental: only items updated after the crawl start (the cached watermark minus a small
9
+ * overlap, or the requested `--since` bound) are listed, and comments come from the repo-wide `issues/comments?since=`
10
+ * endpoint (a few paginated calls) instead of one call per item.
9
11
  */
10
- import { execFileSync } from "node:child_process";
12
+ import { execFile } from "node:child_process";
13
+ import { promisify } from "node:util";
11
14
  import { emptyCache, loadCache, saveCache } from "./cache.mjs";
12
15
 
13
16
  export const MARKER_RE = /^<!-- FORGE:TELEMETRY v1( part=(\d+))? -->/;
14
17
  const OWNER_REPO_RE = /^[\w.-]+\/[\w.-]+$/;
15
18
  export const isValidRepo = (r) => typeof r === "string" && OWNER_REPO_RE.test(r) && !r.split("/").some((p) => p === "." || p === "..");
16
19
 
17
- export function defaultGh(args) {
18
- return execFileSync("gh", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 120000, maxBuffer: 256 * 1024 * 1024 });
20
+ const execFileAsync = promisify(execFile);
21
+
22
+ /** Async `gh` runner (concurrency needs it). A test stub may stay synchronous: every call site awaits the result. */
23
+ export async function defaultGh(args) {
24
+ const { stdout } = await execFileAsync("gh", args, { encoding: "utf8", timeout: 120000, maxBuffer: 256 * 1024 * 1024 });
25
+ return stdout;
19
26
  }
20
27
 
21
28
  /** `gh api --paginate` prints one JSON array per page; join them into one flat array. */
@@ -102,7 +109,6 @@ export function runsFromRecords(records) {
102
109
  export const gateEvents = (records) => records.filter((r) => r.event !== "phase_attempt" && r.event !== "run_terminal")
103
110
  .map((r) => ({ ...r, version: r.forge_version ?? null }));
104
111
 
105
- const CHECKPOINT_EVERY = 25;
106
112
  const PAGE_SIZE = 100;
107
113
  // The next incremental run re-reads this much before the persisted watermark (an overlap window), so a write that races a
108
114
  // crawl or a small clock skew is re-listed. Re-reads are idempotent: items are keyed by issue number.
@@ -125,7 +131,7 @@ const sinceParam = (ts) => encodeURIComponent(ts).replace(/%3A/g, ":");
125
131
  * the cursor (more than PAGE_SIZE items share one timestamp) is drained with `page=N` for that timestamp only.
126
132
  * Yields each page's unseen items sorted updated-asc.
127
133
  */
128
- function* listIssuePages(gh, repo, startSince) {
134
+ async function* listIssuePages(gh, repo, startSince) {
129
135
  const base = ["state=all", "sort=updated", "direction=asc", `per_page=${PAGE_SIZE}`];
130
136
  const seen = new Set();
131
137
  const key = (it) => `${it.number}@${it.updated_at}`;
@@ -137,8 +143,8 @@ function* listIssuePages(gh, repo, startSince) {
137
143
  const maxTs = (page) => page.reduce((m, it) => (it?.updated_at && (!m || it.updated_at > m) ? it.updated_at : m), null);
138
144
  let cursor = startSince ?? null;
139
145
  for (let n = 0; n < MAX_LIST_REQUESTS; n++) {
140
- const get = (extra = []) => parsePages(gh(["api", `repos/${repo}/issues?${[...base, ...(cursor ? [`since=${sinceParam(cursor)}`] : []), ...extra].join("&")}`]));
141
- const page = get();
146
+ const get = async (extra = []) => parsePages(await gh(["api", `repos/${repo}/issues?${[...base, ...(cursor ? [`since=${sinceParam(cursor)}`] : []), ...extra].join("&")}`]));
147
+ const page = await get();
142
148
  const fresh = take(page);
143
149
  if (fresh.length > 0) yield fresh;
144
150
  if (page.length < PAGE_SIZE) return;
@@ -146,7 +152,7 @@ function* listIssuePages(gh, repo, startSince) {
146
152
  if (next && next !== cursor) { cursor = next; continue; }
147
153
  // No cursor progress on a full page: drain this timestamp by page number, then step past it.
148
154
  for (let p = 2; p < MAX_LIST_REQUESTS; p++) {
149
- const more = get([`page=${p}`]);
155
+ const more = await get([`page=${p}`]);
150
156
  const freshMore = take(more);
151
157
  if (freshMore.length > 0) yield freshMore;
152
158
  if (more.length < PAGE_SIZE || freshMore.length === 0) break;
@@ -159,35 +165,137 @@ function* listIssuePages(gh, repo, startSince) {
159
165
  const meta = (it) => ({ number: it.number, isPr: Boolean(it.pull_request), createdAt: it.created_at ?? null, closedAt: it.closed_at ?? null,
160
166
  updatedAt: it.updated_at ?? null, labels: (it.labels ?? []).map((l) => (typeof l === "string" ? l : l.name)).filter(Boolean), authorType: it.user?.type ?? null });
161
167
 
168
+ const CHECKPOINT_EVERY = 1000;
169
+ const RATE_CHECK_EVERY = 50;
170
+ const RATE_LOW = 200;
171
+ const MAX_RATE_SLEEP_MS = 60 * 60 * 1000;
172
+ const DEFAULT_CONCURRENCY = 6;
173
+ const isoNow = (ms) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, "Z");
174
+
175
+ /** Run `fn` over `list` with at most `limit` in flight; the first failure stops new work and is rethrown. */
176
+ async function pool(list, limit, fn) {
177
+ let next = 0, failed = null;
178
+ const worker = async () => {
179
+ while (!failed && next < list.length) {
180
+ const item = list[next++];
181
+ try { await fn(item); } catch (err) { failed ??= err; }
182
+ }
183
+ };
184
+ await Promise.all(Array.from({ length: Math.min(Math.max(1, limit), list.length) }, worker));
185
+ if (failed) throw failed;
186
+ }
187
+
188
+ /** Best effort: when the core rate limit is nearly spent, sleep until it resets. Never throws. */
189
+ async function waitForRate(gh, sleep, now) {
190
+ try {
191
+ const core = JSON.parse(await gh(["api", "rate_limit"]))?.resources?.core;
192
+ if (core && Number.isFinite(core.remaining) && core.remaining < RATE_LOW && Number.isFinite(core.reset)) {
193
+ await sleep(Math.min(Math.max(core.reset * 1000 - now() + 1000, 0), MAX_RATE_SLEEP_MS));
194
+ }
195
+ } catch { /* the limit check is advisory */ }
196
+ }
197
+
162
198
  /**
163
- * Fetch (or incrementally refresh) the repo's issues, PRs and trusted telemetry records.
164
- * Returns { items, records, fetched, fromCache }. `refresh` ignores the cache; `useCache:false` never touches disk.
199
+ * Page the repo-wide comments endpoint (comments updated since `from`, id-ascending) and return the numbers of the issues
200
+ * and PRs that carry at least one trusted FORGE:TELEMETRY comment in that window. Items without one cost nothing more.
165
201
  */
166
- export function fetchGithubRecords({ repo, refresh = false, useCache = true, gh = defaultGh, env = process.env } = {}) {
202
+ async function scanWindowComments(call, repo, from, env, onPage) {
203
+ const base = ["per_page=100", ...(from ? [`since=${sinceParam(from)}`] : [])];
204
+ const numbers = new Set();
205
+ for (let p = 1; p < MAX_LIST_REQUESTS; p++) {
206
+ const page = parsePages(await call(["api", `repos/${repo}/issues/comments?${[...base, `page=${p}`].join("&")}`]));
207
+ for (const c of page) {
208
+ if (!MARKER_RE.test(c?.body ?? "") || !isTrusted(c, env)) continue;
209
+ const m = /\/issues\/(\d+)$/.exec(c.issue_url ?? "");
210
+ if (m) numbers.add(Number(m[1]));
211
+ }
212
+ onPage(p);
213
+ if (page.length < PAGE_SIZE) break;
214
+ }
215
+ return numbers;
216
+ }
217
+
218
+ /**
219
+ * Fetch (or incrementally refresh) the repo's issues, PRs and trusted telemetry records. Async; `gh` may be sync or async.
220
+ * `since` (ISO) bounds the crawl: only items and comments updated on/after it are read, plus whatever the cache already
221
+ * holds. The cache records the lowest window start it covers (`coveredFrom`); a request that starts earlier re-crawls from
222
+ * its own start and then lowers the floor, so a later wider window is never under-reported.
223
+ * Returns { items, records, fetched, fromCache, apiCalls, coveredFrom }. `refresh` ignores the cache; `useCache:false`
224
+ * never touches disk. `onProgress({phase, done, total})` is called as the crawl moves (total is null when unknown).
225
+ */
226
+ export async function fetchGithubRecords({ repo, since = null, refresh = false, useCache = true, gh = defaultGh, env = process.env,
227
+ onProgress = () => {}, concurrency = DEFAULT_CONCURRENCY, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = Date.now,
228
+ rateCheck = gh === defaultGh } = {}) {
167
229
  if (!isValidRepo(repo)) throw new Error(`invalid repo: ${repo}`);
168
230
  const cache = useCache && !refresh ? loadCache(repo, env) : emptyCache();
169
- const startSince = cache.watermark ? shiftIso(cache.watermark, -WATERMARK_OVERLAP_MS) : null;
231
+ const start = since ?? null;
232
+ const hasCover = cache.watermark != null;
233
+ const floor = cache.coveredFrom;
234
+ // Does the cache already hold everything from `start` on? An unknown floor (a cache from before coveredFrom) is only
235
+ // trusted for a full-history request, never for a window.
236
+ const covers = hasCover && (floor === null || (floor === undefined ? start == null : start != null && Date.parse(start) >= Date.parse(floor)));
237
+ const crawlFrom = covers ? shiftIso(cache.watermark, -WATERMARK_OVERLAP_MS) : start;
238
+ const newFloor = covers ? (floor === undefined ? null : floor) : start;
239
+ const scanTs = isoNow(now());
240
+ let apiCalls = 0, guard = Promise.resolve();
241
+ const call = async (args) => {
242
+ if (rateCheck && apiCalls > 0 && apiCalls % RATE_CHECK_EVERY === 0) guard = guard.then(() => waitForRate(gh, sleep, now));
243
+ apiCalls++;
244
+ await guard;
245
+ return gh(args);
246
+ };
170
247
  const items = { ...cache.items };
171
248
  let watermark = cache.watermark;
172
249
  let fetched = 0;
173
- // Checkpoint: the watermark is the newest updated_at actually processed, and the next run starts WATERMARK_OVERLAP_MS
174
- // before it, so it is a safe resume point. A failure mid-crawl (rate limit, 5xx, a hook timeout) then keeps the
175
- // progress instead of restarting at zero.
176
- const checkpoint = () => { if (useCache) saveCache(repo, { ...emptyCache(), watermark, items }, env); };
250
+ let gapDone = false;
251
+ // Never persist a watermark past the moment the comment scan started: a comment posted after it bumps its issue's
252
+ // updated_at, and the next run must still start before that.
253
+ const cap = (w) => (w && w > scanTs ? scanTs : w);
254
+ // Telemetry issues whose records are not yet in `items` (the records are re-read in memory first). While any remain the
255
+ // watermark must not move past where the comment scan began, or a resumed run would never re-scan their comments.
256
+ const pending = new Set();
257
+ const scanResume = () => (crawlFrom ? shiftIso(crawlFrom, WATERMARK_OVERLAP_MS) : null);
258
+ // Checkpoint: while the crawl extends coverage forward (fresh cache, or the covered range's tail) the watermark is the
259
+ // newest updated_at processed. A gap crawl (window starts before the floor) leaves watermark and floor untouched until
260
+ // it finishes, so a failure never claims coverage it does not have.
261
+ const checkpoint = () => {
262
+ if (!useCache) return;
263
+ const extend = !hasCover || covers || gapDone;
264
+ saveCache(repo, { ...emptyCache(), watermark: extend ? (pending.size > 0 ? scanResume() : cap(watermark)) : cache.watermark, coveredFrom: extend ? newFloor : cache.coveredFrom, items }, env);
265
+ };
177
266
  try {
178
- for (const page of listIssuePages(gh, repo, startSince)) for (const it of page) {
267
+ const telemetry = [...await scanWindowComments(call, repo, crawlFrom, env, (p) => onProgress({ phase: "comments", done: p, total: null }))];
268
+ // A telemetry run spans several comment parts and the window holds only the touched ones, so each such issue's full
269
+ // comment list is re-read once (bounded pool) and replaces its records.
270
+ const recordsOf = new Map();
271
+ for (const n of telemetry) pending.add(n);
272
+ let done = 0;
273
+ await pool(telemetry, concurrency, async (n) => {
274
+ recordsOf.set(n, parseTelemetryComments(parsePages(await call(["api", "--paginate", `repos/${repo}/issues/${n}/comments`])), env));
275
+ fetched++;
276
+ onProgress({ phase: "telemetry", done: ++done, total: telemetry.length });
277
+ });
278
+ const listed = new Set();
279
+ let processed = 0;
280
+ for await (const page of listIssuePages(call, repo, crawlFrom)) for (const it of page) {
179
281
  const m = meta(it);
180
- let records = items[it.number]?.records ?? [];
181
- // A telemetry comment is edited in place (which may not bump the issue's updated_at), so every listed item with
182
- // comments is re-read rather than trusting a cached copy.
183
- if ((it.comments ?? 0) > 0) {
184
- records = parseTelemetryComments(parsePages(gh(["api", "--paginate", `repos/${repo}/issues/${it.number}/comments`])), env);
185
- fetched++;
186
- }
187
- items[it.number] = { ...m, records };
282
+ items[it.number] = { ...m, records: recordsOf.get(it.number) ?? items[it.number]?.records ?? [] };
283
+ listed.add(it.number);
284
+ pending.delete(it.number);
188
285
  if (m.updatedAt && (!watermark || m.updatedAt > watermark)) watermark = m.updatedAt;
189
- if (fetched > 0 && fetched % CHECKPOINT_EVERY === 0 && (it.comments ?? 0) > 0) checkpoint();
286
+ if (++processed % CHECKPOINT_EVERY === 0) checkpoint();
287
+ if (processed % PAGE_SIZE === 0) onProgress({ phase: "issues", done: processed, total: null });
190
288
  }
289
+ onProgress({ phase: "issues", done: processed, total: null });
290
+ // A telemetry comment edited in place does not bump its issue's updated_at, so such an issue can be missing from the
291
+ // listing: update its cached records, and read its metadata once if the cache has never seen it.
292
+ await pool(telemetry.filter((n) => !listed.has(n)), concurrency, async (n) => {
293
+ if (items[n]) { items[n] = { ...items[n], records: recordsOf.get(n) }; pending.delete(n); return; }
294
+ const [it] = parsePages(await call(["api", `repos/${repo}/issues/${n}`]));
295
+ if (it && Number.isInteger(it.number)) items[n] = { ...meta(it), records: recordsOf.get(n) };
296
+ pending.delete(n);
297
+ });
298
+ gapDone = true;
191
299
  } catch (err) {
192
300
  checkpoint();
193
301
  throw err;
@@ -198,7 +306,7 @@ export function fetchGithubRecords({ repo, refresh = false, useCache = true, gh
198
306
  for (const it of Object.values(items)) for (const r of it.records ?? []) {
199
307
  if (!seen.has(r.eid)) { seen.add(r.eid); records.push(r); }
200
308
  }
201
- return { items: Object.values(items), records, fetched, fromCache: cache.watermark != null };
309
+ return { items: Object.values(items), records, fetched, fromCache: hasCover, apiCalls, coveredFrom: newFloor };
202
310
  }
203
311
 
204
312
  /** Aggregate-model inputs for one repo from a fetch result. */
@@ -22,10 +22,12 @@ export const HELP = `Usage: forgedock stats [--since <version|date>] [--repo <ow
22
22
  Version-over-version report: Cost, Time, Flow, Review funnel, Gates and Issue flow per (version, repo), with a
23
23
  delta against the previous version and a low-n flag for samples under 10. Missing data prints n/a.
24
24
 
25
- --since <v|date> only versions >= <v> (e.g. 1.16.0) or activity on/after <date> (YYYY-MM-DD)
25
+ --since <v|date> only versions >= <v> (e.g. 1.16.0) or activity on/after <date> (YYYY-MM-DD);
26
+ also bounds the GitHub crawl to that window
26
27
  --repo <owner/repo> target repo (default: forge.yaml project, else the git origin remote)
27
28
  --json machine-readable output
28
29
  --refresh ignore the local cache and re-read everything from GitHub
30
+ (progress is printed to stderr while crawling)
29
31
  --compact one-row-per-version table (embedded in the staging review summary)
30
32
  --no-local ignore ~/.forge/runs (FORGE_RUNS_DIR)
31
33
  --no-cache do not read or write ~/.forge/stats-cache
@@ -78,6 +80,38 @@ export function parseReleaseTimes(text) {
78
80
  .filter((p) => p.length === 2 && /^\d+$/.test(p[1])).map((p) => ({ v: p[0].replace(/^v/, ""), t: Number(p[1]) }));
79
81
  }
80
82
 
83
+ // A version --since becomes the tag's release time minus this margin: unversioned records are placed by release time, so
84
+ // the crawl must start a little before the tag.
85
+ const VERSION_SINCE_MARGIN_MS = 24 * 60 * 60 * 1000;
86
+
87
+ /**
88
+ * Lower-bound timestamp for the GitHub crawl from `--since`: a date as is, a version as its tag time minus a margin.
89
+ * Returns { since: ISO | null, note }: since null means a full crawl (no --since, or a version with no known tag time).
90
+ */
91
+ export function sinceToTimestamp(since, releaseTimes = []) {
92
+ const p = parseSince(since);
93
+ const iso = (ms) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, "Z");
94
+ if (p.sinceMs != null) return { since: iso(p.sinceMs), note: null };
95
+ if (p.sinceVersion) {
96
+ const hit = releaseTimes.find((r) => r.v === p.sinceVersion);
97
+ if (hit) return { since: iso(hit.t * 1000 - VERSION_SINCE_MARGIN_MS), note: null };
98
+ return { since: null, note: `no release time known for ${p.sinceVersion}; crawling the full history (pass --release-times or use a date to bound it)` };
99
+ }
100
+ return { since: null, note: null };
101
+ }
102
+
103
+ /** Throttled stderr progress printer (never stdout, so --json stays parseable). */
104
+ export function makeProgressPrinter(write = (t) => process.stderr.write(t), now = Date.now) {
105
+ let last = 0, lastPhase = null;
106
+ return ({ phase, done, total }) => {
107
+ const t = now();
108
+ const final = total != null && done >= total;
109
+ if (phase === lastPhase && !final && t - last < 2000) return;
110
+ last = t; lastPhase = phase;
111
+ write(`forgedock stats: ${phase} ${done}${total != null ? `/${total}` : ""}\n`);
112
+ };
113
+ }
114
+
81
115
  function gitTagTimes() {
82
116
  try {
83
117
  const out = execFileSync("git", ["-C", join(here, "..", ".."), "for-each-ref", "refs/tags", "--format=%(refname:short) %(creatordate:unix)"],
@@ -107,21 +141,24 @@ export function resolveRepo(explicit, cwd = process.cwd()) {
107
141
  return null;
108
142
  }
109
143
 
110
- /** Build the report object. deps: { gh, env, localRuns, now } for tests. Never touches the network when `gh` is stubbed. */
111
- export function buildReport(opts, deps = {}) {
144
+ /** Build the report object. deps: { gh, env, localRuns, now, onProgress, warn, sleep } for tests. Never touches the network when `gh` is stubbed. */
145
+ export async function buildReport(opts, deps = {}) {
112
146
  const env = deps.env ?? process.env;
113
147
  const repo = opts.repo;
114
- const gh = fetchGithubRecords({ repo, refresh: opts.refresh, useCache: opts.cache, gh: deps.gh, env });
148
+ let releaseTimes = [];
149
+ if (opts.releaseTimes) releaseTimes = parseReleaseTimes(readFileSync(opts.releaseTimes, "utf8"));
150
+ else if (deps.releaseTimes) releaseTimes = deps.releaseTimes;
151
+ else releaseTimes = gitTagTimes();
152
+ const bound = sinceToTimestamp(opts.since, releaseTimes);
153
+ if (bound.note) (deps.warn ?? ((m) => process.stderr.write(`forgedock stats: ${m}\n`)))(bound.note);
154
+ const gh = await fetchGithubRecords({ repo, since: bound.since, refresh: opts.refresh, useCache: opts.cache, gh: deps.gh, env,
155
+ onProgress: deps.onProgress, sleep: deps.sleep, now: deps.now });
115
156
  const model = toModel(repo, gh);
116
157
  const ghKeys = new Set(model.runs.map((r) => `${r.repo}#${r.issue}`));
117
158
  let local = [];
118
159
  if (opts.local) {
119
160
  local = (deps.localRuns ?? readLocalRuns(runsDir(env))).filter((r) => r.repo === repo && !ghKeys.has(`${r.repo}#${r.issue}`));
120
161
  }
121
- let releaseTimes = [];
122
- if (opts.releaseTimes) releaseTimes = parseReleaseTimes(readFileSync(opts.releaseTimes, "utf8"));
123
- else if (deps.releaseTimes) releaseTimes = deps.releaseTimes;
124
- else releaseTimes = gitTagTimes();
125
162
  const report = aggregate({ runs: [...model.runs, ...local], events: model.events, issues: model.issues },
126
163
  { minN: opts.minN, releaseTimes, ...parseSince(opts.since) });
127
164
  return { repo, sources: { github: { items: gh.items.length, records: gh.records.length, cached: gh.fromCache }, local: { runs: local.length } }, ...report };
@@ -140,7 +177,7 @@ export async function runStats(argv, deps = {}) {
140
177
  return 2;
141
178
  }
142
179
  try {
143
- const report = buildReport(opts, deps);
180
+ const report = await buildReport(opts, { onProgress: makeProgressPrinter(), ...deps });
144
181
  process.stdout.write(`${opts.json ? renderJson(report) : opts.compact ? renderCompact(report) : renderMarkdown(report)}\n`);
145
182
  return 0;
146
183
  } catch (err) {
@@ -55,7 +55,7 @@ function sections(g) {
55
55
  const kv = (o) => Object.entries(o).map(([a, b]) => `${a} ${b}`).join(", ") || NA;
56
56
  out.push("### Issue flow", ...table(["metric", "value"], [
57
57
  ["created by source", kv(i.created)], ["closed by kind", kv(i.closed)],
58
- ["net flow (created - closed)", isNum(i.net) ? signed(i.net, String) : NA], ["findings per merged issue", f1(i.findingsPerMerge)],
58
+ ["net flow (created - closed)", isNum(i.net) ? signed(i.net, String) : NA], ["findings per merged issue", f1(i.findingsPerMerge)], ["introduced findings per merged issue", f1(i.introducedPerMerge)],
59
59
  ]), "");
60
60
  if (g.delta) {
61
61
  const d = g.delta;