@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.
@@ -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
+ }