@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/CHANGELOG.md +49 -0
- package/README.es.md +113 -70
- package/README.fr.md +145 -65
- package/README.hi.md +125 -65
- package/README.it.md +126 -65
- package/README.ja.md +111 -70
- package/README.md +89 -23
- package/README.pt-BR.md +144 -76
- package/README.zh.md +130 -66
- package/housekeeping.config.example.json +2 -1
- package/package.json +1 -1
- package/rules/atlas-map.md +6 -1
- package/rules/github-actions.md +7 -3
- package/src/analyze.mjs +201 -44
- package/src/cli.mjs +2 -2
- package/src/collect.mjs +5 -2
- package/src/config.mjs +13 -4
- package/src/load.mjs +7 -3
- package/src/mcp.mjs +2 -1
- package/src/report.mjs +14 -10
- package/src/schema.sql +9 -1
- package/src/workflow-risks.mjs +125 -0
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,
|
|
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
|
|
491
|
-
// fleet
|
|
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
|
|
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
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
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
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
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 (
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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,
|
|
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 (
|
|
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','
|
|
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 =
|
|
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
|
|
472
|
-
?
|
|
473
|
-
|
|
474
|
-
? '
|
|
475
|
-
|
|
476
|
-
|
|
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
|