@vodmal/vdx-cli 0.21.1 → 0.22.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/src/predicates.ts CHANGED
@@ -12,9 +12,70 @@ import {
12
12
  readStructured,
13
13
  resolveConfigPath,
14
14
  } from './facts.ts';
15
+ import { checkingJobs, ciSystems, ciTaskCalls, mainPushSteps, readWorkflows, shippingJobs, upstream } from './gha.ts';
16
+ import { detectHookFrameworks } from './hooks.ts';
17
+ import { covers, expandTasks, isVocabularyTask, projectTaskCalls } from './vocabulary.ts';
15
18
 
16
19
  type Args = Record<string, any>;
17
- type PredicateFn = (args: Args, ctx: Ctx) => boolean;
20
+
21
+ /** A fact vdx could not read here — a CI system or a hosting it does not inspect. */
22
+ export interface Unknown {
23
+ unknown: string;
24
+ }
25
+ export type Verdict = boolean | Unknown;
26
+ type PredicateFn = (args: Args, ctx: Ctx) => Verdict;
27
+
28
+ export function isUnknown(v: Verdict): v is Unknown {
29
+ return typeof v === 'object' && v !== null;
30
+ }
31
+
32
+ /**
33
+ * Removed from schema 0.3: each name promised more than its body checked
34
+ * (DL #20 of docs/tasks/vdm-gates-wiring-axis). They stay in the registry for
35
+ * sets of schema 0.2 only — the same tag gives the same score on any CLI — and
36
+ * a 0.3 set that names one does not load.
37
+ */
38
+ export const REMOVED_IN_0_3 = new Set(['gh_workflow_blocks_pr', 'git_hook_installed', 'command_succeeds']);
39
+
40
+ const DEFAULT_TEST_PATTERN =
41
+ '\\b(phpunit|pest|composer test|npm test|npm run test|yarn test|pnpm test|jest|vitest|pytest|go test|cargo test|mocha|mix test|rspec|vdx test|mise run test)\\b';
42
+
43
+ function testPattern(args: Args): RegExp {
44
+ try {
45
+ return new RegExp(String(args.pattern ?? DEFAULT_TEST_PATTERN));
46
+ } catch {
47
+ return new RegExp(DEFAULT_TEST_PATTERN);
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Above "a CI config exists" vdx reads GitHub Actions only (DL #14). What the
53
+ * workflows show decides when it says yes; a no is only a no when no other CI
54
+ * system could say otherwise.
55
+ */
56
+ function ciVerdict(ctx: Ctx, readGha: () => boolean): Verdict {
57
+ const systems = ciSystems(ctx);
58
+ if (systems.length === 0) return false;
59
+ if (systems.includes('gha') && readGha()) return true;
60
+ const others = systems.filter((s) => s !== 'gha');
61
+ if (others.length > 0) return { unknown: `vdx reads GitHub Actions only, not ${others.join(', ')}` };
62
+ return false;
63
+ }
64
+
65
+ function hookWords(args: Args): string[] {
66
+ return Array.isArray(args.tasks) ? args.tasks.map(String) : ['test', 'check'];
67
+ }
68
+
69
+ /** Tasks the hooks of `events` call, through the project's runner. */
70
+ function hookTaskCalls(ctx: Ctx, events: string[]): Set<string> {
71
+ const out = new Set<string>();
72
+ for (const fw of detectHookFrameworks(ctx)) {
73
+ for (const e of events) {
74
+ for (const cmd of fw.hooks.get(e) ?? []) for (const t of projectTaskCalls(cmd, ctx)) out.add(t);
75
+ }
76
+ }
77
+ return out;
78
+ }
18
79
 
19
80
  const warned = new Set<string>();
20
81
  function warnOnce(name: string) {
@@ -157,4 +218,65 @@ export const REGISTRY: Record<string, PredicateFn> = {
157
218
  warnOnce('command_succeeds');
158
219
  return false;
159
220
  },
221
+
222
+ // --- ci: the signal, read from GitHub Actions workflows (schema 0.3) ---
223
+
224
+ /** L2: tests run on a push to main/master, and their failure fails the job. */
225
+ gha_tests_on_push: (args, ctx) =>
226
+ ciVerdict(ctx, () => {
227
+ const re = testPattern(args);
228
+ return mainPushSteps(ctx).some(({ step }) => re.test(step.run!));
229
+ }),
230
+
231
+ /** L3: on that push CI calls the project's own tasks — each of `tasks` (or its `name:*` form). */
232
+ gha_runs_tasks: (args, ctx) =>
233
+ ciVerdict(ctx, () => {
234
+ const calls = [...ciTaskCalls(ctx)];
235
+ return hookWords(args).every((w) => calls.some((t) => isVocabularyTask(t, [w])));
236
+ }),
237
+
238
+ /** L4 (library): the testing job runs across a matrix. */
239
+ gha_test_matrix: (args, ctx) =>
240
+ ciVerdict(ctx, () => [...checkingJobs(ctx, testPattern(args))].some((j) => j.matrix)),
241
+
242
+ /** L4 (service): whatever CI ships on a push to main waits, via `needs:`, for a checking job. */
243
+ gha_ship_needs_checks: (args, ctx) =>
244
+ ciVerdict(ctx, () => {
245
+ const checking = checkingJobs(ctx, testPattern(args));
246
+ const shipping = readWorkflows(ctx)
247
+ .filter((wf) => wf.pushesMain)
248
+ .flatMap((wf) => shippingJobs(wf).map((job) => ({ wf, job })));
249
+ if (shipping.length === 0) return false;
250
+ return shipping.every(({ wf, job }) => [...upstream(wf, job)].some((j) => checking.has(j)));
251
+ }),
252
+
253
+ // --- branch-protection: only the hosting knows (schema 0.3) ---
254
+
255
+ /** L3: main takes changes only after a required check. No hosting extension yet — unknown everywhere (DL #12). */
256
+ branch_requires_checks: () => ({
257
+ unknown: 'only the hosting knows whether main is protected — vdx has no extension for it yet',
258
+ }),
259
+
260
+ // --- git-hygiene: the hook recognizer (schema 0.3) ---
261
+
262
+ /** L2: the repository declares hooks, and every framework it declares them with is switched on by an install step. */
263
+ git_hooks_arranged: (_args, ctx) => {
264
+ const fws = detectHookFrameworks(ctx);
265
+ return fws.length > 0 && fws.every((f) => f.installedBy !== null);
266
+ },
267
+
268
+ /** L3: pre-commit or pre-push calls a task of the vocabulary (`test`, `check`, `check:*`, `test:*`). */
269
+ git_hook_runs_task: (args, ctx) => {
270
+ const calls = [...hookTaskCalls(ctx, ['pre-commit', 'pre-push'])];
271
+ return calls.some((t) => isVocabularyTask(t, hookWords(args)));
272
+ },
273
+
274
+ /** L4: pre-push calls every vocabulary task CI calls on a push to main — a push past the hook does not fail CI on a check. */
275
+ git_hook_covers_ci: (args, ctx) =>
276
+ ciVerdict(ctx, () => {
277
+ const ci = [...ciTaskCalls(ctx)].filter((t) => isVocabularyTask(t, hookWords(args)));
278
+ if (ci.length === 0) return false;
279
+ const hook = expandTasks(ctx, hookTaskCalls(ctx, ['pre-push']));
280
+ return ci.every((t) => covers(ctx, hook, t));
281
+ }),
160
282
  };
package/src/report.ts CHANGED
@@ -2,6 +2,7 @@ import { Marked } from 'marked';
2
2
  import { markedTerminal } from 'marked-terminal';
3
3
  import Table from 'cli-table3';
4
4
  import type { AuditResult } from './audit.ts';
5
+ import { shownLevel } from './scoring.ts';
5
6
  import type { DoctorReport, CheckStatus } from './doctor.ts';
6
7
 
7
8
  const ANSI = {
@@ -43,7 +44,27 @@ export function reportMarkdown(r: AuditResult): string {
43
44
  for (const a of r.per_axis) {
44
45
  const cls = a.class === 'critical' ? '**C**' : 's';
45
46
  const drift = a.suppressed ? '— suppressed —' : `${SYMBOL[a.drift_kind] ?? ''} ${a.drift_kind}`;
46
- lines.push(`| \`${a.axis_id}\` | ${cls} | ${a.achieved} | ${a.target} | ${drift} |`);
47
+ lines.push(`| \`${a.axis_id}\` | ${cls} | ${achievedCell(a)} | ${a.target} | ${drift} |`);
48
+ }
49
+
50
+ const unknown = r.per_axis.filter((a) => a.unknown);
51
+ if (unknown.length > 0) {
52
+ lines.push('');
53
+ lines.push(`## Not checked`);
54
+ lines.push('');
55
+ lines.push(`Counted as not met; the level stops below.`);
56
+ lines.push('');
57
+ for (const a of unknown) lines.push(`- \`${a.axis_id}\` ${a.unknown!.level}: ${a.unknown!.reason}`);
58
+ }
59
+
60
+ const notes = r.per_axis.filter((a) => a.note);
61
+ if (notes.length > 0) {
62
+ lines.push('');
63
+ lines.push(`## In this clone`);
64
+ lines.push('');
65
+ lines.push(`Not part of the level: the same repository scores the same in every clone.`);
66
+ lines.push('');
67
+ for (const a of notes) lines.push(`- \`${a.axis_id}\`: ${a.note}`);
47
68
  }
48
69
 
49
70
  if (r.overrides.length > 0) {
@@ -65,6 +86,16 @@ export function reportMarkdown(r: AuditResult): string {
65
86
  return lines.join('\n') + '\n';
66
87
  }
67
88
 
89
+ /** `L1`, `— (L3 ?)` for an axis that asks nothing below an unchecked L3, `L1 · L2 ?`, `L2 ⚑` with a clone note. */
90
+ function achievedCell(a: AuditResult['per_axis'][number]): string {
91
+ if (a.suppressed || a.drift_kind === 'excluded') return a.achieved;
92
+ const shown = shownLevel(a);
93
+ let cell = shown ?? '—';
94
+ if (a.unknown) cell += shown ? ` · ${a.unknown.level} ?` : ` (${a.unknown.level} ?)`;
95
+ if (a.note) cell += ' ⚑';
96
+ return cell;
97
+ }
98
+
68
99
  export function reportJson(r: AuditResult): string {
69
100
  return JSON.stringify(r, null, 2);
70
101
  }
package/src/rubric.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import * as fs from 'node:fs';
2
2
  import YAML from 'js-yaml';
3
+ import { COMBINATORS, predicateNames } from './evaluator.ts';
4
+ import { REGISTRY, REMOVED_IN_0_3 } from './predicates.ts';
3
5
 
4
6
  export type LevelName = 'L0' | 'L1' | 'L2' | 'L3' | 'L4';
5
7
  export type AxisClass = 'critical' | 'supporting';
@@ -27,10 +29,23 @@ export interface Axis {
27
29
  storage: Storage;
28
30
  default_target: LevelName;
29
31
  fact_sources?: string[];
30
- levels?: Partial<Record<LevelName, { requires: Predicate }>>;
32
+ /**
33
+ * A level is either a requirement or, from schema 0.3, `not_required`: the
34
+ * axis asks nothing at that level (branch-protection below L3). Such a level
35
+ * counts as met for the overall level, and the report does not show it as
36
+ * an achievement.
37
+ */
38
+ levels?: Partial<Record<LevelName, { requires?: Predicate; not_required?: boolean }>>;
31
39
  stack_implementations?: Record<string, StackImpl>;
32
40
  applies_to?: string[];
33
41
  applies_when?: Predicate;
42
+ /**
43
+ * From schema 0.3: a clone check whose result `vdx audit` prints next to the
44
+ * axis without changing its level — `git-hooks`: are the declared hooks on
45
+ * in this clone. A clone property cannot be a level (the same tag would score
46
+ * differently per clone), and without the note only `vdx doctor` would see it.
47
+ */
48
+ clone_check?: 'git-hooks';
34
49
  }
35
50
 
36
51
  export interface Rubric {
@@ -50,16 +65,77 @@ export interface Rubric {
50
65
  axes: Axis[];
51
66
  }
52
67
 
53
- export function loadRubric(yamlPath: string): Rubric {
54
- const text = fs.readFileSync(yamlPath, 'utf8');
68
+ /** The newest rubric schema this CLI reads. A newer set is refused, not misread. */
69
+ export const SUPPORTED_SCHEMA = '0.3';
70
+
71
+ function schemaParts(v: string): [number, number] | null {
72
+ const m = String(v).match(/^(\d+)\.(\d+)$/);
73
+ return m ? [parseInt(m[1]!, 10), parseInt(m[2]!, 10)] : null;
74
+ }
75
+
76
+ /** -1 / 0 / 1 as `a` is older / equal / newer than `b`; null when either is not `M.m`. */
77
+ export function compareSchema(a: string, b: string): number | null {
78
+ const pa = schemaParts(a);
79
+ const pb = schemaParts(b);
80
+ if (!pa || !pb) return null;
81
+ return pa[0] !== pb[0] ? Math.sign(pa[0] - pb[0]) : Math.sign(pa[1] - pb[1]);
82
+ }
83
+
84
+ /**
85
+ * Predicates a 0.3 set may not name: removed ones, and any the registry does
86
+ * not know. In 0.2 an unknown name is a warning at evaluation, as it was.
87
+ */
88
+ function invalidPredicates(rubric: Rubric): string[] {
89
+ const out = new Set<string>();
90
+ const check = (p: unknown, where: string) => {
91
+ for (const name of predicateNames(p)) {
92
+ if ((COMBINATORS as readonly string[]).includes(name)) continue;
93
+ if (REMOVED_IN_0_3.has(name)) out.add(`${where}: ${name} (removed in schema 0.3)`);
94
+ else if (!REGISTRY[name]) out.add(`${where}: ${name} (unknown predicate)`);
95
+ }
96
+ };
97
+ for (const ax of rubric.axes) {
98
+ if (ax.applies_when !== undefined) check(ax.applies_when, `${ax.id}.applies_when`);
99
+ for (const [L, lvl] of Object.entries(ax.levels ?? {})) {
100
+ if (lvl?.requires !== undefined) check(lvl.requires, `${ax.id}.${L}`);
101
+ }
102
+ for (const [stack, impl] of Object.entries(ax.stack_implementations ?? {})) {
103
+ for (const f of impl.flags ?? []) check(f.predicate, `${ax.id}.${stack}.${f.id}`);
104
+ }
105
+ }
106
+ return [...out];
107
+ }
108
+
109
+ export function parseRubric(text: string, source: string): Rubric {
55
110
  const data = YAML.load(text) as Rubric;
56
111
  if (!data || !data.schema_version || !data.axes) {
57
- throw new Error(`Invalid rubric at ${yamlPath}: missing schema_version or axes`);
112
+ throw new Error(`Invalid rubric at ${source}: missing schema_version or axes`);
113
+ }
114
+ const schema = String(data.schema_version);
115
+ const cmp = compareSchema(schema, SUPPORTED_SCHEMA);
116
+ if (cmp === null) {
117
+ throw new Error(`Invalid rubric at ${source}: schema_version "${schema}" is not of the form M.m`);
118
+ }
119
+ if (cmp > 0) {
120
+ throw new Error(
121
+ `rubric ${source} is schema ${schema}; this vdx reads up to ${SUPPORTED_SCHEMA} — ` +
122
+ `update vdx (npm i -g @vodmal/vdx-cli@latest)`,
123
+ );
58
124
  }
59
125
  // нормализуем default_target (если кто-то напишет "L4" со spaces)
60
126
  for (const ax of data.axes) {
61
127
  ax.default_target = (ax.default_target ?? 'L4') as LevelName;
62
128
  ax.storage = (ax.storage ?? 'level') as Storage;
63
129
  }
130
+ if (compareSchema(schema, '0.3')! >= 0) {
131
+ const bad = invalidPredicates(data);
132
+ if (bad.length > 0) {
133
+ throw new Error(`rubric ${source} (schema ${schema}) names predicates it may not:\n ${bad.join('\n ')}`);
134
+ }
135
+ }
64
136
  return data;
65
137
  }
138
+
139
+ export function loadRubric(yamlPath: string): Rubric {
140
+ return parseRubric(fs.readFileSync(yamlPath, 'utf8'), yamlPath);
141
+ }
package/src/scoring.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Axis, LevelName, Rubric } from './rubric.ts';
2
2
  import type { Ctx } from './facts.ts';
3
- import { evalPredicate } from './evaluator.ts';
3
+ import { evalPredicate, evalVerdict, isUnknown } from './evaluator.ts';
4
4
 
5
5
  const LEVELS: LevelName[] = ['L1', 'L2', 'L3', 'L4'];
6
6
 
@@ -14,25 +14,65 @@ export interface AxisResult {
14
14
  achieved: LevelName;
15
15
  target: LevelName;
16
16
  drift_kind: 'aligned' | 'gap' | 'over' | 'excluded';
17
+ /** Levels the axis asks nothing at; counted as met in `achieved`, not shown as an achievement. */
18
+ not_required?: LevelName[];
19
+ /** The level whose requirement could not be checked, and why; the ascent stopped there. */
20
+ unknown?: { level: LevelName; reason: string };
21
+ /** A clone check printed next to the axis; does not change the level. */
22
+ note?: string;
23
+ }
24
+
25
+ export interface AxisEvaluation {
26
+ achieved: LevelName;
27
+ not_required?: LevelName[];
28
+ unknown?: { level: LevelName; reason: string };
17
29
  }
18
30
 
19
31
  export function evalAxis(axis: Axis, ctx: Ctx): LevelName {
20
- if (axis.storage === 'flags') return evalAxisFlags(axis, ctx);
32
+ return evalAxisDetailed(axis, ctx).achieved;
33
+ }
34
+
35
+ export function evalAxisDetailed(axis: Axis, ctx: Ctx): AxisEvaluation {
36
+ if (axis.storage === 'flags') return { achieved: evalAxisFlags(axis, ctx) };
21
37
  return evalAxisLevels(axis, ctx);
22
38
  }
23
39
 
24
- function evalAxisLevels(axis: Axis, ctx: Ctx): LevelName {
40
+ function evalAxisLevels(axis: Axis, ctx: Ctx): AxisEvaluation {
25
41
  let achieved: LevelName = 'L0';
42
+ const notRequired: LevelName[] = [];
43
+ let unknown: AxisEvaluation['unknown'];
26
44
  for (const L of LEVELS) {
27
45
  const lvl = axis.levels?.[L];
28
46
  if (!lvl) break;
29
- if (evalPredicate(lvl.requires, ctx)) {
47
+ if (lvl.not_required === true) {
48
+ achieved = L;
49
+ notRequired.push(L);
50
+ continue;
51
+ }
52
+ const v = evalVerdict(lvl.requires, ctx);
53
+ if (v === true) {
30
54
  achieved = L;
31
55
  } else {
32
- break; // delta-style: discontinuity → stop ascent
56
+ // delta-style: discontinuity → stop ascent; unknown is "not met", named
57
+ if (isUnknown(v)) unknown = { level: L, reason: v.unknown };
58
+ break;
33
59
  }
34
60
  }
35
- return achieved;
61
+ return {
62
+ achieved,
63
+ ...(notRequired.length > 0 ? { not_required: notRequired } : {}),
64
+ ...(unknown ? { unknown } : {}),
65
+ };
66
+ }
67
+
68
+ /**
69
+ * The level a report shows: the highest met level that asked something.
70
+ * `null` — none did (an unprotected repo's branch-protection is not "L2").
71
+ */
72
+ export function shownLevel(r: Pick<AxisResult, 'achieved' | 'not_required'>): LevelName | null {
73
+ let n = levelToInt(r.achieved);
74
+ while (n > 0 && r.not_required?.includes(`L${n}` as LevelName)) n--;
75
+ return n === 0 && r.not_required?.length ? null : (`L${n}` as LevelName);
36
76
  }
37
77
 
38
78
  function evalAxisFlags(axis: Axis, ctx: Ctx): LevelName {
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Which project tasks a command line calls through the project's own runner
3
+ * (mise, vdx, composer, npm/pnpm/yarn/bun, make, just, task), and what a task
4
+ * calls in turn. Shared by the `ci` predicates (what CI runs) and the hook
5
+ * predicates (what a hook runs), so both read "the project's task" the same way.
6
+ */
7
+ import { listAllTasks, readJson, readToml, type Ctx } from './facts.ts';
8
+
9
+ export const LIFECYCLE_VOCABULARY = ['up', 'down', 'build', 'test', 'check', 'fix'] as const;
10
+
11
+ /** npm-family options that take a value; their value is not the script name. */
12
+ const VALUE_OPTIONS = new Set(['--prefix', '-C', '--dir', '-w', '--workspace', '--filter', '--cwd']);
13
+
14
+ /** Whitespace split that keeps quoted spans together and drops the quotes. */
15
+ function tokenize(s: string): string[] {
16
+ const out: string[] = [];
17
+ const re = /"((?:\\.|[^"\\])*)"|'([^']*)'|(\S+)/g;
18
+ for (const m of s.matchAll(re)) out.push(m[1] ?? m[2] ?? m[3] ?? '');
19
+ return out;
20
+ }
21
+
22
+ function positionalArgs(tokens: string[]): string[] {
23
+ const out: string[] = [];
24
+ for (let i = 0; i < tokens.length; i++) {
25
+ const t = tokens[i]!;
26
+ if (t.startsWith('-')) {
27
+ if (VALUE_OPTIONS.has(t)) i++;
28
+ continue;
29
+ }
30
+ out.push(t);
31
+ }
32
+ return out;
33
+ }
34
+
35
+ /** Task names a shell command calls through a project runner, in order. */
36
+ export function runnerCalls(command: string): string[] {
37
+ const out: string[] = [];
38
+ for (const raw of command.split(/&&|\|\||;|\n|\|/)) {
39
+ const toks = tokenize(raw.trim());
40
+ let i = 0;
41
+ // `FOO=1 npm test`, `exec npm test`, `npx --no -- vdx test`
42
+ while (i < toks.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(toks[i]!) || ['exec', 'time', 'command'].includes(toks[i]!))) i++;
43
+ const head = toks[i];
44
+ if (!head) continue;
45
+ const runner = head.split('/').pop()!;
46
+ const args = positionalArgs(toks.slice(i + 1));
47
+ const first = args[0];
48
+ switch (runner) {
49
+ case 'mise':
50
+ if ((first === 'run' || first === 'r') && args[1]) out.push(args[1]);
51
+ break;
52
+ case 'vdx':
53
+ if (first && (LIFECYCLE_VOCABULARY as readonly string[]).includes(first)) out.push(first);
54
+ break;
55
+ case 'composer':
56
+ if ((first === 'run-script' || first === 'run') && args[1]) out.push(args[1]);
57
+ else if (first) out.push(first);
58
+ break;
59
+ case 'npm':
60
+ if ((first === 'run' || first === 'run-script') && args[1]) out.push(args[1]);
61
+ else if (first === 'test' || first === 't' || first === 'tst') out.push('test');
62
+ break;
63
+ case 'pnpm':
64
+ case 'yarn':
65
+ if ((first === 'run' || first === 'run-script') && args[1]) out.push(args[1]);
66
+ else if (first) out.push(first);
67
+ break;
68
+ case 'bun':
69
+ // `bun test` is bun's own runner, not the project's script.
70
+ if (first === 'run' && args[1]) out.push(args[1]);
71
+ break;
72
+ case 'make':
73
+ case 'just':
74
+ case 'task':
75
+ if (first) out.push(first);
76
+ break;
77
+ default:
78
+ break;
79
+ }
80
+ }
81
+ return out;
82
+ }
83
+
84
+ /** Calls of tasks the project actually has (a runner word that is no task — `composer install` — is dropped). */
85
+ export function projectTaskCalls(command: string, ctx: Ctx): string[] {
86
+ const tasks = listAllTasks(ctx);
87
+ return runnerCalls(command).filter((t) => tasks.has(t));
88
+ }
89
+
90
+ /** A name of the lifecycle vocabulary, or its namespaced form: `test`, `check:lint`. */
91
+ export function isVocabularyTask(name: string, words: readonly string[] = ['test', 'check']): boolean {
92
+ return words.some((w) => name === w || name.startsWith(`${w}:`));
93
+ }
94
+
95
+ /** Tasks a task's definition calls: composer `@refs`, `npm run X` inside a script, mise `depends`/`run`. */
96
+ export function taskChildren(ctx: Ctx, name: string): string[] {
97
+ const out: string[] = [];
98
+ const composer = readJson(ctx, 'composer.json');
99
+ const entry = composer?.scripts?.[name];
100
+ if (entry !== undefined) {
101
+ const list = Array.isArray(entry) ? entry : [entry];
102
+ for (const e of list) {
103
+ if (typeof e !== 'string') continue;
104
+ if (e.startsWith('@')) {
105
+ const [ref, ...rest] = e.slice(1).trim().split(/\s+/);
106
+ if (ref === 'composer') out.push(...runnerCalls(`composer ${rest.join(' ')}`));
107
+ else if (ref && ref !== 'php' && ref !== 'putenv') out.push(ref);
108
+ } else {
109
+ out.push(...runnerCalls(e));
110
+ }
111
+ }
112
+ }
113
+ const pkg = readJson(ctx, 'package.json');
114
+ const script = pkg?.scripts?.[name];
115
+ if (typeof script === 'string') out.push(...runnerCalls(script));
116
+ const mise = readToml(ctx, 'mise.toml');
117
+ const task = mise?.tasks?.[name];
118
+ if (task && typeof task === 'object') {
119
+ if (Array.isArray(task.depends)) out.push(...task.depends.filter((d: unknown): d is string => typeof d === 'string'));
120
+ const runs = Array.isArray(task.run) ? task.run : [task.run];
121
+ for (const r of runs) if (typeof r === 'string') out.push(...runnerCalls(r));
122
+ }
123
+ const tasks = listAllTasks(ctx);
124
+ return [...new Set(out.filter((t) => t !== name && tasks.has(t)))];
125
+ }
126
+
127
+ /** A set of task names together with everything they call, transitively. */
128
+ export function expandTasks(ctx: Ctx, names: Iterable<string>): Set<string> {
129
+ const seen = new Set<string>();
130
+ const queue = [...names];
131
+ while (queue.length > 0) {
132
+ const t = queue.shift()!;
133
+ if (seen.has(t)) continue;
134
+ seen.add(t);
135
+ queue.push(...taskChildren(ctx, t));
136
+ }
137
+ return seen;
138
+ }
139
+
140
+ /**
141
+ * Does `have` cover task `t`: it is called itself, or every task it calls is
142
+ * covered (CI calls `check`, the hook calls the `check:*` it consists of).
143
+ */
144
+ export function covers(ctx: Ctx, have: Set<string>, t: string, seen = new Set<string>()): boolean {
145
+ if (have.has(t)) return true;
146
+ if (seen.has(t)) return false;
147
+ seen.add(t);
148
+ const children = taskChildren(ctx, t);
149
+ return children.length > 0 && children.every((c) => covers(ctx, have, c, seen));
150
+ }