@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/CHANGELOG.md +69 -0
- package/README.es.md +171 -81
- package/README.fr.md +161 -76
- package/README.hi.md +123 -67
- package/README.it.md +184 -81
- package/README.ja.md +116 -72
- package/README.md +91 -23
- package/README.pt-BR.md +179 -93
- package/README.zh.md +122 -75
- package/housekeeping.config.example.json +3 -1
- package/package.json +1 -1
- package/rules/atlas-map.md +6 -1
- package/rules/github-actions.md +7 -3
- package/src/analyze.mjs +148 -41
- package/src/cli.mjs +85 -2
- package/src/collect.mjs +78 -5
- package/src/config.mjs +22 -4
- package/src/load.mjs +62 -6
- package/src/report.mjs +13 -9
- package/src/schema.sql +35 -1
- package/src/workflow-risks.mjs +160 -2
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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','
|
|
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
|
|
@@ -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);
|