@mcptoolshop/housekeeping 0.0.0 → 1.3.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/src/report.mjs ADDED
@@ -0,0 +1,570 @@
1
+ #!/usr/bin/env node
2
+ // Generate reports/AUDIT-<date>.md from a snapshot. The report is derived, never
3
+ // hand-edited: regenerating it from the same snapshot yields the same bytes.
4
+ import { writeFileSync, mkdirSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { openDb } from './load.mjs';
7
+ import { healthScores, RUN_IS_LIVE_MAINLINE_SIGNAL, doorFindingRows, ATLAS_DOOR_CHECKS_SINCE, engineBehindCounts } from './analyze.mjs';
8
+ import { exitCodeFor, formatError, userError } from './errors.mjs';
9
+ import { REPORTS_DIR } from './paths.mjs';
10
+
11
+
12
+ const md = {
13
+ h1: s => `# ${s}\n`,
14
+ h2: s => `\n## ${s}\n`,
15
+ h3: s => `\n### ${s}\n`,
16
+ p: s => `\n${s}\n`,
17
+ table(rows, cols) {
18
+ if (!rows.length) return '\n_None._\n';
19
+ const keys = cols ?? Object.keys(rows[0]);
20
+ const esc = v => String(v ?? '').replace(/\|/g, '\\|').replace(/\n/g, ' ');
21
+ return '\n| ' + keys.join(' | ') + ' |\n|' + keys.map(() => '---').join('|') + '|\n' +
22
+ rows.map(r => '| ' + keys.map(k => esc(r[k])).join(' | ') + ' |').join('\n') + '\n';
23
+ },
24
+ };
25
+
26
+ export function buildReport(db, sid) {
27
+ const q = (sql, ...p) => db.prepare(sql).all(sid, ...p);
28
+ const one = (sql, ...p) => db.prepare(sql).get(sid, ...p);
29
+ const snap = db.prepare('SELECT * FROM snapshot WHERE id=?').get(sid);
30
+
31
+ const totals = one(`SELECT
32
+ (SELECT COUNT(*) FROM repo WHERE snapshot_id=?1) repos,
33
+ (SELECT COUNT(*) FROM repo WHERE snapshot_id=?1 AND is_archived=1) archived,
34
+ (SELECT COUNT(*) FROM repo WHERE snapshot_id=?1 AND is_private=1) private,
35
+ (SELECT COUNT(*) FROM repo WHERE snapshot_id=?1 AND is_empty=1) empty,
36
+ (SELECT COUNT(*) FROM issue WHERE snapshot_id=?1) issues,
37
+ (SELECT COUNT(*) FROM pull_request WHERE snapshot_id=?1) prs,
38
+ (SELECT COUNT(*) FROM workflow WHERE snapshot_id=?1) workflows,
39
+ (SELECT COUNT(*) FROM workflow_run WHERE snapshot_id=?1) runs,
40
+ (SELECT COUNT(*) FROM release WHERE snapshot_id=?1) releases,
41
+ (SELECT COUNT(*) FROM repo WHERE snapshot_id=?1 AND npm_status='published') published,
42
+ (SELECT COUNT(*) FROM finding WHERE snapshot_id=?1) findings,
43
+ (SELECT COUNT(*) FROM collect_error WHERE snapshot_id=?1) errors`);
44
+
45
+ const sev = q(`SELECT severity, COUNT(*) findings, COUNT(DISTINCT repo) repos
46
+ FROM finding WHERE snapshot_id=? GROUP BY severity
47
+ ORDER BY CASE severity WHEN 'critical' THEN 0 WHEN 'high' THEN 1
48
+ WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END`);
49
+
50
+ const out = [];
51
+ const push = (...s) => out.push(...s);
52
+
53
+ push(md.h1(`${snap.org} — operational audit`));
54
+ push(md.p(
55
+ `**Snapshot ${sid}** · collected \`${snap.taken_at}\` by \`${snap.gh_login}\` ` +
56
+ `· collector v${snap.collector_version} · ${snap.duration_ms} ms · ` +
57
+ `${totals.errors} collection errors.\n\n` +
58
+ 'Every number below is a query against `data/housekeeping.db`, rebuilt from ' +
59
+ '`data/snapshots/*.json`. Nothing here is hand-entered. Reproduce with `npm run refresh`.'
60
+ ));
61
+
62
+ push(md.h2('Portfolio at a glance'));
63
+ push(md.table([{
64
+ repos: totals.repos, archived: totals.archived, private: totals.private,
65
+ empty: totals.empty, 'npm published': totals.published,
66
+ workflows: totals.workflows, 'runs sampled': totals.runs, releases: totals.releases,
67
+ 'open issues': totals.issues, 'open PRs': totals.prs,
68
+ }]));
69
+ push(md.p('Findings by severity:'));
70
+ push(md.table(sev));
71
+
72
+ // ---------------------------------------------------------------- CI ----
73
+ push(md.h2('1. CI status'));
74
+ const redMain = q(`SELECT DISTINCT r.name repo, r.default_branch branch, r.ci_rollup rollup,
75
+ (SELECT GROUP_CONCAT(wr.workflow_name, '; ') FROM workflow_run wr
76
+ WHERE wr.snapshot_id=r.snapshot_id AND wr.repo=r.name
77
+ AND ${RUN_IS_LIVE_MAINLINE_SIGNAL}) failing_workflows
78
+ FROM repo r
79
+ WHERE r.snapshot_id=? AND r.is_archived=0
80
+ AND (r.ci_rollup IN ('FAILURE','ERROR') OR EXISTS (
81
+ SELECT 1 FROM workflow_run wr WHERE wr.snapshot_id=r.snapshot_id AND wr.repo=r.name
82
+ AND ${RUN_IS_LIVE_MAINLINE_SIGNAL}))
83
+ ORDER BY r.name`);
84
+ push(md.h3(`Default branch is red — ${redMain.length} repos`));
85
+ push(md.p('These are the only CI failures that mean "the mainline is broken." Fix first.'));
86
+ push(md.table(redMain));
87
+
88
+ const sched = q(`SELECT repo, message, evidence url FROM finding
89
+ WHERE snapshot_id=? AND code='CI_SCHEDULED_FAILING' ORDER BY repo`);
90
+ push(md.h3(`Scheduled workflows failing — ${sched.length}`));
91
+ push(md.p('Cron jobs that have been failing unattended. Not mainline breakage, but silent rot.'));
92
+ push(md.table(sched));
93
+
94
+ const depFail = q(`SELECT repo, COUNT(*) failing_runs FROM finding
95
+ WHERE snapshot_id=? AND code IN ('CI_DEPENDABOT_FAILING','CI_BRANCH_FAILING')
96
+ GROUP BY repo ORDER BY failing_runs DESC, repo`);
97
+ push(md.h3(`Failing on Dependabot / side branches — ${depFail.length} repos`));
98
+ push(md.p('Backlog, not breakage: these block dependency PRs from merging.'));
99
+ push(md.table(depFail));
100
+
101
+ const noCi = q(`SELECT repo, message FROM finding
102
+ WHERE snapshot_id=? AND code IN ('CI_NO_WORKFLOWS','CI_NEVER_RAN') ORDER BY repo`);
103
+ push(md.h3(`No CI at all — ${noCi.length} repos`));
104
+ push(md.table(noCi));
105
+
106
+ // Sits inside the CI section because it is a CI verdict, but it is the one
107
+ // kind of red that every other view here reports as green: the runs all
108
+ // succeed, the rollup is SUCCESS, and the PR is still unmergeable.
109
+ const staleGate = q(`SELECT repo, message, evidence FROM finding
110
+ WHERE snapshot_id=? AND code='CI_REQUIRED_CHECK_STALE' ORDER BY repo`);
111
+ push(md.h3(`Required checks nothing reports — ${staleGate.length} repos`));
112
+ push(md.p('Branch protection is waiting on a context no job emits, so every PR is '
113
+ + 'BLOCKED and merges only with `--admin`. Usually a renamed job or a deleted '
114
+ + 'matrix cell that protection was never updated to match. Fix in protection '
115
+ + '(drop the context) or in the matrix (restore the cell) — check which the '
116
+ + 'workflow intends before dropping.'));
117
+ push(md.table(staleGate));
118
+
119
+ const gatedGate = q(`SELECT repo, message, evidence FROM finding
120
+ WHERE snapshot_id=? AND code='CI_REQUIRED_CHECK_GATED' ORDER BY repo`);
121
+ push(md.h3(`Required checks the PR never triggered — ${gatedGate.length} repos`));
122
+ push(md.p('The job is alive; these PRs just changed files its workflow filters out, '
123
+ + 'so the context never reports and the PR waits forever. A paths filter on a '
124
+ + '`pull_request` trigger and a required status check cannot both be satisfied — '
125
+ + 'rules/github-actions.md wants the filter, branch protection wants the report. '
126
+ + 'Fix in the workflow trigger, NOT by dropping the context: the gate still works.'));
127
+ push(md.table(gatedGate));
128
+
129
+ const refused = q(`SELECT repo, code, message, evidence FROM finding
130
+ WHERE snapshot_id=? AND code IN ('CI_PAGES_NOT_ENABLED','CI_ENVIRONMENT_EXCLUDES_DEFAULT')
131
+ ORDER BY repo, code`);
132
+ push(md.h3(`Deploys the repository's settings refuse — ${new Set(refused.map(x => x.repo)).size} repos`));
133
+ push(md.p('The workflow is correct and the deploy still cannot succeed, because a setting '
134
+ + 'outside the code says no: GitHub Pages is switched off under a job that deploys to it, '
135
+ + 'or an environment\'s branch policy does not admit the default branch the job runs on. '
136
+ + 'No workflow edit fixes these; the setting does. Read from `repos/{r}/pages` and '
137
+ + '`repos/{r}/environments` only for repos whose workflows need them; a setting that '
138
+ + 'could not be read produces no row here rather than a guess.'));
139
+ push(md.table(refused));
140
+
141
+ const runHealth = q(`SELECT conclusion, COUNT(*) runs FROM workflow_run
142
+ WHERE snapshot_id=? GROUP BY conclusion ORDER BY runs DESC`);
143
+ push(md.h3('Run outcomes across the sampled window'));
144
+ push(md.p(`Last ${totals.runs} runs across all repos (up to 30 most recent per repo):`));
145
+ push(md.table(runHealth));
146
+
147
+ // ------------------------------------------------------------ issues ----
148
+ // Security sits directly after CI because it answers the same question --
149
+ // "is this repo safe to depend on today" -- and because nothing else in this
150
+ // report would surface it.
151
+ const secRows = q(`SELECT * FROM repo_security
152
+ WHERE snapshot_id=? AND alerts_total > 0
153
+ ORDER BY critical DESC, high DESC, alerts_total DESC`);
154
+ if (secRows.length) {
155
+ const sum = k => secRows.reduce((a, r) => a + r[k], 0);
156
+ const off = secRows.filter(r => r.auto_security_fixes === 0);
157
+ const fixable = sum('fixable'), total = sum('alerts_total');
158
+ push(md.h2('2. Security'));
159
+ push(md.p(`${total} open Dependabot alerts across ${secRows.length} repos — ` +
160
+ `${sum('critical')} critical, ${sum('high')} high. ` +
161
+ `${fixable} of ${total} already have a patched version available.`));
162
+ if (off.length) {
163
+ push(md.p(`${off.length} of these repos have Dependabot security updates **disabled**, so no fix PR ` +
164
+ 'will ever be opened: ' + off.map(r => '`' + r.repo + '`').join(', ') + '.'));
165
+ }
166
+ push(md.table(secRows.map(r => ({
167
+ repo: r.repo, critical: r.critical, high: r.high, medium: r.medium, low: r.low,
168
+ 'oldest (days)': r.oldest_age_days ?? '',
169
+ fixable: `${r.fixable}/${r.alerts_total}`,
170
+ 'auto-fix': r.auto_security_fixes === 1 ? 'on' : r.auto_security_fixes === 0 ? 'OFF' : '?',
171
+ }))));
172
+
173
+ push(md.h3('Most common vulnerable packages'));
174
+ push(md.p('Alerts cluster: a handful of dependencies account for most of the count.'));
175
+ push(md.table(q(`SELECT package, ecosystem, severity, COUNT(*) alerts
176
+ FROM security_alert WHERE snapshot_id=? GROUP BY package, ecosystem, severity
177
+ ORDER BY alerts DESC LIMIT 12`)));
178
+ }
179
+
180
+ // The warehouse's own reading of every committed lockfile, beside GitHub's.
181
+ // Two numbers on purpose: the TRUE exposure (what npm's advisory registry
182
+ // says about the trees actually in the repos) and the part of it GitHub is
183
+ // blind to. The second number can be the whole story -- many repos reporting
184
+ // zero while carrying the same critical -- and no view of repo_security can
185
+ // show it, because that table IS GitHub's count.
186
+ const lockRows = q(`SELECT * FROM lockfile WHERE snapshot_id=? AND error IS NULL`);
187
+ if (lockRows.length) {
188
+ const sum = k => lockRows.reduce((a, r) => a + (r[k] ?? 0), 0);
189
+ const exposed = lockRows.filter(r => (r.critical + r.high) > 0);
190
+ const blind = exposed.filter(r => r.github_alerts === 0);
191
+ const blindProd = blind.filter(r => (r.prod_critical + r.prod_high) > 0);
192
+ const repoCount = rows => new Set(rows.map(r => r.repo)).size;
193
+ const errored = q(`SELECT COUNT(*) n FROM lockfile WHERE snapshot_id=? AND error IS NOT NULL`)[0]?.n ?? 0;
194
+ // One vendored monorepo can carry a hundred copies of the same lockfile
195
+ // and dominate every sum. Name its share in the sentence, data-driven,
196
+ // so the reader does not have to subtract it from the headline.
197
+ const share = new Map();
198
+ for (const r of lockRows) share.set(r.repo, (share.get(r.repo) ?? 0) + (r.prod_critical ?? 0) + (r.prod_high ?? 0));
199
+ const [topRepo, topShare] = [...share.entries()].sort((a, b) => b[1] - a[1])[0] ?? [null, 0];
200
+ const shippedTotal = sum('prod_critical') + sum('prod_high');
201
+ const topNote = topRepo && shippedTotal && topShare / shippedTotal >= 0.5
202
+ ? ` — **${Math.round(100 * topShare / shippedTotal)}% of that is \`${topRepo}\` alone** (${share.size > 1 ? 'one row in the table below' : ''})`
203
+ : '';
204
+ push(md.h3('Committed lockfiles, audited directly'));
205
+ push(md.p(`${lockRows.length} lockfiles in ${repoCount(lockRows)} repos, read against the npm advisory registry this sweep. ` +
206
+ `On **shipped** dependencies they resolve to **${sum('prod_critical')} critical** and **${sum('prod_high')} high**${topNote}; ` +
207
+ `counting dev-only toolchain packages too, ${sum('critical')} critical and ${sum('high')} high. ` +
208
+ `**${blindProd.length} lockfiles in ${repoCount(blindProd)} repos carry shipped critical/high advisories that GitHub reports as 0 alerts** ` +
209
+ `for that manifest, so Dependabot will never open a fix PR for them` +
210
+ (blind.length > blindProd.length ? ` (${blind.length - blindProd.length} more are dev-only)` : '') + '.' +
211
+ (errored ? ` ${errored} lockfile(s) could not be read and are NOT counted as clean.` : '')));
212
+ if (blindProd.length) {
213
+ // Group by repo: a vendored monorepo contributes one row here, not one
214
+ // per lockfile, so it cannot drown the other findings.
215
+ const byRepo = new Map();
216
+ for (const r of blindProd) {
217
+ const g = byRepo.get(r.repo) ?? { repo: r.repo, lockfiles: 0, 'critical (shipped)': 0, 'high (shipped)': 0, 'GitHub alerts': 0 };
218
+ g.lockfiles++; g['critical (shipped)'] += r.prod_critical; g['high (shipped)'] += r.prod_high; g['GitHub alerts'] += r.github_alerts;
219
+ byRepo.set(r.repo, g);
220
+ }
221
+ push(md.table([...byRepo.values()].sort((a, b) => b['critical (shipped)'] - a['critical (shipped)'] || b['high (shipped)'] - a['high (shipped)'])));
222
+ push(md.h4 ? md.h4('Most common shipped advisories GitHub is blind to') : md.p('**Most common shipped advisories GitHub is blind to**'));
223
+ push(md.table(q(`SELECT a.package, a.severity, a.ghsa, COUNT(DISTINCT a.repo) repos, COUNT(*) lockfiles
224
+ FROM lockfile_advisory a JOIN lockfile l ON l.snapshot_id=a.snapshot_id AND l.repo=a.repo AND l.path=a.path
225
+ WHERE a.snapshot_id=? AND a.dev=0 AND a.severity IN ('critical','high') AND l.github_alerts=0
226
+ GROUP BY a.package, a.severity, a.ghsa ORDER BY repos DESC, lockfiles DESC LIMIT 10`)));
227
+ }
228
+ }
229
+
230
+ push(md.h2('3. Issues'));
231
+ const issueTop = q(`SELECT r.name repo, r.open_issues open, r.closed_issues closed,
232
+ (SELECT COUNT(*) FROM issue i WHERE i.snapshot_id=r.snapshot_id AND i.repo=r.name AND i.stale_days>90) stale_90d,
233
+ (SELECT MAX(age_days) FROM issue i WHERE i.snapshot_id=r.snapshot_id AND i.repo=r.name) oldest_days
234
+ FROM repo r WHERE r.snapshot_id=? AND r.open_issues>0 ORDER BY r.open_issues DESC, r.name`);
235
+ push(md.p(`${totals.issues} open issues across ${issueTop.length} repos.`));
236
+ push(md.table(issueTop));
237
+
238
+ const oldIssues = q(`SELECT repo, number, age_days age, stale_days stale, author,
239
+ SUBSTR(title,1,70) title FROM issue WHERE snapshot_id=?
240
+ ORDER BY stale_days DESC LIMIT 20`);
241
+ push(md.h3('Stalest 20 issues'));
242
+ push(md.table(oldIssues));
243
+
244
+ // --------------------------------------------------------------- PRs ----
245
+ push(md.h2('4. Pull requests'));
246
+ const prTop = q(`SELECT r.name repo, r.open_prs open,
247
+ (SELECT COUNT(*) FROM pull_request p WHERE p.snapshot_id=r.snapshot_id AND p.repo=r.name AND p.age_days>30) older_30d,
248
+ (SELECT COUNT(*) FROM pull_request p WHERE p.snapshot_id=r.snapshot_id AND p.repo=r.name AND p.mergeable='CONFLICTING') conflicting,
249
+ (SELECT COUNT(*) FROM pull_request p WHERE p.snapshot_id=r.snapshot_id AND p.repo=r.name AND p.is_draft=1) draft,
250
+ (SELECT MAX(age_days) FROM pull_request p WHERE p.snapshot_id=r.snapshot_id AND p.repo=r.name) oldest_days
251
+ FROM repo r WHERE r.snapshot_id=? AND r.open_prs>0 ORDER BY r.open_prs DESC, r.name`);
252
+ push(md.p(`${totals.prs} open pull requests across ${prTop.length} repos.`));
253
+ push(md.table(prTop));
254
+
255
+ const prAuthors = q(`SELECT author, COUNT(*) prs, COUNT(DISTINCT repo) repos,
256
+ SUM(CASE WHEN age_days>30 THEN 1 ELSE 0 END) older_30d
257
+ FROM pull_request WHERE snapshot_id=? GROUP BY author ORDER BY prs DESC`);
258
+ push(md.h3('Who opened the open PRs'));
259
+ push(md.p('Bot-authored backlog is a batching problem; human backlog is a review problem.'));
260
+ push(md.table(prAuthors));
261
+
262
+ const oldPrs = q(`SELECT repo, number, age_days age, mergeable, author,
263
+ SUBSTR(title,1,60) title FROM pull_request WHERE snapshot_id=?
264
+ ORDER BY age_days DESC LIMIT 20`);
265
+ push(md.h3('Oldest 20 pull requests'));
266
+ push(md.table(oldPrs));
267
+
268
+ // ---------------------------------------------------- version drift -----
269
+ push(md.h2('5. Release and version drift'));
270
+ const drift = q(`SELECT r.name repo, r.pkg_name package, r.pkg_version 'package.json',
271
+ r.latest_tag 'latest tag', r.latest_release_tag 'latest release',
272
+ r.npm_latest_version 'npm latest', r.npm_status
273
+ FROM repo r JOIN finding f ON f.snapshot_id=r.snapshot_id AND f.repo=r.name
274
+ WHERE r.snapshot_id=? AND f.code IN ('VERSION_TAG_DRIFT','NPM_DRIFT','NEVER_TAGGED')
275
+ GROUP BY r.name ORDER BY r.name`);
276
+ push(md.p(
277
+ 'Repo version, git **tag**, and npm disagree. Tags and Releases are tracked ' +
278
+ 'separately: a repo can be correctly tagged and simply never have had a ' +
279
+ 'Release published, which is the next table, not this one.'
280
+ ));
281
+ push(md.table(drift));
282
+
283
+ const relMissing = q(`SELECT repo, message, evidence tags FROM finding
284
+ WHERE snapshot_id=? AND code='RELEASE_MISSING' ORDER BY repo`);
285
+ push(md.h3(`Tagged but never released — ${relMissing.length} repos`));
286
+ push(md.p('A semver tag exists with no GitHub Release behind it. The version is correct; the release was never published.'));
287
+ push(md.table(relMissing));
288
+
289
+ const unpub = q(`SELECT repo, message FROM finding
290
+ WHERE snapshot_id=? AND code IN ('NPM_UNPUBLISHED','PRE_1_0','MONOREPO_ROOT') ORDER BY code, repo`);
291
+ push(md.h3('Other version observations'));
292
+ push(md.table(unpub));
293
+
294
+ // ------------------------------------------------------- actions cost ---
295
+ push(md.h2('6. GitHub Actions — cost and rule compliance'));
296
+ push(md.p(
297
+ 'Checked mechanically against `rules/github-actions.md`. ' +
298
+ 'Each row is one workflow file, parsed as YAML rather than grepped.'
299
+ ));
300
+
301
+ // ---- measured cost ------------------------------------------------------
302
+ // Absent for snapshots taken before the billing pass, or without the billing
303
+ // scope. Say so rather than printing zeros, which would read as "free".
304
+ const billTotals = one(`SELECT COUNT(*) lines, SUM(gross) gross, SUM(net) net
305
+ FROM billing_usage WHERE snapshot_id=? AND product='actions'`);
306
+ if (!billTotals?.lines) {
307
+ push(md.h3('Measured cost'));
308
+ const why = q("SELECT message FROM collect_error WHERE snapshot_id=? AND stage='billing'");
309
+ push(md.p(why.length
310
+ ? `_Not measured — the billing API was unreadable: ${why[0].message}_`
311
+ : '_Not measured — this snapshot predates the billing pass. Run `npm run refresh`._'));
312
+ } else {
313
+ push(md.h3('Measured cost — what Actions actually consumed'));
314
+ push(md.p(
315
+ `**$${(billTotals.gross ?? 0).toFixed(2)} gross · $${(billTotals.net ?? 0).toFixed(2)} net** ` +
316
+ `across ${billTotals.lines} invoice lines, straight from GitHub's billing API.\n\n` +
317
+ 'The two are not the same number and must not be read as one. GitHub meters **public** ' +
318
+ 'repositories on standard runners at full price and then discounts them to zero, so gross ' +
319
+ 'is real compute while net is real money. Gross is the number worth managing — it is the ' +
320
+ 'CI minutes the rules call finite — but nothing in it is owed unless it sits on a private repo.'
321
+ ));
322
+
323
+ push(md.table(q(`SELECT sku,
324
+ ROUND(SUM(quantity)) quantity, unit_type,
325
+ '$' || printf('%.2f', SUM(gross)) gross,
326
+ '$' || printf('%.2f', SUM(net)) net
327
+ FROM billing_usage WHERE snapshot_id=? AND product='actions'
328
+ GROUP BY sku ORDER BY SUM(gross) DESC`)));
329
+
330
+ const byVis = q(`SELECT
331
+ CASE WHEN r.is_private=1 THEN 'private' ELSE 'public' END visibility,
332
+ COUNT(DISTINCT b.repo) repos,
333
+ '$' || printf('%.2f', SUM(b.gross)) gross,
334
+ '$' || printf('%.2f', SUM(b.net)) net
335
+ FROM billing_usage b JOIN repo r ON r.snapshot_id=b.snapshot_id AND r.name=b.repo
336
+ WHERE b.snapshot_id=? AND b.product='actions' AND b.sku<>'Actions storage'
337
+ GROUP BY visibility ORDER BY SUM(b.gross) DESC`);
338
+ push(md.h3('Gross by visibility'));
339
+ push(md.p('Only the private row can ever become a bill.'));
340
+ push(md.table(byVis));
341
+
342
+ push(md.h3('Costliest repos this month'));
343
+ push(md.table(q(`SELECT b.repo,
344
+ '$' || printf('%.2f', SUM(b.gross)) gross,
345
+ ROUND(SUM(CASE WHEN b.sku='Actions Linux' THEN b.quantity ELSE 0 END)) linux_min,
346
+ ROUND(SUM(CASE WHEN b.sku='Actions Windows' THEN b.quantity ELSE 0 END)) win_min,
347
+ ROUND(SUM(CASE WHEN b.sku LIKE 'Actions macOS%' THEN b.quantity ELSE 0 END)) mac_min
348
+ FROM billing_usage b
349
+ WHERE b.snapshot_id=? AND b.product='actions' AND b.sku<>'Actions storage'
350
+ AND b.repo IS NOT NULL
351
+ GROUP BY b.repo ORDER BY SUM(b.gross) DESC LIMIT 15`)));
352
+
353
+ // Workflow-level attribution, which the invoice cannot give. Only shown for
354
+ // repos whose priced runs reconciled against it.
355
+ const attributed = q(`SELECT c.repo, c.workflow_name workflow,
356
+ COUNT(*) runs,
357
+ SUM(c.billable_minutes) min,
358
+ '$' || printf('%.2f', SUM(c.cost_usd)) cost,
359
+ printf('%.1f', AVG(c.billed_job_count)) jobs_per_run
360
+ FROM run_cost c
361
+ JOIN cost_reconciliation x ON x.snapshot_id=c.snapshot_id AND x.repo=c.repo
362
+ WHERE c.snapshot_id=? AND x.trustworthy=1
363
+ GROUP BY c.repo, c.workflow_name
364
+ HAVING SUM(c.cost_usd) >= 0.25
365
+ ORDER BY SUM(c.cost_usd) DESC LIMIT 20`);
366
+ if (attributed.length) {
367
+ push(md.h3('Where it went — cost by workflow'));
368
+ push(md.p(
369
+ 'Computed from per-job durations (GitHub bills per job, rounded up to the minute, ' +
370
+ 'x2 Windows / x10 macOS), because the `/timing` endpoints return zeros. ' +
371
+ 'Only repos whose total reconciled against the invoice appear here.'
372
+ ));
373
+ push(md.table(attributed));
374
+ }
375
+
376
+ const waste = q(`SELECT c.repo,
377
+ SUM(CASE WHEN c.conclusion='failure' THEN c.billable_minutes ELSE 0 END) failed_min,
378
+ SUM(CASE WHEN c.conclusion='cancelled' THEN c.billable_minutes ELSE 0 END) cancelled_min,
379
+ SUM(c.billable_minutes) total_min,
380
+ printf('%.0f%%', 100.0 * SUM(CASE WHEN c.conclusion='failure' THEN c.cost_usd ELSE 0 END)
381
+ / NULLIF(SUM(c.cost_usd),0)) failed_pct
382
+ FROM run_cost c
383
+ JOIN cost_reconciliation x ON x.snapshot_id=c.snapshot_id AND x.repo=c.repo
384
+ WHERE c.snapshot_id=? AND x.trustworthy=1
385
+ GROUP BY c.repo
386
+ HAVING failed_min > 0 ORDER BY failed_min DESC LIMIT 15`);
387
+ if (waste.length) {
388
+ push(md.h3('Compute spent on runs that did not pass'));
389
+ push(md.p(
390
+ 'Failed and cancelled are kept apart on purpose. A cancelled run is usually ' +
391
+ '`cancel-in-progress` killing a superseded run, which is the concurrency rule ' +
392
+ 'working — counting it as waste files a finding against correct behaviour. ' +
393
+ 'Only the failed column is a defect.'
394
+ ));
395
+ push(md.table(waste));
396
+ }
397
+
398
+ // The verifier. Shown in full, including disagreements: a cost number
399
+ // nobody checked is exactly what this table exists to prevent.
400
+ const recon = q(`SELECT repo,
401
+ '$' || printf('%.2f', computed_usd) computed,
402
+ '$' || printf('%.2f', billed_gross_usd) billed_gross,
403
+ printf('%.3f', ratio) ratio,
404
+ CASE trustworthy WHEN 1 THEN 'ok' ELSE 'DIVERGED' END agrees, reason
405
+ FROM cost_reconciliation WHERE snapshot_id=? ORDER BY billed_gross_usd DESC`);
406
+ if (recon.length) {
407
+ push(md.h3('Reconciliation — per-job sum vs the invoice'));
408
+ push(md.p(
409
+ 'The attribution above is derived from a different endpoint than the invoice, so ' +
410
+ 'agreement is evidence and divergence is a warning that the priced runs are not the ' +
411
+ 'billed runs. Rules stay silent on any repo marked DIVERGED.'
412
+ ));
413
+ push(md.table(recon));
414
+ }
415
+ }
416
+ const wfRule = q(`SELECT code, COUNT(*) violations, COUNT(DISTINCT repo) repos
417
+ FROM finding WHERE snapshot_id=? AND category='actions'
418
+ GROUP BY code ORDER BY violations DESC`);
419
+ push(md.table(wfRule));
420
+
421
+ const costly = q(`SELECT repo, path, runners, on_triggers triggers, job_count jobs
422
+ FROM workflow WHERE snapshot_id=? AND (uses_macos=1 OR uses_windows=1) ORDER BY uses_macos DESC, repo`);
423
+ push(md.h3(`Non-Linux runners — ${costly.length} workflows`));
424
+ push(md.p('macOS bills ~10x Linux per minute; Windows ~2x.'));
425
+ push(md.table(costly));
426
+
427
+ const wfCount = q(`SELECT name repo, workflow_count files FROM repo
428
+ WHERE snapshot_id=? AND workflow_count>2 ORDER BY workflow_count DESC, name LIMIT 20`);
429
+ push(md.h3('Most workflow files (rule caps at 2)'));
430
+ push(md.table(wfCount));
431
+
432
+ const scheduled = q(`SELECT repo, path, on_triggers triggers FROM workflow
433
+ WHERE snapshot_id=? AND on_triggers LIKE '%schedule%' ORDER BY repo`);
434
+ push(md.h3(`Scheduled workflows — ${scheduled.length}`));
435
+ push(md.p('`rules/github-actions.md` allows a scheduled workflow only when it does what a push cannot, '
436
+ + 'runs weekly or slower, is bounded, and opens a pull request. Each of these needs checking against that.'));
437
+ push(md.table(scheduled));
438
+
439
+ // ---------------------------------------------------------- hygiene -----
440
+ push(md.h2('7. Repo hygiene and metadata'));
441
+ const hyg = q(`SELECT code, COUNT(*) repos FROM finding
442
+ WHERE snapshot_id=? AND category IN ('hygiene','metadata','lifecycle','docs')
443
+ GROUP BY code ORDER BY repos DESC`);
444
+ push(md.table(hyg));
445
+
446
+ const missing = q(`SELECT f.repo, GROUP_CONCAT(f.code, ', ') missing
447
+ FROM finding f WHERE f.snapshot_id=? AND f.category IN ('hygiene','metadata')
448
+ GROUP BY f.repo HAVING COUNT(*) >= 3 ORDER BY COUNT(*) DESC, f.repo`);
449
+ push(md.h3('Repos missing three or more standard files/metadata'));
450
+ push(md.table(missing));
451
+
452
+ // ------------------------------------------------------------ atlas -----
453
+ // rules/atlas-map.md. Said plainly when the snapshot never looked, because
454
+ // an empty table would otherwise read as "every repo has its map".
455
+ push(md.h2('8. Atlas maps'));
456
+ const atlas = one(`SELECT
457
+ COUNT(*) running,
458
+ SUM(CASE WHEN r.has_atlas_map=1 THEN 1 ELSE 0 END) mapped,
459
+ SUM(CASE WHEN r.has_atlas_map=0 THEN 1 ELSE 0 END) unmapped,
460
+ SUM(CASE WHEN r.has_atlas_map IS NULL THEN 1 ELSE 0 END) unknown
461
+ FROM repo r WHERE r.snapshot_id=?1 AND r.is_archived=0 AND r.is_empty=0
462
+ AND EXISTS (SELECT 1 FROM workflow w WHERE w.snapshot_id=r.snapshot_id AND w.repo=r.name)`);
463
+ const fleet = one('SELECT version, error FROM atlas_fleet WHERE snapshot_id=?');
464
+ if (!atlas?.running || atlas.unknown === atlas.running) {
465
+ push(md.p('_Not measured — this snapshot predates the Atlas map pass (collector 1.3.0). Run `npm run refresh`._'));
466
+ } else {
467
+ push(md.p('Every repository that runs workflows keeps a committed map and runs a pinned ' +
468
+ '`atlas check` in CI (`rules/atlas-map.md`). ' +
469
+ `${atlas.mapped} of ${atlas.running} such repos have \`atlas/structure.json\` on their default branch; ` +
470
+ `${atlas.unmapped} do not` + (atlas.unknown ? `; ${atlas.unknown} could not be read` : '') + '. ' +
471
+ (fleet?.version
472
+ ? `The fleet engine is **${fleet.version}** (latest \`@dogfood-lab/atlas\` on npm). ` +
473
+ (engineBehindCounts(snap.taken_at)
474
+ ? 'An engine behind it counts as a defect (`low`): the first pin-bump wave ended on 2026-09-30.'
475
+ : 'This snapshot predates the end of the first pin-bump wave, so an older engine is reported here and not counted in the health score.')
476
+ : `The fleet engine version could not be read${fleet?.error ? ` (${fleet.error})` : ''}, so no engine is judged behind.`)));
477
+ push(md.table(q(`SELECT repo, code, message FROM finding
478
+ WHERE snapshot_id=? AND code IN ('ATLAS_MAP_MISSING','ATLAS_CHECK_NOT_IN_CI')
479
+ ORDER BY code, repo`)));
480
+ push(md.h3('Engine pins in CI'));
481
+ push(md.table(q(`SELECT w.atlas_check pin, COUNT(DISTINCT w.repo) repos FROM workflow w
482
+ JOIN repo r ON r.snapshot_id=w.snapshot_id AND r.name=w.repo
483
+ WHERE w.snapshot_id=? AND r.is_archived=0 AND w.atlas_check <> ''
484
+ GROUP BY w.atlas_check ORDER BY repos DESC, pin`)));
485
+
486
+ // Atlas's own door checks: D1, a toolchain a job pins that a package it
487
+ // runs refuses; D2, a lockfile missing the build for the job's platform.
488
+ // Reported as Atlas reports them, as notices, and counted only among maps
489
+ // made by an engine that records them.
490
+ push(md.h3('Door findings'));
491
+ const doors = doorFindingRows(
492
+ q('SELECT repo, engine, error FROM atlas_map WHERE snapshot_id=?'),
493
+ q('SELECT repo, file, findings, unresolved_checks FROM atlas_door WHERE snapshot_id=?'));
494
+ if (!doors.measured) {
495
+ push(md.p(`_Not measured — no map is made by Atlas ${ATLAS_DOOR_CHECKS_SINCE} or later, which is the first engine that records door checks._`));
496
+ } else {
497
+ push(md.p(`${doors.measured} of ${doors.mapped} readable maps are made by Atlas ${ATLAS_DOOR_CHECKS_SINCE} or later and record door checks; ` +
498
+ `the other ${doors.mapped - doors.measured} are not measured. ` +
499
+ (doors.rows.length ? `${doors.rows.length} finding(s):` : 'No door finding in any of them.')));
500
+ if (doors.rows.length) push(md.table(doors.rows));
501
+ if (doors.unresolved.length) {
502
+ push(md.p('Checks Atlas could not judge (not findings):'));
503
+ push(md.table(doors.unresolved));
504
+ }
505
+ }
506
+ // One fact, two readers: the environment a job deploys to. Where a map
507
+ // records it, it is the one the deploy rules use; a disagreement with the
508
+ // workflow's own reading is shown, not settled here.
509
+ const env = one(`SELECT SUM(source='atlas') fromMap,
510
+ SUM(source='atlas' AND parsed IS NOT NULL AND parsed <> environment) disagree
511
+ FROM workflow_environment WHERE snapshot_id=?`);
512
+ if (env?.fromMap) {
513
+ push(md.p(`Job environments taken from the map: ${env.fromMap}; ` +
514
+ `${env.disagree ? `**${env.disagree} disagree with the workflow's own reading**` : 'all agree with the workflow\'s own reading'}.`));
515
+ }
516
+ }
517
+
518
+ // ----------------------------------------------------------- health -----
519
+ push(md.h2('9. Health leaderboard'));
520
+ push(md.p(
521
+ 'Score starts at 100; each finding deducts by severity (high 15, medium 6, low 2, info 0). ' +
522
+ 'It ranks attention, it is not a quality judgment — a busy repo accrues findings faster than a dormant one.'
523
+ ));
524
+ const health = healthScores(db, sid);
525
+ push(md.h3('Needs attention (worst 25)'));
526
+ push(md.table(health.slice(0, 25).map(h => ({ repo: h.name, score: h.score, findings: h.findings }))));
527
+ const clean = health.filter(h => h.findings === 0);
528
+ push(md.h3(`Clean — ${clean.length} repos with zero findings`));
529
+ push(md.p(clean.length ? clean.map(h => `\`${h.name}\``).join(', ') : '_None._'));
530
+
531
+ // -------------------------------------------------------- appendix ------
532
+ push(md.h2('Appendix — finding codes'));
533
+ push(md.table(q(`SELECT code, severity, category, COUNT(*) n,
534
+ MIN(message) example FROM finding WHERE snapshot_id=?
535
+ GROUP BY code ORDER BY category, code`)));
536
+
537
+ const errs = q('SELECT repo, stage, message FROM collect_error WHERE snapshot_id=?');
538
+ if (errs.length) {
539
+ push(md.h2('Collection errors'));
540
+ push(md.table(errs));
541
+ }
542
+
543
+ return out.join('');
544
+ }
545
+
546
+ /** Write reports/AUDIT-<date>.md for a loaded snapshot. Returns the path written. */
547
+ export function writeReport(db, sid) {
548
+ const snap = db.prepare('SELECT taken_at FROM snapshot WHERE id=?').get(sid);
549
+ if (!snap) throw userError('NO_SNAPSHOT', `snapshot ${sid} is not loaded`, '`hk snapshots` lists the ones that are');
550
+ const text = buildReport(db, sid);
551
+ mkdirSync(REPORTS_DIR, { recursive: true });
552
+ const file = join(REPORTS_DIR, `AUDIT-${snap.taken_at.slice(0, 10)}.md`);
553
+ writeFileSync(file, text);
554
+ return file;
555
+ }
556
+
557
+ const invokedDirectly = process.argv[1] &&
558
+ import.meta.url === new URL(`file://${process.argv[1].replace(/\\/g, '/')}`).href;
559
+
560
+ if (invokedDirectly) {
561
+ try {
562
+ const db = openDb();
563
+ const latest = db.prepare('SELECT MAX(id) AS id FROM snapshot').get().id;
564
+ if (!process.argv[2] && !latest) throw userError('NO_SNAPSHOT', 'no snapshot is loaded', 'run `hk refresh` first');
565
+ console.error(`[report] wrote ${writeReport(db, process.argv[2] ? Number(process.argv[2]) : latest)}`);
566
+ } catch (e) {
567
+ console.error(formatError(e));
568
+ process.exit(exitCodeFor(e));
569
+ }
570
+ }