forgedock 1.18.0 → 1.20.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 (135) hide show
  1. package/README.md +2 -0
  2. package/bin/engine/outcome-codes.mjs +13 -0
  3. package/bin/engine/phases.mjs +54 -4
  4. package/bin/engine/runlog.mjs +10 -2
  5. package/bin/engine/telemetry-seam.mjs +3 -0
  6. package/bin/engine/telemetry.test.mjs +3 -2
  7. package/bin/engine-cli.mjs +32 -2
  8. package/bin/engine.mjs +187 -10
  9. package/bin/runner.mjs +89 -13
  10. package/bin/stats/aggregate.mjs +7 -3
  11. package/bin/stats/cache.mjs +7 -3
  12. package/bin/stats/github-source.mjs +136 -28
  13. package/bin/stats/index.mjs +46 -9
  14. package/bin/stats/render.mjs +1 -1
  15. package/bin/stats/stats.test.mjs +201 -40
  16. package/commands/orchestrate/phase-4-execution.md +1 -1
  17. package/commands/review-pr-agents/protocols.md +19 -0
  18. package/commands/review-pr-staging.md +4 -4
  19. package/commands/review-pr.md +31 -20
  20. package/commands/work-on/build/architect.md +54 -1
  21. package/commands/work-on/build/implement.md +28 -1
  22. package/commands/work-on/build/validate.md +321 -29
  23. package/commands/work-on/build.md +184 -17
  24. package/commands/work-on/close.md +9 -4
  25. package/commands/work-on/investigate.md +7 -0
  26. package/commands/work-on/remediate.md +43 -17
  27. package/commands/work-on/review.md +43 -22
  28. package/commands/work-on.md +4 -4
  29. package/docs/CONFIG.md +93 -2
  30. package/docs/OUTCOME-CODES.md +5 -1
  31. package/docs/RELEASE-NOTES.md +15 -0
  32. package/docs/STATS.md +11 -4
  33. package/docs/TELEMETRY.md +5 -2
  34. package/docs/WORK-ON-RUNTIME.md +29 -3
  35. package/package.json +2 -2
  36. package/packages/protocol/src/types.js +1 -1
  37. package/scripts/acceptance-red-green.sh +90 -0
  38. package/scripts/acceptance-red-green.test.sh +74 -0
  39. package/scripts/architect-resume-match.test.sh +12 -3
  40. package/scripts/build-knowledge-index.mjs +8 -5
  41. package/scripts/builder-resume-match.test.sh +12 -3
  42. package/scripts/calibration.mjs +5 -2
  43. package/scripts/cap-findings.test.sh +2 -0
  44. package/scripts/check-contract-scope.test.sh +2 -0
  45. package/scripts/check-edge-cases.sh +143 -0
  46. package/scripts/check-edge-cases.test.sh +175 -0
  47. package/scripts/check-spec-markers.sh +4 -0
  48. package/scripts/check-test-tmp-cleanup.sh +56 -0
  49. package/scripts/check-test-tmp-cleanup.test.sh +69 -0
  50. package/scripts/ci-local/adapters/azure-pipelines.mjs +113 -0
  51. package/scripts/ci-local/adapters/bitbucket.mjs +64 -0
  52. package/scripts/ci-local/adapters/buildkite.mjs +58 -0
  53. package/scripts/ci-local/adapters/circleci.mjs +101 -0
  54. package/scripts/ci-local/adapters/common.mjs +115 -0
  55. package/scripts/ci-local/adapters/explicit.mjs +21 -0
  56. package/scripts/ci-local/adapters/github-actions.mjs +349 -0
  57. package/scripts/ci-local/adapters/gitlab-ci.mjs +184 -0
  58. package/scripts/ci-local/adapters/portability.mjs +104 -0
  59. package/scripts/ci-local/adapters/task-runners.mjs +67 -0
  60. package/scripts/ci-local/cli.mjs +50 -0
  61. package/scripts/ci-local/lib.mjs +379 -0
  62. package/scripts/ci-local/plan.mjs +186 -0
  63. package/scripts/ci-local/run.mjs +451 -0
  64. package/scripts/ci-local/scoping.mjs +531 -0
  65. package/scripts/ci-local.sh +98 -0
  66. package/scripts/ci-local.test.sh +1108 -0
  67. package/scripts/classify-finding.sh +33 -7
  68. package/scripts/classify-finding.test.sh +23 -0
  69. package/scripts/classify-test-run.sh +69 -0
  70. package/scripts/classify-test-run.test.sh +90 -0
  71. package/scripts/close-parent-c3.test.sh +2 -0
  72. package/scripts/consumer-sweep.sh +569 -0
  73. package/scripts/consumer-sweep.test.sh +357 -0
  74. package/scripts/detect-append-registries.sh +106 -0
  75. package/scripts/detect-append-registries.test.sh +64 -0
  76. package/scripts/file-deferred-followups.sh +91 -30
  77. package/scripts/file-deferred-followups.test.sh +101 -8
  78. package/scripts/fixtures/ci-local/azure/azure-pipelines.yml +11 -0
  79. package/scripts/fixtures/ci-local/bitbucket/bitbucket-pipelines.yml +12 -0
  80. package/scripts/fixtures/ci-local/buildkite/.buildkite/pipeline.yml +10 -0
  81. package/scripts/fixtures/ci-local/circleci/.circleci/config.yml +29 -0
  82. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/ci.yml +59 -0
  83. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/docs.yml +10 -0
  84. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/nightly.yml +10 -0
  85. package/scripts/fixtures/ci-local/gha-basic/docs/index.md +1 -0
  86. package/scripts/fixtures/ci-local/gha-basic/src/greet.sh +2 -0
  87. package/scripts/fixtures/ci-local/gha-basic/tests/greet.test.sh +3 -0
  88. package/scripts/fixtures/ci-local/gha-dorny/.github/workflows/ci.yml +51 -0
  89. package/scripts/fixtures/ci-local/gha-dorny/backend/app.py +1 -0
  90. package/scripts/fixtures/ci-local/gha-dorny/docs/a.md +1 -0
  91. package/scripts/fixtures/ci-local/gha-dorny/frontend/app.js +1 -0
  92. package/scripts/fixtures/ci-local/gha-run/.github/workflows/ci.yml +39 -0
  93. package/scripts/fixtures/ci-local/gha-run/src/x.txt +1 -0
  94. package/scripts/fixtures/ci-local/gitlab/.gitlab-ci.yml +43 -0
  95. package/scripts/fixtures/ci-local/gitlab/docs/d.md +1 -0
  96. package/scripts/fixtures/ci-local/gitlab/src/a.txt +1 -0
  97. package/scripts/fixtures/ci-local/monorepo/.github/workflows/ci.yml +13 -0
  98. package/scripts/fixtures/ci-local/monorepo/package-lock.json +1 -0
  99. package/scripts/fixtures/ci-local/monorepo/packages/a/src/a.sh +2 -0
  100. package/scripts/fixtures/ci-local/monorepo/packages/a/tests/a.test.sh +3 -0
  101. package/scripts/fixtures/ci-local/monorepo/packages/b/src/b.sh +2 -0
  102. package/scripts/fixtures/ci-local/monorepo/packages/b/tests/b.test.sh +3 -0
  103. package/scripts/fixtures/ci-local/no-ci/Makefile +6 -0
  104. package/scripts/fixtures/ci-local/no-ci/package.json +1 -0
  105. package/scripts/fixtures/ci-local/no-ci/src/a.js +1 -0
  106. package/scripts/fixtures/ci-local/no-ci/src/b.js +1 -0
  107. package/scripts/fixtures/ci-local/no-ci/tests/a.test.js +2 -0
  108. package/scripts/fixtures/ci-local/no-ci/tests/b.test.js +2 -0
  109. package/scripts/forge-root.test.sh +1 -1
  110. package/scripts/lib/telemetry.sh +3 -0
  111. package/scripts/remediate-approved-head.test.sh +2 -0
  112. package/scripts/remediate-basesync.test.sh +1 -1
  113. package/scripts/review-acceptance-gate.sh +2 -2
  114. package/scripts/review-acceptance-gate.test.sh +20 -0
  115. package/scripts/review-delta.sh +8 -1
  116. package/scripts/review-delta.test.sh +19 -0
  117. package/scripts/review-finalize.sh +294 -53
  118. package/scripts/review-finalize.test.sh +347 -9
  119. package/scripts/review-pr-delta-wiring.test.sh +13 -0
  120. package/scripts/review-prepare.sh +5 -3
  121. package/scripts/review-prepare.test.sh +18 -2
  122. package/scripts/review-roster.sh +18 -5
  123. package/scripts/review-roster.test.sh +57 -1
  124. package/scripts/run-shell-tests.sh +5 -0
  125. package/scripts/selfreview-plan.sh +86 -0
  126. package/scripts/selfreview-plan.test.sh +90 -0
  127. package/scripts/spawn-depth-check.sh +5 -4
  128. package/scripts/spawn-depth-check.test.sh +3 -0
  129. package/scripts/telemetry.test.sh +10 -0
  130. package/scripts/transition-label.sh +26 -0
  131. package/scripts/trusted-comments.test.sh +2 -1
  132. package/scripts/verify-phase-trail.test.sh +14 -0
  133. package/scripts/wait-ci-green.sh +61 -9
  134. package/scripts/wait-ci-green.test.sh +41 -3
  135. 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,28 +107,65 @@ 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 */ }
