@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
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
// Static detection of two GitHub Actions failures that a green CI run can never
|
|
2
|
+
// surface, because both only fire on a schedule nobody is watching.
|
|
3
|
+
//
|
|
4
|
+
// Kept separate from load.mjs on purpose: these encode facts about the GitHub
|
|
5
|
+
// platform (token capabilities, org settings), which change on GitHub's cadence,
|
|
6
|
+
// not on ours. The rest of load.mjs encodes our storage layout.
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Steps flattened with their effective env (workflow < job < step).
|
|
10
|
+
*
|
|
11
|
+
* `name` and `continue-on-error` ride along because one rule below needs to
|
|
12
|
+
* know what a step is CALLED and whether it can actually fail the job -- a
|
|
13
|
+
* scanner that cannot fail is indistinguishable, in the Checks tab, from one
|
|
14
|
+
* that found nothing. Job-level continue-on-error is inherited; the step-level
|
|
15
|
+
* value wins where both are set, which is GitHub's own precedence.
|
|
16
|
+
*/
|
|
17
|
+
function flattenSteps(doc, jobs) {
|
|
18
|
+
const steps = [];
|
|
19
|
+
for (const job of jobs) {
|
|
20
|
+
if (!job || typeof job !== 'object') continue;
|
|
21
|
+
for (const st of Array.isArray(job.steps) ? job.steps : []) {
|
|
22
|
+
if (st && typeof st.run === 'string') {
|
|
23
|
+
steps.push({
|
|
24
|
+
run: st.run,
|
|
25
|
+
name: typeof st.name === 'string' ? st.name : '',
|
|
26
|
+
continueOnError: st['continue-on-error'] ?? job['continue-on-error'] ?? false,
|
|
27
|
+
env: { ...(doc?.env ?? {}), ...(job.env ?? {}), ...(st.env ?? {}) },
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return steps;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// A write must be an actual write verb aimed at the path. Reading workflow files
|
|
36
|
+
// is common and legitimate — an org guard lints them in a loop, and
|
|
37
|
+
// `gh api .../contents` is a GET. An earlier version of this rule flagged an
|
|
38
|
+
// org's `.github` repo for auditing its own workflows, which is the opposite
|
|
39
|
+
// of the defect. Forward slash only: these run on Linux runners.
|
|
40
|
+
const WF = '\\.github/workflows';
|
|
41
|
+
const WRITE_TO_WORKFLOW = new RegExp([
|
|
42
|
+
`sed\\s+-i[^\\n]*${WF}`,
|
|
43
|
+
`(?:>|>>|\\|\\s*tee)\\s*[^\\n|]*${WF}`,
|
|
44
|
+
`(?:cp|mv)\\s+[^\\n]*\\s+${WF}`,
|
|
45
|
+
`gh\\s+api\\s+[^\\n]*-X\\s*(?:PUT|POST|PATCH)[^\\n]*${WF}`,
|
|
46
|
+
].join('|'), 'i');
|
|
47
|
+
|
|
48
|
+
const COMMITS = /git\s+(?:push|commit)|gh\s+api\s+[^\n]*-X\s*(?:PUT|POST|PATCH)/i;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* GITHUB_TOKEN cannot modify anything under .github/workflows/ under ANY
|
|
52
|
+
* permission setting — there is no `workflows: write` scope to grant. A job that
|
|
53
|
+
* rewrites a workflow file and then pushes is dead on arrival; it needs a PAT
|
|
54
|
+
* with `workflow` scope, or the data moved out of the workflow file entirely.
|
|
55
|
+
*/
|
|
56
|
+
export function editsWorkflowFiles(doc, jobs) {
|
|
57
|
+
const steps = flattenSteps(doc, jobs);
|
|
58
|
+
return steps.some(s => WRITE_TO_WORKFLOW.test(s.run))
|
|
59
|
+
&& steps.some(s => COMMITS.test(s.run));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* `gh pr create` with the default token fails unless the org enables "Allow
|
|
64
|
+
* GitHub Actions to create and approve pull requests", which is off by default.
|
|
65
|
+
*
|
|
66
|
+
* A `secrets.SOME_PAT || secrets.GITHUB_TOKEN` fallback is NOT this defect — it
|
|
67
|
+
* prefers a real PAT and degrades gracefully. Flag only when the default token
|
|
68
|
+
* is the only thing the expression can resolve to.
|
|
69
|
+
*/
|
|
70
|
+
export function prCreateWithDefaultToken(doc, jobs) {
|
|
71
|
+
const defaultOnly = (v) => {
|
|
72
|
+
if (typeof v !== 'string') return false;
|
|
73
|
+
if (!/secrets\.GITHUB_TOKEN|github\.token/.test(v)) return false;
|
|
74
|
+
const named = [...v.matchAll(/secrets\.([A-Za-z0-9_]+)/g)].map(m => m[1].toUpperCase());
|
|
75
|
+
return !named.some(n => n !== 'GITHUB_TOKEN');
|
|
76
|
+
};
|
|
77
|
+
return flattenSteps(doc, jobs).some(s =>
|
|
78
|
+
/gh\s+pr\s+create/.test(s.run) && Object.values(s.env).some(defaultOnly));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// A dependency scanner that cannot fail the job.
|
|
83
|
+
//
|
|
84
|
+
// An npm/pnpm audit step that ends in `|| true` reports a green check named
|
|
85
|
+
// something like "scan dependencies" while enforcing nothing. That is strictly
|
|
86
|
+
// worse than a repo GitHub is not scanning at all, which at least has no check
|
|
87
|
+
// to point at -- here the green check is the evidence someone cites.
|
|
88
|
+
//
|
|
89
|
+
// The defect is the UNDISCLOSED defang. A step named "npm audit (advisory --
|
|
90
|
+
// does not fail the build)" is honest engineering: a human reading the Checks
|
|
91
|
+
// tab is not misled, and some repos do exactly that deliberately. Flagging it
|
|
92
|
+
// would manufacture noise, which is the failure mode this repo exists to
|
|
93
|
+
// avoid: a finding that is confident, plausible and wrong.
|
|
94
|
+
|
|
95
|
+
// A regex LITERAL, not new RegExp(['\\b…'].join('|')). The string form was the
|
|
96
|
+
// first draft and it reported zero findings across all 89 audit steps in the
|
|
97
|
+
// org, which read as "nothing to fix": '\b' inside a JS string is the BACKSPACE
|
|
98
|
+
// character, so the pattern was hunting for a 0x08 byte. Same shape as the
|
|
99
|
+
// heredoc `\b` that became a literal backspace on 2026-09-17 -- a wrong regex
|
|
100
|
+
// does not throw, it just matches nothing, and nothing looks like clean.
|
|
101
|
+
const AUDIT_CMD = /\b(?:npm|pnpm|yarn|bun)\s+audit\b|\bpip-audit\b|\bcargo\s+audit\b|\bosv-scanner\b|\bgovulncheck\b|\bsafety\s+(?:check|scan)\b|\bbundler?[\s-]audit\b|\btrivy\s+(?:fs|repo|image)\b/i;
|
|
102
|
+
|
|
103
|
+
// `npm audit signatures` verifies registry provenance, not advisories. Different
|
|
104
|
+
// control, different remediation; collapsing them would misname the finding.
|
|
105
|
+
const NOT_AN_ADVISORY_SCAN = /\baudit\s+signatures\b/i;
|
|
106
|
+
|
|
107
|
+
// Only the forms that unambiguously swallow a non-zero exit. `set +e` and
|
|
108
|
+
// trap-based handling are deliberately NOT matched: they can be followed by a
|
|
109
|
+
// real check, and guessing produces exactly the confident-but-wrong finding
|
|
110
|
+
// this repo has been bitten by three times.
|
|
111
|
+
const SWALLOWS_EXIT = /\|\|\s*(?:true|:|echo\b)|;\s*true\s*$/m;
|
|
112
|
+
|
|
113
|
+
// Wording that tells a reader the step is informational. Checked against the
|
|
114
|
+
// step name only -- a comment in the YAML is not visible in the Checks tab,
|
|
115
|
+
// which is where the misreading happens.
|
|
116
|
+
const DISCLOSES = /advisory|advisor|non[-\s]?blocking|informational|info only|report only|does not fail|doesn't fail|warn only|warning only|\bfyi\b/i;
|
|
117
|
+
|
|
118
|
+
// Swallowing the exit is the NORMAL way to write a threshold gate: run the
|
|
119
|
+
// scanner with `|| true` so the report is still written, then parse it and fail
|
|
120
|
+
// on the tier you actually care about. A "pip-audit (CRITICAL floor)" step does
|
|
121
|
+
// exactly that and ends in sys.exit(1). A step that can still fail is not
|
|
122
|
+
// defanged, whatever its middle looks like.
|
|
123
|
+
const CAN_STILL_FAIL = /\bexit\s+1\b|sys\.exit\(\s*1\s*\)|\breturn\s+1\b|::error::/;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Split a workflow's dependency-scanner steps into the ones that can fail the
|
|
127
|
+
* job and the ones that cannot-and-do-not-say-so.
|
|
128
|
+
*
|
|
129
|
+
* Both halves are needed because they answer different questions. A defanged
|
|
130
|
+
* step is a hygiene problem; a repo whose ONLY audit step is defanged has no
|
|
131
|
+
* dependency gate at all while displaying a green check named like one, and
|
|
132
|
+
* those are not the same finding: a repo can carry a defanged step and keep a
|
|
133
|
+
* blocking scan alongside it.
|
|
134
|
+
*
|
|
135
|
+
* Verified against every audit step in a whole org before being trusted, which
|
|
136
|
+
* is how a threshold gate and a summary step were found to be false positives
|
|
137
|
+
* rather than shipped as findings.
|
|
138
|
+
*/
|
|
139
|
+
export function auditSteps(doc, jobs) {
|
|
140
|
+
const enforcing = [];
|
|
141
|
+
const defanged = [];
|
|
142
|
+
for (const s of flattenSteps(doc, jobs)) {
|
|
143
|
+
if (!AUDIT_CMD.test(s.run)) continue;
|
|
144
|
+
if (NOT_AN_ADVISORY_SCAN.test(s.run)) continue;
|
|
145
|
+
|
|
146
|
+
// Join backslash continuations so a wrapped invocation reads as one line,
|
|
147
|
+
// then judge each invocation separately: a step with one gated audit and one
|
|
148
|
+
// ungated one can still fail, so it is not defanged.
|
|
149
|
+
const lines = s.run.replace(/\\\r?\n\s*/g, ' ').split('\n');
|
|
150
|
+
const invocations = lines.filter((l) => {
|
|
151
|
+
const at = l.search(AUDIT_CMD);
|
|
152
|
+
if (at < 0) return false;
|
|
153
|
+
// An audit inside `$( )` is being READ, not run as a gate -- for example
|
|
154
|
+
// rendering `npm audit --json` into a job summary. Counting that as a
|
|
155
|
+
// failed gate would flag a repo whose real gate is a separate, blocking
|
|
156
|
+
// step three lines above.
|
|
157
|
+
return !l.slice(0, at).includes('$(');
|
|
158
|
+
});
|
|
159
|
+
if (!invocations.length) continue;
|
|
160
|
+
|
|
161
|
+
// Name the step the way the Checks tab does; fall back to the command when
|
|
162
|
+
// the step is unnamed, because "" would make the finding unactionable.
|
|
163
|
+
const label = s.name || s.run.trim().split('\n')[0].slice(0, 80);
|
|
164
|
+
|
|
165
|
+
const swallowed = s.continueOnError === true || invocations.every(l => SWALLOWS_EXIT.test(l));
|
|
166
|
+
if (!swallowed || CAN_STILL_FAIL.test(s.run)) { enforcing.push(label); continue; }
|
|
167
|
+
// A step that announces itself as advisory is honest engineering, not a
|
|
168
|
+
// defect: nobody reading the Checks tab is misled. Some repos do this
|
|
169
|
+
// deliberately. It still is not an enforcing gate, so it counts as neither.
|
|
170
|
+
if (DISCLOSES.test(s.name)) continue;
|
|
171
|
+
defanged.push(label);
|
|
172
|
+
}
|
|
173
|
+
return { enforcing, defanged };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// Doors that deploy: which jobs publish to GitHub Pages, which name a
|
|
178
|
+
// deployment environment, and whether that job can run on the default branch.
|
|
179
|
+
//
|
|
180
|
+
// These read the workflow as GitHub will run it, so they sit with the other
|
|
181
|
+
// platform facts. They decide nothing about a defect: the settings a deploy
|
|
182
|
+
// needs are collected from GitHub (collect.mjs), and the rules that join the
|
|
183
|
+
// two live in analyze.mjs.
|
|
184
|
+
|
|
185
|
+
const jobSteps = job => (job && typeof job === 'object' && Array.isArray(job.steps) ? job.steps : []);
|
|
186
|
+
|
|
187
|
+
/** Job ids with a step that uses actions/deploy-pages, at any version. */
|
|
188
|
+
export function pagesDeployJobs(jobEntries) {
|
|
189
|
+
return jobEntries
|
|
190
|
+
.filter(([, job]) => jobSteps(job).some(st =>
|
|
191
|
+
typeof st?.uses === 'string' && /^actions\/deploy-pages(?:@|$)/i.test(st.uses.trim())))
|
|
192
|
+
.map(([id]) => id);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The environment a job names, or null when it names none or names it with an
|
|
197
|
+
* expression. `environment:` is either a string or `{ name, url }`. A name built
|
|
198
|
+
* from `${{ }}` cannot be known from the file, so it is left out rather than
|
|
199
|
+
* stored half-resolved -- a wrong name would be joined to the wrong settings.
|
|
200
|
+
*/
|
|
201
|
+
export function jobEnvironment(job) {
|
|
202
|
+
if (!job || typeof job !== 'object') return null;
|
|
203
|
+
const env = job.environment;
|
|
204
|
+
const name = typeof env === 'string' ? env : (env && typeof env === 'object' ? env.name : null);
|
|
205
|
+
if (typeof name !== 'string' || !name.trim() || name.includes('${{')) return null;
|
|
206
|
+
return name.trim();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const reEscape = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Does a workflow `branches:` / `branches-ignore:` pattern list match this
|
|
213
|
+
* branch? 1, 0, or null when a pattern uses syntax this does not evaluate.
|
|
214
|
+
*
|
|
215
|
+
* GitHub's filter syntax: `*` is any run of characters but `/`, `**` is any run
|
|
216
|
+
* at all. `?`, `+`, `[]` and `!` negation (whose meaning depends on order) are
|
|
217
|
+
* real syntax too, and are not evaluated here: a pattern this cannot read makes
|
|
218
|
+
* the answer unknown unless another pattern already matched outright.
|
|
219
|
+
*/
|
|
220
|
+
export function filterMatches(patterns, branch) {
|
|
221
|
+
const list = Array.isArray(patterns) ? patterns : [patterns];
|
|
222
|
+
let unknown = false;
|
|
223
|
+
for (const p of list) {
|
|
224
|
+
if (typeof p !== 'string') { unknown = true; continue; }
|
|
225
|
+
if (/[?+[\]!\\]/.test(p)) { unknown = true; continue; }
|
|
226
|
+
const re = new RegExp('^' + p.split('**').map(part =>
|
|
227
|
+
part.split('*').map(reEscape).join('[^/]*')).join('.*') + '$');
|
|
228
|
+
if (re.test(branch)) return 1;
|
|
229
|
+
}
|
|
230
|
+
return unknown ? null : 0;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Can a push to `branch` start this workflow? 1, 0 or null.
|
|
235
|
+
*
|
|
236
|
+
* Only `branches`/`branches-ignore` filter a branch push. A push trigger that
|
|
237
|
+
* declares only `tags`/`tags-ignore` runs for tags alone -- GitHub does not run
|
|
238
|
+
* a workflow for the kind of ref a filter leaves undefined -- so a
|
|
239
|
+
* `push: tags: [v*]` release never runs on the default branch.
|
|
240
|
+
*/
|
|
241
|
+
export function pushAdmitsBranch(push, branch) {
|
|
242
|
+
if (push == null || push === true || typeof push !== 'object' || Array.isArray(push)) return 1;
|
|
243
|
+
const hasBranches = 'branches' in push, hasIgnore = 'branches-ignore' in push;
|
|
244
|
+
if (!hasBranches && !hasIgnore) {
|
|
245
|
+
return ('tags' in push || 'tags-ignore' in push) ? 0 : 1;
|
|
246
|
+
}
|
|
247
|
+
if (hasBranches) return filterMatches(push.branches, branch);
|
|
248
|
+
const ignored = filterMatches(push['branches-ignore'], branch);
|
|
249
|
+
return ignored === null ? null : ignored === 1 ? 0 : 1;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Where each event runs. A scheduled run, a workflow_run follow-up and a
|
|
253
|
+
// repository_dispatch run on the default branch's latest commit whatever
|
|
254
|
+
// started them. A pull request runs on its merge ref, a release on its tag, and
|
|
255
|
+
// workflow_dispatch on whichever ref the person picks -- a release workflow
|
|
256
|
+
// dispatched from a tag is not a job that runs on main, so it is not counted as
|
|
257
|
+
// one. Every other event (workflow_call, pull_request_target, ...) is unknown.
|
|
258
|
+
const ON_DEFAULT = new Set(['schedule', 'workflow_run', 'repository_dispatch']);
|
|
259
|
+
const NOT_ON_DEFAULT = new Set(['pull_request', 'release', 'workflow_dispatch', 'merge_group']);
|
|
260
|
+
const eventOnDefault = (e, on, branch) =>
|
|
261
|
+
e === 'push' ? pushAdmitsBranch(on && typeof on === 'object' && !Array.isArray(on) ? on.push : null, branch)
|
|
262
|
+
: ON_DEFAULT.has(e) ? 1 : NOT_ON_DEFAULT.has(e) ? 0 : null;
|
|
263
|
+
|
|
264
|
+
// The job-level `if:` terms that are read. Anything else in an `if:` makes
|
|
265
|
+
// that alternative unknown: an expression this does not evaluate must never be
|
|
266
|
+
// guessed into "runs on main".
|
|
267
|
+
const IF_EVENT_IS = /^github\.event_name\s*==\s*'([^']+)'$/;
|
|
268
|
+
const IF_EVENT_NOT = /^github\.event_name\s*!=\s*'([^']+)'$/;
|
|
269
|
+
const IF_REF = /^github\.ref\s*==\s*'refs\/heads\/([^']+)'$|^'refs\/heads\/([^']+)'\s*==\s*github\.ref$/;
|
|
270
|
+
const unwrap = s => s.trim().replace(/^\((.*)\)$/, '$1').trim();
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Read one `&&`-joined alternative of a job's `if:`. Returns false when it can
|
|
274
|
+
* never hold on the default branch, null when it cannot be read, or the
|
|
275
|
+
* predicate over events it allows.
|
|
276
|
+
*/
|
|
277
|
+
function readAlternative(alt, branch) {
|
|
278
|
+
let allow = () => true;
|
|
279
|
+
for (const raw of alt.split('&&').map(unwrap)) {
|
|
280
|
+
let m;
|
|
281
|
+
if ((m = IF_EVENT_IS.exec(raw))) { const e = m[1], prev = allow; allow = x => prev(x) && x === e; continue; }
|
|
282
|
+
if ((m = IF_EVENT_NOT.exec(raw))) { const e = m[1], prev = allow; allow = x => prev(x) && x !== e; continue; }
|
|
283
|
+
if ((m = IF_REF.exec(raw))) { if ((m[1] ?? m[2]) !== branch) return false; continue; }
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
return allow;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Can this job run on the default branch? 1, 0 or null (cannot tell).
|
|
291
|
+
*
|
|
292
|
+
* The workflow's triggers say which events start it and whether each lands on
|
|
293
|
+
* the default branch; the job's `if:` then narrows which of those it runs for.
|
|
294
|
+
* Only plain `||` / `&&` over `github.event_name` and `github.ref` comparisons
|
|
295
|
+
* is read. A parenthesised mix, a function call or any other context makes the
|
|
296
|
+
* answer unknown, and unknown produces no finding.
|
|
297
|
+
*/
|
|
298
|
+
export function jobRunsOnDefaultBranch(doc, job, branch) {
|
|
299
|
+
if (!branch || !doc || typeof doc !== 'object') return null;
|
|
300
|
+
const on = doc.on ?? doc[true];
|
|
301
|
+
const names = typeof on === 'string' ? [on]
|
|
302
|
+
: Array.isArray(on) ? on
|
|
303
|
+
: on && typeof on === 'object' ? Object.keys(on) : [];
|
|
304
|
+
const events = names.map(e => [e, eventOnDefault(e, on, branch)]);
|
|
305
|
+
// No trigger lands on the default branch: nothing in an `if:` can change that.
|
|
306
|
+
if (events.every(([, v]) => v === 0)) return 0;
|
|
307
|
+
|
|
308
|
+
let alternatives = [() => true];
|
|
309
|
+
const cond = job && typeof job === 'object' ? job.if : undefined;
|
|
310
|
+
if (cond !== undefined && cond !== null) {
|
|
311
|
+
if (typeof cond !== 'string') return null;
|
|
312
|
+
const expr = cond.trim().replace(/^\$\{\{\s*([\s\S]*?)\s*\}\}$/, '$1').trim();
|
|
313
|
+
// An operator inside parentheses is grouping that a split on `||` and `&&`
|
|
314
|
+
// would misread, so the whole condition is unknown. A call such as
|
|
315
|
+
// startsWith(github.ref, 'x') has no operator inside and only makes its
|
|
316
|
+
// own alternative unknown.
|
|
317
|
+
let depth = 0;
|
|
318
|
+
for (let i = 0; i < expr.length; i++) {
|
|
319
|
+
const c = expr[i];
|
|
320
|
+
if (c === '(') depth++;
|
|
321
|
+
else if (c === ')') depth--;
|
|
322
|
+
else if (depth > 0 && (expr.startsWith('||', i) || expr.startsWith('&&', i))) return null;
|
|
323
|
+
}
|
|
324
|
+
alternatives = expr.split('||').map(unwrap).map(a => readAlternative(a, branch));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
let unknown = false;
|
|
328
|
+
for (const allow of alternatives) {
|
|
329
|
+
if (allow === false) continue;
|
|
330
|
+
if (allow === null) { unknown = true; continue; }
|
|
331
|
+
for (const [e, v] of events) {
|
|
332
|
+
if (!allow(e)) continue;
|
|
333
|
+
if (v === 1) return 1;
|
|
334
|
+
if (v === null) unknown = true;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return unknown ? null : 0;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ---------------------------------------------------------------------------
|
|
341
|
+
// `atlas check` in CI (rules/atlas-map.md): "CI runs `npx --yes
|
|
342
|
+
// @dogfood-lab/atlas@<version> check` as a step of an existing push-triggered
|
|
343
|
+
// workflow." Read from `run:` steps only. A version is anything after the `@`
|
|
344
|
+
// up to whitespace or a quote; a call with no `@<version>` is recorded as
|
|
345
|
+
// `unpinned`, because the rule forbids a floating engine and a pinned one is
|
|
346
|
+
// what the engine check compares.
|
|
347
|
+
const ATLAS_CHECK = /@dogfood-lab\/atlas(?:@([^\s'"`]+))?\s+check\b/g;
|
|
348
|
+
|
|
349
|
+
/** The pins of every `@dogfood-lab/atlas ... check` a run step issues, in order. */
|
|
350
|
+
export function atlasCheckPins(doc, jobs) {
|
|
351
|
+
const pins = [];
|
|
352
|
+
for (const s of flattenSteps(doc, jobs)) {
|
|
353
|
+
for (const m of s.run.matchAll(ATLAS_CHECK)) pins.push(m[1] ?? 'unpinned');
|
|
354
|
+
}
|
|
355
|
+
return pins;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ---------------------------------------------------------------------------
|
|
359
|
+
// Where a red run broke, in the terms an Atlas map uses. The Actions API names
|
|
360
|
+
// a failed job and step as GitHub DISPLAYS them; an Atlas door records a
|
|
361
|
+
// command by the job's key and the step's `name`, or the step's index from 0
|
|
362
|
+
// as a string when it has none. This turns one into the other by reading the
|
|
363
|
+
// workflow text, never by counting: GitHub numbers "Set up job" as step 1 and
|
|
364
|
+
// inserts container and post steps, so a step number is not an index.
|
|
365
|
+
//
|
|
366
|
+
// Display names, as the API reports them (read from live runs, 2026-09-30):
|
|
367
|
+
// a job -> its `name:` (expressions evaluated) or its key; a matrix cell
|
|
368
|
+
// of a job whose name has no expression gets " (a, b)" appended
|
|
369
|
+
// a step -> its `name:`; unnamed, "Run <uses as written>" or
|
|
370
|
+
// "Run <first line of the script>"
|
|
371
|
+
// post / pre -> "Post <that display name>", "Pre <that display name>"
|
|
372
|
+
// the runner's own steps -> "Set up job", "Complete job", container steps
|
|
373
|
+
|
|
374
|
+
/** A display template with `${{ ... }}` holes, as a whole-string pattern. */
|
|
375
|
+
function templateRe(template, matrixSuffix) {
|
|
376
|
+
const body = template.split(/\$\{\{[\s\S]*?\}\}/).map(reEscape).join('[\\s\\S]*?');
|
|
377
|
+
return new RegExp(`^${body}${matrixSuffix ? '(?: \\(.*\\))?' : ''}$`);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const RUNNER_STEPS = /^(?:Set up job|Complete job|Initialize containers|Stop containers|Set up runner|Build container for action use: .*)$/;
|
|
381
|
+
|
|
382
|
+
/** How GitHub displays step `st`, or null for a step with neither run nor uses. */
|
|
383
|
+
export function stepDisplayName(st) {
|
|
384
|
+
if (!st || typeof st !== 'object') return null;
|
|
385
|
+
if (typeof st.name === 'string' && st.name.trim()) return st.name;
|
|
386
|
+
if (typeof st.uses === 'string') return `Run ${st.uses}`;
|
|
387
|
+
if (typeof st.run === 'string') {
|
|
388
|
+
const first = st.run.split('\n').map(l => l.trim()).find(Boolean);
|
|
389
|
+
return first ? `Run ${first}` : null;
|
|
390
|
+
}
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* The Atlas reference of the step a failed run stopped in. Pure.
|
|
396
|
+
*
|
|
397
|
+
* `doc` is the parsed workflow, `jobName` and `stepName` are the API's display
|
|
398
|
+
* names, `stepNumber` the API's step number (a tie-break only). Returns
|
|
399
|
+
* `{ job, step, index, phase }` -- `phase` is 'main', 'post' or 'pre' -- or
|
|
400
|
+
* `{ job?, unresolved }` naming why no single step of this file could be
|
|
401
|
+
* identified. An unresolved answer is never guessed into a resolved one: a
|
|
402
|
+
* wrong step would pin the failure on a command that did not run.
|
|
403
|
+
*/
|
|
404
|
+
export function resolveFailedStep(doc, jobName, stepName, stepNumber = null) {
|
|
405
|
+
const jobs = doc?.jobs && typeof doc.jobs === 'object' && !Array.isArray(doc.jobs)
|
|
406
|
+
? Object.entries(doc.jobs) : [];
|
|
407
|
+
if (typeof jobName !== 'string' || !jobName) return { unresolved: 'no job name' };
|
|
408
|
+
|
|
409
|
+
// A reusable-workflow call shows as "<caller> / <called job>": its steps live
|
|
410
|
+
// in the called file, which is a different door.
|
|
411
|
+
const callers = jobs.filter(([, j]) => typeof j?.uses === 'string');
|
|
412
|
+
for (const [id, j] of callers) {
|
|
413
|
+
const t = typeof j.name === 'string' ? j.name : id;
|
|
414
|
+
if (jobName === t || jobName.startsWith(`${t} / `) || templateRe(t, true).test(jobName.split(' / ')[0])) {
|
|
415
|
+
return { job: id, unresolved: 'reusable workflow: the step is in the called file' };
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
const matches = jobs.filter(([id, j]) => {
|
|
419
|
+
if (!j || typeof j !== 'object' || typeof j.uses === 'string') return false;
|
|
420
|
+
const t = typeof j.name === 'string' ? j.name : id;
|
|
421
|
+
return templateRe(t, true).test(jobName);
|
|
422
|
+
});
|
|
423
|
+
if (!matches.length) return { unresolved: 'no job of this file displays as that name; the workflow may have changed since the run' };
|
|
424
|
+
const exact = matches.filter(([id, j]) => (typeof j.name === 'string' ? j.name : id) === jobName);
|
|
425
|
+
const hits = exact.length === 1 ? exact : matches;
|
|
426
|
+
if (hits.length > 1) return { unresolved: `the name fits ${hits.length} jobs: ${hits.map(([id]) => id).join(', ')}` };
|
|
427
|
+
const [jobId, job] = hits[0];
|
|
428
|
+
|
|
429
|
+
if (typeof stepName !== 'string' || !stepName) return { job: jobId, unresolved: 'the job failed outside any step' };
|
|
430
|
+
if (RUNNER_STEPS.test(stepName)) return { job: jobId, unresolved: `the runner's own step "${stepName}", not one the workflow declares` };
|
|
431
|
+
const m = /^(Post|Pre) (.+)$/.exec(stepName);
|
|
432
|
+
const phase = m ? m[1].toLowerCase() : 'main';
|
|
433
|
+
const shown = m ? m[2] : stepName;
|
|
434
|
+
|
|
435
|
+
const steps = Array.isArray(job.steps) ? job.steps : [];
|
|
436
|
+
const fits = [];
|
|
437
|
+
steps.forEach((st, index) => {
|
|
438
|
+
const d = stepDisplayName(st);
|
|
439
|
+
if (d == null) return;
|
|
440
|
+
const hasHole = typeof st.name === 'string' && /\$\{\{/.test(st.name);
|
|
441
|
+
if (hasHole ? templateRe(d, false).test(shown) : d === shown) fits.push(index);
|
|
442
|
+
});
|
|
443
|
+
// Post and pre steps exist only for `uses:` steps; a script step cannot own one.
|
|
444
|
+
const eligible = phase === 'main' ? fits : fits.filter(i => typeof steps[i].uses === 'string');
|
|
445
|
+
let index = eligible.length === 1 ? eligible[0] : null;
|
|
446
|
+
// Two identical steps (checkout twice): GitHub's number settles it only when
|
|
447
|
+
// "Set up job" is the one step before them, i.e. no container or services.
|
|
448
|
+
if (eligible.length > 1 && phase === 'main' && Number.isInteger(stepNumber)
|
|
449
|
+
&& !job.container && !job.services && eligible.includes(stepNumber - 2)) index = stepNumber - 2;
|
|
450
|
+
if (index == null) {
|
|
451
|
+
return { job: jobId, unresolved: eligible.length ? `the name fits ${eligible.length} steps` : 'no step of this job displays as that name; the workflow may have changed since the run' };
|
|
452
|
+
}
|
|
453
|
+
const st = steps[index];
|
|
454
|
+
return { job: jobId, step: typeof st.name === 'string' && st.name.trim() ? st.name : String(index), index, phase };
|
|
455
|
+
}
|