@mcptoolshop/housekeeping 1.3.0 → 1.4.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/analyze.mjs CHANGED
@@ -25,6 +25,43 @@ export const RUN_IS_LIVE_MAINLINE_SIGNAL = `
25
25
  WHERE w.snapshot_id=wr.snapshot_id AND w.repo=wr.repo AND w.path=wr.path
26
26
  AND (','||w.on_triggers||',' LIKE '%,push,%'
27
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
+ }
28
65
  // A checker declared in package.json but invoked by no script is a gate that
29
66
  // exists on paper and never runs. The case that earned the rule: a repo carried
30
67
  // `typescript` with no typecheck script -- build is tsup and tests are vitest,
@@ -221,12 +258,80 @@ export function environmentAdmitsBranch(env, settings, branch) {
221
258
  // a missing check counted from the start, and are unaffected by it.
222
259
  export const ATLAS_ENGINE_BEHIND_COUNTS_SINCE = '2026-09-30T21:44:11Z';
223
260
 
261
+ /**
262
+ * Is this repository a fork of another organization's project? Pure.
263
+ *
264
+ * rules/atlas-map.md: "A fork of another organization's project is exempt."
265
+ * `repo` is a repo row (is_fork, fork_parent_owner); `homeOrgs` the
266
+ * organizations whose projects are the org's own, compared without case. A
267
+ * fork whose parent was not read (a snapshot before collector 1.5.0) is NOT
268
+ * exempt: not measured is never read as someone else's.
269
+ */
270
+ export function forkOfAnotherOrg(repo, homeOrgs = []) {
271
+ if (repo?.is_fork !== 1) return false;
272
+ const owner = repo.fork_parent_owner;
273
+ if (typeof owner !== 'string' || !owner) return false;
274
+ return !homeOrgs.some(o => String(o).toLowerCase() === owner.toLowerCase());
275
+ }
276
+
224
277
  /** Does an engine behind the fleet's count, for a snapshot taken at `takenAt`? Pure. */
225
278
  export function engineBehindCounts(takenAt, since = ATLAS_ENGINE_BEHIND_COUNTS_SINCE) {
226
279
  const t = Date.parse(takenAt ?? ''), s = Date.parse(since);
227
280
  return Number.isFinite(t) && Number.isFinite(s) && t >= s;
228
281
  }
229
282
 
283
+ /**
284
+ * The Atlas version the fleet carries: the `@dogfood-lab/atlas@<v> check` pin
285
+ * that the most repositories use in push-triggered CI. Pure.
286
+ *
287
+ * rules/atlas-map.md: "the whole fleet carries the same one. It moves by pull
288
+ * request." So the fleet's version is what the repositories pin, and it moves
289
+ * when a pin-bump wave lands -- not when npm publishes a newer engine. Each
290
+ * repository counts once per pin it uses; a tie goes to the higher version.
291
+ * `rows` are { repo, atlas_check, on_triggers } for non-archived repositories.
292
+ * Returns { version, repos, pinned } or null when no repository pins one.
293
+ */
294
+ export function fleetPin(rows) {
295
+ const byPin = new Map();
296
+ const pinned = new Set();
297
+ for (const w of rows ?? []) {
298
+ const push = (w.on_triggers ?? '').split(',').some(t => t === 'push' || t === 'pull_request');
299
+ if (!push) continue;
300
+ for (const pin of (w.atlas_check ?? '').split('\n')) {
301
+ if (!pin || pin === 'unpinned' || !parseSemver(pin)) continue;
302
+ if (!byPin.has(pin)) byPin.set(pin, new Set());
303
+ byPin.get(pin).add(w.repo);
304
+ pinned.add(w.repo);
305
+ }
306
+ }
307
+ if (!byPin.size) return null;
308
+ // compareSemver sorts newest first, which is the tie-break wanted here.
309
+ const [version, repos] = [...byPin].sort(([a, ra], [b, rb]) =>
310
+ rb.size - ra.size || compareSemver(parseSemver(a), parseSemver(b)))[0];
311
+ return { version, repos: repos.size, pinned: pinned.size };
312
+ }
313
+
314
+ /**
315
+ * The version maps and pins are measured against, for one snapshot.
316
+ *
317
+ * From the end of the first pin-bump wave (ATLAS_ENGINE_BEHIND_COUNTS_SINCE)
318
+ * it is the fleet's own pin (fleetPin). Before it, it is npm's latest, which
319
+ * is what those snapshots were reported against; keeping it means a rebuild
320
+ * reproduces their findings. npm's latest is returned beside it either way.
321
+ */
322
+ export function fleetEngineFor(db, sid) {
323
+ const npm = db.prepare('SELECT version, error FROM atlas_fleet WHERE snapshot_id = ?').get(sid) ?? null;
324
+ const takenAt = db.prepare('SELECT taken_at FROM snapshot WHERE id = ?').get(sid)?.taken_at;
325
+ if (!engineBehindCounts(takenAt)) {
326
+ return { version: npm?.version ?? null, source: 'npm-latest', npmLatest: npm?.version ?? null, npmError: npm?.error ?? null };
327
+ }
328
+ const pin = fleetPin(db.prepare(`SELECT w.repo, w.atlas_check, w.on_triggers FROM workflow w
329
+ JOIN repo r ON r.snapshot_id = w.snapshot_id AND r.name = w.repo
330
+ WHERE w.snapshot_id = ? AND r.is_archived = 0 AND w.atlas_check <> ''`).all(sid));
331
+ return { version: pin?.version ?? null, source: 'fleet-pin', repos: pin?.repos ?? 0, pinned: pin?.pinned ?? 0,
332
+ npmLatest: npm?.version ?? null, npmError: npm?.error ?? null };
333
+ }
334
+
230
335
  const ATLAS_RULE = 'rules/atlas-map.md';
231
336
  const isOlder = (v, fleet) => {
232
337
  const a = parseSemver(v), b = parseSemver(fleet);
@@ -412,14 +517,62 @@ export function classifyRequiredChecks({ required, observedNames, declaredJobs }
412
517
  return { stale, live: required.filter(c => !staleSet.has(c)) };
413
518
  }
414
519
 
520
+ /**
521
+ * Which open PRs are missing a live required check because their files missed
522
+ * the workflow's paths filter. `live` comes from classifyRequiredChecks;
523
+ * `observed` is the repo's check_context rows.
524
+ */
525
+ export function gatedPrs({ prs, live, observed }) {
526
+ const seenByPr = new Map();
527
+ for (const c of observed) {
528
+ if (c.source !== 'pr' || c.pr_number == null) continue;
529
+ if (!seenByPr.has(c.pr_number)) seenByPr.set(c.pr_number, new Set());
530
+ seenByPr.get(c.pr_number).add(c.name);
531
+ }
532
+ // Drafts are not trying to merge, and a PR opened moments ago may have
533
+ // checks still being created. The window is measured off `created_at`,
534
+ // NOT `updated_at`: updated_at moves for comments, labels and rebases,
535
+ // none of which say anything about whether CI ran, so a long-open PR
536
+ // that someone commented on today would read as "too fresh to judge".
537
+ // created_at never moves.
538
+ //
539
+ // This deliberately holds fire for a day. Dependabot PRs opened 90
540
+ // minutes before a snapshot can have zero checks and never get any
541
+ // (site-only changes against a paths-gated ci.yml), but at that age
542
+ // "no checks yet" and "no checks ever" are genuinely indistinguishable.
543
+ // They surface on the next run.
544
+ // Residual gap: a week-old PR force-pushed seconds before a snapshot.
545
+ // Rare, self-heals, and the cheap direction to be wrong in.
546
+ //
547
+ // A conflicting PR is left out. GitHub builds no merge ref for it, so no
548
+ // pull_request workflow runs at all, and a check missing from it says
549
+ // nothing about paths; the repair is a rebase, which PR_CONFLICTED already
550
+ // asks for. Counting them called eight repos' trigger broken when every
551
+ // blocked PR there was simply conflicted. The cost: a conflicted PR that
552
+ // ALSO misses the paths filter is only reported once it is rebased.
553
+ const settled = prs.filter(p => !p.is_draft && (p.age_days ?? 0) >= 1 && p.mergeable !== 'CONFLICTING');
554
+ const blocked = settled
555
+ .map(p => ({ p, absent: live.filter(c => !(seenByPr.get(p.number)?.has(c))) }))
556
+ .filter(x => x.absent.length);
557
+ return { settled, blocked };
558
+ }
559
+
415
560
  /**
416
561
  * `metaRepos` are exempt from product-hygiene rules: repos that hold org
417
562
  * defaults, assets or tooling rather than a shipped product. Which repos those
418
563
  * are is a fact about the org, so it comes from the config file (config.mjs),
419
564
  * not from this file. Tests pass it explicitly.
420
565
  */
421
- export function analyze(db, sid, { metaRepos = loadConfig().metaRepos } = {}) {
566
+ export function analyze(db, sid, opts = {}) {
567
+ // The config file is read only when the caller passes nothing, so a caller
568
+ // that names its metaRepos (every test) never picks up a local config.
569
+ const cfg = opts.metaRepos === undefined ? loadConfig() : null;
570
+ const metaRepos = opts.metaRepos ?? cfg.metaRepos;
422
571
  const META_REPOS = new Set(metaRepos);
572
+ // The organizations whose projects are the org's own (rules/atlas-map.md:
573
+ // a fork of another organization's project is exempt). Unset: the swept org.
574
+ const homeOrgs = opts.homeOrgs ?? cfg?.homeOrgs
575
+ ?? [db.prepare('SELECT org FROM snapshot WHERE id = ?').get(sid)?.org].filter(Boolean);
423
576
  db.prepare('DELETE FROM finding WHERE snapshot_id = ?').run(sid);
424
577
  const add = db.prepare('INSERT INTO finding VALUES (?,?,?,?,?,?,?)');
425
578
  const F = (repo, code, severity, category, message, evidence = null) =>
@@ -487,13 +640,13 @@ export function analyze(db, sid, { metaRepos = loadConfig().metaRepos } = {}) {
487
640
  const envBy = byRepo(q('SELECT * FROM environment WHERE snapshot_id = ?'));
488
641
  const wfEnvBy = byRepo(q('SELECT * FROM workflow_environment WHERE snapshot_id = ?'));
489
642
 
490
- // The committed maps and the engine version they are measured against. No
491
- // fleet row (older snapshot, or the registry did not answer) leaves the
492
- // engine check silent.
643
+ // The committed maps and the engine version they are measured against
644
+ // (fleetEngineFor: the fleet's own pin after the first wave, npm's latest
645
+ // before it). No version to measure against leaves the engine check silent.
493
646
  const atlasBy = new Map(
494
647
  q('SELECT * FROM atlas_map WHERE snapshot_id = ?').map(x => [x.repo, x]),
495
648
  );
496
- const fleetEngine = db.prepare('SELECT version FROM atlas_fleet WHERE snapshot_id = ?').get(sid)?.version ?? null;
649
+ const fleetEngine = fleetEngineFor(db, sid).version;
497
650
  const engineCounts = engineBehindCounts(db.prepare('SELECT taken_at FROM snapshot WHERE id = ?').get(sid)?.taken_at);
498
651
 
499
652
  // Where each red run broke, and the door it broke in. Keyed by run id and by
@@ -710,10 +863,23 @@ export function analyze(db, sid, { metaRepos = loadConfig().metaRepos } = {}) {
710
863
  }
711
864
 
712
865
  // ---- CI health -------------------------------------------------------
866
+ // The rollup is read through the head's own checks when the snapshot has
867
+ // them: a rollup red only with cancelled runs that a green run of the
868
+ // same check replaced is not a failing mainline (failingHeadChecks).
713
869
  if (r.ci_rollup === 'FAILURE' || r.ci_rollup === 'ERROR') {
714
- F(name, 'CI_FAILING', 'high', 'ci',
715
- `Default-branch check rollup is ${r.ci_rollup}.`,
716
- `${r.default_branch}@${(r.head_oid ?? '').slice(0, 7)}`);
870
+ const head = (ctxBy.get(name) ?? []).filter(c => c.source === 'default');
871
+ const failing = failingHeadChecks(head);
872
+ const at = `${r.default_branch}@${(r.head_oid ?? '').slice(0, 7)}`;
873
+ // The collector reads the first 100 checks of a commit (most seen: 19).
874
+ // A red rollup whose failing check could lie past that page is read as
875
+ // the rollup says, never as clean.
876
+ if (failing === null || (failing.length === 0 && head.length >= 100)) {
877
+ F(name, 'CI_FAILING', 'high', 'ci', `Default-branch check rollup is ${r.ci_rollup}.`, at);
878
+ } else if (failing.length) {
879
+ F(name, 'CI_FAILING', 'high', 'ci',
880
+ `Default-branch checks failing: ${failing.map(c => `${c.name} (${[...new Set(c.states)].join(', ')})`).join('; ')}.`,
881
+ `${at}; rollup ${r.ci_rollup}`);
882
+ }
717
883
  }
718
884
  // A run only speaks for the mainline if its workflow could still be
719
885
  // triggered by a push today. Match the run back to the workflow file it
@@ -827,35 +993,13 @@ export function analyze(db, sid, { metaRepos = loadConfig().metaRepos } = {}) {
827
993
  + stale.map(c => `"${c}"`).join(', '));
828
994
  }
829
995
  if (live.length) {
830
- const seenByPr = new Map();
831
- for (const c of observed) {
832
- if (c.source !== 'pr' || c.pr_number == null) continue;
833
- if (!seenByPr.has(c.pr_number)) seenByPr.set(c.pr_number, new Set());
834
- seenByPr.get(c.pr_number).add(c.name);
835
- }
836
- // Drafts are not trying to merge, and a PR opened moments ago may have
837
- // checks still being created. The window is measured off `created_at`,
838
- // NOT `updated_at`: updated_at moves for comments, labels and rebases,
839
- // none of which say anything about whether CI ran, so a long-open PR
840
- // that someone commented on today would read as "too fresh to judge".
841
- // created_at never moves.
842
- //
843
- // This deliberately holds fire for a day. Dependabot PRs opened 90
844
- // minutes before a snapshot can have zero checks and never get any
845
- // (site-only changes against a paths-gated ci.yml), but at that age
846
- // "no checks yet" and "no checks ever" are genuinely indistinguishable.
847
- // They surface on the next run.
848
- // Residual gap: a week-old PR force-pushed seconds before a snapshot.
849
- // Rare, self-heals, and the cheap direction to be wrong in.
850
- const settled = myPrs.filter(p => !p.is_draft && (p.age_days ?? 0) >= 1);
851
- const blocked = settled
852
- .map(p => ({ p, absent: live.filter(c => !(seenByPr.get(p.number)?.has(c))) }))
853
- .filter(x => x.absent.length);
996
+ const { settled, blocked } = gatedPrs({ prs: myPrs, live, observed });
854
997
  if (blocked.length) {
855
998
  const worst = blocked.slice().sort((a, b) => b.absent.length - a.absent.length)[0];
856
999
  F(name, 'CI_REQUIRED_CHECK_GATED', 'medium', 'ci',
857
- `${blocked.length} of ${settled.length} open PRs are BLOCKED by required checks that `
858
- + `never ran on them: the job still exists, the PR's files just missed its paths filter.`,
1000
+ `${blocked.length} of ${settled.length} open PRs without conflicts are BLOCKED by required `
1001
+ + `checks that never ran on them: the job still exists, the PR's files just missed its paths filter. `
1002
+ + `rules/github-actions.md: a workflow that emits a required check takes no paths filter on pull_request.`,
859
1003
  `#${worst.p.number} is missing ${worst.absent.map(c => `"${c}"`).join(', ')}`
