@mcptoolshop/housekeeping 0.0.0 → 1.3.1

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.
@@ -0,0 +1,1360 @@
1
+ #!/usr/bin/env node
2
+ // Derive findings from a loaded snapshot.
3
+ //
4
+ // Every rule cites the org rule it enforces, so a finding is arguable against a
5
+ // written standard rather than against taste. Rules are pure functions of the DB:
6
+ // re-running analyze() on an old snapshot reproduces that day's findings exactly.
7
+ import { DatabaseSync } from 'node:sqlite';
8
+ import { openDb, DB_PATH, rangeAdmits039, parseSemver, compareSemver } from './load.mjs';
9
+ import { loadConfig } from './config.mjs';
10
+
11
+ // The one definition of "this failing run means the mainline is broken".
12
+ // cli.mjs `hk ci` and report.mjs both interpolate this so the CLI and the
13
+ // report can never disagree about what red means. A run counts only if its
14
+ // event is push-like AND its workflow can still be triggered by a push today:
15
+ // a workflow moved to `release:`-only keeps
16
+ // its last main-branch failure forever and would otherwise read as live
17
+ // breakage. A run whose workflow file is gone is left loud on purpose -- the
18
+ // row is missing rather than known-harmless.
19
+ export const RUN_IS_LIVE_MAINLINE_SIGNAL = `
20
+ wr.is_latest_on_default=1 AND wr.conclusion='failure'
21
+ AND wr.event NOT IN ('dynamic','schedule')
22
+ AND (NOT EXISTS (SELECT 1 FROM workflow w
23
+ WHERE w.snapshot_id=wr.snapshot_id AND w.repo=wr.repo AND w.path=wr.path)
24
+ OR EXISTS (SELECT 1 FROM workflow w
25
+ WHERE w.snapshot_id=wr.snapshot_id AND w.repo=wr.repo AND w.path=wr.path
26
+ AND (','||w.on_triggers||',' LIKE '%,push,%'
27
+ OR ','||w.on_triggers||',' LIKE '%,pull_request,%')))`;
28
+
29
+ // The one definition of "the default branch head's checks are red", for the
30
+ // same three readers: the analyzer decides (CI_FAILING, below) and the CLI,
31
+ // the MCP server and the report ask whether it did, never the raw rollup.
32
+ export const HEAD_CHECKS_RED = `EXISTS (SELECT 1 FROM finding f
33
+ WHERE f.snapshot_id=r.snapshot_id AND f.repo=r.name AND f.code='CI_FAILING')`;
34
+
35
+ // Check-run states that mean a check ran and did not pass. CANCELLED is not
36
+ // among them: `cancel-in-progress` cancels a superseded run as designed.
37
+ const FAILED_STATES = new Set(['FAILURE', 'ERROR', 'TIMED_OUT', 'STARTUP_FAILURE', 'ACTION_REQUIRED']);
38
+
39
+ /**
40
+ * Which checks on the default branch head actually fail. Pure.
41
+ *
42
+ * GitHub's rollup is FAILURE when any check run on the commit is, and it
43
+ * counts a CANCELLED run as a failure. The same commit pushed twice starts
44
+ * two runs; the concurrency rule cancels the first and the second passes,
45
+ * and the rollup still reads FAILURE forever (measured 2026-09-30 on two
46
+ * repos, every non-green check CANCELLED beside a SUCCESS of the same name).
47
+ * So a check fails here only when one of its runs is in a failed state and
48
+ * none of its runs on this commit succeeded. A check that was only ever
49
+ * cancelled is not a failure either: a failed run is not a cancelled one.
50
+ *
51
+ * `contexts` are check_context rows for the head ({ name, state }). Returns
52
+ * [{ name, states }] for the failing checks, or null when there are no rows
53
+ * -- not measured, so the caller falls back to the rollup.
54
+ */
55
+ export function failingHeadChecks(contexts) {
56
+ if (!contexts?.length) return null;
57
+ const byName = new Map();
58
+ for (const c of contexts) {
59
+ if (!byName.has(c.name)) byName.set(c.name, []);
60
+ byName.get(c.name).push(c.state ?? 'RUNNING');
61
+ }
62
+ return [...byName].filter(([, states]) => !states.includes('SUCCESS') && states.some(s => FAILED_STATES.has(s)))
63
+ .map(([name, states]) => ({ name, states }));
64
+ }
65
+ // A checker declared in package.json but invoked by no script is a gate that
66
+ // exists on paper and never runs. The case that earned the rule: a repo carried
67
+ // `typescript` with no typecheck script -- build is tsup and tests are vitest,
68
+ // both esbuild, which strips types without checking them -- and real type
69
+ // errors sat in src/ invisible to CI. Same family as a workflow whose token
70
+ // cannot do what the workflow asks.
71
+ //
72
+ // `invokers` lists every binary that counts as running the checker, so a repo
73
+ // using vue-tsc or `astro check` instead of bare tsc is not flagged.
74
+ // A root script that hands off to a workspace runner cannot be judged from the
75
+ // root manifest: the invocation lives in a sub-package this collector does not
76
+ // fetch. A root running `pnpm -r typecheck` and one running `turbo run typecheck`
77
+ // both DO typecheck and were both false positives on the first pass.
78
+ //
79
+ // Note these are NOT caught by is_monorepo, which keys off the npm `workspaces`
80
+ // field; pnpm-workspace.yaml and turbo.json roots look like plain packages.
81
+ export const WORKSPACE_RUNNERS = [
82
+ 'pnpm -r', 'pnpm --filter', 'pnpm --recursive', 'nx ', 'lerna ',
83
+ // bare `turbo build` is as much a delegation as `turbo run build`;
84
+ // matching only the latter let a bare-`turbo` root through as a false positive.
85
+ 'turbo ',
86
+ 'npm -w', 'npm --workspace', 'yarn workspaces',
87
+ ];
88
+
89
+ export const CHECKERS = [
90
+ { dep: 'typescript', invokers: ['tsc', 'vue-tsc', 'svelte-check', 'astro check', 'tsgo'] },
91
+ { dep: 'eslint', invokers: ['eslint'] },
92
+ { dep: '@biomejs/biome', invokers: ['biome'] },
93
+ { dep: 'oxlint', invokers: ['oxlint'] },
94
+ ];
95
+ export const SEVERITIES = ['critical', 'high', 'medium', 'low', 'info'];
96
+ const SEV_WEIGHT = { critical: 40, high: 15, medium: 6, low: 2, info: 0 };
97
+
98
+
99
+ const norm = v => (v == null ? null : String(v).trim().replace(/^v/i, ''));
100
+
101
+ /**
102
+ * Which declared checkers are invoked by no script?
103
+ * Returns [] when the repo delegates to a workspace runner, because the answer
104
+ * then lives in a manifest this collector does not fetch.
105
+ */
106
+ export function uninvokedCheckers(commands, depNames) {
107
+ if (commands.length === 0) return []; // no scripts == nothing to conclude
108
+ const all = commands.join(' ; ').toLowerCase();
109
+ if (WORKSPACE_RUNNERS.some(r => all.includes(r))) return [];
110
+ const deps = new Set(depNames);
111
+ return CHECKERS
112
+ .filter(c => deps.has(c.dep))
113
+ .filter(c => !c.invokers.some(bin => all.includes(bin.toLowerCase())));
114
+ }
115
+
116
+ /**
117
+ * Split required status-check contexts into the two defects they can be.
118
+ *
119
+ * `observedNames` is what CI emitted RECENTLY -- the caller time-bounds it,
120
+ * because check runs are frozen on their commit and a long-open PR keeps
121
+ * reporting a matrix cell that was deleted months ago.
122
+ *
123
+ * Absent is not the same as dead. Only two absences prove death:
124
+ * - a SIBLING cell of the same job reported, so the job demonstrably runs and
125
+ * this cell is gone (protection requires `build-and-test (20)`; main reports
126
+ * (22) and (24), because the matrix is [22, 24]);
127
+ * - no workflow declares a job by that name at all (protection still requires
128
+ * `test (<package>)` after those jobs were renamed `python`/`nodejs`).
129
+ *
130
+ * Anything else is a live job that merely did not run here, which is a
131
+ * different defect with a different repair -- see the two finding codes.
132
+ */
133
+ /**
134
+ * Strip the matrix cell GitHub appends to a job's check name:
135
+ * `test (20, ubuntu-latest)` -> `test`. The leading `\s*` eats the separating
136
+ * space, so no trim is needed.
137
+ */
138
+ export const jobBaseName = n => String(n ?? '').replace(/\s*\([^()]*\)\s*$/, '');
139
+
140
+ /**
141
+ * Widest EXPANDED matrix per (repo, workflow, job), from raw job rows.
142
+ *
143
+ * `rules/github-actions.md` caps a matrix at 6 jobs, and static parsing
144
+ * cannot check it: a 3x2 matrix is ONE declared job in the YAML. Only the cells
145
+ * that actually ran reveal the real width.
146
+ *
147
+ * Two traps, both found against live data, decide the shape here:
148
+ *
149
+ * 1. Taking the maximum across the whole window reports a matrix that has
150
+ * already been fixed. One repo ran a 9-cell `test` (node 20/22/24 x
151
+ * ubuntu/windows/macos) early in the window and 6 cells after its macOS
152
+ * removal landed. A window maximum files a stale finding against the repo
153
+ * that did the work, so width is read from the NEWEST run instead.
154
+ *
155
+ * 2. A skipped cell collapses. One `lint-and-test` job is 3 cells on
156
+ * push, but its scheduled canary gates the job off with
157
+ * `if: github.event_name != 'schedule'`, and GitHub then emits ONE
158
+ * unsuffixed skipped check rather than three. Reading width from the
159
+ * newest run alone therefore reports 1 for a 3-cell matrix. Skipped jobs
160
+ * are excluded, and "newest" is resolved per (workflow, job) rather than
161
+ * per run, so a job is measured from the last run in which it truly ran.
162
+ *
163
+ * Rows need `repo`, `workflow_name`, `name`, `conclusion`, `created_at`.
164
+ */
165
+ export function matrixWidths(rows = []) {
166
+ const best = new Map(); // key -> { at, names:Set, meta }
167
+ for (const r of rows) {
168
+ if ((r.conclusion ?? '') === 'skipped') continue;
169
+ const base = jobBaseName(r.name);
170
+ const key = `${r.repo}\0${r.workflow_name}\0${base}`;
171
+ const cur = best.get(key);
172
+ if (!cur || r.created_at > cur.at) {
173
+ best.set(key, {
174
+ at: r.created_at, names: new Set([r.name]),
175
+ repo: r.repo, workflow_name: r.workflow_name, base,
176
+ });
177
+ } else if (r.created_at === cur.at) {
178
+ cur.names.add(r.name);
179
+ }
180
+ }
181
+ return [...best.values()].map(v => ({
182
+ repo: v.repo, workflow_name: v.workflow_name, base: v.base,
183
+ cells: v.names.size, at: v.at,
184
+ }));
185
+ }
186
+
187
+ /**
188
+ * Does one deployment-branch-policy pattern match this branch? 1, 0 or null.
189
+ *
190
+ * GitHub documents these patterns as fnmatch patterns in which `*` does not
191
+ * cross a `/` (so `release/*` does not match `release/a/b`). Only literals,
192
+ * `*` and `?` are evaluated. `**` is read as `*` for a branch with no `/` in
193
+ * it, which is the one case where the two cannot differ; `[`, `{` and `\` are
194
+ * real syntax this does not evaluate, so they answer unknown.
195
+ */
196
+ export function deploymentPatternMatches(pattern, branch) {
197
+ if (typeof pattern !== 'string' || typeof branch !== 'string') return null;
198
+ if (/[[\]{}\\]/.test(pattern)) return null;
199
+ if (pattern.includes('**') && branch.includes('/')) return null;
200
+ const re = new RegExp('^' + pattern.replace(/\*\*/g, '*').split('').map(c =>
201
+ c === '*' ? '[^/]*' : c === '?' ? '[^/]' : c.replace(/[.+^${}()|[\]\\]/g, '\\$&')).join('') + '$');
202
+ return re.test(branch) ? 1 : 0;
203
+ }
204
+
205
+ /**
206
+ * Can `branch` deploy to this environment? 1, 0, or null when that cannot be
207
+ * told from what was collected. Pure.
208
+ *
209
+ * `env` is an `environment` row; `settings` the repo's `deploy_settings` row.
210
+ * all -- no restriction.
211
+ * custom -- the branch must match a branch-type pattern. Tag patterns
212
+ * admit tags, never a branch. Unreadable patterns, or a
213
+ * pattern this cannot evaluate when nothing else matched, are
214
+ * unknown.
215
+ * protected -- the branch must be protected. GitHub lets every branch deploy
216
+ * when no branch in the repo is protected, so that case admits.
217
+ * A ruleset on the branch is unknown: whether GitHub counts it
218
+ * as protection here was not verified.
219
+ */
220
+ export function environmentAdmitsBranch(env, settings, branch) {
221
+ if (!env || !branch) return null;
222
+ if (env.policy === 'all') return 1;
223
+ if (env.policy === 'custom') {
224
+ if (env.policies_readable !== 1) return null;
225
+ let unknown = false;
226
+ for (const line of (env.branch_policies ?? '').split('\n').filter(Boolean)) {
227
+ const at = line.indexOf(':');
228
+ const type = line.slice(0, at), pattern = line.slice(at + 1);
229
+ if (type === 'tag') continue;
230
+ const m = deploymentPatternMatches(pattern, branch);
231
+ if (m === 1) return 1;
232
+ if (m === null) unknown = true;
233
+ }
234
+ return unknown ? null : 0;
235
+ }
236
+ if (env.policy === 'protected') {
237
+ if (!settings || settings.default_branch !== branch) return null;
238
+ if (settings.default_protected === 1) return 1;
239
+ if (settings.default_protected !== 0) return null;
240
+ if (settings.any_branch_protected === 0) return 1;
241
+ if (settings.any_branch_protected !== 1) return null;
242
+ return settings.default_ruleset_rules === 0 ? 0 : null;
243
+ }
244
+ return null;
245
+ }
246
+
247
+ // ---- the committed Atlas map (rules/atlas-map.md) -------------------------
248
+ //
249
+ // rules/atlas-map.md, "Transition (ended 2026-09-30)": "made by the fleet's
250
+ // current engine version" was reported and not counted until the first
251
+ // pin-bump wave completed, and counts as a defect from then on. The wave to
252
+ // Atlas 1.24.0 ended when its last pull request merged, at the instant below
253
+ // (measured from GitHub: 78 of 78 merged). A snapshot taken before it keeps
254
+ // the finding at `info`, which the health score weighs at 0; one taken at or
255
+ // after it files `low`. The switch is a date, not a boolean, so that
256
+ // `npm run rebuild` still reproduces each earlier day's findings -- a flag
257
+ // flipped in source would rewrite every pre-wave snapshot. A missing map and
258
+ // a missing check counted from the start, and are unaffected by it.
259
+ export const ATLAS_ENGINE_BEHIND_COUNTS_SINCE = '2026-09-30T21:44:11Z';
260
+
261
+ /** Does an engine behind the fleet's count, for a snapshot taken at `takenAt`? Pure. */
262
+ export function engineBehindCounts(takenAt, since = ATLAS_ENGINE_BEHIND_COUNTS_SINCE) {
263
+ const t = Date.parse(takenAt ?? ''), s = Date.parse(since);
264
+ return Number.isFinite(t) && Number.isFinite(s) && t >= s;
265
+ }
266
+
267
+ const ATLAS_RULE = 'rules/atlas-map.md';
268
+ const isOlder = (v, fleet) => {
269
+ const a = parseSemver(v), b = parseSemver(fleet);
270
+ return a && b ? compareSemver(a, b) > 0 : null;
271
+ };
272
+
273
+ /**
274
+ * The map-health findings for one repository. Pure.
275
+ *
276
+ * `workflows` are workflow rows (path, on_triggers, atlas_check). `hasMap` is
277
+ * repo.has_atlas_map: NULL means the snapshot never looked, and yields nothing.
278
+ * `scripts` are package.json commands: a check reached through an npm script
279
+ * is not read from the workflow, so a script that carries one makes the
280
+ * no-check finding unknown rather than fired.
281
+ */
282
+ export function atlasMapFindings({
283
+ hasMap, workflows = [], scripts = [], mapEngine = null, fleetVersion = null,
284
+ engineCounts = false,
285
+ }) {
286
+ const out = [];
287
+ // "Runs workflows" means at least one file under .github/workflows/.
288
+ if (!workflows.length || (hasMap !== 0 && hasMap !== 1)) return out;
289
+ if (hasMap === 0) {
290
+ out.push({
291
+ code: 'ATLAS_MAP_MISSING', severity: 'medium',
292
+ message: `Runs ${workflows.length} workflow file(s) and keeps no committed Atlas map (atlas/structure.json is not on the default branch).`,
293
+ evidence: `${ATLAS_RULE}: "Every repository that runs workflows keeps a committed Atlas map on its default branch"`,
294
+ });
295
+ return out;
296
+ }
297
+
298
+ const pushLike = w => (w.on_triggers ?? '').split(',').some(t => t === 'push' || t === 'pull_request');
299
+ const checks = workflows.flatMap(w => (w.atlas_check ?? '').split('\n').filter(Boolean)
300
+ .map(pin => ({ path: w.path, pin, push: pushLike(w) })));
301
+ const pinnedOnPush = checks.filter(c => c.push && c.pin !== 'unpinned');
302
+ if (!pinnedOnPush.length) {
303
+ const viaScript = scripts.some(s => /@dogfood-lab\/atlas\b[^;&|]*\bcheck\b/.test(s ?? ''));
304
+ if (!viaScript) {
305
+ const why = !checks.length ? 'no workflow runs `atlas check`'
306
+ : checks.some(c => c.pin !== 'unpinned') ? `\`atlas check\` runs only in ${[...new Set(checks.filter(c => !c.push).map(c => c.path))].join(', ')}, which no push or pull request triggers`
307
+ : '`atlas check` runs with no pinned version';
308
+ out.push({
309
+ code: 'ATLAS_CHECK_NOT_IN_CI', severity: 'medium',
310
+ message: `A committed Atlas map, and ${why}: nothing tells a change that it moved the structure the map describes.`,
311
+ evidence: `${ATLAS_RULE}: "CI runs \`npx --yes @dogfood-lab/atlas@<version> check\` as a step of an existing push-triggered workflow"`
312
+ + (checks.length ? `; found ${checks.map(c => `${c.path}: ${c.pin}`).join(', ')}` : ''),
313
+ });
314
+ }
315
+ }
316
+
317
+ if (fleetVersion && parseSemver(fleetVersion)) {
318
+ const pins = [...new Set(checks.map(c => c.pin).filter(p => p !== 'unpinned'))];
319
+ const behindPins = pins.filter(p => isOlder(p, fleetVersion) === true);
320
+ const engineBehind = mapEngine ? isOlder(mapEngine, fleetVersion) === true : false;
321
+ if (behindPins.length || engineBehind) {
322
+ const parts = [];
323
+ if (behindPins.length) parts.push(`CI pins ${behindPins.join(', ')}`);
324
+ if (engineBehind) parts.push(`the map was made by ${mapEngine}`);
325
+ out.push({
326
+ code: 'ATLAS_ENGINE_BEHIND', severity: engineCounts ? 'low' : 'info',
327
+ message: `${parts.join(' and ')}; the fleet engine is ${fleetVersion}.`
328
+ + (engineCounts ? '' : ' Reported, not counted: this snapshot predates the end of the first fleet pin-bump wave.'),
329
+ evidence: `${ATLAS_RULE}: "made by the fleet's current engine version"; map engine ${mapEngine ?? 'not recorded (before 1.23.0)'}`,
330
+ });
331
+ }
332
+ }
333
+ return out;
334
+ }
335
+
336
+ // ---- where a red run broke, and whether the map saw it coming --------------
337
+ //
338
+ // A red default branch says THAT a workflow failed; the run's jobs say WHERE.
339
+ // The Atlas map names the same place by job key and step reference, so a
340
+ // failed step joins the door command it ran and any door finding (D1: the
341
+ // toolchain the job pins is one a package refuses; D2: a lockfile that lacks
342
+ // the job's platform) recorded against that exact step. A finding at a
343
+ // different step of the same door is not cited: it may be real, but it is
344
+ // not what broke this run.
345
+
346
+ // Built from the facts a map records; Atlas's own sentences are built the same
347
+ // way by its door-checks module, and the map holds facts only.
348
+ const DOOR_RULES = {
349
+ D1: f => (`${f.package ?? 'a package'} ${f.version ?? ''} requires Node ${f.requires ?? '(unstated)'}`
350
+ + `${f.refuses ? ' and refuses to start below it' : ''}, and the job pins ${(f.pins ?? []).join(', ') || 'an older one'}`
351
+ + `${f.engineStrict ? ` (${f.engineStrict} sets engine-strict, so the install itself refuses)` : ''}`).replace(/ {2,}/g, ' '),
352
+ 'D1-python': f => `${f.manifest ?? 'the project'} requires Python ${f.requires ?? '(unstated)'}, and the job pins ${(f.pins ?? []).join(', ') || 'an older one'}`,
353
+ D2: f => {
354
+ const pk = f.packages ?? [];
355
+ return `${f.tool ?? 'the install'} runs on ${(f.platforms ?? []).join(', ') || 'a platform'} from ${f.lock ?? 'a lockfile'}, which lacks that platform's build of ${pk.slice(0, 3).join(', ')}${pk.length > 3 ? ` and ${pk.length - 3} more` : ''}`;
356
+ },
357
+ };
358
+
359
+ /** One sentence for an Atlas door finding, or null for a rule this does not know. Pure. */
360
+ export function doorFindingSentence(f) {
361
+ return f && DOOR_RULES[f.rule] ? DOOR_RULES[f.rule](f) : null;
362
+ }
363
+
364
+ /** The first release whose maps record door findings; an older map is not measured. */
365
+ export const ATLAS_DOOR_CHECKS_SINCE = '1.24.0';
366
+
367
+ /**
368
+ * The door-finding rows of a snapshot, for the report. Pure over the given
369
+ * rows: `maps` are atlas_map rows, `doors` atlas_door rows (findings and
370
+ * unresolved_checks as stored JSON). Returns the measured population as well
371
+ * as the rows, because a map made before 1.24.0 records no door checks and
372
+ * its silence is "not measured", never "clean".
373
+ */
374
+ export function doorFindingRows(maps, doors) {
375
+ const since = parseSemver(ATLAS_DOOR_CHECKS_SINCE);
376
+ const measured = new Set(maps
377
+ // compareSemver sorts newest first: <= 0 means "this release or newer".
378
+ .filter(m => !m.error && m.engine && parseSemver(m.engine) && compareSemver(parseSemver(m.engine), since) <= 0)
379
+ .map(m => m.repo));
380
+ const rows = [], unresolved = new Map();
381
+ const parse = t => { try { return JSON.parse(t); } catch { return null; } };
382
+ for (const d of doors) {
383
+ if (!measured.has(d.repo)) continue;
384
+ for (const f of parse(d.findings) ?? []) {
385
+ rows.push({ repo: d.repo, file: d.file, rule: f.rule, where: `${f.job} / ${f.step}`,
386
+ says: doorFindingSentence(f) ?? `a ${f.rule} finding this version of housekeeping does not describe` });
387
+ }
388
+ for (const u of parse(d.unresolved_checks) ?? []) {
389
+ const k = `${u.rule}: ${u.why}`;
390
+ unresolved.set(k, (unresolved.get(k) ?? 0) + 1);
391
+ }
392
+ }
393
+ rows.sort((a, b) => a.rule.localeCompare(b.rule) || a.repo.localeCompare(b.repo) || a.file.localeCompare(b.file));
394
+ return {
395
+ measured: measured.size, mapped: maps.filter(m => !m.error).length, rows,
396
+ unresolved: [...unresolved].map(([why, count]) => ({ why, count })).sort((a, b) => b.count - a.count),
397
+ };
398
+ }
399
+
400
+ /**
401
+ * One sentence on where a red run broke, for the CI finding's message. Pure.
402
+ *
403
+ * `steps` are run_failed_step rows of the run; `commands` the
404
+ * atlas_door_command rows of its workflow file; `doorFindings` the parsed
405
+ * `findings` of that door, or null when the map records none. Empty string
406
+ * when the run was not read: silence, never a guess.
407
+ */
408
+ export function failedStepNote({ steps = [], commands = [], doorFindings = null }) {
409
+ if (!steps.length) return '';
410
+ const parts = [], predicted = [];
411
+ if (steps.length === 1 && steps[0].api_job == null) {
412
+ const why = steps[0].unresolved ?? 'no job failed';
413
+ return `${why[0].toUpperCase()}${why.slice(1)}.`;
414
+ }
415
+ for (const s of steps.slice(0, 3)) {
416
+ const where = s.api_step ? `job "${s.api_job}", step "${s.api_step}"` : `job "${s.api_job}", outside any step`;
417
+ if (s.step == null) {
418
+ const said = !s.unresolved || s.unresolved === 'the job failed outside any step';
419
+ parts.push(said ? where : `${where} (${s.unresolved})`);
420
+ continue;
421
+ }
422
+ const cmd = commands.find(c => c.job === s.job && c.step === s.step);
423
+ const runs = cmd?.programs ? ` (runs ${cmd.programs.split('\n').join(', ')}${cmd.directory ? ` in ${cmd.directory}/` : ''})` : '';
424
+ parts.push(`${where}${runs}`);
425
+ for (const f of doorFindings ?? []) {
426
+ if (f?.job === s.job && String(f?.step) === s.step && DOOR_RULES[f.rule]) predicted.push(`${f.rule}: ${DOOR_RULES[f.rule](f)}`);
427
+ }
428
+ }
429
+ const more = steps.length > 3 ? ` and ${steps.length - 3} more` : '';
430
+ return `It broke at ${parts.join('; ')}${more}.`
431
+ + (predicted.length ? ` The Atlas map flagged that step before it ran: ${predicted.join('; ')}.` : '');
432
+ }
433
+
434
+ export function classifyRequiredChecks({ required, observedNames, declaredJobs }) {
435
+ // GitHub appends the matrix cell in parentheses only when a job has no
436
+ // `name:`, so the text before it is the job id.
437
+ const base = c => c.replace(/\s*\([^()]*\)\s*$/, '');
438
+ const universe = new Set(observedNames);
439
+ const observedBases = new Set([...universe].map(base));
440
+ const jobs = new Set(declaredJobs);
441
+
442
+ const stale = required
443
+ .filter(c => !universe.has(c))
444
+ .filter(c => observedBases.has(base(c)) || !jobs.has(base(c)));
445
+ const staleSet = new Set(stale);
446
+ // Everything not proven dead. A stale context is absent from every PR by
447
+ // definition, so leaving it in `live` would bill one defect twice under two
448
+ // repairs that contradict each other.
449
+ return { stale, live: required.filter(c => !staleSet.has(c)) };
450
+ }
451
+
452
+ /**
453
+ * `metaRepos` are exempt from product-hygiene rules: repos that hold org
454
+ * defaults, assets or tooling rather than a shipped product. Which repos those
455
+ * are is a fact about the org, so it comes from the config file (config.mjs),
456
+ * not from this file. Tests pass it explicitly.
457
+ */
458
+ export function analyze(db, sid, { metaRepos = loadConfig().metaRepos } = {}) {
459
+ const META_REPOS = new Set(metaRepos);
460
+ db.prepare('DELETE FROM finding WHERE snapshot_id = ?').run(sid);
461
+ const add = db.prepare('INSERT INTO finding VALUES (?,?,?,?,?,?,?)');
462
+ const F = (repo, code, severity, category, message, evidence = null) =>
463
+ add.run(sid, repo, code, severity, category, message, evidence);
464
+
465
+ const repos = db.prepare('SELECT * FROM repo WHERE snapshot_id = ?').all(sid);
466
+ const q = (sql, ...p) => db.prepare(sql).all(sid, ...p);
467
+
468
+ const wfs = q('SELECT * FROM workflow WHERE snapshot_id = ?');
469
+ // "main is red" and "a PR branch is red" are different defects with different owners.
470
+ const runs = q('SELECT * FROM workflow_run WHERE snapshot_id = ? AND is_latest_on_default = 1');
471
+ const branchRuns = q(`SELECT * FROM workflow_run
472
+ WHERE snapshot_id = ? AND is_latest_for_workflow = 1 AND on_default_branch = 0`);
473
+ const allRuns = q('SELECT * FROM workflow_run WHERE snapshot_id = ?');
474
+ const fps = q('SELECT * FROM file_presence WHERE snapshot_id = ?');
475
+ const tags = q('SELECT * FROM tag WHERE snapshot_id = ?');
476
+ const prs = q('SELECT * FROM pull_request WHERE snapshot_id = ?');
477
+ const issues = q('SELECT * FROM issue WHERE snapshot_id = ?');
478
+ const pkgScripts = q('SELECT * FROM package_script WHERE snapshot_id = ?');
479
+ const pkgDeps = q('SELECT * FROM package_dep WHERE snapshot_id = ?');
480
+
481
+ const byRepo = (rows) => {
482
+ const m = new Map();
483
+ for (const r of rows) {
484
+ if (!m.has(r.repo)) m.set(r.repo, []);
485
+ m.get(r.repo).push(r);
486
+ }
487
+ return m;
488
+ };
489
+ const wfBy = byRepo(wfs), runBy = byRepo(runs), allRunBy = byRepo(allRuns);
490
+ const branchRunBy = byRepo(branchRuns);
491
+ const prBy = byRepo(prs), issueBy = byRepo(issues);
492
+ const scriptBy = byRepo(pkgScripts), depBy = byRepo(pkgDeps);
493
+ const fpBy = new Map(fps.map(f => [f.repo, f]));
494
+ const tagBy = byRepo(tags);
495
+
496
+ // Both empty when the snapshot predates the protection sweep, which the
497
+ // gate rules below read as "not measured" rather than "nothing is gated".
498
+ //
499
+ // KNOWN GAP: this reads CLASSIC branch protection only. Rulesets are a second,
500
+ // independent mechanism that can also require status checks, and they do not
501
+ // appear in `branchProtectionRule` -- they need
502
+ // GET /repos/{o}/{r}/rules/branches/{branch}. Where no ruleset declares a
503
+ // required status check, classic protection is the whole story; that was
504
+ // true of the org this was built against, and it is not true in general.
505
+ // The org-level ruleset list needs the admin:org scope to read at all, so a
506
+ // ruleset added later would be invisible here. Re-measure before trusting a
507
+ // clean result on a repo whose PRs are stuck for no visible reason.
508
+ //
509
+ // Rulesets also carry non-check gates this rule deliberately ignores, because
510
+ // they are policy rather than defect: a ruleset can set
511
+ // require_extra_approval_for_unattributed_changes, which holds every
512
+ // Dependabot PR for one approving review even with
513
+ // required_approving_review_count at 0 and all contexts green.
514
+ const protBy = new Map(
515
+ q('SELECT * FROM branch_protection WHERE snapshot_id = ?').map(x => [x.repo, x]),
516
+ );
517
+ const ctxBy = byRepo(q('SELECT * FROM check_context WHERE snapshot_id = ?'));
518
+
519
+ // What a deploy needs from the repo's settings. Empty for a snapshot taken
520
+ // before the pass, and a repo with no row was not asked: both are silence.
521
+ const deployBy = new Map(
522
+ q('SELECT * FROM deploy_settings WHERE snapshot_id = ?').map(x => [x.repo, x]),
523
+ );
524
+ const envBy = byRepo(q('SELECT * FROM environment WHERE snapshot_id = ?'));
525
+ const wfEnvBy = byRepo(q('SELECT * FROM workflow_environment WHERE snapshot_id = ?'));
526
+
527
+ // The committed maps and the engine version they are measured against. No
528
+ // fleet row (older snapshot, or the registry did not answer) leaves the
529
+ // engine check silent.
530
+ const atlasBy = new Map(
531
+ q('SELECT * FROM atlas_map WHERE snapshot_id = ?').map(x => [x.repo, x]),
532
+ );
533
+ const fleetEngine = db.prepare('SELECT version FROM atlas_fleet WHERE snapshot_id = ?').get(sid)?.version ?? null;
534
+ const engineCounts = engineBehindCounts(db.prepare('SELECT taken_at FROM snapshot WHERE id = ?').get(sid)?.taken_at);
535
+
536
+ // Where each red run broke, and the door it broke in. Keyed by run id and by
537
+ // repo + workflow path; empty for a snapshot before collector 1.4.0, when the
538
+ // CI findings simply say less.
539
+ const failedStepsBy = new Map();
540
+ for (const x of q('SELECT * FROM run_failed_step WHERE snapshot_id = ? ORDER BY rowid')) {
541
+ if (!failedStepsBy.has(x.run_id)) failedStepsBy.set(x.run_id, []);
542
+ failedStepsBy.get(x.run_id).push(x);
543
+ }
544
+ const doorKey = (repo, file) => `${repo}\n${file}`;
545
+ const doorCmdsBy = new Map();
546
+ for (const c of q('SELECT * FROM atlas_door_command WHERE snapshot_id = ?')) {
547
+ const k = doorKey(c.repo, c.file);
548
+ if (!doorCmdsBy.has(k)) doorCmdsBy.set(k, []);
549
+ doorCmdsBy.get(k).push(c);
550
+ }
551
+ const doorFindingsBy = new Map(
552
+ q('SELECT repo, file, findings FROM atlas_door WHERE snapshot_id = ? AND findings IS NOT NULL')
553
+ .map(d => { try { return [doorKey(d.repo, d.file), JSON.parse(d.findings)]; } catch { return [doorKey(d.repo, d.file), null]; } }),
554
+ );
555
+ const whereItBroke = (repo, x) => {
556
+ const note = failedStepNote({
557
+ steps: failedStepsBy.get(x.run_id) ?? [],
558
+ commands: doorCmdsBy.get(doorKey(repo, x.path)) ?? [],
559
+ doorFindings: doorFindingsBy.get(doorKey(repo, x.path)) ?? null,
560
+ });
561
+ return note ? ` ${note}` : '';
562
+ };
563
+
564
+ // Empty when the snapshot predates the security sweep. The rules below then
565
+ // see `undefined` and stay silent, rather than reporting a clean org.
566
+ const secByRepo = new Map(
567
+ q('SELECT * FROM repo_security WHERE snapshot_id = ?').map(x => [x.repo, x]),
568
+ );
569
+ // Committed lockfiles audited by the warehouse itself; empty when the
570
+ // snapshot predates that pass. A row with `error` set is UNKNOWN and is
571
+ // skipped below -- it must never read as zero exposure.
572
+ const lockBy = byRepo(q('SELECT * FROM lockfile WHERE snapshot_id = ?'));
573
+ const lockAdvBy = byRepo(q(`SELECT * FROM lockfile_advisory WHERE snapshot_id = ?
574
+ AND severity IN ('critical','high')`));
575
+
576
+ // ---- Actions cost --------------------------------------------------------
577
+ // Only the repos the invoice ranked highest get priced, so absence here means
578
+ // NOT MEASURED. Every rule below is gated on `costOK`, which requires both a
579
+ // row and a reconciliation that agreed with the invoice: a cost claim built
580
+ // on a partial window is the exact failure mode this warehouse keeps finding
581
+ // in its own rules.
582
+ const reconBy = new Map(
583
+ q('SELECT * FROM cost_reconciliation WHERE snapshot_id = ?').map(x => [x.repo, x]),
584
+ );
585
+ const costOK = repo => reconBy.get(repo)?.trustworthy === 1;
586
+ const costRollup = new Map(
587
+ q(`SELECT repo,
588
+ SUM(cost_usd) cost, SUM(billable_minutes) minutes,
589
+ SUM(CASE WHEN conclusion='failure' THEN cost_usd ELSE 0 END) fail_cost,
590
+ SUM(CASE WHEN conclusion='failure' THEN billable_minutes ELSE 0 END) fail_min,
591
+ SUM(CASE WHEN conclusion='failure' THEN 1 ELSE 0 END) fail_runs,
592
+ SUM(CASE WHEN conclusion='cancelled' THEN cost_usd ELSE 0 END) cancel_cost,
593
+ COUNT(*) runs, SUM(unpriced_minutes) unpriced_min
594
+ FROM run_cost WHERE snapshot_id = ? GROUP BY repo`).map(x => [x.repo, x]),
595
+ );
596
+ // Minutes AND dollars per runner class, so the static WF_MACOS_RUNNER /
597
+ // WF_WINDOWS_RUNNER findings can quote what the runner actually cost instead
598
+ // of the "~10x" estimate they carried before this data existed. Grouped on
599
+ // the stored `runner_class` rather than a LIKE over labels: `self-hosted`
600
+ // plus a `macos` label is free hardware, and matching the string would bill
601
+ // it at the 10x rate.
602
+ const classCost = new Map(
603
+ q(`SELECT repo,
604
+ SUM(CASE WHEN runner_class='MACOS' THEN minutes ELSE 0 END) macos_min,
605
+ SUM(CASE WHEN runner_class='MACOS' THEN cost_usd ELSE 0 END) macos_cost,
606
+ SUM(CASE WHEN runner_class='WINDOWS' THEN minutes ELSE 0 END) windows_min,
607
+ SUM(CASE WHEN runner_class='WINDOWS' THEN cost_usd ELSE 0 END) windows_cost,
608
+ SUM(CASE WHEN runner_class='UBUNTU' THEN minutes ELSE 0 END) ubuntu_min,
609
+ SUM(CASE WHEN runner_class='UBUNTU' THEN cost_usd ELSE 0 END) ubuntu_cost
610
+ FROM run_cost_job WHERE snapshot_id = ? GROUP BY repo`).map(x => [x.repo, x]),
611
+ );
612
+
613
+ // Widest expanded matrix per (workflow, job). The decision lives in
614
+ // matrixWidths() so its two traps are unit-tested; the query only feeds it.
615
+ const matrixBy = byRepo(matrixWidths(q(`
616
+ SELECT j.repo, c.workflow_name, j.name, j.conclusion, c.created_at
617
+ FROM run_cost_job j JOIN run_cost c
618
+ ON c.snapshot_id = j.snapshot_id AND c.run_id = j.run_id
619
+ WHERE j.snapshot_id = ?`)));
620
+
621
+ // Gross Actions spend per repo straight off the invoice. Distinct from
622
+ // costRollup: this is every run in the month whether or not we priced it.
623
+ const billedBy = new Map(
624
+ q(`SELECT repo, SUM(gross) gross, SUM(net) net FROM billing_usage
625
+ WHERE snapshot_id = ? AND product = 'actions' AND sku <> 'Actions storage'
626
+ AND repo IS NOT NULL GROUP BY repo`).map(x => [x.repo, x]),
627
+ );
628
+
629
+ for (const r of repos) {
630
+ const name = r.name;
631
+ if (r.is_archived) continue; // archived is a deliberate end-state
632
+ const isMeta = META_REPOS.has(name);
633
+ const fp = fpBy.get(name) ?? {};
634
+ const myWfs = wfBy.get(name) ?? [];
635
+ const myLatest = runBy.get(name) ?? [];
636
+ const myPrs = prBy.get(name) ?? [];
637
+ const myIssues = issueBy.get(name) ?? [];
638
+
639
+ // ---- lifecycle -------------------------------------------------------
640
+ if (r.is_empty) {
641
+ F(name, 'REPO_EMPTY', 'medium', 'lifecycle',
642
+ 'Repository has no commits.', `created ${r.created_at}`);
643
+ continue; // nothing else is measurable
644
+ }
645
+ if (r.default_branch && r.default_branch !== 'main') {
646
+ F(name, 'DEFAULT_BRANCH_NOT_MAIN', 'high', 'lifecycle',
647
+ `Default branch is "${r.default_branch}", not "main".`,
648
+ 'rules/repo-first.md: "Default branch is main"');
649
+ }
650
+ // ---- security --------------------------------------------------------
651
+ // Exposure is not derivable from anything else here: a repo can be green,
652
+ // released, documented and still carry critical advisories. Alerts can sit
653
+ // open for months in an org whose audit measures everything else, because
654
+ // no rule about CI, releases or hygiene ever looks at them.
655
+ //
656
+ // Two findings, not one, because they have different remedies. Alerts are
657
+ // the exposure. The disabled setting is the REASON the exposure persists:
658
+ // GitHub detects the advisory and never opens a PR, so the count only grows.
659
+ // Collapsing them would hide the cause behind the symptom.
660
+ const sec = secByRepo.get(name);
661
+
662
+ // GitHub's alert coverage is silently per-manifest (lockfile.mjs has the
663
+ // measurements). This is the false negative stated from one row: scanning
664
+ // is ON for the repo, the committed lockfile resolves to critical/high
665
+ // advisories, and GitHub reports zero alerts for that exact manifest. It
666
+ // is `high` regardless of the advisory's own severity because the defect
667
+ // is that nothing will ever open a fix PR -- the same reasoning as
668
+ // SECURITY_FIXES_DISABLED. Repos where scanning is off are already
669
+ // SECURITY_SCANNING_DISABLED; double-billing them would drown that.
670
+ //
671
+ // Two codes, because the remedies differ: a shipped dependency is fixed by
672
+ // a version bump users receive; a dev-only one (vitest, esbuild) is fixed
673
+ // by a toolchain bump nobody installs. Collapsing them makes a critical
674
+ // count read as shipped exposure when most of it is test runners. A repo
675
+ // of vendored examples can carry a hundred lockfiles; each is a real row,
676
+ // and the report groups by repo so it does not drown the rest.
677
+ for (const l of lockBy.get(name) ?? []) {
678
+ if (l.error != null) continue; // not measured
679
+ if (l.github_alerts > 0) continue; // GitHub sees this one
680
+ if (!sec || sec.vuln_alerts !== 1) continue; // that repo is SECURITY_SCANNING_DISABLED's
681
+ const total = (l.critical ?? 0) + (l.high ?? 0);
682
+ if (total === 0) continue;
683
+ const advs = (lockAdvBy.get(name) ?? []).filter(a => a.path === l.path);
684
+ const bySev = (list) => list.sort((a, b) => (a.severity === 'critical' ? -1 : 1) - (b.severity === 'critical' ? -1 : 1))[0];
685
+ const ev = (w) => w ? `${w.package}@${w.version} ${w.ghsa} (${w.severity}); npm bulk advisory API vs manifest_path` : l.path;
686
+ // A snapshot taken before dev/prod attribution has NULL here. Report the
687
+ // exposure without the split rather than guessing which side it falls
688
+ // on -- and say so, so the reader re-sweeps instead of trusting it.
689
+ if (l.prod_critical == null) {
690
+ F(name, 'SECURITY_LOCKFILE_UNREPORTED', 'high', 'security',
691
+ `GitHub reports 0 alerts for ${l.path} while its committed lockfile resolves to `
692
+ + `${l.critical} critical and ${l.high} high advisories (dev/prod not attributed in this snapshot -- re-sweep). `
693
+ + 'That zero is a false negative: Dependabot will never open a fix PR for what it has not counted.',
694
+ ev(bySev(advs)));
695
+ continue;
696
+ }
697
+ const prod = l.prod_critical + (l.prod_high ?? 0);
698
+ const devOnly = total - prod;
699
+ const pick = (wantDev) => bySev(advs.filter(a => (a.dev === 1) === wantDev));
700
+ if (prod > 0) {
701
+ F(name, 'SECURITY_LOCKFILE_UNREPORTED', 'high', 'security',
702
+ `GitHub reports 0 alerts for ${l.path} while its committed lockfile resolves to `
703
+ + `${l.prod_critical} critical and ${l.prod_high} high advisories on SHIPPED dependencies. `
704
+ + 'That zero is a false negative: Dependabot will never open a fix PR for what it has not counted.',
705
+ ev(pick(false)));
706
+ }
707
+ if (devOnly > 0) {
708
+ F(name, 'SECURITY_LOCKFILE_UNREPORTED_DEV', 'medium', 'security',
709
+ `GitHub reports 0 alerts for ${l.path} while ${devOnly} critical/high advisor${devOnly === 1 ? 'y' : 'ies'} `
710
+ + 'sit on dev-only packages (test runner, bundler). Not shipped, but the toolchain runs it on every CI job.',
711
+ ev(pick(true)));
712
+ }
713
+ }
714
+
715
+ if (sec && sec.alerts_total > 0) {
716
+ const worst = sec.critical > 0 ? 'critical' : sec.high > 0 ? 'high' : 'medium';
717
+ const parts = [];
718
+ if (sec.critical) parts.push(`${sec.critical} critical`);
719
+ if (sec.high) parts.push(`${sec.high} high`);
720
+ if (sec.medium) parts.push(`${sec.medium} medium`);
721
+ if (sec.low) parts.push(`${sec.low} low`);
722
+ F(name, 'SECURITY_ALERTS_OPEN', worst, 'security',
723
+ `${sec.alerts_total} open Dependabot alerts (${parts.join(', ')}); oldest ${sec.oldest_age_days} days.`,
724
+ `${sec.fixable} of ${sec.alerts_total} have a patched version available`);
725
+ }
726
+ // The quietest failure of all: a repo GitHub is not scanning reports zero
727
+ // alerts, which is indistinguishable from a clean repo in every other view.
728
+ // A sweep that counts alerts without asking whether the counter is switched
729
+ // on reports every such repo as healthy.
730
+ if (sec && sec.vuln_alerts === 0) {
731
+ F(name, 'SECURITY_SCANNING_DISABLED', 'high', 'security',
732
+ 'Dependabot vulnerability alerts are off, so this repo is not scanned and its zero-alert count means nothing.',
733
+ 'GitHub repos/<repo>/vulnerability-alerts: 404');
734
+ }
735
+ // Only a finding where there is something to fix. A quiet repo with the
736
+ // setting off is a preference; an exposed one is a leak that cannot close.
737
+ if (sec && sec.auto_security_fixes === 0 && sec.alerts_total > 0) {
738
+ F(name, 'SECURITY_FIXES_DISABLED', 'high', 'security',
739
+ `Dependabot security updates are off while ${sec.alerts_total} alerts are open, so no fix PR will ever be opened.`,
740
+ 'GitHub repos/<repo>/automated-security-fixes: enabled=false');
741
+ }
742
+
743
+ if (r.days_since_push != null && r.days_since_push > 180) {
744
+ F(name, 'REPO_DORMANT', 'low', 'lifecycle',
745
+ `No push in ${r.days_since_push} days and not archived.`,
746
+ `pushed_at ${r.pushed_at}`);
747
+ }
748
+
749
+ // ---- CI health -------------------------------------------------------
750
+ // The rollup is read through the head's own checks when the snapshot has
751
+ // them: a rollup red only with cancelled runs that a green run of the
752
+ // same check replaced is not a failing mainline (failingHeadChecks).
753
+ if (r.ci_rollup === 'FAILURE' || r.ci_rollup === 'ERROR') {
754
+ const head = (ctxBy.get(name) ?? []).filter(c => c.source === 'default');
755
+ const failing = failingHeadChecks(head);
756
+ const at = `${r.default_branch}@${(r.head_oid ?? '').slice(0, 7)}`;
757
+ // The collector reads the first 100 checks of a commit (most seen: 19).
758
+ // A red rollup whose failing check could lie past that page is read as
759
+ // the rollup says, never as clean.
760
+ if (failing === null || (failing.length === 0 && head.length >= 100)) {
761
+ F(name, 'CI_FAILING', 'high', 'ci', `Default-branch check rollup is ${r.ci_rollup}.`, at);
762
+ } else if (failing.length) {
763
+ F(name, 'CI_FAILING', 'high', 'ci',
764
+ `Default-branch checks failing: ${failing.map(c => `${c.name} (${[...new Set(c.states)].join(', ')})`).join('; ')}.`,
765
+ `${at}; rollup ${r.ci_rollup}`);
766
+ }
767
+ }
768
+ // A run only speaks for the mainline if its workflow could still be
769
+ // triggered by a push today. Match the run back to the workflow file it
770
+ // came from; if that file is gone or no longer takes push/pull_request,
771
+ // the run is history rather than a live signal.
772
+ const wfByPath = new Map(myWfs.map(w => [w.path, w]));
773
+ const wfTriggers = (x) => wfByPath.get(x.path)?.on_triggers ?? null;
774
+ const canStillRunOnPush = (x) => {
775
+ const t = wfTriggers(x);
776
+ if (t == null) return true; // unknown shape: keep the louder reading
777
+ return t.split(',').some(s => s === 'push' || s === 'pull_request');
778
+ };
779
+
780
+ const failing = myLatest.filter(x => x.conclusion === 'failure' || x.conclusion === 'timed_out');
781
+ for (const x of failing) {
782
+ // A run's event decides what its failure means. Only push-like events say
783
+ // "the default branch is broken"; cron and Dependabot say something else.
784
+ if (x.event === 'dynamic') {
785
+ F(name, 'CI_DEPENDABOT_FAILING', 'medium', 'backlog',
786
+ `Dependabot security-update run "${x.workflow_name}" failed.`, x.url);
787
+ } else if (x.event === 'schedule') {
788
+ F(name, 'CI_SCHEDULED_FAILING', 'medium', 'ci',
789
+ `Scheduled workflow "${x.workflow_name}" is failing (last run ${x.created_at?.slice(0, 10)}).`
790
+ + whereItBroke(name, x),
791
+ x.url);
792
+ } else if (!canStillRunOnPush(x)) {
793
+ // A workflow that no longer fires on push cannot refresh its
794
+ // default-branch history. When publish.yml moves to `release: published`
795
+ // only, its last main-branch run is frozen where it stood and will never
796
+ // update, while the workflow itself can be demonstrably healthy (still
797
+ // shipping releases). Reading that stale run as a broken mainline is the
798
+ // same error class as reading a deleted workflow's last failure as live.
799
+ F(name, 'CI_STALE_TRIGGER_FAILING', 'info', 'ci',
800
+ `"${x.workflow_name}" last failed on ${r.default_branch} (${x.created_at?.slice(0, 10)}) `
801
+ + `under triggers it no longer has; it now runs on ${wfTriggers(x) || 'other events'}.`
802
+ + whereItBroke(name, x),
803
+ x.url);
804
+ } else {
805
+ F(name, 'CI_RUN_FAILING', 'high', 'ci',
806
+ `Latest ${r.default_branch} run of "${x.workflow_name}" concluded ${x.conclusion} (${x.event}).`
807
+ + whereItBroke(name, x),
808
+ x.url);
809
+ }
810
+ }
811
+ // Red PR branches are backlog, not a broken default branch.
812
+ const branchFailing = (branchRunBy.get(name) ?? [])
813
+ .filter(x => x.conclusion === 'failure' || x.conclusion === 'timed_out');
814
+ for (const x of branchFailing) {
815
+ const bot = /^dependabot\//.test(x.branch ?? '');
816
+ F(name, bot ? 'CI_DEPENDABOT_FAILING' : 'CI_BRANCH_FAILING', 'medium', 'backlog',
817
+ `"${x.workflow_name}" failing on ${bot ? 'a Dependabot' : 'non-default'} branch "${x.branch}".`,
818
+ x.url);
819
+ }
820
+ // ---- branch-protection gates ----------------------------------------
821
+ // A required status check is the last thing between a PR and main. When the
822
+ // gate names a context nothing emits, the PR is BLOCKED forever -- and every
823
+ // other view still reads green: `gh run list` is all-success, the default
824
+ // branch rollup is SUCCESS, and only mergeStateStatus dissents. Every open
825
+ // PR in such a repo is unmergeable without --admin, and no rule that reads
826
+ // runs alone can see it.
827
+ //
828
+ // rules/shipcheck-product-standards.md, "CI verification (Non-Negotiable):
829
+ // After pushing, verify CI passes on the repo (`gh run list --limit 1`) ...
830
+ // Never leave a repo with failing CI." A gate whose verdict can never be
831
+ // obtained makes that unsatisfiable, and unlike a red run it is invisible to
832
+ // the exact command the rule names.
833
+ //
834
+ // Two codes, because the repair lands in different files and neither
835
+ // substitutes for the other:
836
+ // STALE -- no commit anywhere reports it. The job was renamed, its matrix
837
+ // cell was dropped, or its workflow left pull_request. Fix belongs in
838
+ // protection (drop the context) or in the matrix (restore the cell).
839
+ // GATED -- the default branch reports it, a PR head does not. The job is
840
+ // alive and green; that PR's file set just missed the workflow's paths
841
+ // filter (rules/github-actions.md requires those filters, so this is a
842
+ // cost rule and a merge gate pulling against each other). Fix belongs in
843
+ // the workflow trigger. Dropping the context would retire a live gate.
844
+ const prot = protBy.get(name);
845
+ const required = prot?.contexts ? prot.contexts.split('\n').filter(Boolean) : [];
846
+ const observed = ctxBy.get(name) ?? [];
847
+ // Check runs are frozen on the commit they ran against, so a stale PR head
848
+ // keeps reporting a matrix cell for as long as the PR stays open. Evidence
849
+ // of what CI emits TODAY has to be time-bounded or a dropped cell looks
850
+ // alive forever: a repo that deleted its Python 3.11 cell still had two
851
+ // earlier PRs carrying "Python 3.11 on ubuntu-latest" three months
852
+ // later. Same distinction
853
+ // CI_STALE_TRIGGER_FAILING draws -- an old run is history, not evidence.
854
+ // The default branch head is exempt: it is current by construction.
855
+ const FRESH_PR_DAYS = 30;
856
+ const freshPrs = new Set(
857
+ myPrs.filter(p => (p.stale_days ?? Infinity) <= FRESH_PR_DAYS).map(p => p.number),
858
+ );
859
+ const universe = new Set(
860
+ observed
861
+ .filter(c => c.source === 'default' || freshPrs.has(c.pr_number))
862
+ .map(c => c.name),
863
+ );
864
+ // An empty universe cannot tell a dead gate from a repo whose CI has simply
865
+ // not run yet, so it buys silence rather than a confident wrong finding.
866
+ if (required.length && universe.size) {
867
+ const { stale, live } = classifyRequiredChecks({
868
+ required,
869
+ observedNames: universe,
870
+ declaredJobs: myWfs.flatMap(w => (w.job_names ?? '').split('\n').filter(Boolean)),
871
+ });
872
+ if (stale.length) {
873
+ F(name, 'CI_REQUIRED_CHECK_STALE', 'high', 'ci',
874
+ `${stale.length} of ${required.length} required status checks are reported by no job: `
875
+ + `every PR is BLOCKED and cannot merge without --admin.`,
876
+ `never reported on ${r.default_branch} or any open PR head: `
877
+ + stale.map(c => `"${c}"`).join(', '));
878
+ }
879
+ if (live.length) {
880
+ const seenByPr = new Map();
881
+ for (const c of observed) {
882
+ if (c.source !== 'pr' || c.pr_number == null) continue;
883
+ if (!seenByPr.has(c.pr_number)) seenByPr.set(c.pr_number, new Set());
884
+ seenByPr.get(c.pr_number).add(c.name);
885
+ }
886
+ // Drafts are not trying to merge, and a PR opened moments ago may have
887
+ // checks still being created. The window is measured off `created_at`,
888
+ // NOT `updated_at`: updated_at moves for comments, labels and rebases,
889
+ // none of which say anything about whether CI ran, so a long-open PR
890
+ // that someone commented on today would read as "too fresh to judge".
891
+ // created_at never moves.
892
+ //
893
+ // This deliberately holds fire for a day. Dependabot PRs opened 90
894
+ // minutes before a snapshot can have zero checks and never get any
895
+ // (site-only changes against a paths-gated ci.yml), but at that age
896
+ // "no checks yet" and "no checks ever" are genuinely indistinguishable.
897
+ // They surface on the next run.
898
+ // Residual gap: a week-old PR force-pushed seconds before a snapshot.
899
+ // Rare, self-heals, and the cheap direction to be wrong in.
900
+ const settled = myPrs.filter(p => !p.is_draft && (p.age_days ?? 0) >= 1);
901
+ const blocked = settled
902
+ .map(p => ({ p, absent: live.filter(c => !(seenByPr.get(p.number)?.has(c))) }))
903
+ .filter(x => x.absent.length);
904
+ if (blocked.length) {
905
+ const worst = blocked.slice().sort((a, b) => b.absent.length - a.absent.length)[0];
906
+ F(name, 'CI_REQUIRED_CHECK_GATED', 'medium', 'ci',
907
+ `${blocked.length} of ${settled.length} open PRs are BLOCKED by required checks that `
908
+ + `never ran on them: the job still exists, the PR's files just missed its paths filter.`,
909
+ `#${worst.p.number} is missing ${worst.absent.map(c => `"${c}"`).join(', ')}`
910
+ + (blocked.length > 1 ? `; also #${blocked.filter(x => x !== worst).map(x => x.p.number).join(', #')}` : ''));
911
+ }
912
+ }
913
+ }
914
+
915
+ // ---- settings a deploy depends on -----------------------------------
916
+ // rules/shipcheck-product-standards.md, "CI verification (Non-Negotiable):
917
+ // ... Never leave a repo with failing CI." A deploy whose repository
918
+ // settings refuse it cannot succeed as configured, however correct the
919
+ // workflow is -- and the workflow file is all a code review ever sees. Two
920
+ // causes met in practice are exactly this: Pages switched off under a
921
+ // deploy-pages workflow, and an environment whose branch policy still
922
+ // named the old default branch.
923
+ //
924
+ // Both codes stay silent unless the setting was READ. No deploy_settings
925
+ // row (not asked, or an older snapshot), a status other than the ones the
926
+ // endpoint documents, a pattern or protection state this cannot evaluate:
927
+ // each is unknown, and unknown is never a finding.
928
+ const ds = deployBy.get(name);
929
+ const pagesWfs = myWfs.filter(w => (w.pages_deploy_jobs ?? '') !== '');
930
+ if (ds && ds.pages_enabled === 0 && pagesWfs.length) {
931
+ const doors = pagesWfs.map(w => `${w.path} (${w.pages_deploy_jobs.split('\n').join(', ')})`);
932
+ F(name, 'CI_PAGES_NOT_ENABLED', 'high', 'ci',
933
+ `${doors.length === 1 ? 'A workflow deploys' : `${doors.length} workflows deploy`} to GitHub Pages `
934
+ + 'with actions/deploy-pages, and Pages is not enabled for this repository: the deploy job cannot succeed.',
935
+ `GitHub repos/<repo>/pages: ${ds.pages_status}; ${doors.join('; ')}; `
936
+ + 'rules/shipcheck-product-standards.md: "Never leave a repo with failing CI"');
937
+ }
938
+ if (ds && ds.environments_status === 200 && r.default_branch) {
939
+ const envs = new Map((envBy.get(name) ?? []).map(e => [String(e.name).toLowerCase(), e]));
940
+ const jobsByEnv = new Map();
941
+ for (const j of wfEnvBy.get(name) ?? []) {
942
+ if (j.runs_on_default !== 1) continue;
943
+ const k = String(j.environment).toLowerCase();
944
+ if (!jobsByEnv.has(k)) jobsByEnv.set(k, []);
945
+ jobsByEnv.get(k).push(j);
946
+ }
947
+ for (const [k, jobs] of jobsByEnv) {
948
+ // Named by a job but absent from the repo: GitHub creates it on first
949
+ // use with no branch policy, so nothing refuses the deploy.
950
+ const env = envs.get(k);
951
+ if (!env) continue;
952
+ if (environmentAdmitsBranch(env, ds, r.default_branch) !== 0) continue;
953
+ const allows = env.policy === 'protected'
954
+ ? `protected branches only, and "${r.default_branch}" is not protected`
955
+ : `only ${(env.branch_policies ?? '').split('\n').filter(Boolean).join(', ') || 'no branch at all'}`;
956
+ F(name, 'CI_ENVIRONMENT_EXCLUDES_DEFAULT', 'high', 'ci',
957
+ `Environment "${env.name}" admits ${allows}, so ${jobs.length === 1 ? 'the job' : `the ${jobs.length} jobs`} `
958
+ + `that deploy${jobs.length === 1 ? 's' : ''} to it from "${r.default_branch}" cannot succeed.`,
959
+ `${jobs.map(j => `${j.path}: ${j.job}`).join('; ')}; GitHub environments/${env.name} `
960
+ + `deployment branch policy: ${env.policy}; `
961
+ + 'rules/shipcheck-product-standards.md: "Never leave a repo with failing CI"');
962
+ }
963
+ }
964
+
965
+ if (!isMeta && myWfs.length === 0) {
966
+ F(name, 'CI_NO_WORKFLOWS', 'medium', 'ci',
967
+ 'No GitHub Actions workflows: nothing verifies this repo on push.',
968
+ `${r.primary_language ?? 'unknown'} repo, ${r.disk_usage_kb}KB`);
969
+ }
970
+ if (myWfs.length > 0 && (allRunBy.get(name) ?? []).length === 0) {
971
+ F(name, 'CI_NEVER_RAN', 'medium', 'ci',
972
+ `${myWfs.length} workflow file(s) present but zero runs recorded.`,
973
+ myWfs.map(w => w.path).join(', '));
974
+ }
975
+
976
+ // ---- declared-but-never-invoked checkers -----------------------------
977
+ const myScripts = scriptBy.get(name) ?? [];
978
+ for (const c of uninvokedCheckers(
979
+ myScripts.map(x => x.command ?? ''),
980
+ (depBy.get(name) ?? []).map(d => d.name),
981
+ )) {
982
+ F(name, 'PKG_CHECKER_NEVER_RUN', 'low', 'hygiene',
983
+ `"${c.dep}" is declared but no npm script invokes it (looked for ${c.invokers.join(', ')}).`,
984
+ `scripts: ${myScripts.map(x => x.name).join(', ').slice(0, 160)}`);
985
+ }
986
+
987
+ // ---- the committed Atlas map (rules/atlas-map.md) --------------------
988
+ // The rule, in force since 2026-09-30: every repository that runs workflows keeps
989
+ // a committed map and runs `atlas check` in CI; archived repositories are
990
+ // exempt (skipped above). The decision is atlasMapFindings(), so both
991
+ // directions are unit-tested. A snapshot that did not look for the map
992
+ // (has_atlas_map NULL) yields nothing.
993
+ //
994
+ // Severity: a missing map and a missing check are `medium`, as
995
+ // CI_NO_WORKFLOWS is -- a verification the org requires, absent -- and as
996
+ // REPO_EMPTY is among the lifecycle findings. Neither breaks a build today.
997
+ // "Map older than the branch" is deliberately not a finding: a map needs
998
+ // regenerating only when the structure changes, and `atlas check` in CI is
999
+ // what enforces that.
1000
+ for (const f of atlasMapFindings({
1001
+ hasMap: r.has_atlas_map,
1002
+ workflows: myWfs,
1003
+ scripts: myScripts.map(x => x.command ?? ''),
1004
+ mapEngine: atlasBy.get(name)?.engine ?? null,
1005
+ fleetVersion: fleetEngine,
1006
+ engineCounts,
1007
+ })) {
1008
+ F(name, f.code, f.severity, 'atlas', f.message, f.evidence);
1009
+ }
1010
+
1011
+ // ---- Actions cost + shape rules (rules/github-actions.md) ------------
1012
+ // The cap counts PUSH-TRIGGERED workflows only (rules/github-actions.md).
1013
+ // Counting every file puts most repos out of compliance with a rule whose
1014
+ // intent is CI-minute cost -- and a release-only publish.yml costs nothing
1015
+ // until you cut a release. A whole-file cap can also be unsatisfiable: PyPI
1016
+ // Trusted Publishing authenticates the workflow FILENAME, so a project's
1017
+ // publish workflows cannot be merged into one file.
1018
+ const pushWfs = myWfs.filter(w => (w.on_triggers ?? '').split(',')
1019
+ .some(t => t === 'push' || t === 'pull_request'));
1020
+ if (pushWfs.length > 2) {
1021
+ F(name, 'WF_FILE_COUNT', 'medium', 'actions',
1022
+ `${pushWfs.length} push-triggered workflow files (rule caps at 2).`,
1023
+ pushWfs.map(w => w.path).join(', '));
1024
+ }
1025
+ // A dependency scanner that cannot fail its job renders exactly the same
1026
+ // green check as a clean one, so "this repo audits its dependencies" is not
1027
+ // decidable from the presence of an audit step. This is the SECURITY_
1028
+ // family's own lesson pointed at our side of the fence: absent renders as
1029
+ // fine, and a swallowed exit is absent wearing a check name.
1030
+ //
1031
+ // The two severities are different defects with the same repair. Where no
1032
+ // workflow has an enforcing scan, the repo has no dependency gate at all
1033
+ // while displaying one. Where an enforcing scan exists elsewhere, the
1034
+ // swallowed step is only misleading.
1035
+ const defangedWfs = myWfs.filter(w => (w.audit_steps_defanged ?? '').length > 0);
1036
+ if (defangedWfs.length) {
1037
+ const enforcingAnywhere = myWfs.reduce((n, w) => n + (w.audit_steps_enforcing ?? 0), 0);
1038
+ const steps = defangedWfs.flatMap(w =>
1039
+ w.audit_steps_defanged.split('\n').filter(Boolean).map(s => `${w.path}: ${s}`));
1040
+ const open = sec?.alerts_total ?? 0;
1041
+ F(name, 'SECURITY_AUDIT_GATE_DEFANGED', enforcingAnywhere ? 'low' : 'medium', 'security',
1042
+ enforcingAnywhere
1043
+ ? `${steps.length} dependency-scan step(s) cannot fail the job and do not say so in the step name, alongside ${enforcingAnywhere} that can. The green check is misleading, but the repo is still gated.`
1044
+ : `Every dependency-scan step here is swallowed (\`|| true\` or continue-on-error) and none says so in its name, so this repo has no dependency gate while displaying a green check named like one. ${open} alert(s) open today; the cost is the next one, not this one.`,
1045
+ steps.join(' | '));
1046
+ }
1047
+
1048
+ for (const w of myWfs) {
1049
+ const at = `${w.path}`;
1050
+ if (w.state === 'parse_error') {
1051
+ F(name, 'WF_PARSE_ERROR', 'medium', 'actions', `Workflow YAML failed to parse.`, at);
1052
+ continue;
1053
+ }
1054
+ // The multiplier in these two messages used to be an estimate. Where the
1055
+ // cost pass priced this repo, quote what the runner actually cost this
1056
+ // month instead -- "re-measure, never estimate" is the rule these
1057
+ // findings exist to enforce, and it applies to the finding too.
1058
+ const cc = costOK(name) ? classCost.get(name) : null;
1059
+ if (w.uses_macos) {
1060
+ const measured = cc && cc.macos_min > 0
1061
+ ? ` Measured this month: ${cc.macos_min} macOS min = $${cc.macos_cost.toFixed(2)}, `
1062
+ + `against ${cc.ubuntu_min} Linux min = $${cc.ubuntu_cost.toFixed(2)}.`
1063
+ : '';
1064
+ F(name, 'WF_MACOS_RUNNER', 'high', 'actions',
1065
+ `Uses a macOS runner (~10x Linux cost): ${w.runners}.${measured}`, at);
1066
+ }
1067
+ if (w.uses_windows) {
1068
+ const measured = cc && cc.windows_min > 0
1069
+ ? ` Measured this month: ${cc.windows_min} Windows min = $${cc.windows_cost.toFixed(2)}.`
1070
+ : '';
1071
+ F(name, 'WF_WINDOWS_RUNNER', 'low', 'actions',
1072
+ `Uses a Windows runner (~2x Linux cost): ${w.runners}.${measured}`, at);
1073
+ }
1074
+ const triggers = (w.on_triggers ?? '').split(',').filter(Boolean);
1075
+ const pushLike = triggers.some(t => t === 'push' || t === 'pull_request');
1076
+ if (pushLike && !w.has_paths_filter) {
1077
+ F(name, 'WF_NO_PATHS_FILTER', 'medium', 'actions',
1078
+ `push/pull_request trigger has no paths filter - fires on every change.`, at);
1079
+ }
1080
+ if (pushLike && !w.has_concurrency) {
1081
+ F(name, 'WF_NO_CONCURRENCY', 'medium', 'actions',
1082
+ 'No concurrency block: superseded runs are not cancelled.', at);
1083
+ }
1084
+ if (pushLike && !w.has_workflow_dispatch) {
1085
+ F(name, 'WF_NO_DISPATCH', 'low', 'actions',
1086
+ 'No workflow_dispatch fallback for manual runs.', at);
1087
+ }
1088
+ // Both of these are dead on arrival and only discover it on a schedule,
1089
+ // so CI stays green while the automation silently never works.
1090
+ if (w.edits_workflow_files) {
1091
+ F(name, 'WF_TOKEN_CANNOT_EDIT_WORKFLOWS', 'high', 'actions',
1092
+ 'Job edits a file under .github/workflows/ and then commits or pushes. '
1093
+ + 'GITHUB_TOKEN cannot modify workflow files under any permission setting '
1094
+ + '(there is no workflows: write scope), so this needs a PAT with `workflow` '
1095
+ + 'scope, or the data moved out of the workflow file.', at);
1096
+ }
1097
+ if (w.pr_create_default_token) {
1098
+ F(name, 'WF_PR_CREATE_DEFAULT_TOKEN', 'medium', 'actions',
1099
+ '`gh pr create` runs with the default GITHUB_TOKEN, which fails unless the '
1100
+ + 'org enables "Allow GitHub Actions to create and approve pull requests" '
1101
+ + '(off by default). Push a branch and open an issue instead, or supply a PAT.',
1102
+ at);
1103
+ }
1104
+ if (triggers.includes('schedule')) {
1105
+ F(name, 'WF_SCHEDULED', 'medium', 'actions',
1106
+ 'Scheduled (cron) workflow. rules/github-actions.md allows one only when it does what a push '
1107
+ + 'cannot, runs weekly or slower, is bounded, and opens a pull request; check it against those.',
1108
+ at);
1109
+ }
1110
+ }
1111
+
1112
+ // ---- Actions cost (rules/github-actions.md) ---------------------------
1113
+ // "CI minutes are finite. Every workflow must be paths-gated and
1114
+ // right-sized." Every rule here needs measured minutes, so each is gated on
1115
+ // a reconciliation that agreed with the invoice. No row, or a row that
1116
+ // diverged, means silence -- not a clean bill.
1117
+ const cost = costOK(name) ? costRollup.get(name) : null;
1118
+ if (cost && cost.cost > 0) {
1119
+ // Minutes spent on runs that FAILED. Cancelled runs are deliberately
1120
+ // excluded: `cancel-in-progress` exists to kill superseded runs, so
1121
+ // counting those as waste would file a finding against the concurrency
1122
+ // block working correctly. One measured repo looked like 82% waste until
1123
+ // the two were separated, at which point it was 12% failure and 67%
1124
+ // concurrency doing its job, while another was 41% genuine failure.
1125
+ // Collapsing them would have flagged the wrong repo.
1126
+ const failPct = cost.fail_cost / cost.cost;
1127
+ if (failPct >= 0.30 && cost.fail_min >= 120) {
1128
+ F(name, 'ACTIONS_COST_FAILURE_WASTE', 'medium', 'actions',
1129
+ `${Math.round(failPct * 100)}% of measured Actions compute went to runs that FAILED `
1130
+ + `(${cost.fail_runs} runs, ${cost.fail_min} billable min, $${cost.fail_cost.toFixed(2)} of `
1131
+ + `$${cost.cost.toFixed(2)}). Concurrency cancellations are excluded and cost a further `
1132
+ + `$${cost.cancel_cost.toFixed(2)}. A suite that fails this often is paying full price per attempt.`,
1133
+ `${cost.runs} runs priced`);
1134
+ }
1135
+
1136
+ // "Never exceed 6 total jobs in a matrix without explicit approval."
1137
+ // Static parsing cannot see this: a 3x2 matrix is one declared job. Only
1138
+ // the expanded cells say what actually ran.
1139
+ for (const m of matrixBy.get(name) ?? []) {
1140
+ if (m.cells > 6) {
1141
+ F(name, 'ACTIONS_COST_MATRIX_EXPANDED', 'medium', 'actions',
1142
+ `\`${m.base}\` in ${m.workflow_name} expands to ${m.cells} matrix cells, over the `
1143
+ + 'cap of 6. Declared job count does not show this - the cells were counted from '
1144
+ + `the run of ${m.at?.slice(0, 10)}.`,
1145
+ `${m.workflow_name}: ${m.base}`);
1146
+ }
1147
+ }
1148
+ }
1149
+
1150
+ // The only rule here about MONEY rather than compute. On a public repo
1151
+ // GitHub meters Actions at full price and discounts it to zero, so gross is
1152
+ // real compute but nothing is owed; on a private repo gross draws down the
1153
+ // included allowance and then bills. An org of mostly public repos can
1154
+ // meter a three-figure gross month and owe nothing -- so this is a tripwire
1155
+ // for a change in that shape, not a finding anyone is expected to see on an
1156
+ // ordinary day.
1157
+ const billed = billedBy.get(name);
1158
+ if (r.is_private && billed && billed.gross >= 5) {
1159
+ F(name, 'ACTIONS_COST_BILLED_PRIVATE', billed.net > 0 ? 'high' : 'medium', 'actions',
1160
+ `Private repo consuming $${billed.gross.toFixed(2)} of Actions compute this month `
1161
+ + `($${billed.net.toFixed(2)} billed after allowance). Unlike the public repos, this `
1162
+ + 'draws down the included minutes and is charged once they run out.',
1163
+ 'billing_usage');
1164
+ }
1165
+
1166
+ // ---- hygiene / ship gates --------------------------------------------
1167
+ // A README is for whoever opens the repo, so it is owed regardless of
1168
+ // visibility. The rest are PUBLIC-SURFACE obligations: a LICENSE grants
1169
+ // rights to people outside the org, SECURITY.md tells an outside reporter
1170
+ // where to go, topics and descriptions serve discovery. A private repo owes
1171
+ // none of them, and the ship gates they encode (A, D, E) are about shipped
1172
+ // products. Unscoped, these findings land almost entirely on private repos
1173
+ // -- NO_LICENSE fired on private repos only, so the rule had never once
1174
+ // found a real problem. Scoping them to public repos removes the noise
1175
+ // without weakening a single gate that was ever load-bearing.
1176
+ if (!isMeta) {
1177
+ if (!fp.readme) F(name, 'NO_README', 'high', 'hygiene', 'No README at repo root.', 'gate C');
1178
+
1179
+ if (!r.is_private) {
1180
+ if (!fp.license) F(name, 'NO_LICENSE', 'medium', 'hygiene', 'No LICENSE on a public repo.', 'gate D');
1181
+ if (!fp.security) F(name, 'NO_SECURITY', 'medium', 'hygiene', 'No SECURITY.md on a public repo.', 'gate A');
1182
+ if (!fp.changelog) F(name, 'NO_CHANGELOG', 'low', 'hygiene', 'No CHANGELOG on a public repo.', 'gate C');
1183
+ if (!r.description) F(name, 'NO_DESCRIPTION', 'medium', 'metadata', 'No GitHub description set.', 'gate E');
1184
+ if (!r.topic_count) F(name, 'NO_TOPICS', 'low', 'metadata', 'No GitHub topics set.', 'gate E');
1185
+ }
1186
+ }
1187
+
1188
+ // ---- handbook site (rules/shipcheck-product-standards.md gate E) ------
1189
+ // Starlight 0.39 removed the top-level autogenerate key on a labelled
1190
+ // sidebar group and added the nested `items: [{ autogenerate }]` form.
1191
+ // Neither shape is simply "the old one": each is the ONLY valid shape on
1192
+ // its side of that boundary. Verified in both directions on 2026-09-17 --
1193
+ // a site on Starlight 0.37.7 rejects the nested form outright
1194
+ // ("Expected type { label, link } | { label, items } | { label } | { slug }
1195
+ // | string"), and a site on 0.42.1 rejects the labelled one.
1196
+ //
1197
+ // So the defect is a DISAGREEMENT between the declared range and the shape,
1198
+ // never the shape alone. Reporting the shape by itself would flag every
1199
+ // repo whose config is correct for the version it pins, and bury the
1200
+ // handful that are actually broken -- the failure mode this file's header
1201
+ // exists to prevent.
1202
+ const shape = r.site_sidebar_shape;
1203
+ const slAdmits = rangeAdmits039(r.site_starlight_range);
1204
+ // Both halves must be measured. A null shape means no site/astro.config.mjs
1205
+ // (or a snapshot predating this collection); a null verdict means the range
1206
+ // did not parse. Either way, stay silent rather than guess.
1207
+ if (shape && slAdmits !== null && shape !== 'none' && shape !== 'explicit') {
1208
+ const range = r.site_starlight_range;
1209
+ if (shape === 'labeled-autogenerate' && slAdmits) {
1210
+ F(name, 'HANDBOOK_SIDEBAR_INCOMPATIBLE', 'high', 'ci',
1211
+ `site/astro.config.mjs uses a labelled autogenerate group, removed in Starlight 0.39; site declares ${range}. The site build fails.`,
1212
+ 'nest it as items: [{ autogenerate: { directory: ... } }]');
1213
+ } else if (shape === 'items-autogenerate' && !slAdmits) {
1214
+ // The mirror image, and the reason this rule is not one-directional:
1215
+ // applying the >=0.39 fix to a repo still pinned below it breaks a
1216
+ // site that builds today.
1217
+ F(name, 'HANDBOOK_SIDEBAR_INCOMPATIBLE', 'high', 'ci',
1218
+ `site/astro.config.mjs nests autogenerate under items, which Starlight only accepts from 0.39; site declares ${range}. The site build fails.`,
1219
+ 'either bump @astrojs/starlight to >=0.39 or move autogenerate back onto the labelled group');
1220
+ } else if (shape === 'labeled-autogenerate' && !slAdmits) {
1221
+ // Correct today, and deliberately NOT reported as broken. A caret on a
1222
+ // 0.x version pins the minor (^0.37.6 never resolves to 0.39), so this
1223
+ // only detonates when something widens the range -- which is exactly
1224
+ // how two sites went red on the same day.
1225
+ F(name, 'HANDBOOK_SIDEBAR_BUMP_RISK', 'low', 'ci',
1226
+ `site/astro.config.mjs uses a labelled autogenerate group, valid for the declared ${range} but removed in Starlight 0.39.`,
1227
+ 'the bump and the sidebar rewrite must land in the same PR');
1228
+ }
1229
+ }
1230
+
1231
+ // ---- versioning (rules/shipcheck-product-standards.md) ----------------
1232
+ const pv = norm(r.pkg_version), npmv = norm(r.npm_latest_version);
1233
+ // Compare against the newest semver TAG, not the newest GitHub Release.
1234
+ // They are different objects, and collapsing them reported "version drift"
1235
+ // for repos that were correctly tagged and simply never had a Release
1236
+ // published.
1237
+ const tag = norm(r.latest_tag);
1238
+ const myTags = tagBy.get(name) ?? [];
1239
+ const unreleased = myTags.filter(t => t.is_semver && !t.has_release);
1240
+
1241
+ if (r.is_monorepo) {
1242
+ // A workspace root's version is not the shipped artifact's version, so
1243
+ // comparing it to tags or npm produces noise rather than signal.
1244
+ F(name, 'MONOREPO_ROOT', 'info', 'version',
1245
+ `Workspace root package "${r.pkg_name}" v${pv}; per-package versions are not audited yet.`,
1246
+ `latest tag ${r.latest_tag ?? 'none'}`);
1247
+ } else {
1248
+ if (pv && /^0\./.test(pv)) {
1249
+ F(name, 'PRE_1_0', 'medium', 'version',
1250
+ `package.json is v${pv}; standard requires promotion to 1.0.0 before shipping.`,
1251
+ r.pkg_name ?? '');
1252
+ }
1253
+ if (pv && tag && pv !== tag) {
1254
+ F(name, 'VERSION_TAG_DRIFT', 'high', 'version',
1255
+ `package.json v${pv} != latest release tag v${tag}.`,
1256
+ `${r.pkg_name ?? name} released ${r.latest_release_at ?? '?'}`);
1257
+ }
1258
+ if (pv && npmv && pv !== npmv) {
1259
+ F(name, 'NPM_DRIFT', 'high', 'version',
1260
+ `package.json v${pv} != npm latest v${npmv}.`, r.pkg_name ?? '');
1261
+ }
1262
+ if (pv && !tag && r.pkg_name) {
1263
+ F(name, 'NEVER_TAGGED', 'medium', 'version',
1264
+ `Package "${r.pkg_name}" is at v${pv} but the repo has no semver tag at all.`,
1265
+ `${r.tag_count ?? 0} tag(s) total`);
1266
+ }
1267
+ }
1268
+
1269
+ // A tag with no Release is a release that was cut and never published.
1270
+ // Independent of monorepo status, and not the same defect as version drift.
1271
+ // Two very different cases: if the CURRENT version was never announced,
1272
+ // that is live and actionable; if only superseded tags lack Releases, that
1273
+ // is historical backfill and must not crowd out real findings.
1274
+ if (unreleased.length) {
1275
+ const currentUnreleased = tag && unreleased.some(t => norm(t.name) === tag);
1276
+ if (currentUnreleased) {
1277
+ F(name, 'RELEASE_MISSING', 'medium', 'version',
1278
+ `Newest tag ${r.latest_tag} has no GitHub Release - the current version was never published.`,
1279
+ unreleased.slice(0, 5).map(t => t.name).join(', '));
1280
+ } else {
1281
+ F(name, 'RELEASE_BACKFILL', 'info', 'version',
1282
+ `${unreleased.length} superseded tag(s) have no GitHub Release; the newest tag does.`,
1283
+ unreleased.slice(0, 5).map(t => t.name).join(', '));
1284
+ }
1285
+ }
1286
+ if (r.npm_status === 'unpublished' && r.pkg_name) {
1287
+ F(name, 'NPM_UNPUBLISHED', 'info', 'version',
1288
+ `Public package "${r.pkg_name}" is not on npm.`, `local v${pv ?? '?'}`);
1289
+ }
1290
+ if (r.npm_status === 'published' && r.translation_count === 0) {
1291
+ F(name, 'NO_TRANSLATIONS', 'info', 'docs',
1292
+ 'Published package with no translated READMEs (full-treatment expects 7).',
1293
+ r.pkg_name ?? '');
1294
+ }
1295
+ if (!isMeta && r.release_count === 0 && r.disk_usage_kb > 200) {
1296
+ F(name, 'NO_RELEASES', 'info', 'version', 'Substantial repo with zero releases.',
1297
+ `${r.disk_usage_kb}KB`);
1298
+ }
1299
+
1300
+ // ---- backlog ---------------------------------------------------------
1301
+ if (r.open_prs > 10) {
1302
+ F(name, 'PR_PILEUP', 'high', 'backlog',
1303
+ `${r.open_prs} open pull requests.`,
1304
+ `oldest ${Math.max(0, ...myPrs.map(p => p.age_days ?? 0))}d`);
1305
+ } else if (r.open_prs > 4) {
1306
+ F(name, 'PR_PILEUP', 'medium', 'backlog', `${r.open_prs} open pull requests.`, null);
1307
+ }
1308
+ const stalePrs = myPrs.filter(p => (p.age_days ?? 0) > 30);
1309
+ if (stalePrs.length) {
1310
+ F(name, 'PR_STALE', 'medium', 'backlog',
1311
+ `${stalePrs.length} pull request(s) open longer than 30 days.`,
1312
+ stalePrs.slice(0, 5).map(p => `#${p.number} (${p.age_days}d)`).join(', '));
1313
+ }
1314
+ const conflicted = myPrs.filter(p => p.mergeable === 'CONFLICTING');
1315
+ if (conflicted.length) {
1316
+ F(name, 'PR_CONFLICTED', 'medium', 'backlog',
1317
+ `${conflicted.length} pull request(s) have merge conflicts.`,
1318
+ conflicted.slice(0, 5).map(p => `#${p.number}`).join(', '));
1319
+ }
1320
+ if (r.open_issues > 10) {
1321
+ F(name, 'ISSUE_PILEUP', 'medium', 'backlog', `${r.open_issues} open issues.`, null);
1322
+ }
1323
+ const staleIssues = myIssues.filter(i => (i.stale_days ?? 0) > 90);
1324
+ if (staleIssues.length) {
1325
+ F(name, 'ISSUE_STALE', 'low', 'backlog',
1326
+ `${staleIssues.length} issue(s) untouched for 90+ days.`,
1327
+ staleIssues.slice(0, 5).map(i => `#${i.number} (${i.stale_days}d)`).join(', '));
1328
+ }
1329
+ }
1330
+
1331
+ return db.prepare('SELECT COUNT(*) AS n FROM finding WHERE snapshot_id = ?').get(sid).n;
1332
+ }
1333
+
1334
+ /** 100 = clean. Deducts per finding by severity, floored at 0. */
1335
+ export function healthScores(db, sid) {
1336
+ const rows = db.prepare(`
1337
+ SELECT r.name,
1338
+ COALESCE(SUM(CASE f.severity WHEN 'critical' THEN ${SEV_WEIGHT.critical}
1339
+ WHEN 'high' THEN ${SEV_WEIGHT.high}
1340
+ WHEN 'medium' THEN ${SEV_WEIGHT.medium}
1341
+ WHEN 'low' THEN ${SEV_WEIGHT.low}
1342
+ ELSE 0 END), 0) AS penalty,
1343
+ COUNT(f.code) AS findings
1344
+ FROM repo r LEFT JOIN finding f ON f.snapshot_id = r.snapshot_id AND f.repo = r.name
1345
+ WHERE r.snapshot_id = ? AND r.is_archived = 0
1346
+ GROUP BY r.name ORDER BY penalty DESC, r.name`).all(sid);
1347
+ return rows.map(r => ({ ...r, score: Math.max(0, 100 - r.penalty) }));
1348
+ }
1349
+
1350
+ const invokedDirectly = process.argv[1] &&
1351
+ import.meta.url === new URL(`file://${process.argv[1].replace(/\\/g, '/')}`).href;
1352
+
1353
+ if (invokedDirectly) {
1354
+ const db = openDb();
1355
+ const sid = process.argv[2]
1356
+ ? Number(process.argv[2])
1357
+ : db.prepare('SELECT MAX(id) AS id FROM snapshot').get().id;
1358
+ const n = analyze(db, sid);
1359
+ console.error(`[analyze] snapshot ${sid}: ${n} findings`);
1360
+ }