@mcptoolshop/housekeeping 0.0.0 → 1.3.1
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 +103 -0
- package/LICENSE +21 -0
- package/README.es.md +217 -0
- package/README.fr.md +195 -0
- package/README.hi.md +195 -0
- package/README.it.md +195 -0
- package/README.ja.md +199 -0
- package/README.md +246 -5
- package/README.pt-BR.md +222 -0
- package/README.zh.md +197 -0
- package/housekeeping.config.example.json +4 -0
- package/package.json +55 -5
- package/rules/atlas-map.md +30 -0
- package/rules/github-actions.md +59 -0
- package/rules/repo-first.md +12 -0
- package/rules/shipcheck-product-standards.md +37 -0
- package/src/analyze.mjs +1360 -0
- package/src/cli.mjs +316 -0
- package/src/collect.mjs +1184 -0
- package/src/config.mjs +75 -0
- package/src/cost.mjs +271 -0
- package/src/errors.mjs +102 -0
- package/src/gh.mjs +286 -0
- package/src/load.mjs +907 -0
- package/src/lockfile.mjs +272 -0
- package/src/log.mjs +41 -0
- package/src/mcp.mjs +298 -0
- package/src/paths.mjs +31 -0
- package/src/report.mjs +570 -0
- package/src/schema.sql +385 -0
- package/src/workflow-risks.mjs +455 -0
package/src/load.mjs
ADDED
|
@@ -0,0 +1,907 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Load a raw snapshot JSON into SQLite. The DB is derived and disposable:
|
|
3
|
+
// `npm run rebuild` reconstructs it from data/snapshots/*.json at any time.
|
|
4
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
5
|
+
import { readFileSync, readdirSync, mkdirSync, rmSync } from 'node:fs';
|
|
6
|
+
import { join, dirname } from 'node:path';
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
8
|
+
import { parse as parseYaml } from 'yaml';
|
|
9
|
+
import {
|
|
10
|
+
editsWorkflowFiles, prCreateWithDefaultToken, auditSteps,
|
|
11
|
+
pagesDeployJobs, jobEnvironment, jobRunsOnDefaultBranch, atlasCheckPins,
|
|
12
|
+
resolveFailedStep,
|
|
13
|
+
} from './workflow-risks.mjs';
|
|
14
|
+
import { safeJson } from './collect.mjs';
|
|
15
|
+
import { reconcileRepos } from './cost.mjs';
|
|
16
|
+
import { HkError, userError } from './errors.mjs';
|
|
17
|
+
import { DATA_DIR, PACKAGE_ROOT } from './paths.mjs';
|
|
18
|
+
|
|
19
|
+
export const DB_PATH = process.env.HK_DB || join(DATA_DIR, 'housekeeping.db');
|
|
20
|
+
|
|
21
|
+
const DAY = 86400_000;
|
|
22
|
+
const daysSince = iso => (iso ? Math.floor((Date.now() - Date.parse(iso)) / DAY) : null);
|
|
23
|
+
const bool = v => (v ? 1 : 0);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Flatten a statusCheckRollup into {name, state} rows.
|
|
27
|
+
*
|
|
28
|
+
* The two arms are not interchangeable and collapsing them loses findings: a
|
|
29
|
+
* required context can be satisfied by the Checks API (a job, `CheckRun.name`)
|
|
30
|
+
* OR by the legacy commit-status API (an external integration,
|
|
31
|
+
* `StatusContext.context`). Reading only CheckRun would report every
|
|
32
|
+
* status-only gate as unsatisfiable.
|
|
33
|
+
*
|
|
34
|
+
* A null rollup means the commit has no checks at all, which is a real and
|
|
35
|
+
* different observation from "not collected" -- the caller writes no rows and
|
|
36
|
+
* the analyzer sees an empty universe.
|
|
37
|
+
*/
|
|
38
|
+
export function rollupNames(rollup) {
|
|
39
|
+
return (rollup?.contexts?.nodes ?? [])
|
|
40
|
+
.map(n => (n.__typename === 'StatusContext'
|
|
41
|
+
? { name: n.context, state: n.state ?? null }
|
|
42
|
+
: { name: n.name, state: n.conclusion ?? null }))
|
|
43
|
+
.filter(c => c.name);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const TRANSLATION_RE = /^README\.[a-z]{2}(-[A-Za-z]{2,4})?\.md$/;
|
|
47
|
+
|
|
48
|
+
const SEMVER_RE = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/;
|
|
49
|
+
|
|
50
|
+
/** A tag is comparable to package.json only if it is plain semver. */
|
|
51
|
+
export function parseSemver(name) {
|
|
52
|
+
const m = SEMVER_RE.exec((name ?? '').trim());
|
|
53
|
+
if (!m) return null;
|
|
54
|
+
return {
|
|
55
|
+
major: +m[1], minor: +m[2], patch: +m[3],
|
|
56
|
+
pre: m[4] ?? null,
|
|
57
|
+
version: `${+m[1]}.${+m[2]}.${+m[3]}${m[4] ? '-' + m[4] : ''}`,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Newest-first semver ordering; a prerelease sorts below its release. */
|
|
62
|
+
export function compareSemver(a, b) {
|
|
63
|
+
for (const k of ['major', 'minor', 'patch']) {
|
|
64
|
+
if (a[k] !== b[k]) return b[k] - a[k];
|
|
65
|
+
}
|
|
66
|
+
if (a.pre === b.pre) return 0;
|
|
67
|
+
if (!a.pre) return -1;
|
|
68
|
+
if (!b.pre) return 1;
|
|
69
|
+
return a.pre < b.pre ? 1 : -1;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function openDb(path = DB_PATH, { fresh = false } = {}) {
|
|
73
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
74
|
+
if (fresh) {
|
|
75
|
+
for (const suffix of ['', '-wal', '-shm']) {
|
|
76
|
+
try { rmSync(path + suffix); } catch { /* absent */ }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const db = new DatabaseSync(path);
|
|
80
|
+
const ddl = readFileSync(join(PACKAGE_ROOT, 'src', 'schema.sql'), 'utf8');
|
|
81
|
+
db.exec(ddl);
|
|
82
|
+
|
|
83
|
+
// CREATE TABLE IF NOT EXISTS silently does nothing when a table already
|
|
84
|
+
// exists, so a column added to schema.sql never reaches an existing database
|
|
85
|
+
// and the mismatch only surfaces later as an opaque INSERT arity error.
|
|
86
|
+
// Fail here instead, with the fix in the message.
|
|
87
|
+
db.exec('CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)');
|
|
88
|
+
const fingerprint = createHash('sha256').update(ddl).digest('hex').slice(0, 16);
|
|
89
|
+
const seen = db.prepare("SELECT value FROM meta WHERE key='schema_fingerprint'").get();
|
|
90
|
+
if (!seen) {
|
|
91
|
+
db.prepare("INSERT INTO meta VALUES ('schema_fingerprint', ?)").run(fingerprint);
|
|
92
|
+
} else if (seen.value !== fingerprint) {
|
|
93
|
+
throw new HkError('SCHEMA_CHANGED',
|
|
94
|
+
`schema.sql changed since this database was built (${seen.value} -> ${fingerprint})`,
|
|
95
|
+
{ hint: 'the database is derived: run `npm run rebuild` to re-create it from data/snapshots/' });
|
|
96
|
+
}
|
|
97
|
+
return db;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Mechanically score one workflow YAML against rules/github-actions.md.
|
|
102
|
+
* Regex would misread nested keys, so this parses the document properly.
|
|
103
|
+
*
|
|
104
|
+
* `atlasEnv` maps a job key to the `environment` the repository's Atlas map
|
|
105
|
+
* records for it (Atlas 1.24.0 and later), when the map has this file.
|
|
106
|
+
*/
|
|
107
|
+
export function inspectWorkflow(text, path, defaultBranch = null, atlasEnv = null) {
|
|
108
|
+
const out = {
|
|
109
|
+
path, name: path.split('/').pop(), state: 'unknown',
|
|
110
|
+
has_paths_filter: 0, has_workflow_dispatch: 0, has_concurrency: 0,
|
|
111
|
+
runners: '', uses_macos: 0, uses_windows: 0,
|
|
112
|
+
on_triggers: '', job_count: 0, has_matrix: 0, size_bytes: text.length, job_names: '',
|
|
113
|
+
edits_workflow_files: 0, pr_create_default_token: 0,
|
|
114
|
+
audit_steps_enforcing: 0, audit_steps_defanged: '',
|
|
115
|
+
pages_deploy_jobs: '', environments: [], atlas_check: '',
|
|
116
|
+
};
|
|
117
|
+
let doc;
|
|
118
|
+
try { doc = parseYaml(text, { logLevel: 'silent' }); } catch { out.state = 'parse_error'; return out; }
|
|
119
|
+
if (!doc || typeof doc !== 'object') { out.state = 'parse_error'; return out; }
|
|
120
|
+
|
|
121
|
+
out.state = 'ok';
|
|
122
|
+
out.name = typeof doc.name === 'string' ? doc.name : out.name;
|
|
123
|
+
|
|
124
|
+
// YAML 1.1 turns a bare `on:` key into boolean true; accept either spelling.
|
|
125
|
+
const on = doc.on ?? doc[true] ?? doc['on'];
|
|
126
|
+
const triggers = on == null ? []
|
|
127
|
+
: typeof on === 'string' ? [on]
|
|
128
|
+
: Array.isArray(on) ? on
|
|
129
|
+
: Object.keys(on);
|
|
130
|
+
out.on_triggers = triggers.join(',');
|
|
131
|
+
out.has_workflow_dispatch = bool(triggers.includes('workflow_dispatch'));
|
|
132
|
+
|
|
133
|
+
// "paths-gated" only means anything for push/pull_request triggers.
|
|
134
|
+
if (on && typeof on === 'object' && !Array.isArray(on)) {
|
|
135
|
+
const gated = ['push', 'pull_request'].filter(t => on[t] && typeof on[t] === 'object');
|
|
136
|
+
const anyPushLike = ['push', 'pull_request'].some(t => t in on);
|
|
137
|
+
out.has_paths_filter = bool(
|
|
138
|
+
!anyPushLike || gated.some(t => on[t]['paths'] || on[t]['paths-ignore'])
|
|
139
|
+
);
|
|
140
|
+
} else {
|
|
141
|
+
// `on: push` bare form is by definition ungated.
|
|
142
|
+
out.has_paths_filter = bool(!triggers.some(t => t === 'push' || t === 'pull_request'));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
out.has_concurrency = bool(Boolean(doc.concurrency));
|
|
146
|
+
|
|
147
|
+
// Array-valued `jobs:` is malformed, and Object.entries would hand back "0",
|
|
148
|
+
// "1" as job ids -- names that match nothing and would make a live required
|
|
149
|
+
// context look unsatisfiable. Only a mapping has ids worth reading.
|
|
150
|
+
const jobEntries = doc.jobs && typeof doc.jobs === 'object' && !Array.isArray(doc.jobs)
|
|
151
|
+
? Object.entries(doc.jobs)
|
|
152
|
+
: [];
|
|
153
|
+
const jobs = jobEntries.map(([, j]) => j);
|
|
154
|
+
out.job_count = jobs.length;
|
|
155
|
+
|
|
156
|
+
// The check name GitHub publishes is the job's `name:` if it has one, else
|
|
157
|
+
// the job id -- with the matrix cell appended in parentheses when `name:` is
|
|
158
|
+
// absent. Both spellings are recorded because a required context can be
|
|
159
|
+
// written either way. A `name:` holding a ${{ }} expression is skipped rather
|
|
160
|
+
// than stored half-resolved: a wrong name here would claim a live job is
|
|
161
|
+
// missing, which is the one mistake this column exists to prevent.
|
|
162
|
+
const names = new Set();
|
|
163
|
+
for (const [id, job] of jobEntries) {
|
|
164
|
+
names.add(id);
|
|
165
|
+
const n = job && typeof job === 'object' ? job.name : null;
|
|
166
|
+
if (typeof n === 'string' && !n.includes('${{')) names.add(n);
|
|
167
|
+
}
|
|
168
|
+
out.job_names = [...names].join('\n');
|
|
169
|
+
|
|
170
|
+
const runners = new Set();
|
|
171
|
+
for (const job of jobs) {
|
|
172
|
+
if (!job || typeof job !== 'object') continue;
|
|
173
|
+
if (job.strategy?.matrix) out.has_matrix = 1;
|
|
174
|
+
const ro = job['runs-on'];
|
|
175
|
+
for (const r of Array.isArray(ro) ? ro : [ro]) {
|
|
176
|
+
if (typeof r === 'string') runners.add(r);
|
|
177
|
+
}
|
|
178
|
+
// matrix-driven runners: matrix.os values are the real runner labels
|
|
179
|
+
const os = job.strategy?.matrix?.os;
|
|
180
|
+
if (Array.isArray(os)) os.forEach(o => typeof o === 'string' && runners.add(o));
|
|
181
|
+
}
|
|
182
|
+
out.runners = [...runners].join(',');
|
|
183
|
+
out.uses_macos = bool([...runners].some(r => /macos/i.test(r)));
|
|
184
|
+
out.uses_windows = bool([...runners].some(r => /windows/i.test(r)));
|
|
185
|
+
|
|
186
|
+
// Platform-level failure modes live in their own module; see the notes there.
|
|
187
|
+
out.edits_workflow_files = bool(editsWorkflowFiles(doc, jobs));
|
|
188
|
+
out.pr_create_default_token = bool(prCreateWithDefaultToken(doc, jobs));
|
|
189
|
+
|
|
190
|
+
const audit = auditSteps(doc, jobs);
|
|
191
|
+
out.audit_steps_enforcing = audit.enforcing.length;
|
|
192
|
+
out.audit_steps_defanged = audit.defanged.join('\n');
|
|
193
|
+
|
|
194
|
+
out.atlas_check = atlasCheckPins(doc, jobs).join('\n');
|
|
195
|
+
|
|
196
|
+
// The deploy doors, joined in analyze.mjs to the settings they depend on.
|
|
197
|
+
out.pages_deploy_jobs = pagesDeployJobs(jobEntries).join('\n');
|
|
198
|
+
// The environment a job deploys to is a fact Atlas also records, so where
|
|
199
|
+
// the map names one it is preferred: one definition for both tools. This
|
|
200
|
+
// file's own reading is kept beside it, so a disagreement stays visible.
|
|
201
|
+
// Where the map has no name (it names none, or could not resolve one) the
|
|
202
|
+
// workflow's reading stands; both refuse `${{ }}` names.
|
|
203
|
+
for (const [id, job] of jobEntries) {
|
|
204
|
+
const parsed = jobEnvironment(job);
|
|
205
|
+
const recorded = atlasEnv?.get(id)?.name;
|
|
206
|
+
const fromMap = typeof recorded === 'string' && recorded.trim() ? recorded.trim() : null;
|
|
207
|
+
const environment = fromMap ?? parsed;
|
|
208
|
+
if (environment) {
|
|
209
|
+
out.environments.push({
|
|
210
|
+
job: id, environment,
|
|
211
|
+
runs_on_default: jobRunsOnDefaultBranch(doc, job, defaultBranch),
|
|
212
|
+
source: fromMap ? 'atlas' : 'workflow', parsed,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return out;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function filePresence(repo) {
|
|
221
|
+
const root = (repo.rootTree?.entries ?? []).map(e => e.name);
|
|
222
|
+
const gh = (repo.ghTree?.entries ?? []).map(e => e.name);
|
|
223
|
+
const has = (list, re) => bool(list.some(n => re.test(n)));
|
|
224
|
+
return {
|
|
225
|
+
readme: has(root, /^README(\.md|\.rst|\.txt)?$/i),
|
|
226
|
+
license: has(root, /^LICENSE(\.md|\.txt)?$/i),
|
|
227
|
+
changelog: has(root, /^CHANGELOG(\.md)?$/i),
|
|
228
|
+
security: bool(has(root, /^SECURITY\.md$/i) || has(gh, /^SECURITY\.md$/i)),
|
|
229
|
+
contributing: bool(has(root, /^CONTRIBUTING\.md$/i) || has(gh, /^CONTRIBUTING\.md$/i)),
|
|
230
|
+
code_of_conduct: bool(has(root, /^CODE_OF_CONDUCT\.md$/i) || has(gh, /^CODE_OF_CONDUCT\.md$/i)),
|
|
231
|
+
codeowners: bool(has(root, /^CODEOWNERS$/i) || has(gh, /^CODEOWNERS$/i)),
|
|
232
|
+
dependabot: has(gh, /^dependabot\.ya?ml$/i),
|
|
233
|
+
gitignore: has(root, /^\.gitignore$/i),
|
|
234
|
+
package_json: has(root, /^package\.json$/i),
|
|
235
|
+
ship_gate: has(root, /^SHIP_GATE\.md$/i),
|
|
236
|
+
claude_md: bool(has(root, /^CLAUDE\.md$/i) || root.includes('.claude')),
|
|
237
|
+
workflows_dir: bool((repo.wfTree?.entries ?? []).length > 0),
|
|
238
|
+
root_entries: root.length,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// A workspace root is not always declared with npm's `workspaces` field.
|
|
243
|
+
// MONOREPO_ROOT exists to stop a root's version being read as a shipped
|
|
244
|
+
// version, and it was keyed on that field alone -- so roots declared by
|
|
245
|
+
// pnpm-workspace.yaml (with or without a turbo.json beside it) were
|
|
246
|
+
// workspace roots the audit did not label. A rule written to stop
|
|
247
|
+
// over-reporting had quietly started under-reporting.
|
|
248
|
+
//
|
|
249
|
+
// Only UNAMBIGUOUS markers count. turbo.json and nx.json are deliberately
|
|
250
|
+
// excluded: both appear in single-package repos that use those tools purely
|
|
251
|
+
// for task running, and treating them as proof of a workspace would trade
|
|
252
|
+
// this under-reporting for the over-reporting the distinction exists to
|
|
253
|
+
// prevent. Every marker below exists only to declare a workspace.
|
|
254
|
+
//
|
|
255
|
+
// ...except pnpm-workspace.yaml, which STOPPED being unambiguous at pnpm 11.
|
|
256
|
+
// pnpm 11 moved settings that used to live in package.json's `pnpm` field
|
|
257
|
+
// (onlyBuiltDependencies and friends) into pnpm-workspace.yaml, and writes the
|
|
258
|
+
// file into SINGLE-package repos to hold them. Observed in a repo that is not
|
|
259
|
+
// a workspace, after one `pnpm install`:
|
|
260
|
+
//
|
|
261
|
+
// allowBuilds:
|
|
262
|
+
// better-sqlite3: set this to true or false
|
|
263
|
+
//
|
|
264
|
+
// No `packages:` key. So presence alone would start reporting MONOREPO_ROOT for
|
|
265
|
+
// any repo whose maintainer ran pnpm 11 once -- turning a rule written to fix
|
|
266
|
+
// under-reporting into an over-reporter, which is the exact trade the comment
|
|
267
|
+
// above says we are avoiding. The file now has to DECLARE a workspace.
|
|
268
|
+
//
|
|
269
|
+
// Direction of error is chosen deliberately. MONOREPO_ROOT *suppresses*
|
|
270
|
+
// version-drift findings, so a false positive HIDES a real problem while a
|
|
271
|
+
// false negative only emits noise a human will check and dismiss. Where the
|
|
272
|
+
// content is genuinely unavailable, prefer the noisy error.
|
|
273
|
+
export const WORKSPACE_MARKERS = [
|
|
274
|
+
'pnpm-workspace.yaml', 'pnpm-workspace.yml', 'lerna.json', 'rush.json',
|
|
275
|
+
];
|
|
276
|
+
|
|
277
|
+
// The subset whose presence still proves a workspace on its own.
|
|
278
|
+
const PRESENCE_ONLY_MARKERS = ['lerna.json', 'rush.json'];
|
|
279
|
+
// The subset that must be read, not merely counted.
|
|
280
|
+
const CONTENT_CHECKED_MARKERS = ['pnpm-workspace.yaml', 'pnpm-workspace.yml'];
|
|
281
|
+
const DECLARES_PACKAGES = /^\s*packages\s*:/m;
|
|
282
|
+
|
|
283
|
+
// `pnpmWorkspaceText` is three-valued and the three values mean different things:
|
|
284
|
+
// undefined the field was never collected -- every snapshot taken before this
|
|
285
|
+
// check existed. Fall back to presence, because that is what those
|
|
286
|
+
// snapshots meant when they were written; doing otherwise would make
|
|
287
|
+
// `npm run rebuild` on historical data silently lose all 16 of the
|
|
288
|
+
// org's workspace roots and invent drift for them.
|
|
289
|
+
// null we asked and got no readable text for a file that IS in the tree.
|
|
290
|
+
// We have no evidence of a workspace, so take the noisy direction.
|
|
291
|
+
// string read it.
|
|
292
|
+
// ---- handbook sidebar shape -------------------------------------------------
|
|
293
|
+
// Starlight 0.39 removed the top-level `autogenerate` on a labelled sidebar
|
|
294
|
+
// group and introduced the nested `items: [{ autogenerate }]` form. The two are
|
|
295
|
+
// mutually exclusive, not old-and-new: building the nested form against 0.37
|
|
296
|
+
// fails with `Expected type { label, link } | { label, items } | { label } |
|
|
297
|
+
// { slug } | string`, and building the labelled form against 0.42 fails with
|
|
298
|
+
// "Support for autogenerated sidebar groups was removed in Starlight v0.39.0".
|
|
299
|
+
// Verified in both directions on live sites, 2026-09-17.
|
|
300
|
+
//
|
|
301
|
+
// So the shape alone says nothing. Only shape-paired-with-declared-range does,
|
|
302
|
+
// which is why these are two columns read together rather than one verdict.
|
|
303
|
+
|
|
304
|
+
// Comments are stripped before matching. Several repos carry prose ABOUT this
|
|
305
|
+
// migration, and matching the commentary instead of the code would report the
|
|
306
|
+
// repos that already fixed it.
|
|
307
|
+
const stripJsComments = (src) =>
|
|
308
|
+
String(src).replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1');
|
|
309
|
+
|
|
310
|
+
const LABELLED_AUTOGEN = [
|
|
311
|
+
// `{ label: 'X', autogenerate: {...} }` and the same with the keys reversed.
|
|
312
|
+
/\{[^{}]*\blabel\s*:[^{}]*\bautogenerate\s*:\s*\{[^{}]*\}[^{}]*\}/,
|
|
313
|
+
/\{[^{}]*\bautogenerate\s*:\s*\{[^{}]*\}[^{}]*\blabel\s*:[^{}]*\}/,
|
|
314
|
+
];
|
|
315
|
+
const ITEMS_AUTOGEN = /items\s*:\s*\[\s*\{\s*autogenerate\s*:/;
|
|
316
|
+
|
|
317
|
+
// null -> not measured (no site/astro.config.mjs, or a pre-collection snapshot)
|
|
318
|
+
// 'none'-> there is a config but it declares no sidebar at all
|
|
319
|
+
export function sidebarShape(astroConfigText) {
|
|
320
|
+
if (typeof astroConfigText !== 'string') return null;
|
|
321
|
+
const code = stripJsComments(astroConfigText);
|
|
322
|
+
if (!/sidebar\s*:/.test(code)) return 'none';
|
|
323
|
+
// Checked first: a config may carry both while a migration is half-done, and
|
|
324
|
+
// the labelled form is the one that breaks on >=0.39.
|
|
325
|
+
if (LABELLED_AUTOGEN.some(re => re.test(code))) return 'labeled-autogenerate';
|
|
326
|
+
if (ITEMS_AUTOGEN.test(code)) return 'items-autogenerate';
|
|
327
|
+
// A hand-written sidebar of links/slugs. Valid on every version, so it is
|
|
328
|
+
// never a finding -- some repos move here deliberately.
|
|
329
|
+
return 'explicit';
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function starlightRange(sitePkgText) {
|
|
333
|
+
const pkg = safeJson(sitePkgText);
|
|
334
|
+
if (!pkg) return null;
|
|
335
|
+
return pkg.dependencies?.['@astrojs/starlight']
|
|
336
|
+
?? pkg.devDependencies?.['@astrojs/starlight']
|
|
337
|
+
?? null;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Does the declared range admit a Starlight at or past the 0.39 break?
|
|
341
|
+
// A caret or tilde on a 0.x version pins the MINOR (^0.37.6 resolves to 0.37.x
|
|
342
|
+
// and never to 0.39), which is why a repo can carry the old shape and still be
|
|
343
|
+
// green. Returns null when the range is not parseable, so the caller can stay
|
|
344
|
+
// silent rather than guess.
|
|
345
|
+
export function rangeAdmits039(range) {
|
|
346
|
+
if (typeof range !== 'string') return null;
|
|
347
|
+
const m = range.match(/(\d+)\.(\d+)\.(\d+)/);
|
|
348
|
+
if (!m) return null;
|
|
349
|
+
const [maj, min] = [Number(m[1]), Number(m[2])];
|
|
350
|
+
if (maj > 0) return true; // 1.x+ is well past the boundary
|
|
351
|
+
if (/^[\^~]/.test(range)) return min >= 39;
|
|
352
|
+
return min >= 39; // exact pin
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ---- deploy settings --------------------------------------------------------
|
|
356
|
+
// The collector stores HTTP statuses; this is where a status becomes a fact.
|
|
357
|
+
// Only the statuses the endpoint documents as answers are read as answers:
|
|
358
|
+
// `repos/{r}/pages` is 200 when Pages is on and 404 when it is off. Anything
|
|
359
|
+
// else -- 403 on a token without the scope, a 5xx that outlived the retries,
|
|
360
|
+
// no HTTP answer at all -- is unknown, and unknown is NULL, never 0.
|
|
361
|
+
export function pagesEnabled(status) {
|
|
362
|
+
return status === 200 ? 1 : status === 404 ? 0 : null;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// GitHub's deployment branch policy: null means every branch may deploy; an
|
|
366
|
+
// object has exactly one of its two flags set. Any other shape is unknown.
|
|
367
|
+
export function environmentPolicy(policy) {
|
|
368
|
+
if (policy === null) return 'all';
|
|
369
|
+
if (!policy || typeof policy !== 'object') return null;
|
|
370
|
+
const { protected_branches: prot, custom_branch_policies: custom } = policy;
|
|
371
|
+
if (prot === true && custom !== true) return 'protected';
|
|
372
|
+
if (custom === true && prot !== true) return 'custom';
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const tri = v => (v === true ? 1 : v === false ? 0 : null);
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* One repo's deploy-settings record from the snapshot, as the rows the two
|
|
380
|
+
* tables hold. Pure. Returns null when the repo was not asked anything.
|
|
381
|
+
*/
|
|
382
|
+
export function deploySettingsRows(rec) {
|
|
383
|
+
if (!rec || typeof rec !== 'object') return null;
|
|
384
|
+
const envs = rec.environments;
|
|
385
|
+
const db = rec.default_branch;
|
|
386
|
+
const row = {
|
|
387
|
+
pages_status: rec.pages?.status ?? null,
|
|
388
|
+
pages_enabled: rec.pages ? pagesEnabled(rec.pages.status) : null,
|
|
389
|
+
pages_build_type: rec.pages?.build_type ?? null,
|
|
390
|
+
pages_source_branch: rec.pages?.source_branch ?? null,
|
|
391
|
+
environments_status: envs?.status ?? null,
|
|
392
|
+
default_branch: db?.name ?? null,
|
|
393
|
+
default_protected: tri(db?.protected),
|
|
394
|
+
any_branch_protected: tri(db?.any_protected),
|
|
395
|
+
default_ruleset_rules: Number.isInteger(db?.ruleset_rules) ? db.ruleset_rules : null,
|
|
396
|
+
};
|
|
397
|
+
// A list is only trusted when the call answered 200 and returned all of it;
|
|
398
|
+
// a repo with more environments than one page holds is read as unknown.
|
|
399
|
+
const listed = envs?.status === 200 && Array.isArray(envs.list)
|
|
400
|
+
&& (envs.total_count == null || envs.total_count <= envs.list.length);
|
|
401
|
+
if (envs && !listed) row.environments_status = envs.status === 200 ? null : (envs.status ?? null);
|
|
402
|
+
const environments = listed ? envs.list.map(e => {
|
|
403
|
+
const bp = e.branch_policies;
|
|
404
|
+
const readable = bp === undefined ? null
|
|
405
|
+
: (bp?.status === 200 && Array.isArray(bp.list)
|
|
406
|
+
&& (bp.total_count == null || bp.total_count <= bp.list.length)) ? 1 : 0;
|
|
407
|
+
return {
|
|
408
|
+
name: e.name,
|
|
409
|
+
policy: environmentPolicy(e.deployment_branch_policy),
|
|
410
|
+
branch_policies: readable === 1
|
|
411
|
+
? bp.list.map(p => `${p.type ?? 'branch'}:${p.name}`).join('\n') : null,
|
|
412
|
+
policies_readable: readable,
|
|
413
|
+
};
|
|
414
|
+
}) : [];
|
|
415
|
+
return { row, environments };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ---- the committed Atlas map ------------------------------------------------
|
|
419
|
+
// Three-valued, like the pnpm workspace text above: the key is absent from
|
|
420
|
+
// every snapshot taken before collector 1.3.0, and reading that absence as
|
|
421
|
+
// "no map" would file a missing-map finding against every repo in history.
|
|
422
|
+
// key absent -> NULL (not collected)
|
|
423
|
+
// null -> 0 (the default branch has no atlas/structure.json)
|
|
424
|
+
// a blob with oid -> 1
|
|
425
|
+
// anything else -> NULL (the path is not a file; nothing is known)
|
|
426
|
+
export function hasAtlasMap(repo) {
|
|
427
|
+
if (!repo || !('atlasMap' in repo)) return null;
|
|
428
|
+
if (repo.atlasMap === null) return 0;
|
|
429
|
+
return typeof repo.atlasMap?.oid === 'string' ? 1 : null;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* The rows of run_failed_step for one collected run. Pure.
|
|
434
|
+
* `record` is an entry of snapshot.failed_steps.runs; `text` is the run's
|
|
435
|
+
* workflow file as the sweep read it, or null when the file is gone. A record
|
|
436
|
+
* that carries an error yields nothing -- the run was not read, which is not
|
|
437
|
+
* the same as a run with no failed step.
|
|
438
|
+
*/
|
|
439
|
+
export function failedStepRows(record, text) {
|
|
440
|
+
if (!record || record.error || !Array.isArray(record.jobs)) return [];
|
|
441
|
+
let doc = null, why = null;
|
|
442
|
+
if (text == null) why = 'the workflow file is not on the default branch any more';
|
|
443
|
+
else {
|
|
444
|
+
try { doc = parseYaml(text, { logLevel: 'silent' }); } catch { doc = null; }
|
|
445
|
+
if (!doc || typeof doc !== 'object') { doc = null; why = 'the workflow file does not parse'; }
|
|
446
|
+
}
|
|
447
|
+
const rows = [];
|
|
448
|
+
if (!record.jobs.length) {
|
|
449
|
+
const unresolved = record.job_count === 0
|
|
450
|
+
? 'no job ran, which is how GitHub fails a run whose workflow file it cannot use'
|
|
451
|
+
: 'no job failed; the run itself did';
|
|
452
|
+
return [{ api_job: null, api_step: null, step_number: null, job: null, step: null, phase: null, unresolved }];
|
|
453
|
+
}
|
|
454
|
+
for (const j of record.jobs) {
|
|
455
|
+
const steps = Array.isArray(j.steps) && j.steps.length ? j.steps : [null];
|
|
456
|
+
for (const s of steps) {
|
|
457
|
+
const at = doc ? resolveFailedStep(doc, j.name, s?.name ?? null, s?.number ?? null) : { unresolved: why };
|
|
458
|
+
rows.push({
|
|
459
|
+
api_job: j.name ?? null, api_step: s?.name ?? null, step_number: s?.number ?? null,
|
|
460
|
+
job: at.job ?? null, step: at.step ?? null, phase: at.phase ?? null, unresolved: at.unresolved ?? null,
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return rows;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
export function isWorkspaceRoot(pkg, rootEntryNames = [], pnpmWorkspaceText) {
|
|
468
|
+
if (pkg?.workspaces) return true;
|
|
469
|
+
const names = new Set(rootEntryNames.map(n => String(n).toLowerCase()));
|
|
470
|
+
if (PRESENCE_ONLY_MARKERS.some(m => names.has(m))) return true;
|
|
471
|
+
if (!CONTENT_CHECKED_MARKERS.some(m => names.has(m))) return false;
|
|
472
|
+
if (pnpmWorkspaceText === undefined) return true;
|
|
473
|
+
if (typeof pnpmWorkspaceText !== 'string') return false;
|
|
474
|
+
return DECLARES_PACKAGES.test(pnpmWorkspaceText);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export function loadSnapshot(db, snap) {
|
|
478
|
+
db.exec('BEGIN');
|
|
479
|
+
try {
|
|
480
|
+
const sid = loadSnapshotInner(db, snap);
|
|
481
|
+
db.exec('COMMIT');
|
|
482
|
+
return sid;
|
|
483
|
+
} catch (e) {
|
|
484
|
+
db.exec('ROLLBACK'); // never leave a half-written snapshot behind
|
|
485
|
+
throw e;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function loadSnapshotInner(db, snap) {
|
|
490
|
+
const ins = sql => db.prepare(sql);
|
|
491
|
+
const snapStmt = ins(`INSERT INTO snapshot
|
|
492
|
+
(taken_at, org, collector_version, repo_count, duration_ms, gh_login, notes)
|
|
493
|
+
VALUES (?,?,?,?,?,?,?)`);
|
|
494
|
+
snapStmt.run(snap.taken_at, snap.org, snap.collector_version,
|
|
495
|
+
snap.repo_count, snap.duration_ms, snap.gh_login ?? null, null);
|
|
496
|
+
const sid = db.prepare('SELECT last_insert_rowid() AS id').get().id;
|
|
497
|
+
|
|
498
|
+
const npmByRepo = new Map((snap.npm ?? []).map(n => [n.repo, n]));
|
|
499
|
+
const failedByRepo = new Map();
|
|
500
|
+
for (const x of snap.failed_steps?.runs ?? []) {
|
|
501
|
+
if (!failedByRepo.has(x.repo)) failedByRepo.set(x.repo, []);
|
|
502
|
+
failedByRepo.get(x.repo).push(x);
|
|
503
|
+
}
|
|
504
|
+
const runsByRepo = new Map();
|
|
505
|
+
for (const r of snap.workflow_runs ?? []) {
|
|
506
|
+
if (!runsByRepo.has(r.repo)) runsByRepo.set(r.repo, []);
|
|
507
|
+
runsByRepo.get(r.repo).push(r);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Placeholders are derived from the live schema, so adding a column to
|
|
511
|
+
// schema.sql can never silently desync the INSERTs below.
|
|
512
|
+
const insertInto = table => {
|
|
513
|
+
const cols = db.prepare(`PRAGMA table_info(${table})`).all().length;
|
|
514
|
+
return ins(`INSERT INTO ${table} VALUES (${Array(cols).fill('?').join(',')})`);
|
|
515
|
+
};
|
|
516
|
+
const stmts = {
|
|
517
|
+
repo: insertInto('repo'),
|
|
518
|
+
topic: insertInto('topic'),
|
|
519
|
+
lang: insertInto('language'),
|
|
520
|
+
pkgScript: insertInto('package_script'),
|
|
521
|
+
pkgDep: insertInto('package_dep'),
|
|
522
|
+
issue: insertInto('issue'),
|
|
523
|
+
pr: insertInto('pull_request'),
|
|
524
|
+
rel: insertInto('release'),
|
|
525
|
+
tag: insertInto('tag'),
|
|
526
|
+
wf: insertInto('workflow'),
|
|
527
|
+
wfEnv: insertInto('workflow_environment'),
|
|
528
|
+
atlasMap: insertInto('atlas_map'),
|
|
529
|
+
atlasDoor: insertInto('atlas_door'),
|
|
530
|
+
atlasCmd: insertInto('atlas_door_command'),
|
|
531
|
+
atlasFleet: insertInto('atlas_fleet'),
|
|
532
|
+
deploy: insertInto('deploy_settings'),
|
|
533
|
+
env: insertInto('environment'),
|
|
534
|
+
run: insertInto('workflow_run'),
|
|
535
|
+
failedStep: insertInto('run_failed_step'),
|
|
536
|
+
fp: insertInto('file_presence'),
|
|
537
|
+
secAlert: insertInto('security_alert'),
|
|
538
|
+
repoSec: insertInto('repo_security'),
|
|
539
|
+
lock: insertInto('lockfile'),
|
|
540
|
+
lockAdv: insertInto('lockfile_advisory'),
|
|
541
|
+
bprot: insertInto('branch_protection'),
|
|
542
|
+
ctx: insertInto('check_context'),
|
|
543
|
+
bill: insertInto('billing_usage'),
|
|
544
|
+
runCost: insertInto('run_cost'),
|
|
545
|
+
runCostJob: insertInto('run_cost_job'),
|
|
546
|
+
recon: insertInto('cost_reconciliation'),
|
|
547
|
+
err: insertInto('collect_error'),
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
for (const r of snap.repos) {
|
|
551
|
+
const pkg = safeJson(r.pkg?.text);
|
|
552
|
+
const npm = npmByRepo.get(r.name);
|
|
553
|
+
const latestRel = (r.releases?.nodes ?? []).find(x => x.isLatest) ?? r.releases?.nodes?.[0] ?? null;
|
|
554
|
+
const wfFiles = snap.workflow_files?.[r.name] ?? [];
|
|
555
|
+
const translations = (r.rootTree?.entries ?? [])
|
|
556
|
+
.filter(e => TRANSLATION_RE.test(e.name)).length;
|
|
557
|
+
|
|
558
|
+
// Tags and Releases are different objects. Track both so "tagged but never
|
|
559
|
+
// released" stops masquerading as version drift.
|
|
560
|
+
const releaseTags = new Set((r.releases?.nodes ?? []).map(x => x.tagName));
|
|
561
|
+
const allTags = (r.tags?.nodes ?? []).map(t => ({
|
|
562
|
+
name: t.name,
|
|
563
|
+
at: t.target?.committedDate ?? t.target?.tagger?.date ?? null,
|
|
564
|
+
sem: parseSemver(t.name),
|
|
565
|
+
}));
|
|
566
|
+
const semTags = allTags.filter(t => t.sem).sort((a, b) => compareSemver(a.sem, b.sem));
|
|
567
|
+
const newestTag = semTags[0] ?? null;
|
|
568
|
+
|
|
569
|
+
stmts.repo.run(
|
|
570
|
+
sid, r.name, r.id, r.description ?? null, r.url ?? null, r.homepageUrl ?? null,
|
|
571
|
+
bool(r.isPrivate), bool(r.isArchived), bool(r.isFork), bool(r.isTemplate), bool(r.isEmpty),
|
|
572
|
+
r.createdAt, r.updatedAt, r.pushedAt, daysSince(r.pushedAt),
|
|
573
|
+
r.diskUsage ?? 0, r.stargazerCount ?? 0, r.forkCount ?? 0, r.watchers?.totalCount ?? 0,
|
|
574
|
+
r.primaryLanguage?.name ?? null, r.licenseInfo?.spdxId ?? null,
|
|
575
|
+
bool(r.hasIssuesEnabled), bool(r.hasWikiEnabled), bool(r.hasDiscussionsEnabled), null,
|
|
576
|
+
r.defaultBranchRef?.name ?? null,
|
|
577
|
+
r.defaultBranchRef?.target?.oid ?? null,
|
|
578
|
+
r.defaultBranchRef?.target?.committedDate ?? null,
|
|
579
|
+
r.defaultBranchRef?.target?.statusCheckRollup?.state ?? 'NONE',
|
|
580
|
+
r.openIssues?.totalCount ?? 0, r.closedIssues?.totalCount ?? 0,
|
|
581
|
+
r.openPRsCount?.totalCount ?? 0, r.mergedPRsCount?.totalCount ?? 0, r.closedPRsCount?.totalCount ?? 0,
|
|
582
|
+
r.releases?.totalCount ?? 0, latestRel?.tagName ?? null, latestRel?.publishedAt ?? null,
|
|
583
|
+
r.tags?.totalCount ?? 0, newestTag?.name ?? null, newestTag?.at ?? null,
|
|
584
|
+
pkg?.name ?? null, pkg?.version ?? null, bool(pkg?.private),
|
|
585
|
+
npm?.latest ?? null, npm?.published_at ?? null, npm?.status ?? null,
|
|
586
|
+
r.repositoryTopics?.nodes?.length ?? 0, wfFiles.length, translations,
|
|
587
|
+
// The `in` check distinguishes "never collected" (older snapshot -- the
|
|
588
|
+
// keys are absent) from "collected, no readable blob" (key present, null).
|
|
589
|
+
// `r.pnpmWs?.text` alone collapses both to undefined and loses that.
|
|
590
|
+
bool(isWorkspaceRoot(
|
|
591
|
+
pkg,
|
|
592
|
+
(r.rootTree?.entries ?? []).map(e => e.name),
|
|
593
|
+
('pnpmWs' in r || 'pnpmWsYml' in r)
|
|
594
|
+
? (r.pnpmWs?.text ?? r.pnpmWsYml?.text ?? null)
|
|
595
|
+
: undefined,
|
|
596
|
+
)),
|
|
597
|
+
// Two halves of one fact, stored separately: see sidebarShape() above.
|
|
598
|
+
sidebarShape(r.siteAstroCfg?.text),
|
|
599
|
+
starlightRange(r.sitePkg?.text),
|
|
600
|
+
hasAtlasMap(r),
|
|
601
|
+
);
|
|
602
|
+
|
|
603
|
+
// The trimmed map record, when the atlas pass read one for this repo.
|
|
604
|
+
const am = snap.atlas_maps?.repos?.[r.name];
|
|
605
|
+
if (am) {
|
|
606
|
+
const doors = Array.isArray(am.doors) ? am.doors : [];
|
|
607
|
+
stmts.atlasMap.run(sid, r.name, am.blob ?? null, am.size ?? null,
|
|
608
|
+
am.error ? null : (am.engine ?? null), am.error ? null : (am.commit ?? null),
|
|
609
|
+
am.error ? null : doors.length, am.error ?? null);
|
|
610
|
+
const json = v => (v === undefined ? null : JSON.stringify(v));
|
|
611
|
+
for (const d of doors) {
|
|
612
|
+
stmts.atlasDoor.run(sid, r.name, d.file ?? null, d.name ?? null, d.kind ?? null,
|
|
613
|
+
json(d.triggers), json(d.sends), json(d.counts), json(d.jobs), json(d.findings),
|
|
614
|
+
json(d.unresolvedChecks));
|
|
615
|
+
for (const c of d.commands ?? []) {
|
|
616
|
+
// Atlas writes `dir`; records trimmed before collector 1.4.0 asked
|
|
617
|
+
// for `directory`, which no map carries, so either spelling is read.
|
|
618
|
+
stmts.atlasCmd.run(sid, r.name, d.file ?? null, d.name ?? null,
|
|
619
|
+
c.job ?? null, c.step == null ? null : String(c.step),
|
|
620
|
+
Array.isArray(c.programs) ? c.programs.join('\n') : null, c.dir ?? c.directory ?? null);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Branch protection + what the default branch head actually reported. A row
|
|
626
|
+
// is written only when the protection rule is readable at all; absent rows
|
|
627
|
+
// mean "not measured", which is how the analyzer stays silent on a snapshot
|
|
628
|
+
// taken without admin rights instead of declaring every repo ungated.
|
|
629
|
+
const bpr = r.defaultBranchRef?.branchProtectionRule;
|
|
630
|
+
if (bpr) {
|
|
631
|
+
const ctxs = bpr.requiredStatusCheckContexts ?? [];
|
|
632
|
+
stmts.bprot.run(
|
|
633
|
+
sid, r.name, r.defaultBranchRef?.name ?? null,
|
|
634
|
+
bool(bpr.requiresStatusChecks), bool(bpr.requiresStrictStatusChecks),
|
|
635
|
+
ctxs.length, ctxs.join('\n'),
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
for (const c of rollupNames(r.defaultBranchRef?.target?.statusCheckRollup))
|
|
639
|
+
stmts.ctx.run(sid, r.name, 'default', null, c.name, c.state);
|
|
640
|
+
|
|
641
|
+
for (const t of r.repositoryTopics?.nodes ?? []) stmts.topic.run(sid, r.name, t.topic.name);
|
|
642
|
+
|
|
643
|
+
// Scripts and declared dependencies. Cheap to store and it is the only
|
|
644
|
+
// way to ask "is this tool ever actually run?" — see WF/PKG rules.
|
|
645
|
+
for (const [name, command] of Object.entries(pkg?.scripts ?? {})) {
|
|
646
|
+
if (typeof command === 'string') stmts.pkgScript.run(sid, r.name, name, command.slice(0, 400));
|
|
647
|
+
}
|
|
648
|
+
for (const [kind, field] of [['prod', 'dependencies'], ['dev', 'devDependencies']]) {
|
|
649
|
+
for (const [name, spec] of Object.entries(pkg?.[field] ?? {})) {
|
|
650
|
+
if (typeof spec === 'string') stmts.pkgDep.run(sid, r.name, name, spec, kind);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const totalBytes = r.languages?.totalSize || 0;
|
|
655
|
+
for (const e of r.languages?.edges ?? []) {
|
|
656
|
+
stmts.lang.run(sid, r.name, e.node.name, e.size, totalBytes ? +(100 * e.size / totalBytes).toFixed(2) : 0);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
for (const i of r.issueList?.nodes ?? []) {
|
|
660
|
+
stmts.issue.run(sid, r.name, i.number, i.title, i.author?.login ?? null,
|
|
661
|
+
i.createdAt, i.updatedAt, daysSince(i.createdAt), daysSince(i.updatedAt),
|
|
662
|
+
i.comments?.totalCount ?? 0,
|
|
663
|
+
(i.labels?.nodes ?? []).map(l => l.name).join(','), i.url);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
for (const p of r.prList?.nodes ?? []) {
|
|
667
|
+
stmts.pr.run(sid, r.name, p.number, p.title, p.author?.login ?? null,
|
|
668
|
+
p.createdAt, p.updatedAt, daysSince(p.createdAt), daysSince(p.updatedAt),
|
|
669
|
+
bool(p.isDraft), p.mergeable ?? null, p.reviewDecision ?? null,
|
|
670
|
+
p.additions ?? 0, p.deletions ?? 0, p.changedFiles ?? 0,
|
|
671
|
+
p.headRefName ?? null, p.baseRefName ?? null,
|
|
672
|
+
(p.labels?.nodes ?? []).map(l => l.name).join(','), p.url);
|
|
673
|
+
for (const c of rollupNames(p.commits?.nodes?.[0]?.commit?.statusCheckRollup))
|
|
674
|
+
stmts.ctx.run(sid, r.name, 'pr', p.number, c.name, c.state);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
for (const t of allTags) {
|
|
678
|
+
stmts.tag.run(sid, r.name, t.name, t.at, bool(t.sem), bool(releaseTags.has(t.name)));
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
for (const rel of r.releases?.nodes ?? []) {
|
|
682
|
+
stmts.rel.run(sid, r.name, rel.tagName, rel.name ?? null, rel.publishedAt,
|
|
683
|
+
bool(rel.isLatest), bool(rel.isDraft), bool(rel.isPrerelease));
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Job environments as the map records them, per workflow file.
|
|
687
|
+
const mapDoors = snap.atlas_maps?.repos?.[r.name]?.error ? [] : (snap.atlas_maps?.repos?.[r.name]?.doors ?? []);
|
|
688
|
+
const atlasEnvByFile = new Map(mapDoors.filter(d => Array.isArray(d.jobs)).map(d => [d.file,
|
|
689
|
+
new Map(d.jobs.filter(j => j && typeof j.name === 'string' && j.environment).map(j => [j.name, j.environment]))]));
|
|
690
|
+
for (const f of wfFiles) {
|
|
691
|
+
const w = inspectWorkflow(f.text, f.path, r.defaultBranchRef?.name ?? null, atlasEnvByFile.get(f.path) ?? null);
|
|
692
|
+
stmts.wf.run(sid, r.name, w.path, w.name, w.state,
|
|
693
|
+
w.has_paths_filter, w.has_workflow_dispatch, w.has_concurrency,
|
|
694
|
+
w.runners, w.uses_macos, w.uses_windows,
|
|
695
|
+
w.on_triggers, w.job_count, w.has_matrix, f.byteSize || w.size_bytes,
|
|
696
|
+
w.edits_workflow_files, w.pr_create_default_token, w.job_names ?? '',
|
|
697
|
+
w.audit_steps_enforcing ?? 0, w.audit_steps_defanged ?? '',
|
|
698
|
+
w.pages_deploy_jobs ?? '', w.atlas_check ?? '');
|
|
699
|
+
for (const e of w.environments ?? []) {
|
|
700
|
+
stmts.wfEnv.run(sid, r.name, w.path, e.job, e.environment, e.runs_on_default,
|
|
701
|
+
e.source ?? 'workflow', e.parsed ?? null);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// Absent for a snapshot taken before the pass, and for a repo that was not
|
|
706
|
+
// asked: no row, which the rules read as not measured.
|
|
707
|
+
const ds = deploySettingsRows(snap.deploy_settings?.repos?.[r.name]);
|
|
708
|
+
if (ds) {
|
|
709
|
+
const d = ds.row;
|
|
710
|
+
stmts.deploy.run(sid, r.name, d.pages_status, d.pages_enabled, d.pages_build_type,
|
|
711
|
+
d.pages_source_branch, d.environments_status, d.default_branch,
|
|
712
|
+
d.default_protected, d.any_branch_protected, d.default_ruleset_rules);
|
|
713
|
+
for (const e of ds.environments) {
|
|
714
|
+
stmts.env.run(sid, r.name, e.name, e.policy, e.branch_policies, e.policies_readable);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// The API returns runs newest-first, so the first sighting of a workflow path
|
|
719
|
+
// is its latest run. Default-branch runs are tracked separately: a red
|
|
720
|
+
// dependabot PR branch is a different problem from a red main.
|
|
721
|
+
const runs = runsByRepo.get(r.name) ?? [];
|
|
722
|
+
const defaultBranch = r.defaultBranchRef?.name ?? null;
|
|
723
|
+
const seenAny = new Set(), seenDefault = new Set();
|
|
724
|
+
for (const run of runs) {
|
|
725
|
+
const isLatest = !seenAny.has(run.path);
|
|
726
|
+
if (isLatest) seenAny.add(run.path);
|
|
727
|
+
const onDefault = Boolean(defaultBranch) && run.branch === defaultBranch;
|
|
728
|
+
const isLatestDefault = onDefault && !seenDefault.has(run.path);
|
|
729
|
+
if (isLatestDefault) seenDefault.add(run.path);
|
|
730
|
+
const dur = run.created_at && run.updated_at
|
|
731
|
+
? Math.max(0, Math.round((Date.parse(run.updated_at) - Date.parse(run.created_at)) / 1000))
|
|
732
|
+
: null;
|
|
733
|
+
stmts.run.run(sid, r.name, run.workflow_name, run.path, run.run_id, run.run_number,
|
|
734
|
+
run.event, run.status, run.conclusion, run.branch,
|
|
735
|
+
run.created_at, run.updated_at, dur, run.url,
|
|
736
|
+
bool(isLatest), bool(onDefault), bool(isLatestDefault));
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
for (const x of failedByRepo.get(r.name) ?? []) {
|
|
740
|
+
const text = wfFiles.find(f => f.path === x.path)?.text ?? null;
|
|
741
|
+
for (const row of failedStepRows(x, text)) {
|
|
742
|
+
stmts.failedStep.run(sid, r.name, x.run_id, x.path, row.api_job, row.api_step,
|
|
743
|
+
row.step_number, row.job, row.step, row.phase, row.unresolved);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const fp = filePresence(r);
|
|
748
|
+
stmts.fp.run(sid, r.name, fp.readme, fp.license, fp.changelog, fp.security,
|
|
749
|
+
fp.contributing, fp.code_of_conduct, fp.codeowners, fp.dependabot,
|
|
750
|
+
fp.gitignore, fp.package_json, fp.ship_gate, fp.claude_md,
|
|
751
|
+
fp.workflows_dir, fp.root_entries);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// ---- security ---------------------------------------------------------
|
|
755
|
+
// Absent `security` means the sweep did not run (old snapshot, or no scope).
|
|
756
|
+
// Leave both tables empty in that case: the analyzer treats "no rows" as
|
|
757
|
+
// "not measured" and stays silent rather than reporting a clean org.
|
|
758
|
+
const sec = snap.security;
|
|
759
|
+
if (sec && sec.ok) {
|
|
760
|
+
const byRepo = new Map();
|
|
761
|
+
for (const a of sec.alerts ?? []) {
|
|
762
|
+
if (!a.repo) continue;
|
|
763
|
+
if (!byRepo.has(a.repo)) byRepo.set(a.repo, []);
|
|
764
|
+
byRepo.get(a.repo).push(a);
|
|
765
|
+
stmts.secAlert.run(
|
|
766
|
+
sid, a.repo, a.number ?? null, a.severity ?? null, a.ecosystem ?? null,
|
|
767
|
+
a.package ?? null, a.manifest ?? null, a.ghsa ?? null, a.summary ?? null,
|
|
768
|
+
a.created_at ?? null, daysSince(a.created_at), a.fixed_version ?? null, a.url ?? null,
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
const fixes = sec.auto_security_fixes ?? {};
|
|
772
|
+
const scanned = sec.vulnerability_alerts ?? {};
|
|
773
|
+
for (const r of snap.repos) {
|
|
774
|
+
const list = byRepo.get(r.name) ?? [];
|
|
775
|
+
const count = sev => list.filter(a => a.severity === sev).length;
|
|
776
|
+
const oldest = list.reduce((m, a) => (!m || (a.created_at && a.created_at < m) ? a.created_at : m), null);
|
|
777
|
+
const enabled = Object.prototype.hasOwnProperty.call(fixes, r.name) ? fixes[r.name] : null;
|
|
778
|
+
// A repo with no alerts still gets a row: it is how the analyzer tells
|
|
779
|
+
// "measured and clean" from "never measured".
|
|
780
|
+
const isScanned = Object.prototype.hasOwnProperty.call(scanned, r.name) ? scanned[r.name] : null;
|
|
781
|
+
stmts.repoSec.run(
|
|
782
|
+
sid, r.name, isScanned, enabled,
|
|
783
|
+
list.length, count('critical'), count('high'), count('medium'), count('low'),
|
|
784
|
+
oldest, daysSince(oldest),
|
|
785
|
+
list.filter(a => a.fixed_version).length,
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// ---- committed lockfiles, audited directly ------------------------------
|
|
791
|
+
// Absent `lockfiles` means the pass did not run (older snapshot): no rows,
|
|
792
|
+
// and the analyzer stays silent rather than reporting zero exposure.
|
|
793
|
+
// `github_alerts` joins the org alert sweep by (repo, manifest_path) so the
|
|
794
|
+
// rule can state the false negative from one row.
|
|
795
|
+
if (snap.lockfiles && sec && sec.ok) {
|
|
796
|
+
const alertsByManifest = new Map();
|
|
797
|
+
for (const a of sec.alerts ?? []) {
|
|
798
|
+
if (!a.repo || !a.manifest) continue;
|
|
799
|
+
const k = `${a.repo}\n${a.manifest}`;
|
|
800
|
+
alertsByManifest.set(k, (alertsByManifest.get(k) ?? 0) + 1);
|
|
801
|
+
}
|
|
802
|
+
for (const [repo, rows] of Object.entries(snap.lockfiles)) {
|
|
803
|
+
for (const l of rows ?? []) {
|
|
804
|
+
if (!l.path) { stmts.err.run(sid, repo, 'lockfiles', l.error ?? 'unknown'); continue; }
|
|
805
|
+
const c = l.counts ?? {};
|
|
806
|
+
stmts.lock.run(
|
|
807
|
+
sid, repo, l.path, l.sha ?? null, l.size ?? null,
|
|
808
|
+
l.lockfile_version ?? null, l.packages ?? null,
|
|
809
|
+
l.error ? null : (c.critical ?? 0), l.error ? null : (c.high ?? 0),
|
|
810
|
+
l.error ? null : (c.medium ?? 0), l.error ? null : (c.low ?? 0),
|
|
811
|
+
// NULL, not 0, when the snapshot predates dev/prod attribution. A 0
|
|
812
|
+
// here reads as "nothing ships", which turned every finding into a
|
|
813
|
+
// toolchain one on 2026-09-18 when an older snapshot was reloaded.
|
|
814
|
+
// Absent is not zero.
|
|
815
|
+
l.error || c.prod_critical === undefined ? null : c.prod_critical,
|
|
816
|
+
l.error || c.prod_high === undefined ? null : c.prod_high,
|
|
817
|
+
alertsByManifest.get(`${repo}\n${l.path}`) ?? 0,
|
|
818
|
+
l.error ?? null,
|
|
819
|
+
);
|
|
820
|
+
for (const a of l.advisories ?? []) {
|
|
821
|
+
stmts.lockAdv.run(sid, repo, l.path, a.package, a.version, a.severity,
|
|
822
|
+
a.ghsa ?? null, a.title ?? null, a.url ?? null, a.vulnerable_versions ?? null,
|
|
823
|
+
a.dev === undefined ? null : (a.dev ? 1 : 0));
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// ---- Actions cost --------------------------------------------------------
|
|
830
|
+
// Three tables that must stay independently absent. A snapshot predating this
|
|
831
|
+
// pass loads with no billing rows AND no run_cost rows; one taken without the
|
|
832
|
+
// billing scope loads with neither but records the error; one taken with
|
|
833
|
+
// billing but a blown drill budget loads billing rows and PARTIAL run_cost
|
|
834
|
+
// rows. Only the reconciliation can tell the third case from a clean one, so
|
|
835
|
+
// it is written per repo rather than inferred later.
|
|
836
|
+
if (snap.billing?.ok) {
|
|
837
|
+
for (const i of snap.billing.items ?? []) {
|
|
838
|
+
stmts.bill.run(sid, i.date, i.product, i.sku, i.unit_type,
|
|
839
|
+
i.quantity ?? null, i.price_per_unit ?? null,
|
|
840
|
+
i.gross ?? null, i.discount ?? null, i.net ?? null, i.repo ?? null);
|
|
841
|
+
}
|
|
842
|
+
} else if (snap.billing?.error) {
|
|
843
|
+
stmts.err.run(sid, null, 'billing', snap.billing.error);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
const rc = snap.run_costs;
|
|
847
|
+
if (rc?.ok) {
|
|
848
|
+
for (const r of rc.runs ?? []) {
|
|
849
|
+
stmts.runCost.run(sid, r.repo, r.run_id, r.workflow_name, r.path ?? null,
|
|
850
|
+
r.event ?? null, r.conclusion ?? null, r.created_at ?? null,
|
|
851
|
+
r.job_count ?? null, r.billed_job_count ?? null,
|
|
852
|
+
r.billable_minutes ?? null,
|
|
853
|
+
r.ubuntu_minutes ?? null, r.windows_minutes ?? null, r.macos_minutes ?? null,
|
|
854
|
+
r.self_hosted_minutes ?? null, r.unpriced_minutes ?? null,
|
|
855
|
+
r.cost_usd ?? null);
|
|
856
|
+
for (const j of r.jobs ?? []) {
|
|
857
|
+
stmts.runCostJob.run(sid, r.repo, r.run_id, j.name,
|
|
858
|
+
j.runner_labels ?? null, j.runner_class ?? null, j.conclusion ?? null,
|
|
859
|
+
j.minutes ?? null, j.cost_usd ?? null);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// Reconciliation is computed at load, not collect, so re-loading an old
|
|
864
|
+
// snapshot re-derives it under today's code -- the point of keeping the
|
|
865
|
+
// database derived. The decision itself lives in cost.mjs so the gate is
|
|
866
|
+
// unit-tested; this only writes the rows.
|
|
867
|
+
for (const row of reconcileRepos({
|
|
868
|
+
billingItems: snap.billing?.items ?? [],
|
|
869
|
+
runs: rc.runs ?? [],
|
|
870
|
+
drilled: rc.drilled ?? [],
|
|
871
|
+
ratesEstimated: rc.rates_estimated === true,
|
|
872
|
+
})) {
|
|
873
|
+
stmts.recon.run(sid, row.repo, row.computed_usd, row.billed_gross_usd,
|
|
874
|
+
row.billed_net_usd, row.ratio, row.trustworthy, row.reason);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// No row when the pass did not run (older snapshot): the engine check then
|
|
879
|
+
// has nothing to measure against and stays silent.
|
|
880
|
+
if (snap.atlas_maps?.fleet_version) {
|
|
881
|
+
const fv = snap.atlas_maps.fleet_version;
|
|
882
|
+
stmts.atlasFleet.run(sid, fv.version ?? null, fv.source ?? null, fv.error ?? null);
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
for (const e of snap.errors ?? []) stmts.err.run(sid, e.repo, e.stage, e.message);
|
|
886
|
+
return sid;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
export function latestSnapshotFile() {
|
|
890
|
+
const dir = join(DATA_DIR, 'snapshots');
|
|
891
|
+
let files = [];
|
|
892
|
+
try { files = readdirSync(dir).filter(f => f.endsWith('.json')).sort(); }
|
|
893
|
+
catch (e) { if (e.code !== 'ENOENT') throw e; } // no directory yet is the same as no snapshots
|
|
894
|
+
if (!files.length) throw userError('NO_SNAPSHOT', 'no snapshots in data/snapshots', 'run `hk refresh` to take one');
|
|
895
|
+
return join(dir, files.at(-1));
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
const invokedDirectly = process.argv[1] &&
|
|
899
|
+
import.meta.url === new URL(`file://${process.argv[1].replace(/\\/g, '/')}`).href;
|
|
900
|
+
|
|
901
|
+
if (invokedDirectly) {
|
|
902
|
+
const file = process.argv[2] ?? latestSnapshotFile();
|
|
903
|
+
const snap = JSON.parse(readFileSync(file, 'utf8'));
|
|
904
|
+
const db = openDb();
|
|
905
|
+
const sid = loadSnapshot(db, snap);
|
|
906
|
+
console.error(`[load] snapshot ${sid} <- ${file} (${snap.repo_count} repos)`);
|
|
907
|
+
}
|