860
1004
  + (blocked.length > 1 ? `; also #${blocked.filter(x => x !== worst).map(x => x.p.number).join(', #')}` : ''));
861
1005
  }
@@ -947,7 +1091,12 @@ export function analyze(db, sid, { metaRepos = loadConfig().metaRepos } = {}) {
947
1091
  // "Map older than the branch" is deliberately not a finding: a map needs
948
1092
  // regenerating only when the structure changes, and `atlas check` in CI is
949
1093
  // what enforces that.
950
- for (const f of atlasMapFindings({
1094
+ //
1095
+ // A fork of another organization's project is exempt too (amended
1096
+ // 2026-10-01): its workflows are the upstream's, and a map and a CI step
1097
+ // would make it diverge from the project it tracks and conflict on every
1098
+ // sync. forkOfAnotherOrg() decides; an unknown parent is not exempt.
1099
+ if (!forkOfAnotherOrg(r, homeOrgs)) for (const f of atlasMapFindings({
951
1100
  hasMap: r.has_atlas_map,
952
1101
  workflows: myWfs,
953
1102
  scripts: myScripts.map(x => x.command ?? ''),
@@ -1051,10 +1200,19 @@ export function analyze(db, sid, { metaRepos = loadConfig().metaRepos } = {}) {
1051
1200
  + '(off by default). Push a branch and open an issue instead, or supply a PAT.',
1052
1201
  at);
1053
1202
  }
1054
- if (triggers.includes('schedule')) {
1055
- F(name, 'WF_SCHEDULED', 'medium', 'actions',
1056
- 'Scheduled (cron) workflow. rules/github-actions.md allows one only when it does what a push '
1057
- + 'cannot, runs weekly or slower, is bounded, and opens a pull request; check it against those.',
1203
+ // rules/github-actions.md, "Scheduled workflows" (amended 2026-09-08): a
1204
+ // schedule is permitted in an org repo when it does what a push cannot, runs weekly or slower ("daily
1205
+ // needs a stated reason"), is bounded, opens a PR rather than pushing, and
1206
+ // carries workflow_dispatch. Only a condition shown to fail fires; the
1207
+ // first is judgment and is never checked. A gap in cadence alone is `low`:
1208
+ // the rule allows a faster schedule with a reason, and the reason lives in
1209
+ // prose this cannot read. A NULL column is a snapshot loaded before the
1210
+ // check existed, which says nothing either way.
1211
+ const gaps = (w.schedule_gaps ?? '').split('\n').filter(Boolean);
1212
+ if (triggers.includes('schedule') && gaps.length) {
1213
+ const cadenceOnly = gaps.every(g => g.startsWith('runs more often than weekly'));
1214
+ F(name, 'WF_SCHEDULED', cadenceOnly ? 'low' : 'medium', 'actions',
1215
+ `Scheduled workflow outside rules/github-actions.md: ${gaps.join('; ')}.`,
1058
1216
  at);
1059
1217
  }
1060
1218
  }
@@ -1195,11 +1353,10 @@ export function analyze(db, sid, { metaRepos = loadConfig().metaRepos } = {}) {
1195
1353
  `Workspace root package "${r.pkg_name}" v${pv}; per-package versions are not audited yet.`,
1196
1354
  `latest tag ${r.latest_tag ?? 'none'}`);
1197
1355
  } else {
1198
- if (pv && /^0\./.test(pv)) {
1199
- F(name, 'PRE_1_0', 'medium', 'version',
1200
- `package.json is v${pv}; standard requires promotion to 1.0.0 before shipping.`,
1201
- r.pkg_name ?? '');
1202
- }
1356
+ // PRE_1_0 was retired on 2026-09-30. It filed every 0.x package as a
1357
+ // defect, but the standard changed on 2026-09-09: 1.0.0 means the product
1358
+ // is 1.0, and new or unfinished work stays 0.x. A finding against a
1359
+ // version that follows the standard is not arguable against it.
1203
1360
  if (pv && tag && pv !== tag) {
1204
1361
  F(name, 'VERSION_TAG_DRIFT', 'high', 'version',
1205
1362
  `package.json v${pv} != latest release tag v${tag}.`,
package/src/cli.mjs CHANGED
@@ -6,7 +6,7 @@
6
6
  import { readFileSync } from 'node:fs';
7
7
  import { DatabaseSync } from 'node:sqlite';
8
8
  import { openDb, loadSnapshot, latestSnapshotFile, DB_PATH } from './load.mjs';
9
- import { analyze, healthScores, RUN_IS_LIVE_MAINLINE_SIGNAL } from './analyze.mjs';
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
12
  import { resolveOrg } from './config.mjs';
@@ -61,7 +61,7 @@ const REPORTS = {
61
61
  WHERE wr.snapshot_id=r.snapshot_id AND wr.repo=r.name
62
62
  AND ${RUN_IS_LIVE_MAINLINE_SIGNAL}) failing
63
63
  FROM repo r WHERE r.snapshot_id=? AND r.is_archived=0
64
- AND (r.ci_rollup IN ('FAILURE','ERROR') OR EXISTS (
64
+ AND (${HEAD_CHECKS_RED} OR EXISTS (
65
65
  SELECT 1 FROM workflow_run wr WHERE wr.snapshot_id=r.snapshot_id AND wr.repo=r.name
66
66
  AND ${RUN_IS_LIVE_MAINLINE_SIGNAL}))
67
67
  ORDER BY r.name`).all(sid)),
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.5.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 }
@@ -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
 
package/src/config.mjs CHANGED
@@ -7,7 +7,12 @@
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
+ //
13
+ // `homeOrgs` names the organizations whose projects count as the org's own.
14
+ // A fork of a project outside them is exempt from the map rule. Unset, it is
15
+ // the swept org alone.
11
16
  //
12
17
  // No network and no database here, so every branch is unit-testable.
13
18
  import { readFileSync } from 'node:fs';
@@ -17,7 +22,7 @@ import { WORK_DIR } from './paths.mjs';
17
22
 
18
23
  export const CONFIG_PATH = process.env.HK_CONFIG ?? join(WORK_DIR, 'housekeeping.config.json');
19
24
 
20
- const KEYS = ['org', 'metaRepos'];
25
+ const KEYS = ['org', 'metaRepos', 'homeOrgs'];
21
26
  const invalid = message => userError('CONFIG_INVALID', message,
22
27
  'fix housekeeping.config.json; housekeeping.config.example.json shows the shape');
23
28
  const DEFAULT_META = ['.github'];
@@ -47,7 +52,11 @@ export function parseConfig(text) {
47
52
  if (!Array.isArray(meta) || meta.some(m => typeof m !== 'string' || !m.trim())) {
48
53
  throw invalid('housekeeping config: "metaRepos" must be an array of repo names');
49
54
  }
50
- return { org, metaRepos: [...meta] };
55
+ const home = raw.homeOrgs ?? null;
56
+ if (home !== null && (!Array.isArray(home) || !home.length || home.some(o => typeof o !== 'string' || !o.trim()))) {
57
+ throw invalid('housekeeping config: "homeOrgs" must be a non-empty array of organization names');
58
+ }
59
+ return { org, metaRepos: [...meta], homeOrgs: home ? [...home] : null };
51
60
  }
52
61
 
53
62
  /** Read the config file. A missing file is the defaults; an unreadable or malformed one throws. */
@@ -55,7 +64,7 @@ export function loadConfig(path = CONFIG_PATH) {
55
64
  let text;
56
65
  try { text = readFileSync(path, 'utf8'); }
57
66
  catch (e) {
58
- if (e.code === 'ENOENT') return { org: null, metaRepos: [...DEFAULT_META] };
67
+ if (e.code === 'ENOENT') return { org: null, metaRepos: [...DEFAULT_META], homeOrgs: null };
59
68
  throw e;
60
69
  }
61
70
  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';
@@ -112,7 +112,7 @@ export function inspectWorkflow(text, path, defaultBranch = null, atlasEnv = nul
112
112
  on_triggers: '', job_count: 0, has_matrix: 0, size_bytes: text.length, job_names: '',
113
113
  edits_workflow_files: 0, pr_create_default_token: 0,
114
114
  audit_steps_enforcing: 0, audit_steps_defanged: '',
115
- pages_deploy_jobs: '', environments: [], atlas_check: '',
115
+ pages_deploy_jobs: '', environments: [], atlas_check: '', schedule_gaps: null,
116
116
  };
117
117
  let doc;
118
118
  try { doc = parseYaml(text, { logLevel: 'silent' }); } catch { out.state = 'parse_error'; return out; }
@@ -193,6 +193,9 @@ export function inspectWorkflow(text, path, defaultBranch = null, atlasEnv = nul
193
193
 
194
194
  out.atlas_check = atlasCheckPins(doc, jobs).join('\n');
195
195
 
196
+ const gaps = scheduleGaps(doc, jobEntries);
197
+ out.schedule_gaps = gaps ? gaps.join('\n') : null;
198
+
196
199
  // The deploy doors, joined in analyze.mjs to the settings they depend on.
197
200
  out.pages_deploy_jobs = pagesDeployJobs(jobEntries).join('\n');
198
201
  // The environment a job deploys to is a fact Atlas also records, so where
@@ -598,6 +601,7 @@ function loadSnapshotInner(db, snap) {
598
601
  sidebarShape(r.siteAstroCfg?.text),
599
602
  starlightRange(r.sitePkg?.text),
600
603
  hasAtlasMap(r),
604
+ r.isFork ? (r.parent?.owner?.login ?? null) : null,
601
605
  );
602
606
 
603
607
  // The trimmed map record, when the atlas pass read one for this repo.
@@ -695,7 +699,7 @@ function loadSnapshotInner(db, snap) {
695
699
  w.on_triggers, w.job_count, w.has_matrix, f.byteSize || w.size_bytes,
696
700
  w.edits_workflow_files, w.pr_create_default_token, w.job_names ?? '',
697
701
  w.audit_steps_enforcing ?? 0, w.audit_steps_defanged ?? '',
698
- w.pages_deploy_jobs ?? '', w.atlas_check ?? '');
702
+ w.pages_deploy_jobs ?? '', w.atlas_check ?? '', w.schedule_gaps ?? null);
699
703
  for (const e of w.environments ?? []) {
700
704
  stmts.wfEnv.run(sid, r.name, w.path, e.job, e.environment, e.runs_on_default,
701
705
  e.source ?? 'workflow', e.parsed ?? null);
package/src/mcp.mjs CHANGED
@@ -12,6 +12,7 @@ import { existsSync, readFileSync } from 'node:fs';
12
12
  import { join } from 'node:path';
13
13
  import { EXIT, formatError, readOnlyQuery, structured, userError } from './errors.mjs';
14
14
  import { DATA_DIR, PACKAGE_ROOT } from './paths.mjs';
15
+ import { HEAD_CHECKS_RED } from './analyze.mjs';
15
16
 
16
17
  const DB_FILE = process.env.HK_DB || join(DATA_DIR, 'housekeeping.db');
17
18
  const VERSION = JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8')).version;
@@ -115,7 +116,7 @@ const TOOLS = [
115
116
  WHERE wr.snapshot_id=r.snapshot_id AND wr.repo=r.name AND wr.is_latest_on_default=1
116
117
  AND wr.conclusion='failure' AND wr.event NOT IN ('dynamic','schedule')) failing
117
118
  FROM repo r WHERE r.snapshot_id=? AND r.is_archived=0
118
- AND (r.ci_rollup IN ('FAILURE','ERROR') OR EXISTS (
119
+ AND (${HEAD_CHECKS_RED} OR EXISTS (
119
120
  SELECT 1 FROM workflow_run wr WHERE wr.snapshot_id=r.snapshot_id AND wr.repo=r.name
120
121
  AND wr.is_latest_on_default=1 AND wr.conclusion='failure'
121
122
  AND wr.event NOT IN ('dynamic','schedule')))
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, 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
 
@@ -77,7 +77,7 @@ export function buildReport(db, sid) {
77
77
  AND ${RUN_IS_LIVE_MAINLINE_SIGNAL}) failing_workflows
78
78
  FROM repo r
79
79
  WHERE r.snapshot_id=? AND r.is_archived=0
80
- AND (r.ci_rollup IN ('FAILURE','ERROR') OR EXISTS (
80
+ AND (${HEAD_CHECKS_RED} OR EXISTS (
81
81
  SELECT 1 FROM workflow_run wr WHERE wr.snapshot_id=r.snapshot_id AND wr.repo=r.name
82
82
  AND ${RUN_IS_LIVE_MAINLINE_SIGNAL}))
83
83
  ORDER BY r.name`);
@@ -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