@mcptoolshop/housekeeping 1.3.1 → 1.5.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/cli.mjs CHANGED
@@ -9,7 +9,7 @@ import { openDb, loadSnapshot, latestSnapshotFile, DB_PATH } from './load.mjs';
9
9
  import { analyze, healthScores, RUN_IS_LIVE_MAINLINE_SIGNAL, HEAD_CHECKS_RED } from './analyze.mjs';
10
10
  import { collect, DRILL_BUDGET } from './collect.mjs';
11
11
  import { preflight } from './gh.mjs';
12
- import { resolveOrg } from './config.mjs';
12
+ import { resolveOrg, loadConfig } from './config.mjs';
13
13
  import { writeReport } from './report.mjs';
14
14
  import { EXIT, HkError, userError, exitCodeFor, formatError, readOnlyQuery, sweepGaps } from './errors.mjs';
15
15
  import { info, isDebug, levelFromFlags, parseLevel, setLevel } from './log.mjs';
@@ -141,6 +141,35 @@ const REPORTS = {
141
141
  disk_usage_kb kb, days_since_push push_age, open_issues iss, open_prs prs,
142
142
  release_count rels, ci_rollup, workflow_count wfs
143
143
  FROM repo WHERE snapshot_id=? ORDER BY name`).all(sid), { max: 200 }),
144
+ // Branches by repo: what a cleanup would delete (merged), what it must not
145
+ // (merged_moved: commits after the merge), and what has no PR at all.
146
+ branches: (sid, repo) => {
147
+ if (!db.prepare('SELECT 1 FROM repo_branches WHERE snapshot_id=? LIMIT 1').get(sid)) {
148
+ return console.log('no branch data in this snapshot (collector 1.6.0 or later) - run `hk refresh`');
149
+ }
150
+ if (repo) {
151
+ return table(db.prepare(`SELECT name, state, age_days age, pr_number pr, SUBSTR(head_oid,1,7) head
152
+ FROM branch WHERE snapshot_id=? AND repo=? ORDER BY state, age_days DESC`).all(sid, repo), { max: 500 });
153
+ }
154
+ table(db.prepare(`SELECT s.repo, CASE s.delete_on_merge WHEN 1 THEN 'on' WHEN 0 THEN 'off' END auto_delete,
155
+ s.branch_count branches,
156
+ SUM(b.state='merged') merged, SUM(b.state='merged_moved') moved,
157
+ SUM(b.state='closed') closed, SUM(b.state='no_pr' AND b.age_days>=${STALE_BRANCH_DAYS}) no_pr_stale,
158
+ SUM(b.state IN ('open_pr','open_base')) open
159
+ FROM repo_branches s LEFT JOIN branch b ON b.snapshot_id=s.snapshot_id AND b.repo=s.repo
160
+ WHERE s.snapshot_id=? GROUP BY s.repo HAVING s.branch_count > 1
161
+ ORDER BY merged + moved + closed + no_pr_stale DESC, s.repo`).all(sid), { max: 200 });
162
+ },
163
+ watch: (sid, ...names) => {
164
+ const repos = names.length ? names : loadConfig().watch;
165
+ if (!repos.length) {
166
+ throw userError('USAGE', 'no repositories to watch',
167
+ 'name them (hk watch <repo> ...) or set "watch" in housekeeping.config.json');
168
+ }
169
+ const s = db.prepare('SELECT taken_at, collector_version FROM snapshot WHERE id=?').get(sid);
170
+ console.log(`watch: ${repos.join(', ')}\nsnapshot ${sid}, taken ${s.taken_at} (collector ${s.collector_version})`);
171
+ for (const name of repos) watchRepo(sid, name);
172
+ },
144
173
  repo: (sid, name) => {
145
174
  if (!name) throw userError('USAGE', '`hk repo` needs a repository name', 'hk repo <name>');
146
175
  table(db.prepare('SELECT * FROM repo WHERE snapshot_id=? AND name=?').all(sid, name));
@@ -155,6 +184,56 @@ const REPORTS = {
155
184
 
156
185
  function latestOr(sid) { return sid; }
157
186
 
187
+ // A branch with no PR and no commit for this long is listed as stale. The
188
+ // number is the Publisher's sweep brief of 2026-10-11, not an org rule.
189
+ const STALE_BRANCH_DAYS = 7;
190
+
191
+ // One watched repo: open PRs by idle time, open alerts, and the branches a
192
+ // cleanup would touch. Every section says when it was not measured, because
193
+ // an empty list from an unscanned repo reads exactly like a clean one.
194
+ function watchRepo(sid, name) {
195
+ const r = db.prepare('SELECT is_archived FROM repo WHERE snapshot_id=? AND name=?').get(sid, name);
196
+ console.log(`\n== ${name}`);
197
+ if (!r) return console.log('not in this snapshot (misspelt, or not in the org)');
198
+ if (r.is_archived) console.log('archived');
199
+
200
+ const prs = db.prepare(`SELECT number, stale_days idle, age_days age, is_draft draft, mergeable,
201
+ base_ref base, head_ref head, SUBSTR(title,1,60) title
202
+ FROM pull_request WHERE snapshot_id=? AND repo=? ORDER BY stale_days DESC, number`).all(sid, name);
203
+ console.log(`\n-- open PRs (${prs.length}), longest idle first --`);
204
+ table(prs);
205
+
206
+ const sec = db.prepare('SELECT vuln_alerts FROM repo_security WHERE snapshot_id=? AND repo=?').get(sid, name);
207
+ console.log('\n-- open Dependabot alerts --');
208
+ if (!sec) console.log('not measured (the security pass did not read this repo)');
209
+ else if (sec.vuln_alerts === 0) console.log('NOT SCANNED: vulnerability alerts are off, so no alert can appear');
210
+ else {
211
+ if (sec.vuln_alerts === null) console.log('(whether alerts are on could not be read)');
212
+ table(db.prepare(`SELECT number, severity, package, manifest, fixed_version fix, age_days age, ghsa
213
+ FROM security_alert WHERE snapshot_id=? AND repo=?
214
+ ORDER BY CASE severity WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 ELSE 3 END, number`)
215
+ .all(sid, name));
216
+ }
217
+
218
+ const set = db.prepare('SELECT delete_on_merge, branch_count FROM repo_branches WHERE snapshot_id=? AND repo=?').get(sid, name);
219
+ console.log('\n-- branches --');
220
+ if (!set) return console.log('not measured (no branch data for this repo in this snapshot)');
221
+ const auto = set.delete_on_merge === 1 ? 'on' : set.delete_on_merge === 0 ? 'off' : 'unknown';
222
+ const rows = db.prepare(`SELECT name, state, age_days age, pr_number pr, SUBSTR(head_oid,1,7) head
223
+ FROM branch WHERE snapshot_id=? AND repo=? ORDER BY name`).all(sid, name);
224
+ const of = st => rows.filter(b => b.state === st);
225
+ const stale = of('no_pr').filter(b => b.age_days >= STALE_BRANCH_DAYS);
226
+ console.log(`${set.branch_count} branches; auto-delete head branches: ${auto}; ` +
227
+ `open ${of('open_pr').length + of('open_base').length}, merged ${of('merged').length}, ` +
228
+ `moved after merge ${of('merged_moved').length}, closed ${of('closed').length}, ` +
229
+ `no PR ${of('no_pr').length} (${stale.length} idle ${STALE_BRANCH_DAYS}+ days)`);
230
+ const list = (label, bs) => { if (bs.length) { console.log(`\n${label}:`); table(bs); } };
231
+ list('merged, tip = merged head (deletable; restorable from the PR page)', of('merged'));
232
+ list('MOVED after merge (commits exist only here; do not delete unconfirmed)', of('merged_moved'));
233
+ list('closed without merging', of('closed'));
234
+ list(`no PR, idle ${STALE_BRANCH_DAYS}+ days`, stale);
235
+ }
236
+
158
237
  // ---- commands ---------------------------------------------------------------
159
238
  // One table drives both the dispatch and `hk help`, so the help text cannot
160
239
  // describe a command that does not exist or omit one that does.
@@ -178,7 +257,9 @@ async function refresh(args) {
178
257
  // silently.
179
258
  const pre = await preflight({
180
259
  rest: 300 + Math.min(DRILL_BUDGET, 900),
181
- graphql: 300,
260
+ // ~195 for the repo pages and workflow files, ~1 per repo for the
261
+ // branch pass (collector 1.6.0, measured 2026-10-11), with headroom.
262
+ graphql: 450,
182
263
  });
183
264
  if (!pre.ok && !force) {
184
265
  if (pre.limit === 'secondary') {
@@ -265,6 +346,8 @@ const COMMANDS = {
265
346
  versions: ['', 'package.json against git tag against npm', viaReport('versions')],
266
347
  prs: ['', 'every open pull request by age', viaReport('prs')],
267
348
  issues: ['', 'every open issue by staleness', viaReport('issues')],
349
+ branches: ['[repo]', 'branches by state: merged, moved after merge, closed, no PR', viaReport('branches')],
350
+ watch: ['[repo ...]', 'open PRs, alerts and branches for the watch list in the config', viaReport('watch')],
268
351
  snapshots: ['', 'the snapshots loaded into the database', () => {
269
352
  db = openDb();
270
353
  table(db.prepare('SELECT id, taken_at, org, repo_count, duration_ms FROM snapshot ORDER BY id').all());
package/src/collect.mjs CHANGED
@@ -14,11 +14,12 @@ import { priceRun, ratesFromUsage, withFallback, runnerClass } from './cost.mjs'
14
14
  import { pagesDeployJobs, jobEnvironment } from './workflow-risks.mjs';
15
15
  import { DATA_DIR } from './paths.mjs';
16
16
 
17
- export const COLLECTOR_VERSION = '1.4.0';
17
+ export const COLLECTOR_VERSION = '1.6.0';
18
18
 
19
19
  const REPO_FIELDS = `
20
20
  id name description url homepageUrl
21
21
  isPrivate isArchived isFork isTemplate isEmpty
22
+ parent { owner { login } }
22
23
  createdAt updatedAt pushedAt diskUsage
23
24
  stargazerCount forkCount
24
25
  watchers { totalCount }
@@ -621,7 +622,7 @@ function readAtlasCache() {
621
622
  */
622
623
  async function atlasFleetVersion(fetchImpl = fetch) {
623
624
  try {
624
- const res = await fetchImpl(`https://registry.npmjs.org/${ATLAS_PACKAGE.replace('/', '%2f')}`, {
625
+ const res = await fetchImpl(`https://registry.npmjs.org/${encodeURIComponent(ATLAS_PACKAGE)}`, {
625
626
  headers: { accept: 'application/vnd.npm.install-v1+json' }, signal: AbortSignal.timeout(30_000),
626
627
  });
627
628
  if (!res.ok) return { version: null, source: 'npm dist-tags latest', error: `http_${res.status}` };
@@ -680,7 +681,9 @@ export async function collectAtlasMaps(org, repos, { get = rest, fetchImpl = fet
680
681
  }
681
682
  const fleet = await atlasFleetVersion(fetchImpl);
682
683
  log(`atlas maps: ${targets.length} committed (${fetched} fetched, ${cached} from cache, ${errored} errors); ` +
683
- `fleet engine ${fleet.version ?? `unknown (${fleet.error})`}`);
684
+ // npm's newest, not the fleet's version: the fleet carries what the repos
685
+ // pin (analyze.mjs fleetPin), which moves by a pin-bump wave.
686
+ `npm latest ${fleet.version ?? `unknown (${fleet.error})`}`);
684
687
  return { ok: true, fleet_version: fleet, fetched, cached, repos: out };
685
688
  }
686
689
 
@@ -691,7 +694,7 @@ async function collectNpm(repos, concurrency = 12) {
691
694
  .filter(x => x.pkg && x.pkg.name && !x.pkg.private);
692
695
 
693
696
  const results = await pMap(named, async x => {
694
- const res = await fetch(`https://registry.npmjs.org/${x.pkg.name.replace('/', '%2f')}`, {
697
+ const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(x.pkg.name)}`, {
695
698
  headers: { accept: 'application/vnd.npm.install-v1+json' },
696
699
  });
697
700
  if (res.status === 404) return { repo: x.repo, pkg_name: x.pkg.name, status: 'unpublished' };
@@ -814,6 +817,74 @@ async function collectSecurity(org, repos, concurrency = 10) {
814
817
  return { ok: alertsOk, alerts, auto_security_fixes: fixes, vulnerability_alerts: scanned };
815
818
  }
816
819
 
820
+ // Every branch, the pull requests it was the head of, and whether the repo
821
+ // deletes a head branch when its pull request merges.
822
+ //
823
+ // A merged PR's branch is restorable from the PR page only while its tip is
824
+ // the commit the PR merged; a branch pushed to after its merge carries work
825
+ // that exists nowhere else. So the head commit is kept beside each PR's
826
+ // headRefOid, and the loader tells the two apart.
827
+ //
828
+ // Cost: one GraphQL query per non-archived, non-empty repo, plus one per
829
+ // further 100 branches. Measured 2026-10-11 by rateLimit(dryRun): 1 point per
830
+ // repo at first:100 with five associated PRs per branch (8 repos: 8 points).
831
+ const BRANCH_QUERY = `
832
+ query($owner:String!, $name:String!, $cursor:String) {
833
+ repository(owner:$owner, name:$name) {
834
+ deleteBranchOnMerge
835
+ refs(refPrefix:"refs/heads/", first:100, after:$cursor) {
836
+ totalCount
837
+ pageInfo { hasNextPage endCursor }
838
+ nodes {
839
+ name
840
+ target { ... on Commit { oid committedDate } }
841
+ associatedPullRequests(first:5, orderBy:{field:UPDATED_AT, direction:DESC}) {
842
+ nodes { number state headRefOid }
843
+ }
844
+ }
845
+ }
846
+ }
847
+ }`;
848
+
849
+ export async function collectBranches(org, repos, { query = graphql, concurrency = 6 } = {}) {
850
+ const out = {};
851
+ const errors = [];
852
+ const targets = repos.filter(r => !r.isArchived && !r.isEmpty);
853
+ await pMap(targets, async r => {
854
+ const branches = [];
855
+ let cursor = null, total = 0, deleteOnMerge = null;
856
+ try {
857
+ for (;;) {
858
+ const body = await query(BRANCH_QUERY, { owner: org, name: r.name, cursor });
859
+ const repo = body.data?.repository;
860
+ if (!repo) throw new Error('repository not visible');
861
+ deleteOnMerge = repo.deleteBranchOnMerge ?? null;
862
+ total = repo.refs?.totalCount ?? 0;
863
+ for (const b of repo.refs?.nodes ?? []) {
864
+ branches.push({
865
+ name: b.name,
866
+ oid: b.target?.oid ?? null,
867
+ committed_at: b.target?.committedDate ?? null,
868
+ prs: (b.associatedPullRequests?.nodes ?? []).map(p => ({
869
+ number: p.number, state: p.state, head_oid: p.headRefOid ?? null,
870
+ })),
871
+ });
872
+ }
873
+ cursor = repo.refs?.pageInfo?.hasNextPage ? repo.refs.pageInfo.endCursor : null;
874
+ if (!cursor) break;
875
+ }
876
+ out[r.name] = { delete_branch_on_merge: deleteOnMerge, total, branches };
877
+ } catch (e) {
878
+ // A repo that could not be read gets no record at all, never an empty
879
+ // one: no branches would read as "nothing to clean up".
880
+ errors.push({ repo: r.name, stage: 'branches', message: String(e.message ?? e).slice(0, 200) });
881
+ }
882
+ }, concurrency);
883
+ const n = Object.values(out).reduce((s, x) => s + x.branches.length, 0);
884
+ log(`branches: ${n} in ${Object.keys(out).length} repos, ${errors.length} unreadable`);
885
+ return { ok: errors.length === 0, repos: out, errors };
886
+ }
887
+
817
888
  // ---------------------------------------------------------------------------
818
889
  // Committed lockfiles, audited by the warehouse itself. See lockfile.mjs for
819
890
  // why GitHub's own count cannot be trusted per manifest.
@@ -1094,6 +1165,7 @@ async function sweep(org, ctx) {
1094
1165
  ctx.stage = 'deploy_settings'; const deploySettings = await collectDeploySettings(org, repos, workflowFiles);
1095
1166
  ctx.stage = 'npm'; const npm = await collectNpm(repos);
1096
1167
  ctx.stage = 'security'; const security = await collectSecurity(org, repos);
1168
+ ctx.stage = 'branches'; const branches = await collectBranches(org, repos);
1097
1169
  ctx.stage = 'lockfiles'; const lockfiles = await collectLockfiles(org, repos);
1098
1170
  ctx.stage = 'atlas_maps'; const atlasMaps = await collectAtlasMaps(org, repos);
1099
1171
  ctx.stage = 'billing'; const billing = await collectBilling(org);
@@ -1118,13 +1190,14 @@ async function sweep(org, ctx) {
1118
1190
  deploy_settings: { ok: deploySettings.ok, calls: deploySettings.calls, repos: deploySettings.repos },
1119
1191
  npm,
1120
1192
  security,
1193
+ branches: { ok: branches.ok, repos: branches.repos },
1121
1194
  lockfiles,
1122
1195
  // fetched/cached counts are left out: they describe this machine's cache,
1123
1196
  // not the org, and would make two identical observations look different.
1124
1197
  atlas_maps: { ok: atlasMaps.ok, fleet_version: atlasMaps.fleet_version, repos: atlasMaps.repos },
1125
1198
  billing,
1126
1199
  run_costs: runCosts,
1127
- errors: errors.concat(deploySettings.errors ?? [], runCosts.errors ?? [],
1200
+ errors: errors.concat(deploySettings.errors ?? [], runCosts.errors ?? [], branches.errors,
1128
1201
  failedSteps.runs.filter(x => x.error).map(x => ({ repo: x.repo, stage: 'failed_steps', message: x.error }))),
1129
1202
  };
1130
1203
 
package/src/config.mjs CHANGED
@@ -7,7 +7,17 @@
7
7
  // treated as a non-product.
8
8
  //
9
9
  // housekeeping.config.json
10
- // { "org": "your-org", "metaRepos": [".github", "design-assets"] }
10
+ // { "org": "your-org", "metaRepos": [".github", "design-assets"],
11
+ // "homeOrgs": ["your-org", "your-other-org"],
12
+ // "watch": ["repo-a", "repo-b"] }
13
+ //
14
+ // `homeOrgs` names the organizations whose projects count as the org's own.
15
+ // A fork of a project outside them is exempt from the map rule. Unset, it is
16
+ // the swept org alone.
17
+ //
18
+ // `watch` names the repos `hk watch` reports on when it is given none: a short
19
+ // list someone reviews every week. It narrows a view, never the sweep, and
20
+ // changes no finding.
11
21
  //
12
22
  // No network and no database here, so every branch is unit-testable.
13
23
  import { readFileSync } from 'node:fs';
@@ -17,7 +27,7 @@ import { WORK_DIR } from './paths.mjs';
17
27
 
18
28
  export const CONFIG_PATH = process.env.HK_CONFIG ?? join(WORK_DIR, 'housekeeping.config.json');
19
29
 
20
- const KEYS = ['org', 'metaRepos'];
30
+ const KEYS = ['org', 'metaRepos', 'homeOrgs', 'watch'];
21
31
  const invalid = message => userError('CONFIG_INVALID', message,
22
32
  'fix housekeeping.config.json; housekeeping.config.example.json shows the shape');
23
33
  const DEFAULT_META = ['.github'];
@@ -47,7 +57,15 @@ export function parseConfig(text) {
47
57
  if (!Array.isArray(meta) || meta.some(m => typeof m !== 'string' || !m.trim())) {
48
58
  throw invalid('housekeeping config: "metaRepos" must be an array of repo names');
49
59
  }
50
- return { org, metaRepos: [...meta] };
60
+ const home = raw.homeOrgs ?? null;
61
+ if (home !== null && (!Array.isArray(home) || !home.length || home.some(o => typeof o !== 'string' || !o.trim()))) {
62
+ throw invalid('housekeeping config: "homeOrgs" must be a non-empty array of organization names');
63
+ }
64
+ const watch = raw.watch ?? [];
65
+ if (!Array.isArray(watch) || watch.some(w => typeof w !== 'string' || !w.trim())) {
66
+ throw invalid('housekeeping config: "watch" must be an array of repo names');
67
+ }
68
+ return { org, metaRepos: [...meta], homeOrgs: home ? [...home] : null, watch: [...watch] };
51
69
  }
52
70
 
53
71
  /** Read the config file. A missing file is the defaults; an unreadable or malformed one throws. */
@@ -55,7 +73,7 @@ export function loadConfig(path = CONFIG_PATH) {
55
73
  let text;
56
74
  try { text = readFileSync(path, 'utf8'); }
57
75
  catch (e) {
58
- if (e.code === 'ENOENT') return { org: null, metaRepos: [...DEFAULT_META] };
76
+ if (e.code === 'ENOENT') return { org: null, metaRepos: [...DEFAULT_META], homeOrgs: null, watch: [] };
59
77
  throw e;
60
78
  }
61
79
  return parseConfig(text);
package/src/load.mjs CHANGED
@@ -9,7 +9,7 @@ import { parse as parseYaml } from 'yaml';
9
9
  import {
10
10
  editsWorkflowFiles, prCreateWithDefaultToken, auditSteps,
11
11
  pagesDeployJobs, jobEnvironment, jobRunsOnDefaultBranch, atlasCheckPins,
12
- resolveFailedStep,
12
+ resolveFailedStep, scheduleGaps,
13
13
  } from './workflow-risks.mjs';
14
14
  import { safeJson } from './collect.mjs';
15
15
  import { reconcileRepos } from './cost.mjs';
@@ -103,8 +103,10 @@ export function openDb(path = DB_PATH, { fresh = false } = {}) {
103
103
  *
104
104
  * `atlasEnv` maps a job key to the `environment` the repository's Atlas map
105
105
  * records for it (Atlas 1.24.0 and later), when the map has this file.
106
+ * `isPrivate` is the repository's visibility, which decides whether a job
107
+ * gated to public repositories can run at all.
106
108
  */
107
- export function inspectWorkflow(text, path, defaultBranch = null, atlasEnv = null) {
109
+ export function inspectWorkflow(text, path, defaultBranch = null, atlasEnv = null, isPrivate = false) {
108
110
  const out = {
109
111
  path, name: path.split('/').pop(), state: 'unknown',
110
112
  has_paths_filter: 0, has_workflow_dispatch: 0, has_concurrency: 0,
@@ -112,7 +114,7 @@ export function inspectWorkflow(text, path, defaultBranch = null, atlasEnv = nul
112
114
  on_triggers: '', job_count: 0, has_matrix: 0, size_bytes: text.length, job_names: '',
113
115
  edits_workflow_files: 0, pr_create_default_token: 0,
114
116
  audit_steps_enforcing: 0, audit_steps_defanged: '',
115
- pages_deploy_jobs: '', environments: [], atlas_check: '',
117
+ pages_deploy_jobs: '', environments: [], atlas_check: '', schedule_gaps: null,
116
118
  };
117
119
  let doc;
118
120
  try { doc = parseYaml(text, { logLevel: 'silent' }); } catch { out.state = 'parse_error'; return out; }
@@ -193,8 +195,11 @@ export function inspectWorkflow(text, path, defaultBranch = null, atlasEnv = nul
193
195
 
194
196
  out.atlas_check = atlasCheckPins(doc, jobs).join('\n');
195
197
 
198
+ const gaps = scheduleGaps(doc, jobEntries);
199
+ out.schedule_gaps = gaps ? gaps.join('\n') : null;
200
+
196
201
  // The deploy doors, joined in analyze.mjs to the settings they depend on.
197
- out.pages_deploy_jobs = pagesDeployJobs(jobEntries).join('\n');
202
+ out.pages_deploy_jobs = pagesDeployJobs(jobEntries, { isPrivate }).join('\n');
198
203
  // The environment a job deploys to is a fact Atlas also records, so where
199
204
  // the map names one it is preferred: one definition for both tools. This
200
205
  // file's own reading is kept beside it, so a disagreement stays visible.
@@ -464,6 +469,38 @@ export function failedStepRows(record, text) {
464
469
  return rows;
465
470
  }
466
471
 
472
+ /**
473
+ * Classify one repo's branches (schema.sql, table `branch`). Pure.
474
+ *
475
+ * Order matters, and each step was a real case on 2026-10-11:
476
+ * - an open PR beats a merged one: a branch reused for a second PR is live;
477
+ * - being an open PR's BASE beats being merged: a stacked PR targets it, and
478
+ * deleting it would retarget or close that PR;
479
+ * - "merged" needs the tip to equal the merged head. Four branches had commits
480
+ * pushed after their PR merged, which the PR page's restore cannot bring back.
481
+ */
482
+ export function branchRows(record, { defaultBranch = null, openBases = [] } = {}) {
483
+ const bases = new Set(openBases);
484
+ const rows = [];
485
+ for (const b of record?.branches ?? []) {
486
+ if (b.name === defaultBranch) continue;
487
+ const prs = b.prs ?? [];
488
+ const open = prs.find(p => p.state === 'OPEN');
489
+ const merged = prs.filter(p => p.state === 'MERGED');
490
+ const exact = merged.find(p => p.head_oid && p.head_oid === b.oid);
491
+ let state, pr = null;
492
+ if (open) { state = 'open_pr'; pr = open.number; }
493
+ else if (bases.has(b.name)) state = 'open_base';
494
+ else if (exact) { state = 'merged'; pr = exact.number; }
495
+ else if (merged.length) { state = 'merged_moved'; pr = merged[0].number; }
496
+ else if (prs.length) { state = 'closed'; pr = prs[0].number; }
497
+ else state = 'no_pr';
498
+ rows.push({ name: b.name, state, head_oid: b.oid ?? null,
499
+ committed_at: b.committed_at ?? null, age_days: daysSince(b.committed_at), pr_number: pr });
500
+ }
501
+ return rows;
502
+ }
503
+
467
504
  export function isWorkspaceRoot(pkg, rootEntryNames = [], pnpmWorkspaceText) {
468
505
  if (pkg?.workspaces) return true;
469
506
  const names = new Set(rootEntryNames.map(n => String(n).toLowerCase()));
@@ -539,6 +576,8 @@ function loadSnapshotInner(db, snap) {
539
576
  lock: insertInto('lockfile'),
540
577
  lockAdv: insertInto('lockfile_advisory'),
541
578
  bprot: insertInto('branch_protection'),
579
+ branch: insertInto('branch'),
580
+ repoBranches: insertInto('repo_branches'),
542
581
  ctx: insertInto('check_context'),
543
582
  bill: insertInto('billing_usage'),
544
583
  runCost: insertInto('run_cost'),
@@ -598,6 +637,7 @@ function loadSnapshotInner(db, snap) {
598
637
  sidebarShape(r.siteAstroCfg?.text),
599
638
  starlightRange(r.sitePkg?.text),
600
639
  hasAtlasMap(r),
640
+ r.isFork ? (r.parent?.owner?.login ?? null) : null,
601
641
  );
602
642
 
603
643
  // The trimmed map record, when the atlas pass read one for this repo.
@@ -688,14 +728,14 @@ function loadSnapshotInner(db, snap) {
688
728
  const atlasEnvByFile = new Map(mapDoors.filter(d => Array.isArray(d.jobs)).map(d => [d.file,
689
729
  new Map(d.jobs.filter(j => j && typeof j.name === 'string' && j.environment).map(j => [j.name, j.environment]))]));
690
730
  for (const f of wfFiles) {
691
- const w = inspectWorkflow(f.text, f.path, r.defaultBranchRef?.name ?? null, atlasEnvByFile.get(f.path) ?? null);
731
+ const w = inspectWorkflow(f.text, f.path, r.defaultBranchRef?.name ?? null, atlasEnvByFile.get(f.path) ?? null, !!r.isPrivate);
692
732
  stmts.wf.run(sid, r.name, w.path, w.name, w.state,
693
733
  w.has_paths_filter, w.has_workflow_dispatch, w.has_concurrency,
694
734
  w.runners, w.uses_macos, w.uses_windows,
695
735
  w.on_triggers, w.job_count, w.has_matrix, f.byteSize || w.size_bytes,
696
736
  w.edits_workflow_files, w.pr_create_default_token, w.job_names ?? '',
697
737
  w.audit_steps_enforcing ?? 0, w.audit_steps_defanged ?? '',
698
- w.pages_deploy_jobs ?? '', w.atlas_check ?? '');
738
+ w.pages_deploy_jobs ?? '', w.atlas_check ?? '', w.schedule_gaps ?? null);
699
739
  for (const e of w.environments ?? []) {
700
740
  stmts.wfEnv.run(sid, r.name, w.path, e.job, e.environment, e.runs_on_default,
701
741
  e.source ?? 'workflow', e.parsed ?? null);
@@ -787,6 +827,22 @@ function loadSnapshotInner(db, snap) {
787
827
  }
788
828
  }
789
829
 
830
+ // ---- branches ------------------------------------------------------------
831
+ // Absent `branches` (an older snapshot) or a repo missing from it (archived,
832
+ // empty, unreadable) leaves no rows: not measured, never "nothing to clean".
833
+ if (snap.branches) {
834
+ for (const r of snap.repos) {
835
+ const rec = snap.branches.repos?.[r.name];
836
+ if (!rec) continue;
837
+ stmts.repoBranches.run(sid, r.name,
838
+ rec.delete_branch_on_merge === null ? null : bool(rec.delete_branch_on_merge), rec.total ?? null);
839
+ const openBases = (r.prList?.nodes ?? []).map(p => p.baseRefName).filter(Boolean);
840
+ for (const b of branchRows(rec, { defaultBranch: r.defaultBranchRef?.name ?? null, openBases })) {
841
+ stmts.branch.run(sid, r.name, b.name, b.state, b.head_oid, b.committed_at, b.age_days, b.pr_number);
842
+ }
843
+ }
844
+ }
845
+
790
846
  // ---- committed lockfiles, audited directly ------------------------------
791
847
  // Absent `lockfiles` means the pass did not run (older snapshot): no rows,
792
848
  // and the analyzer stays silent rather than reporting zero exposure.
package/src/report.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  import { writeFileSync, mkdirSync } from 'node:fs';
5
5
  import { join } from 'node:path';
6
6
  import { openDb } from './load.mjs';
7
- import { healthScores, RUN_IS_LIVE_MAINLINE_SIGNAL, HEAD_CHECKS_RED, doorFindingRows, ATLAS_DOOR_CHECKS_SINCE, engineBehindCounts } from './analyze.mjs';
7
+ import { healthScores, RUN_IS_LIVE_MAINLINE_SIGNAL, HEAD_CHECKS_RED, doorFindingRows, ATLAS_DOOR_CHECKS_SINCE, fleetEngineFor } from './analyze.mjs';
8
8
  import { exitCodeFor, formatError, userError } from './errors.mjs';
9
9
  import { REPORTS_DIR } from './paths.mjs';
10
10
 
@@ -287,7 +287,7 @@ export function buildReport(db, sid) {
287
287
  push(md.table(relMissing));
288
288
 
289
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`);
290
+ WHERE snapshot_id=? AND code IN ('NPM_UNPUBLISHED','MONOREPO_ROOT') ORDER BY code, repo`);
291
291
  push(md.h3('Other version observations'));
292
292
  push(md.table(unpub));
293
293
 
@@ -460,7 +460,7 @@ export function buildReport(db, sid) {
460
460
  SUM(CASE WHEN r.has_atlas_map IS NULL THEN 1 ELSE 0 END) unknown
461
461
  FROM repo r WHERE r.snapshot_id=?1 AND r.is_archived=0 AND r.is_empty=0
462
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=?');
463
+ const fleet = fleetEngineFor(db, sid);
464
464
  if (!atlas?.running || atlas.unknown === atlas.running) {
465
465
  push(md.p('_Not measured — this snapshot predates the Atlas map pass (collector 1.3.0). Run `npm run refresh`._'));
466
466
  } else {
@@ -468,12 +468,16 @@ export function buildReport(db, sid) {
468
468
  '`atlas check` in CI (`rules/atlas-map.md`). ' +
469
469
  `${atlas.mapped} of ${atlas.running} such repos have \`atlas/structure.json\` on their default branch; ` +
470
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.`)));
471
+ (fleet.version
472
+ ? (fleet.source === 'fleet-pin'
473
+ ? `The fleet engine is **${fleet.version}**, the \`atlas check\` pin ${fleet.repos} of ${fleet.pinned} pinning repos carry` +
474
+ (fleet.npmLatest && fleet.npmLatest !== fleet.version ? ` (npm's newest is ${fleet.npmLatest}; the fleet moves to it by a pin-bump wave)` : '') + '. ' +
475
+ 'An engine behind the fleet\'s counts as a defect (`low`): the first pin-bump wave ended on 2026-09-30.'
476
+ : `The fleet engine is **${fleet.version}** (latest \`@dogfood-lab/atlas\` on npm). ` +
477
+ '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.')
478
+ : (fleet.source === 'fleet-pin'
479
+ ? 'No repository pins an `atlas check` version, so no engine is judged behind.'
480
+ : `The fleet engine version could not be read${fleet.npmError ? ` (${fleet.npmError})` : ''}, so no engine is judged behind.`))));
477
481
  push(md.table(q(`SELECT repo, code, message FROM finding
478
482
  WHERE snapshot_id=? AND code IN ('ATLAS_MAP_MISSING','ATLAS_CHECK_NOT_IN_CI')
479
483
  ORDER BY code, repo`)));
package/src/schema.sql CHANGED
@@ -46,6 +46,10 @@ CREATE TABLE IF NOT EXISTS repo (
46
46
  -- 1 = atlas/structure.json is on the default branch, 0 = it is not, NULL =
47
47
  -- not collected (a snapshot before collector 1.3.0). See rules/atlas-map.md.
48
48
  has_atlas_map INTEGER,
49
+ -- The owner of the repository a fork was made from. NULL for a repo that is
50
+ -- not a fork, and for any repo in a snapshot before collector 1.5.0, which
51
+ -- did not read it: an unknown parent is never read as "someone else's".
52
+ fork_parent_owner TEXT,
49
53
  PRIMARY KEY (snapshot_id, name)
50
54
  );
51
55
 
@@ -147,7 +151,11 @@ CREATE TABLE IF NOT EXISTS workflow (
147
151
  pages_deploy_jobs TEXT,
148
152
  -- The pin of each `@dogfood-lab/atlas@<version> check` a run step issues,
149
153
  -- newline-joined; `unpinned` for a call without a version. Empty: none.
150
- atlas_check TEXT
154
+ atlas_check TEXT,
155
+ -- For a scheduled workflow, the conditions of rules/github-actions.md
156
+ -- ("Scheduled workflows") it is shown to fail, newline-joined (scheduleGaps).
157
+ -- Empty: none shown to fail. NULL: the workflow has no schedule.
158
+ schedule_gaps TEXT
151
159
  );
152
160
 
153
161
  -- One row per job that names a deployment environment, read from the YAML at
@@ -363,6 +371,31 @@ CREATE TABLE IF NOT EXISTS cost_reconciliation (
363
371
  ratio REAL, trustworthy INTEGER, reason TEXT
364
372
  );
365
373
 
374
+ -- One row per non-default branch (collect.mjs collectBranches). `state` is
375
+ -- decided at load time (load.mjs branchRows), first match wins:
376
+ -- open_pr the head of an open pull request
377
+ -- open_base the base of an open pull request (a stacked PR targets it)
378
+ -- merged its PR merged and the tip IS the merged head: restorable
379
+ -- from the PR page, so deleting it loses nothing
380
+ -- merged_moved its PR merged, but the tip moved after: those commits exist
381
+ -- only on this branch
382
+ -- closed only closed, unmerged pull requests
383
+ -- no_pr never the head of a pull request
384
+ -- Data, not findings: no org rule names stale branches. No rows for a repo
385
+ -- means not measured (archived, empty, unreadable, or an older snapshot).
386
+ CREATE TABLE IF NOT EXISTS branch (
387
+ snapshot_id INTEGER, repo TEXT, name TEXT, state TEXT,
388
+ head_oid TEXT, committed_at TEXT, age_days INTEGER,
389
+ pr_number INTEGER -- the PR the state was decided by, if any
390
+ );
391
+
392
+ -- Per-repo branch settings, one row per repo the branch pass read.
393
+ CREATE TABLE IF NOT EXISTS repo_branches (
394
+ snapshot_id INTEGER, repo TEXT,
395
+ delete_on_merge INTEGER, -- "Automatically delete head branches": 1 on, 0 off
396
+ branch_count INTEGER -- every branch, the default included
397
+ );
398
+
366
399
  CREATE TABLE IF NOT EXISTS finding (
367
400
  snapshot_id INTEGER, repo TEXT, code TEXT, severity TEXT,
368
401
  category TEXT, message TEXT, evidence TEXT
@@ -379,6 +412,7 @@ CREATE INDEX IF NOT EXISTS idx_wf_snap ON workflow(snapshot_id, repo);
379
412
  CREATE INDEX IF NOT EXISTS idx_wfrun_snap ON workflow_run(snapshot_id, repo);
380
413
  CREATE INDEX IF NOT EXISTS idx_finding_snap ON finding(snapshot_id, severity, code);
381
414
  CREATE INDEX IF NOT EXISTS idx_bprot_snap ON branch_protection(snapshot_id, repo);
415
+ CREATE INDEX IF NOT EXISTS idx_branch_snap ON branch(snapshot_id, repo);
382
416
  CREATE INDEX IF NOT EXISTS idx_ctx_snap ON check_context(snapshot_id, repo);
383
417
  CREATE INDEX IF NOT EXISTS idx_bill_snap ON billing_usage(snapshot_id, repo);
384
418
  CREATE INDEX IF NOT EXISTS idx_rcost_snap ON run_cost(snapshot_id, repo);