forgedock 1.16.1 → 1.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/engine/outcome-codes.mjs +92 -0
- package/bin/engine/phases.mjs +105 -25
- package/bin/engine/projector.mjs +48 -1
- package/bin/engine/runlog.mjs +13 -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 +12 -1
- package/bin/engine.mjs +151 -39
- package/bin/forgedock.mjs +7 -1
- package/bin/hooks/interactive-engine.mjs +10 -0
- package/bin/hooks/pre-tool-use.mjs +187 -0
- package/bin/runner.mjs +7 -0
- package/bin/stats/aggregate.mjs +231 -0
- package/bin/stats/cache.mjs +40 -0
- package/bin/stats/github-source.mjs +140 -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 +224 -0
- package/commands/cleanup.md +83 -2
- package/commands/deploy-info.md +8 -4
- package/commands/orchestrate/phase-1-resolve.md +6 -3
- package/commands/orchestrate/phase-4-execution.md +25 -10
- 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/quality-gate.md +4 -4
- package/commands/review-pr-agents/protocols.md +1 -0
- package/commands/review-pr-agents.md +10 -2
- package/commands/review-pr-staging.md +134 -57
- package/commands/review-pr.md +135 -119
- package/commands/work-on/build/implement.md +26 -5
- package/commands/work-on/build/validate.md +5 -4
- package/commands/work-on/build.md +143 -80
- package/commands/work-on/close.md +19 -8
- package/commands/work-on/decompose.md +50 -8
- package/commands/work-on/investigate.md +14 -7
- package/commands/work-on/remediate.md +40 -14
- package/commands/work-on/review.md +22 -5
- package/commands/work-on.md +6 -5
- package/docs/CONFIG.md +37 -2
- package/docs/OUTCOME-CODES.md +48 -0
- package/docs/RELEASE-NOTES.md +12 -0
- package/docs/STATS.md +62 -0
- package/docs/TELEMETRY.md +65 -0
- package/docs/WORK-ON-RUNTIME.md +14 -1
- package/docs/site/command-reference.md +1 -1
- package/docs/spec/forge-protocol-v1.md +1 -1
- package/docs/spec/label-state-machine.md +1 -1
- package/package.json +3 -2
- 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-markers.sh +4 -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 +54 -0
- package/scripts/cost-report.sh +146 -0
- package/scripts/cost-report.test.sh +80 -0
- package/scripts/diff-size.sh +41 -3
- package/scripts/diff-size.test.sh +47 -0
- 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 -5
- package/scripts/lib/fence-bootstrap.sh +4 -3
- package/scripts/lib/forge-resolve.sh +13 -4
- 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/remediate-liveness.sh +132 -0
- package/scripts/review-acceptance-gate.sh +141 -0
- package/scripts/review-acceptance-gate.test.sh +103 -0
- package/scripts/review-pr-domain-map.test.sh +8 -3
- package/scripts/review-pr-gate2.test.sh +15 -0
- package/scripts/review-pr-staging-finding-filter.test.sh +44 -0
- package/scripts/review-pr-staging-scope.test.sh +65 -5
- package/scripts/review-prepare.sh +517 -0
- package/scripts/review-prepare.test.sh +270 -0
- package/scripts/review-roster.sh +172 -0
- package/scripts/review-roster.test.sh +163 -0
- package/scripts/run-shell-tests.sh +3 -0
- package/scripts/telemetry.test.sh +106 -0
- package/scripts/wait-ci-green.sh +8 -1
- package/templates/forge.yaml.minimal +9 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* forgedock stats (forge#3835): a deterministic version-over-version report, no model in the loop.
|
|
5
|
+
* GitHub is the primary source (trusted FORGE:TELEMETRY comments + labels); ~/.forge/stats-cache is an optional,
|
|
6
|
+
* deletable cache; ~/.forge/runs is an optional extra source for runs that predate telemetry.
|
|
7
|
+
*/
|
|
8
|
+
import { execFileSync } from "node:child_process";
|
|
9
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
12
|
+
import { aggregate, DEFAULT_MIN_N } from "./aggregate.mjs";
|
|
13
|
+
import { fetchGithubRecords, isValidRepo, toModel } from "./github-source.mjs";
|
|
14
|
+
import { readLocalRuns, runsDir } from "./local-source.mjs";
|
|
15
|
+
import { renderCompact, renderJson, renderMarkdown } from "./render.mjs";
|
|
16
|
+
|
|
17
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
|
|
19
|
+
export const HELP = `Usage: forgedock stats [--since <version|date>] [--repo <owner/repo>] [--json] [--refresh]
|
|
20
|
+
[--compact] [--no-local] [--no-cache] [--min-n N] [--release-times FILE]
|
|
21
|
+
|
|
22
|
+
Version-over-version report: Cost, Time, Flow, Review funnel, Gates and Issue flow per (version, repo), with a
|
|
23
|
+
delta against the previous version and a low-n flag for samples under 10. Missing data prints n/a.
|
|
24
|
+
|
|
25
|
+
--since <v|date> only versions >= <v> (e.g. 1.16.0) or activity on/after <date> (YYYY-MM-DD)
|
|
26
|
+
--repo <owner/repo> target repo (default: forge.yaml project, else the git origin remote)
|
|
27
|
+
--json machine-readable output
|
|
28
|
+
--refresh ignore the local cache and re-read everything from GitHub
|
|
29
|
+
--compact one-row-per-version table (embedded in the staging review summary)
|
|
30
|
+
--no-local ignore ~/.forge/runs (FORGE_RUNS_DIR)
|
|
31
|
+
--no-cache do not read or write ~/.forge/stats-cache
|
|
32
|
+
--min-n N low-n threshold (default ${DEFAULT_MIN_N})
|
|
33
|
+
--release-times FILE "<version> <epoch-seconds>" lines used to estimate versions of unversioned records
|
|
34
|
+
(default: git tags v* of the ForgeDock checkout)
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
export function parseArgs(argv) {
|
|
38
|
+
const o = { since: null, repo: null, json: false, refresh: false, compact: false, local: true, cache: true, minN: DEFAULT_MIN_N, releaseTimes: null, help: false };
|
|
39
|
+
const val = (i, name) => {
|
|
40
|
+
const v = argv[i + 1];
|
|
41
|
+
if (v == null || v.startsWith("--")) throw new Error(`${name} needs a value`);
|
|
42
|
+
return v;
|
|
43
|
+
};
|
|
44
|
+
for (let i = 0; i < argv.length; i++) {
|
|
45
|
+
const a = argv[i];
|
|
46
|
+
const eq = a.startsWith("--") && a.includes("=") ? [a.slice(0, a.indexOf("=")), a.slice(a.indexOf("=") + 1)] : null;
|
|
47
|
+
const name = eq ? eq[0] : a;
|
|
48
|
+
const take = () => { if (eq) return eq[1]; const v = val(i, name); i++; return v; };
|
|
49
|
+
if (name === "--since") o.since = take();
|
|
50
|
+
else if (name === "--repo") o.repo = take();
|
|
51
|
+
else if (name === "--release-times") o.releaseTimes = take();
|
|
52
|
+
else if (name === "--min-n") { const v = take(); if (!/^\d+$/.test(v)) throw new Error("--min-n must be a non-negative integer"); o.minN = Number(v); }
|
|
53
|
+
else if (name === "--json") o.json = true;
|
|
54
|
+
else if (name === "--refresh") o.refresh = true;
|
|
55
|
+
else if (name === "--compact") o.compact = true;
|
|
56
|
+
else if (name === "--no-local") o.local = false;
|
|
57
|
+
else if (name === "--no-cache") o.cache = false;
|
|
58
|
+
else if (name === "--help" || name === "-h") o.help = true;
|
|
59
|
+
else throw new Error(`unknown argument: ${a}`);
|
|
60
|
+
}
|
|
61
|
+
return o;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** "--since" is a date (YYYY-MM-DD[...]) or a version (1.16.0). */
|
|
65
|
+
export function parseSince(since) {
|
|
66
|
+
if (!since) return {};
|
|
67
|
+
if (/^\d{4}-\d{2}-\d{2}/.test(since)) {
|
|
68
|
+
const ms = Date.parse(since);
|
|
69
|
+
if (Number.isNaN(ms)) throw new Error(`invalid --since date: ${since}`);
|
|
70
|
+
return { sinceMs: ms };
|
|
71
|
+
}
|
|
72
|
+
if (/^v?\d+(\.\d+)*$/.test(since)) return { sinceVersion: since.replace(/^v/, "") };
|
|
73
|
+
throw new Error(`--since must be a version (1.16.0) or a date (YYYY-MM-DD): ${since}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function parseReleaseTimes(text) {
|
|
77
|
+
return String(text ?? "").split("\n").map((l) => l.trim().split(/\s+/))
|
|
78
|
+
.filter((p) => p.length === 2 && /^\d+$/.test(p[1])).map((p) => ({ v: p[0].replace(/^v/, ""), t: Number(p[1]) }));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function gitTagTimes() {
|
|
82
|
+
try {
|
|
83
|
+
const out = execFileSync("git", ["-C", join(here, "..", ".."), "for-each-ref", "refs/tags", "--format=%(refname:short) %(creatordate:unix)"],
|
|
84
|
+
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
85
|
+
return parseReleaseTimes(out.split("\n").filter((l) => /^v\d/.test(l)).join("\n"));
|
|
86
|
+
} catch { return []; }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** --repo, else forge.yaml project.owner/repo, else the git origin remote. */
|
|
90
|
+
export function resolveRepo(explicit, cwd = process.cwd()) {
|
|
91
|
+
if (explicit) return explicit;
|
|
92
|
+
for (const f of ["forge.yaml", ".forge.yaml"]) {
|
|
93
|
+
const p = join(cwd, f);
|
|
94
|
+
if (!existsSync(p)) continue;
|
|
95
|
+
try {
|
|
96
|
+
const m = /^project:\s*\n((?:[ \t]+.*\n?|\s*\n)*)/m.exec(readFileSync(p, "utf8"));
|
|
97
|
+
const owner = /^[ \t]+owner:\s*"?([\w.-]+)"?/m.exec(m?.[1] ?? "")?.[1];
|
|
98
|
+
const repo = /^[ \t]+repo:\s*"?([\w.-]+)"?/m.exec(m?.[1] ?? "")?.[1];
|
|
99
|
+
if (owner && repo) return `${owner}/${repo}`;
|
|
100
|
+
} catch { /* fall through */ }
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
const url = execFileSync("git", ["-C", cwd, "remote", "get-url", "origin"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
104
|
+
const m = /[:/]([\w.-]+)\/([\w.-]+?)(?:\.git)?$/.exec(url);
|
|
105
|
+
if (m) return `${m[1]}/${m[2]}`;
|
|
106
|
+
} catch { /* none */ }
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
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 = {}) {
|
|
112
|
+
const env = deps.env ?? process.env;
|
|
113
|
+
const repo = opts.repo;
|
|
114
|
+
const gh = fetchGithubRecords({ repo, refresh: opts.refresh, useCache: opts.cache, gh: deps.gh, env });
|
|
115
|
+
const model = toModel(repo, gh);
|
|
116
|
+
const ghKeys = new Set(model.runs.map((r) => `${r.repo}#${r.issue}`));
|
|
117
|
+
let local = [];
|
|
118
|
+
if (opts.local) {
|
|
119
|
+
local = (deps.localRuns ?? readLocalRuns(runsDir(env))).filter((r) => r.repo === repo && !ghKeys.has(`${r.repo}#${r.issue}`));
|
|
120
|
+
}
|
|
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
|
+
const report = aggregate({ runs: [...model.runs, ...local], events: model.events, issues: model.issues },
|
|
126
|
+
{ minN: opts.minN, releaseTimes, ...parseSince(opts.since) });
|
|
127
|
+
return { repo, sources: { github: { items: gh.items.length, records: gh.records.length, cached: gh.fromCache }, local: { runs: local.length } }, ...report };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function runStats(argv, deps = {}) {
|
|
131
|
+
let opts;
|
|
132
|
+
try { opts = parseArgs(argv); } catch (err) {
|
|
133
|
+
process.stderr.write(`forgedock stats: ${err.message}\n\n${HELP}`);
|
|
134
|
+
return 2;
|
|
135
|
+
}
|
|
136
|
+
if (opts.help) { process.stdout.write(HELP); return 0; }
|
|
137
|
+
opts.repo = resolveRepo(opts.repo);
|
|
138
|
+
if (!opts.repo || !isValidRepo(opts.repo)) {
|
|
139
|
+
process.stderr.write(`forgedock stats: could not resolve a valid owner/repo (pass --repo owner/repo)\n`);
|
|
140
|
+
return 2;
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
const report = buildReport(opts, deps);
|
|
144
|
+
process.stdout.write(`${opts.json ? renderJson(report) : opts.compact ? renderCompact(report) : renderMarkdown(report)}\n`);
|
|
145
|
+
return 0;
|
|
146
|
+
} catch (err) {
|
|
147
|
+
process.stderr.write(`forgedock stats: ${err.message}\n`);
|
|
148
|
+
return 1;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
153
|
+
process.exitCode = await runStats(process.argv.slice(2));
|
|
154
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* Optional extra source for `forgedock stats` (forge#3835): the engine run logs in ~/.forge/runs (FORGE_RUNS_DIR).
|
|
5
|
+
* Needed only for runs that predate the GitHub `phase_attempt` / `run_terminal` records. Same counting rules as
|
|
6
|
+
* scripts/cost-report.sh. Old logs lack `ts`, `code`, `version` and `repo`: those fields become null.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
export const runsDir = (env = process.env) => env.FORGE_RUNS_DIR || join(homedir(), ".forge", "runs");
|
|
13
|
+
|
|
14
|
+
function parseLog(text) {
|
|
15
|
+
const out = [];
|
|
16
|
+
for (const line of text.split("\n")) {
|
|
17
|
+
if (!line.trim()) continue;
|
|
18
|
+
try { const e = JSON.parse(line); if (e && typeof e === "object") out.push(e); } catch { /* truncated or corrupt line */ }
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const n = (x) => (Number.isFinite(x) ? x : 0);
|
|
24
|
+
|
|
25
|
+
/** Summarise one log: the last RUN_START..end slice. Returns null when there is no RUN_START. */
|
|
26
|
+
export function summariseLog(events, fallbackIssue = null) {
|
|
27
|
+
let s = -1;
|
|
28
|
+
events.forEach((e, i) => { if (e.event === "RUN_START") s = i; });
|
|
29
|
+
if (s < 0) return null;
|
|
30
|
+
const ev = events.slice(s);
|
|
31
|
+
const start = ev[0];
|
|
32
|
+
let t = -1; let r = -1;
|
|
33
|
+
ev.forEach((e, i) => {
|
|
34
|
+
if (e.event === "RUN_TERMINAL") t = i;
|
|
35
|
+
if (e.event === "RUN_REOPEN" || e.event === "RUN_NEXT_CYCLE") r = i;
|
|
36
|
+
});
|
|
37
|
+
const attemptPhases = new Set(ev.filter((e) => e.event === "PHASE_ATTEMPT_USAGE").map((e) => e.phase));
|
|
38
|
+
const phases = {};
|
|
39
|
+
let usageEvents = 0;
|
|
40
|
+
for (const e of ev) {
|
|
41
|
+
const attempt = e.event === "PHASE_ATTEMPT_USAGE";
|
|
42
|
+
const legacy = (e.event === "PHASE_COMMIT" || e.event === "PHASE_FAILED") && !attemptPhases.has(e.phase);
|
|
43
|
+
if (!(attempt || legacy) || !e.usage || typeof e.usage !== "object") continue;
|
|
44
|
+
usageEvents++;
|
|
45
|
+
const p = (phases[e.phase ?? "other"] ??= { cr: 0, out: 0, usd: null, ms: null, attempts: 0 });
|
|
46
|
+
p.cr += n(e.usage.cache_read_input_tokens);
|
|
47
|
+
p.out += n(e.usage.output_tokens);
|
|
48
|
+
p.attempts++;
|
|
49
|
+
if (attempt && Number.isFinite(e.duration_ms)) p.ms = (p.ms ?? 0) + e.duration_ms;
|
|
50
|
+
if (attempt && Number.isFinite(e.cost_usd)) p.usd = (p.usd ?? 0) + e.cost_usd;
|
|
51
|
+
}
|
|
52
|
+
const finished = t >= 0 && (r < 0 || t > r);
|
|
53
|
+
const startTs = start.ts ?? null;
|
|
54
|
+
const endTs = finished ? ev[t].ts ?? null : null;
|
|
55
|
+
const a = Date.parse(startTs ?? ""); const b = Date.parse(endTs ?? "");
|
|
56
|
+
return {
|
|
57
|
+
source: "local", repo: start.repo ?? null, version: start.version ?? null, issue: start.issue ?? fallbackIssue, run: start.run ?? null,
|
|
58
|
+
finished, hydrated: start.hydrated === true, usageEvents,
|
|
59
|
+
reason: finished ? ev[t].reason ?? null : null, code: finished ? ev[t].code ?? null : null,
|
|
60
|
+
remediated: ev.some((e) => e.event === "PHASE_COMMIT" && e.phase === "remediate"),
|
|
61
|
+
startTs, endTs, wallMs: finished && !Number.isNaN(a) && !Number.isNaN(b) ? Math.max(0, b - a) : null,
|
|
62
|
+
phases,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** All run summaries in `dir` (a missing dir or an unreadable log yields nothing, never an error). */
|
|
67
|
+
export function readLocalRuns(dir = runsDir()) {
|
|
68
|
+
if (!existsSync(dir)) return [];
|
|
69
|
+
const runs = [];
|
|
70
|
+
for (const f of readdirSync(dir).sort()) {
|
|
71
|
+
if (!f.endsWith(".jsonl")) continue;
|
|
72
|
+
try {
|
|
73
|
+
const m = /^(\d+)\.jsonl$/.exec(f);
|
|
74
|
+
const sum = summariseLog(parseLog(readFileSync(join(dir, f), "utf8")), m ? Number(m[1]) : null);
|
|
75
|
+
if (sum) runs.push({ ...sum, mtimeTs: new Date(statSync(join(dir, f)).mtimeMs).toISOString() });
|
|
76
|
+
} catch { /* unreadable log */ }
|
|
77
|
+
}
|
|
78
|
+
return runs;
|
|
79
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/** Rendering for `forgedock stats` (forge#3835): full markdown, compact table (review-pr-staging) and JSON. */
|
|
4
|
+
|
|
5
|
+
const NA = "n/a";
|
|
6
|
+
const isNum = (x) => typeof x === "number" && Number.isFinite(x);
|
|
7
|
+
const k = (x) => (isNum(x) ? `${Math.round(x / 1000)}k` : NA);
|
|
8
|
+
const pct = (x) => (isNum(x) ? `${Math.round(x * 100)}%` : NA);
|
|
9
|
+
const usd = (x) => (isNum(x) ? `$${x.toFixed(2)}` : NA);
|
|
10
|
+
const f1 = (x) => (isNum(x) ? (Math.round(x * 10) / 10).toString() : NA);
|
|
11
|
+
const dur = (ms) => {
|
|
12
|
+
if (!isNum(ms)) return NA;
|
|
13
|
+
const s = ms / 1000;
|
|
14
|
+
if (s < 90) return `${Math.round(s)}s`;
|
|
15
|
+
if (s < 5400) return `${Math.round(s / 60)}m`;
|
|
16
|
+
return `${f1(s / 3600)}h`;
|
|
17
|
+
};
|
|
18
|
+
const signed = (x, fmt) => (isNum(x) ? `${x > 0 ? "+" : x < 0 ? "-" : ""}${fmt(Math.abs(x))}` : NA);
|
|
19
|
+
const dpp = (x) => signed(x, (v) => `${Math.round(v * 100)}pp`);
|
|
20
|
+
const flag = (g) => (g.lowN ? ` (low-n <${g.minN})` : "");
|
|
21
|
+
const table = (head, rows) => [`| ${head.join(" | ")} |`, `|${head.map(() => "---").join("|")}|`, ...rows.map((r) => `| ${r.join(" | ")} |`)];
|
|
22
|
+
|
|
23
|
+
export const renderJson = (report) => JSON.stringify(report, null, 2);
|
|
24
|
+
|
|
25
|
+
const label = (g) => `${g.version} / ${g.repo}`;
|
|
26
|
+
|
|
27
|
+
function sections(g) {
|
|
28
|
+
const out = [`## ${label(g)} n=${g.n}${flag(g)}`, ""];
|
|
29
|
+
const c = g.cost;
|
|
30
|
+
out.push("### Cost", ...table(["metric", "value"], [
|
|
31
|
+
["cache-read tokens per issue (mean / median)", `${k(c.cacheReadMean)} / ${k(c.cacheReadMedian)}`],
|
|
32
|
+
["output tokens per issue (mean)", k(c.outputMean)],
|
|
33
|
+
["per phase (mean)", Object.entries(c.perPhase).filter(([, v]) => isNum(v)).map(([p, v]) => `${p} ${k(v)}`).join(", ") || NA],
|
|
34
|
+
["cache-read tokens per merged issue", k(c.perMergedIssue)],
|
|
35
|
+
["USD per issue (n=" + c.usdN + ")", usd(c.usdMean)],
|
|
36
|
+
]), "");
|
|
37
|
+
const t = g.time;
|
|
38
|
+
const phaseRows = Object.entries(t.phases).map(([p, v]) => [p, String(v.n), dur(v.p50), dur(v.p90)]);
|
|
39
|
+
out.push("### Time", ...table(["phase", "n", "p50", "p90"], [...phaseRows, ["wall per issue", String(t.wall.n), dur(t.wall.p50), dur(t.wall.p90)]]), "");
|
|
40
|
+
const f = g.flow;
|
|
41
|
+
out.push("### Flow", ...table(["metric", "value"], [
|
|
42
|
+
["merged", pct(f.mergedPct)], ["needs-human", pct(f.needsHumanPct)], ["remediation rate", pct(f.remediationPct)],
|
|
43
|
+
["top outcome codes (n=" + f.codesN + ")", f.codes.slice(0, 5).map((x) => `${x.code} ${x.count} (${pct(x.pct)})`).join(", ") || NA],
|
|
44
|
+
]), "");
|
|
45
|
+
const r = g.funnel;
|
|
46
|
+
const sev = Object.entries(r.bySeverity).map(([s, d]) => [s, String(d.total), f1(d.perReview), String(d.ISSUE), String(d.NOTE), String(d.INPR_FIX)]);
|
|
47
|
+
out.push("### Review funnel", `${r.reviews} reviews, ${r.findings} findings (${f1(r.perReview)} per review). Validated issues: ${r.validated}, false positives: ${r.falsePositive}.`, "",
|
|
48
|
+
...(sev.length ? table(["severity", "findings", "per review", "ISSUE", "NOTE", "in-PR fix"], sev) : [`No finding records: ${NA}`]), "");
|
|
49
|
+
const q = g.gates;
|
|
50
|
+
const rate = (x) => (x.n ? `${pct(x.rate)} (${x.blocked}/${x.n})` : NA);
|
|
51
|
+
out.push("### Gates", ...table(["gate", "block rate"], [
|
|
52
|
+
["acceptance", rate(q.acceptance)], ["diff size (OVER)", rate(q.diffSize)], ["ci", rate(q.ci)], ["liveness", rate(q.liveness)],
|
|
53
|
+
]), `Malformed-check rate: ${pct(q.malformedRate)}. Diff size: COHESIVE ${q.diffSize.cohesive}, OVER ${q.diffSize.over}.`, "");
|
|
54
|
+
const i = g.issueFlow;
|
|
55
|
+
const kv = (o) => Object.entries(o).map(([a, b]) => `${a} ${b}`).join(", ") || NA;
|
|
56
|
+
out.push("### Issue flow", ...table(["metric", "value"], [
|
|
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)],
|
|
59
|
+
]), "");
|
|
60
|
+
if (g.delta) {
|
|
61
|
+
const d = g.delta;
|
|
62
|
+
out.push(`Delta vs ${d.against}: cache-read/issue ${signed(d.cacheReadMean, k)}, merged ${dpp(d.mergedPct)}, needs-human ${dpp(d.needsHumanPct)}, remediation ${dpp(d.remediationPct)}, wall p50 ${signed(d.wallP50, dur)}`, "");
|
|
63
|
+
} else out.push(`Delta: ${NA} (no previous version)`, "");
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function renderMarkdown(report) {
|
|
68
|
+
const out = ["# ForgeDock stats", ""];
|
|
69
|
+
if (!report.groups.length) out.push("No data: no finished runs, telemetry records or issues were found.", "");
|
|
70
|
+
for (const g of report.groups) out.push(...sections(g));
|
|
71
|
+
const e = report.excluded;
|
|
72
|
+
out.push(`Finished runs only. Excluded: ${e.unfinished} unfinished, ${e.noUsage} without usage data (hydrated or empty). "~" = version estimated from release times; "unknown" = no version available. Groups with n < ${report.minN} are flagged low-n.`);
|
|
73
|
+
return out.join("\n");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** One row per (version, repo): the embedded table for the staging review summary. */
|
|
77
|
+
export function renderCompact(report) {
|
|
78
|
+
if (!report.groups.length) return "No stats data.";
|
|
79
|
+
const rows = report.groups.map((g) => [g.version, g.repo, String(g.n), k(g.cost.cacheReadMean), pct(g.flow.mergedPct), pct(g.flow.needsHumanPct),
|
|
80
|
+
pct(g.flow.remediationPct), dur(g.time.wall.p50), f1(g.funnel.perReview), pct(g.gates.acceptance.rate), g.delta ? signed(g.delta.cacheReadMean, k) : NA, g.lowN ? `low-n (<${g.minN})` : "ok"]);
|
|
81
|
+
return table(["Version", "Repo", "n", "Cache-read/issue", "Merged", "Needs-human", "Remediated", "Wall p50", "Findings/review", "Acceptance block", "Delta tokens", "Confidence"], rows).join("\n");
|
|
82
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { spawnSync } from "node:child_process";
|
|
10
|
+
import { aggregate, compareVersions, percentile } from "./aggregate.mjs";
|
|
11
|
+
import { cacheDir } from "./cache.mjs";
|
|
12
|
+
import { fetchGithubRecords, parseTelemetryComments, runsFromRecords, toModel } from "./github-source.mjs";
|
|
13
|
+
import { buildReport, parseArgs, parseSince, runStats } from "./index.mjs";
|
|
14
|
+
import { readLocalRuns } from "./local-source.mjs";
|
|
15
|
+
import { renderCompact, renderJson, renderMarkdown } from "./render.mjs";
|
|
16
|
+
|
|
17
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const fx = (f) => join(here, "fixtures", f);
|
|
19
|
+
const load = (f) => JSON.parse(readFileSync(fx(f), "utf8"));
|
|
20
|
+
const ENV = { FORGE_TRAIL_TRUSTED_ASSOCIATIONS: "OWNER,MEMBER,COLLABORATOR" };
|
|
21
|
+
|
|
22
|
+
// A fake `gh`: lists the fixture issues (honouring `since`), serves comments per number. Counts every call.
|
|
23
|
+
function fakeGh() {
|
|
24
|
+
const comments = { 201: load("comments-201.json"), 301: load("comments-301.json") };
|
|
25
|
+
const issues = load("issues.json");
|
|
26
|
+
const calls = [];
|
|
27
|
+
const gh = (args) => {
|
|
28
|
+
calls.push(args.join(" "));
|
|
29
|
+
const path = args.find((a) => a.startsWith("repos/"));
|
|
30
|
+
const m = /issues\/(\d+)\/comments$/.exec(path);
|
|
31
|
+
if (m) return JSON.stringify(comments[m[1]] ?? []);
|
|
32
|
+
const since = /since=([^&]+)/.exec(path)?.[1];
|
|
33
|
+
return JSON.stringify(issues.filter((i) => !since || i.updated_at >= since));
|
|
34
|
+
};
|
|
35
|
+
return { gh, calls };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let td;
|
|
39
|
+
beforeEach(() => { td = mkdtempSync(join(tmpdir(), "stats-")); });
|
|
40
|
+
afterEach(() => rmSync(td, { recursive: true, force: true }));
|
|
41
|
+
|
|
42
|
+
describe("telemetry comment parsing", () => {
|
|
43
|
+
it("concatenates parts, dedupes on eid and ignores untrusted or unrelated comments", () => {
|
|
44
|
+
const recs = parseTelemetryComments(load("comments-201.json"), ENV);
|
|
45
|
+
assert.deepEqual(recs.map((r) => r.eid), ["e1", "e2", "e3", "e4", "e5"]);
|
|
46
|
+
assert.ok(!recs.some((r) => r.eid === "e90"), "untrusted User comment must not be read");
|
|
47
|
+
});
|
|
48
|
+
it("skips torn lines and keeps identical-looking events that have different eids", () => {
|
|
49
|
+
const body = "<!-- FORGE:TELEMETRY v1 -->\n```jsonl\n{\"event\":\"a\",\"eid\":\"1\"}\n{\"event\":\"a\",\"eid\":\"2\"}\n{torn\n";
|
|
50
|
+
const recs = parseTelemetryComments([{ body, user: { type: "Bot" } }], ENV);
|
|
51
|
+
assert.equal(recs.length, 2);
|
|
52
|
+
});
|
|
53
|
+
it("trusts OWNER/MEMBER/COLLABORATOR, not NONE users", () => {
|
|
54
|
+
const body = "<!-- FORGE:TELEMETRY v1 -->\n```jsonl\n{\"event\":\"a\",\"eid\":\"1\"}\n```";
|
|
55
|
+
assert.equal(parseTelemetryComments([{ body, author_association: "MEMBER", user: { type: "User" } }], ENV).length, 1);
|
|
56
|
+
assert.equal(parseTelemetryComments([{ body, author_association: "NONE", user: { type: "User" } }], ENV).length, 0);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("GitHub-only report (fresh machine)", () => {
|
|
61
|
+
it("rebuilds cost, time, flow, funnel, gates and issue flow from GitHub alone", () => {
|
|
62
|
+
const { gh } = fakeGh();
|
|
63
|
+
const report = buildReport({ repo: "o/r", local: false, cache: false, minN: 10 }, { gh, env: ENV, releaseTimes: [] });
|
|
64
|
+
const g = report.groups.find((x) => x.version === "1.17.0");
|
|
65
|
+
assert.equal(g.n, 1);
|
|
66
|
+
assert.equal(g.cost.cacheReadMean, 3000000);
|
|
67
|
+
assert.equal(g.cost.usdMean, 1.2);
|
|
68
|
+
assert.equal(g.time.wall.p50, 900000);
|
|
69
|
+
assert.equal(g.time.phases.build.p50, 500000);
|
|
70
|
+
assert.equal(g.flow.mergedPct, 1);
|
|
71
|
+
assert.deepEqual(g.flow.codes.map((c) => c.code), ["completed"]);
|
|
72
|
+
assert.equal(g.funnel.reviews, 1);
|
|
73
|
+
assert.equal(g.funnel.findings, 3);
|
|
74
|
+
assert.equal(g.funnel.bySeverity.HIGH.total, 2);
|
|
75
|
+
assert.equal(g.funnel.bySeverity.HIGH.ISSUE, 1);
|
|
76
|
+
assert.equal(g.funnel.bySeverity.HIGH.INPR_FIX, 1);
|
|
77
|
+
assert.equal(g.gates.acceptance.rate, 1, "duplicate eid counted once");
|
|
78
|
+
assert.equal(g.gates.malformedRate, 1);
|
|
79
|
+
assert.equal(g.gates.ci.rate, 0.5);
|
|
80
|
+
assert.deepEqual(g.issueFlow.created, { pipeline: 1 });
|
|
81
|
+
assert.equal(g.issueFlow.net, 0);
|
|
82
|
+
const u = report.groups.find((x) => x.version === "unknown");
|
|
83
|
+
assert.deepEqual(u.issueFlow.created, { "review-finding": 2, human: 1 });
|
|
84
|
+
assert.equal(u.funnel.validated, 1);
|
|
85
|
+
assert.equal(u.funnel.falsePositive, 1);
|
|
86
|
+
assert.equal(u.issueFlow.net, 2);
|
|
87
|
+
assert.equal(u.issueFlow.findingsPerMerge, null);
|
|
88
|
+
assert.ok(!JSON.stringify(report).includes("999999999"), "untrusted record leaked into the report");
|
|
89
|
+
});
|
|
90
|
+
it("matches the run-record field names written by the engine seam", () => {
|
|
91
|
+
const runs = runsFromRecords(parseTelemetryComments(load("comments-201.json"), ENV));
|
|
92
|
+
assert.equal(runs.length, 1);
|
|
93
|
+
assert.equal(runs[0].finished, true);
|
|
94
|
+
assert.equal(runs[0].phases.review.cr, 500000);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("local runs, joins, version delta and low-n", () => {
|
|
99
|
+
const runs = () => readLocalRuns(fx("runs"));
|
|
100
|
+
const agg = (opts = {}) => aggregate({ runs: runs() }, { releaseTimes: [], ...opts });
|
|
101
|
+
|
|
102
|
+
it("applies the cost-report counting rules", () => {
|
|
103
|
+
const r = agg();
|
|
104
|
+
assert.deepEqual(r.excluded, { unfinished: 1, noUsage: 1 });
|
|
105
|
+
const g = r.groups.find((x) => x.version === "1.16.0");
|
|
106
|
+
assert.equal(g.n, 2);
|
|
107
|
+
assert.equal(g.cost.cacheReadMean, 4500000);
|
|
108
|
+
assert.equal(g.cost.perPhase.build, 4000000);
|
|
109
|
+
assert.equal(g.cost.perPhase.remediate, 0);
|
|
110
|
+
assert.equal(g.cost.perMergedIssue, 9000000);
|
|
111
|
+
assert.equal(g.flow.mergedPct, 0.5);
|
|
112
|
+
assert.equal(g.flow.needsHumanPct, 0.5);
|
|
113
|
+
assert.equal(g.flow.remediationPct, 0.5);
|
|
114
|
+
assert.equal(g.cost.usdN, 1);
|
|
115
|
+
assert.equal(g.time.wall.p50, 1500000);
|
|
116
|
+
const legacy = r.groups.find((x) => x.version === "unknown");
|
|
117
|
+
assert.equal(legacy.cost.cacheReadMean, 4000000, "legacy PHASE_COMMIT usage counted when no attempt events exist");
|
|
118
|
+
assert.equal(legacy.flow.codesN, 0, "old logs have no outcome codes");
|
|
119
|
+
});
|
|
120
|
+
it("computes the delta against the previous version of the same repo", () => {
|
|
121
|
+
const g = agg().groups.find((x) => x.version === "1.17.0");
|
|
122
|
+
assert.equal(g.delta.against, "1.16.0");
|
|
123
|
+
assert.equal(g.delta.cacheReadMean, 2000000 - 4500000);
|
|
124
|
+
assert.equal(g.delta.mergedPct, 0.5);
|
|
125
|
+
assert.equal(agg().groups.find((x) => x.version === "1.16.0").delta, null);
|
|
126
|
+
});
|
|
127
|
+
it("flags samples under 10 and honours --min-n", () => {
|
|
128
|
+
assert.ok(agg().groups.every((g) => g.lowN));
|
|
129
|
+
assert.equal(agg({ minN: 2 }).groups.find((x) => x.version === "1.16.0").lowN, false);
|
|
130
|
+
assert.match(renderMarkdown(agg()), /low-n/);
|
|
131
|
+
});
|
|
132
|
+
it("agrees with scripts/cost-report.sh on the same logs", { skip: spawnSync("jq", ["--version"]).status !== 0 }, () => {
|
|
133
|
+
writeFileSync(join(td, "rel.txt"), "");
|
|
134
|
+
const out = spawnSync("bash", [join(here, "..", "..", "scripts", "cost-report.sh"), "--runs-dir", fx("runs"), "--release-times", join(td, "rel.txt")], { encoding: "utf8" }).stdout;
|
|
135
|
+
const row = out.split("\n").find((l) => l.startsWith("| 1.16.0 |"));
|
|
136
|
+
assert.ok(row?.includes("| 4500k |"), row);
|
|
137
|
+
assert.ok(row?.includes("| 9000k |"), row);
|
|
138
|
+
});
|
|
139
|
+
it("--since filters by version or date", () => {
|
|
140
|
+
assert.deepEqual(agg({ ...parseSince("1.17.0") }).groups.map((g) => g.version), ["1.17.0"]);
|
|
141
|
+
assert.ok(agg({ ...parseSince("2026-10-03") }).groups.every((g) => g.version !== "1.16.0"));
|
|
142
|
+
});
|
|
143
|
+
it("estimates versions of unversioned records from release times", () => {
|
|
144
|
+
const r = aggregate({ runs: runs().filter((x) => x.issue === 105) }, { releaseTimes: [{ v: "1.15.0", t: 1 }] });
|
|
145
|
+
assert.equal(r.groups[0].version, "1.15.0~");
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("n/a degradation and rendering", () => {
|
|
150
|
+
it("renders n/a, never NaN or undefined, for missing data and empty input", () => {
|
|
151
|
+
const old = { source: "local", repo: "o/r", version: "1.0.0", issue: 1, finished: true, hydrated: false, usageEvents: 1, reason: "merged", code: null, remediated: false, phases: { build: { cr: 10, out: 0, usd: null, ms: null, attempts: 1 } } };
|
|
152
|
+
const r = aggregate({ runs: [old] });
|
|
153
|
+
const md = renderMarkdown(r);
|
|
154
|
+
assert.match(md, /n\/a/);
|
|
155
|
+
assert.doesNotMatch(md, /NaN|undefined|Infinity/);
|
|
156
|
+
assert.doesNotMatch(renderCompact(r), /NaN|undefined/);
|
|
157
|
+
assert.match(renderMarkdown(aggregate({})), /No data/);
|
|
158
|
+
assert.equal(percentile([], 90), null);
|
|
159
|
+
});
|
|
160
|
+
it("json output is parseable and carries every section", () => {
|
|
161
|
+
const { gh } = fakeGh();
|
|
162
|
+
const report = buildReport({ repo: "o/r", local: false, cache: false, minN: 10 }, { gh, env: ENV, releaseTimes: [] });
|
|
163
|
+
const parsed = JSON.parse(renderJson(report));
|
|
164
|
+
for (const k of ["cost", "time", "flow", "funnel", "gates", "issueFlow"]) assert.ok(k in parsed.groups[0]);
|
|
165
|
+
const md = renderMarkdown(report);
|
|
166
|
+
for (const h of ["### Cost", "### Time", "### Flow", "### Review funnel", "### Gates", "### Issue flow"]) assert.ok(md.includes(h), h);
|
|
167
|
+
});
|
|
168
|
+
it("orders versions numerically", () => {
|
|
169
|
+
assert.ok(compareVersions("1.9.0", "1.10.0") < 0);
|
|
170
|
+
assert.ok(compareVersions("1.10.0~", "1.10.0") === 0);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("cache", () => {
|
|
175
|
+
it("refreshes incrementally from the updated_at watermark and survives deletion or corruption", () => {
|
|
176
|
+
const env = { ...ENV, FORGE_STATS_CACHE_DIR: join(td, "cache") };
|
|
177
|
+
const a = fakeGh();
|
|
178
|
+
const first = fetchGithubRecords({ repo: "o/r", gh: a.gh, env });
|
|
179
|
+
assert.equal(first.fromCache, false);
|
|
180
|
+
assert.ok(!a.calls[0].includes("since="));
|
|
181
|
+
const b = fakeGh();
|
|
182
|
+
const second = fetchGithubRecords({ repo: "o/r", gh: b.gh, env });
|
|
183
|
+
assert.equal(second.fromCache, true);
|
|
184
|
+
assert.match(b.calls[0], /since=2026-10-06T13:00:00Z/);
|
|
185
|
+
assert.deepEqual(second.records.map((r) => r.eid).sort(), first.records.map((r) => r.eid).sort());
|
|
186
|
+
// corrupt cache -> treated as empty -> same result from a full fetch
|
|
187
|
+
writeFileSync(join(cacheDir("o/r", env), "cache.json"), "{not json");
|
|
188
|
+
const c = fakeGh();
|
|
189
|
+
const third = fetchGithubRecords({ repo: "o/r", gh: c.gh, env });
|
|
190
|
+
assert.equal(third.fromCache, false);
|
|
191
|
+
assert.equal(third.records.length, first.records.length);
|
|
192
|
+
// --refresh ignores a good cache
|
|
193
|
+
const d = fakeGh();
|
|
194
|
+
fetchGithubRecords({ repo: "o/r", gh: d.gh, env, refresh: true });
|
|
195
|
+
assert.ok(!d.calls[0].includes("since="));
|
|
196
|
+
assert.equal(toModel("o/r", third).issues.length, 5);
|
|
197
|
+
});
|
|
198
|
+
it("rejects an unsafe repo before any gh call", () => {
|
|
199
|
+
const { gh, calls } = fakeGh();
|
|
200
|
+
assert.throws(() => fetchGithubRecords({ repo: "o/r; rm -rf /", gh }), /invalid repo/);
|
|
201
|
+
assert.equal(calls.length, 0);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe("cli", () => {
|
|
206
|
+
it("parses flags and rejects bad input", () => {
|
|
207
|
+
const o = parseArgs(["--since", "1.2.0", "--repo=o/r", "--json", "--refresh", "--min-n", "3"]);
|
|
208
|
+
assert.deepEqual([o.since, o.repo, o.json, o.refresh, o.minN], ["1.2.0", "o/r", true, true, 3]);
|
|
209
|
+
assert.throws(() => parseArgs(["--since"]), /needs a value/);
|
|
210
|
+
assert.throws(() => parseArgs(["--bogus"]), /unknown argument/);
|
|
211
|
+
assert.throws(() => parseSince("yesterday"), /--since must be/);
|
|
212
|
+
});
|
|
213
|
+
it("--help exits 0 and importing the module did not run it", () => {
|
|
214
|
+
const r = spawnSync(process.execPath, [join(here, "index.mjs"), "--help"], { encoding: "utf8" });
|
|
215
|
+
assert.equal(r.status, 0);
|
|
216
|
+
assert.match(r.stdout, /--since/);
|
|
217
|
+
assert.match(r.stdout, /--refresh/);
|
|
218
|
+
});
|
|
219
|
+
it("runStats returns 2 for an unusable repo", async () => {
|
|
220
|
+
const real = process.stderr.write;
|
|
221
|
+
process.stderr.write = () => true;
|
|
222
|
+
try { assert.equal(await runStats(["--repo", "bad repo"]), 2); } finally { process.stderr.write = real; }
|
|
223
|
+
});
|
|
224
|
+
});
|