126
154
  return env;
127
155
  }
128
156
 
157
+ const LEASE_HOLDER_ENV_RE = /^cli_[A-Za-z0-9._-]+_[0-9]+$/;
158
+
159
+ /**
160
+ * Stamp the phase child's env with `FORGE_LEASE_HOLDER=<engine lease id>` so the engine can tell, after a
161
+ * SIGKILL of the engine, whether an orphaned phase child is still running (env is inherited by grandchildren).
162
+ * An absent or malformed holder leaves the env untouched and never throws.
163
+ */
164
+ export function withLeaseHolder(env, holder) {
165
+ if (typeof holder === "string" && LEASE_HOLDER_ENV_RE.test(holder)) env.FORGE_LEASE_HOLDER = holder;
166
+ return env;
167
+ }
168
+
129
169
  /** First positional arg when it is an issue number (the phase arg convention), else null. */
130
170
  function issueFromArgs(args) {
131
171
  const first = Array.isArray(args) ? args[0] : String(args ?? "").trim().split(/\s+/)[0];
@@ -1402,6 +1442,7 @@ export function runCliBackend({
1402
1442
  spawnFn = spawnSync,
1403
1443
  model = null,
1404
1444
  maxTurns = null,
1445
+ leaseHolder = null,
1405
1446
  }) {
1406
1447
  // forge#3589: opt-in flags, validated again here as defence in depth. Callers
1407
1448
  // (runCommand) are responsible for the version gate. Unset -> argv unchanged.
@@ -1468,7 +1509,7 @@ export function runCliBackend({
1468
1509
  // Mirrors the existing scrub in the `run_bash` tool handler below.
1469
1510
  // GH_TOKEN/GITHUB_TOKEN and all other env vars are intentionally left
1470
1511
  // intact — only the Anthropic key is removed.
1471
- const childEnv = withRunTmpdir(withForgedockHome({ ...process.env }), { issue: issueFromArgs(args), cwd });
1512
+ const childEnv = withLeaseHolder(withRunTmpdir(withForgedockHome({ ...process.env }), { issue: issueFromArgs(args), cwd }), leaseHolder);
1472
1513
  delete childEnv.ANTHROPIC_API_KEY;
1473
1514
 
1474
1515
  // No `shell` option (defaults to false): argv is passed as discrete,
@@ -1493,6 +1534,9 @@ export function runCliBackend({
1493
1534
  // intentionally omitted here since spawnSync's own `timeout` + ETIMEDOUT is
1494
1535
  // reliable for this single, non-looped invocation.
1495
1536
  const timedOut = result.status === null && result.error?.code === "ETIMEDOUT";
1537
+ // forge#4037: only a spawn-stage failure is transient infra. A wall-clock kill leaves evidence that the child ran
1538
+ // (a kill signal, a pid, or captured output); an ETIMEDOUT with none of those never started the child.
1539
+ const childRan = !!result.signal || (Number.isInteger(result.pid) && result.pid > 0) || (stdout + stderr).trim().length > 0;
1496
1540
  if (timedOut) {
1497
1541
  const timeoutSecs = Math.round(timeoutMs / 1000);
1498
1542
  // forge#2360: spawnSync's `timeout` option kills the child outright on
@@ -1510,15 +1554,44 @@ export function runCliBackend({
1510
1554
  // review findings — #2277, #2292, #2293, #2355 — for that defect
1511
1555
  // class).
1512
1556
  const partial = (stdout + stderr).trim();
1513
- throw new Error(
1557
+ const timeoutError = new Error(
1514
1558
  `claude CLI invocation timed out after ${timeoutSecs}s and was killed. ` +
1515
1559
  `Set FORGEDOCK_CLI_TIMEOUT_MS (ms) to adjust, or use --backend api.` +
1516
1560
  (partial ? ` Partial output: ${sanitizeOutputExcerptForLog(partial)}` : ""),
1517
1561
  );
1562
+ if (!childRan) {
1563
+ // forge#4021: stable transient-infra class; the engine retries it on a
1564
+ // separate bounded counter without consuming a phase attempt.
1565
+ timeoutError.code = "CLI_SPAWN_TRANSIENT";
1566
+ timeoutError.spawnErrno = "ETIMEDOUT";
1567
+ throw timeoutError;
1568
+ }
1569
+ // forge#4037: the child ran and was killed at the wall-clock limit: a normal, attempt-consuming failure
1570
+ // (`cli.timeout`), with usage attached when the CLI had already emitted a parseable envelope.
1571
+ timeoutError.code = "CLI_TIMEOUT";
1572
+ timeoutError.usage = null;
1573
+ try {
1574
+ const u = JSON.parse(stdout.trim())?.usage;
1575
+ if (u && typeof u === "object") {
1576
+ timeoutError.usage = {
1577
+ input_tokens: toFiniteUsageNumber(u.input_tokens),
1578
+ output_tokens: toFiniteUsageNumber(u.output_tokens),
1579
+ cache_creation_input_tokens: toFiniteUsageNumber(u.cache_creation_input_tokens),
1580
+ cache_read_input_tokens: toFiniteUsageNumber(u.cache_read_input_tokens),
1581
+ };
1582
+ }
1583
+ } catch {
1584
+ // No parseable envelope from a killed child: usage stays null (never fabricated).
1585
+ }
1586
+ throw timeoutError;
1518
1587
  }
1519
1588
  if (result.error) {
1520
1589
  const error = new Error(`Failed to invoke claude CLI: ${result.error.message}`);
1521
1590
  error.code = result.error.code;
1591
+ if (["EAGAIN", "ENOMEM", "EMFILE"].includes(result.error.code)) {
1592
+ error.spawnErrno = result.error.code;
1593
+ error.code = "CLI_SPAWN_TRANSIENT";
1594
+ }
1522
1595
  throw error;
1523
1596
  }
1524
1597
 
@@ -2245,9 +2318,10 @@ export function resolveBashShell() {
2245
2318
  *
2246
2319
  * @param {string} cwd
2247
2320
  * @param {string|null} [runIssueNumber] - issue number scoping the per-run TMPDIR (forge#3572)
2321
+ * @param {string|null} [leaseHolder] - engine lease id stamped as FORGE_LEASE_HOLDER in the child env
2248
2322
  * @returns {Record<string, (input: object) => string>}
2249
2323
  */
2250
- export function getToolHandlers(cwd, runIssueNumber = null) {
2324
+ export function getToolHandlers(cwd, runIssueNumber = null, leaseHolder = null) {
2251
2325
  return {
2252
2326
  read_file: ({ path }) => {
2253
2327
  if (!path) throw new Error("read_file requires a 'path'");
@@ -2267,7 +2341,7 @@ export function getToolHandlers(cwd, runIssueNumber = null) {
2267
2341
  // into the model loop, so a prompt-injection payload could otherwise issue
2268
2342
  // a run_bash command that exfiltrates the key. gh/git auth tokens
2269
2343
  // (GH_TOKEN/GITHUB_TOKEN) are intentionally left intact.
2270
- const childEnv = withRunTmpdir(withForgedockHome({ ...process.env }), { issue: runIssueNumber, cwd });
2344
+ const childEnv = withLeaseHolder(withRunTmpdir(withForgedockHome({ ...process.env }), { issue: runIssueNumber, cwd }), leaseHolder);
2271
2345
  delete childEnv.ANTHROPIC_API_KEY;
2272
2346
  // Run under bash when available so bash-style commands the model emits
2273
2347
  // (heredocs, scripts/*.sh, single-quote semantics) behave consistently
@@ -2525,6 +2599,7 @@ export async function runCommand(opts = {}) {
2525
2599
  backend = process.env.FORGEDOCK_BACKEND || "auto",
2526
2600
  runtimeEnv = process.env,
2527
2601
  logger = console,
2602
+ leaseHolder = null,
2528
2603
  } = opts;
2529
2604
 
2530
2605
  assertOpenCodeNativeRuntime({
@@ -2665,6 +2740,7 @@ export async function runCommand(opts = {}) {
2665
2740
  logger,
2666
2741
  model: cliModel,
2667
2742
  maxTurns: cliMaxTurns,
2743
+ leaseHolder,
2668
2744
  },
2669
2745
  });
2670
2746
  }
@@ -2707,7 +2783,7 @@ export async function runCommand(opts = {}) {
2707
2783
  // is a known Linux limitation at the OS level; closing it would require
2708
2784
  // a native module (e.g. prctl PR_SET_DUMPABLE) or process isolation.
2709
2785
  // See: issue #1370 for tracking this known limitation.
2710
- const handlers = getToolHandlers(cwd, issueFromArgs(args));
2786
+ const handlers = getToolHandlers(cwd, issueFromArgs(args), leaseHolder);
2711
2787
  const messages = [{ role: "user", content: userMessage }];
2712
2788
 
2713
2789
  // Accumulate token usage across all messages.create() calls in this run.
@@ -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;