@mcptoolshop/housekeeping 1.3.1 → 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/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/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
@@ -453,3 +453,128 @@ export function resolveFailedStep(doc, jobName, stepName, stepNumber = null) {
453
453
  const st = steps[index];
454
454
  return { job: jobId, step: typeof st.name === 'string' && st.name.trim() ? st.name : String(index), index, phase };
455
455
  }
456
+
457
+ // ---------------------------------------------------------------------------
458
+ // A scheduled workflow against rules/github-actions.md, "Scheduled workflows"
459
+ // (the org rule was amended 2026-09-08).
460
+ //
461
+ // The rule permits a schedule in an org repo when ALL of these hold: it does
462
+ // something a push cannot; it runs weekly or slower (daily needs a stated
463
+ // reason); it is bounded (ubuntu-latest, an explicit timeout-minutes, a
464
+ // concurrency block); it opens a PR and never pushes to a protected branch; and
465
+ // it carries workflow_dispatch. The first is judgment and is not checked here.
466
+ // Everything below is read from the file alone, and every unknown reads as
467
+ // "not shown to fail": an expression we cannot resolve is not a violation.
468
+
469
+ const FIELD_RANGES = [[0, 59], [0, 23], [1, 31], [1, 12], [0, 6]];
470
+ const NAMES = {
471
+ 3: ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'],
472
+ 4: ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'],
473
+ };
474
+
475
+ /** Number of values a cron field admits, or null when it cannot be read. */
476
+ function cronFieldSize(text, i) {
477
+ const [lo, hi] = FIELD_RANGES[i];
478
+ const values = new Set();
479
+ const num = s => {
480
+ const k = NAMES[i]?.indexOf(s.toLowerCase());
481
+ if (k != null && k >= 0) return i === 3 ? k + 1 : k;
482
+ return /^\d+$/.test(s) ? +s : NaN;
483
+ };
484
+ for (const part of text.split(',')) {
485
+ const [range, stepText] = part.split('/');
486
+ const step = stepText == null ? 1 : +stepText;
487
+ let a, b;
488
+ if (range === '*') [a, b] = [lo, hi];
489
+ else if (range.includes('-')) [a, b] = range.split('-').map(num);
490
+ else { a = num(range); b = stepText == null ? a : hi; }
491
+ if (![a, b, step].every(Number.isFinite) || step < 1) return null;
492
+ for (let v = a; v <= b; v += step) values.add(i === 4 ? v % 7 : v);
493
+ }
494
+ return values.size;
495
+ }
496
+
497
+ /**
498
+ * How many times a year a five-field cron fires, approximately. Day-of-month
499
+ * and day-of-week are ORed by cron when both are restricted, so both count.
500
+ */
501
+ export function cronFiresPerYear(expr) {
502
+ const f = String(expr ?? '').trim().split(/\s+/);
503
+ if (f.length !== 5) return null;
504
+ const n = f.map(cronFieldSize);
505
+ if (n.some(x => x == null)) return null;
506
+ const domAll = f[2] === '*', dowAll = f[4] === '*';
507
+ const daysPerYear = domAll && dowAll ? 365
508
+ : dowAll ? n[2] * 12
509
+ : domAll ? n[4] * 52
510
+ : n[2] * 12 + n[4] * 52;
511
+ return n[0] * n[1] * daysPerYear * (n[3] / 12);
512
+ }
513
+
514
+ const PUSH = /\bgit\s+push\b/;
515
+ const NEW_BRANCH = /\bgit\s+(?:checkout\s+-[bB]|switch\s+-[cC])\s/;
516
+ const AUTO_COMMIT = /^(?:stefanzweifel\/git-auto-commit-action|EndBug\/add-and-commit|ad-m\/github-push-action)@/;
517
+
518
+ /** Does this job push to the branch it checked out (the default, on a cron)? */
519
+ function pushesCheckedOutBranch(job) {
520
+ const steps = Array.isArray(job?.steps) ? job.steps : [];
521
+ let branched = false;
522
+ for (const st of steps) {
523
+ if (!st || typeof st !== 'object') continue;
524
+ if (typeof st.uses === 'string' && AUTO_COMMIT.test(st.uses)) {
525
+ const w = st.with ?? {};
526
+ if (!w.branch && !w.create_branch && !w.new_branch) return true;
527
+ }
528
+ if (typeof st.run !== 'string') continue;
529
+ if (NEW_BRANCH.test(st.run)) branched = true;
530
+ if (PUSH.test(st.run) && !branched) return true;
531
+ }
532
+ return false;
533
+ }
534
+
535
+ /**
536
+ * The rule's mechanical conditions that a scheduled workflow fails, as short
537
+ * phrases. Empty: none shown to fail. null: the workflow has no schedule.
538
+ */
539
+ export function scheduleGaps(doc, jobEntries) {
540
+ const on = doc?.on ?? doc?.[true];
541
+ const schedule = on && typeof on === 'object' && !Array.isArray(on) ? on.schedule : null;
542
+ if (!Array.isArray(schedule)) return null;
543
+ const gaps = [];
544
+
545
+ const fires = schedule.map(s => cronFiresPerYear(s?.cron)).filter(x => x != null);
546
+ const perYear = fires.reduce((a, b) => a + b, 0);
547
+ if (perYear > 53) {
548
+ const days = 365 / perYear;
549
+ const every = Math.abs(days - 1) < 0.05 ? 'daily'
550
+ : days >= 1 ? `every ${+days.toFixed(1)} days` : `${+(perYear / 365).toFixed(1)} times a day`;
551
+ gaps.push(`runs more often than weekly (${every}, which needs a stated reason)`);
552
+ }
553
+
554
+ const offLinux = new Set();
555
+ const noTimeout = [], noConcurrency = [], pushers = [];
556
+ for (const [id, job] of jobEntries) {
557
+ if (!job || typeof job !== 'object') continue;
558
+ const reusable = typeof job.uses === 'string';
559
+ const ro = job['runs-on'];
560
+ const labels = (Array.isArray(ro) ? ro : [ro]).filter(r => typeof r === 'string');
561
+ const os = job.strategy?.matrix?.os;
562
+ for (const r of labels) {
563
+ if (r.includes('${{')) {
564
+ if (/matrix\.os/.test(r) && Array.isArray(os)) {
565
+ os.filter(o => typeof o === 'string' && !/^ubuntu-/.test(o)).forEach(o => offLinux.add(o));
566
+ }
567
+ } else if (!/^ubuntu-/.test(r)) offLinux.add(r);
568
+ }
569
+ // A job that calls a reusable workflow cannot carry timeout-minutes.
570
+ if (!reusable && job['timeout-minutes'] == null) noTimeout.push(id);
571
+ if (!job.concurrency) noConcurrency.push(id);
572
+ if (pushesCheckedOutBranch(job)) pushers.push(id);
573
+ }
574
+ if (offLinux.size) gaps.push(`runs off Linux (${[...offLinux].join(', ')})`);
575
+ if (noTimeout.length) gaps.push(`no timeout-minutes on ${noTimeout.join(', ')}`);
576
+ if (!doc.concurrency && noConcurrency.length) gaps.push('no concurrency block');
577
+ if (pushers.length) gaps.push(`pushes to the branch it checked out instead of opening a PR (${pushers.join(', ')})`);
578
+ if (!('workflow_dispatch' in on)) gaps.push('no workflow_dispatch');
579
+ return gaps;
580
+ }