@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/README.md +17 -7
- package/package.json +1 -1
- package/rubric/vdx-rubric.yaml +57 -34
- package/src/ai.ts +95 -15
- package/src/audit.ts +9 -2
- package/src/clone-checks.ts +166 -0
- package/src/conversations.ts +6 -3
- package/src/doctor.ts +35 -129
- package/src/evaluator.ts +60 -10
- package/src/gha.ts +211 -0
- package/src/hooks.ts +424 -0
- package/src/index.ts +34 -7
- package/src/init.ts +1 -1
- package/src/personal-hooks.ts +181 -0
- package/src/predicates.ts +123 -1
- package/src/report.ts +32 -1
- package/src/rubric.ts +80 -4
- package/src/scoring.ts +46 -6
- package/src/vocabulary.ts +150 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checks of this clone that both `vdx doctor` (a row) and `vdx audit` (a note
|
|
3
|
+
* next to the axis that names it in `clone_check`) print — one check, two
|
|
4
|
+
* surfaces, so they cannot disagree.
|
|
5
|
+
*/
|
|
6
|
+
import * as fs from 'node:fs';
|
|
7
|
+
import * as path from 'node:path';
|
|
8
|
+
import { execFileSync } from 'node:child_process';
|
|
9
|
+
import type { CheckResult } from './doctor.ts';
|
|
10
|
+
import {
|
|
11
|
+
cloneHookStates,
|
|
12
|
+
detectHookFrameworks,
|
|
13
|
+
effectiveHooksDir,
|
|
14
|
+
missingMachinePaths,
|
|
15
|
+
unsetGatingVars,
|
|
16
|
+
} from './hooks.ts';
|
|
17
|
+
import { autoDetectStack, type Ctx } from './facts.ts';
|
|
18
|
+
|
|
19
|
+
function gitConfig(root: string, key: string): string | null {
|
|
20
|
+
try {
|
|
21
|
+
const out = execFileSync('git', ['-C', root, 'config', '--get', key], {
|
|
22
|
+
encoding: 'utf8',
|
|
23
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
24
|
+
}).trim();
|
|
25
|
+
return out || null;
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isGitWorkTree(root: string): boolean {
|
|
32
|
+
return fs.existsSync(path.join(root, '.git'));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function executableHooks(dir: string): Map<string, string> {
|
|
36
|
+
const out = new Map<string, string>();
|
|
37
|
+
let names: string[] = [];
|
|
38
|
+
try {
|
|
39
|
+
names = fs.readdirSync(dir).filter((f) => !f.startsWith('.') && !f.endsWith('.sample'));
|
|
40
|
+
} catch {
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
for (const f of names) {
|
|
44
|
+
const file = path.join(dir, f);
|
|
45
|
+
try {
|
|
46
|
+
fs.accessSync(file, fs.constants.X_OK);
|
|
47
|
+
if (fs.statSync(file).isFile()) out.set(f, fs.readFileSync(file, 'utf8'));
|
|
48
|
+
} catch {
|
|
49
|
+
/* not executable — git skips it silently */
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Git hooks: declared vs actually running in this clone.
|
|
57
|
+
*
|
|
58
|
+
* A framework leaves two separable traces: files in the repository (tracked,
|
|
59
|
+
* the same for every clone) and activation in the clone (`core.hooksPath`, or
|
|
60
|
+
* hooks written into the git hooks directory). The first says nothing about
|
|
61
|
+
* the second, and a hook that is declared but off fails exactly like success —
|
|
62
|
+
* nothing is printed, the commit goes through. So does a hook gated on a
|
|
63
|
+
* variable nobody set, or one that calls a path missing on this machine.
|
|
64
|
+
*
|
|
65
|
+
* Null (no row) when the repo declares no hooks — there is nothing to be wrong about.
|
|
66
|
+
*/
|
|
67
|
+
export function checkRepoHooks(root: string | null): CheckResult | null {
|
|
68
|
+
if (root === null || !isGitWorkTree(root)) return null;
|
|
69
|
+
const id = 'git-hooks';
|
|
70
|
+
const label = 'git hooks';
|
|
71
|
+
const ctx: Ctx = { projectRoot: root, stack: autoDetectStack(root), cache: new Map() };
|
|
72
|
+
const frameworks = detectHookFrameworks(ctx);
|
|
73
|
+
const hooksPath = gitConfig(root, 'core.hooksPath');
|
|
74
|
+
|
|
75
|
+
if (frameworks.length === 0) {
|
|
76
|
+
if (hooksPath === null) return null;
|
|
77
|
+
// Hooks in a directory no framework owns: read what is there.
|
|
78
|
+
const dir = path.resolve(root, hooksPath);
|
|
79
|
+
if (!fs.existsSync(dir)) {
|
|
80
|
+
return {
|
|
81
|
+
id,
|
|
82
|
+
label,
|
|
83
|
+
status: 'missing',
|
|
84
|
+
message: `core.hooksPath=${hooksPath} but that directory does not exist`,
|
|
85
|
+
remedy: `git -C ${root} config --unset core.hooksPath, or create ${hooksPath}/`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
const hooks = executableHooks(dir);
|
|
89
|
+
if (hooks.size === 0) {
|
|
90
|
+
return {
|
|
91
|
+
id,
|
|
92
|
+
label,
|
|
93
|
+
status: 'warning',
|
|
94
|
+
message: `core.hooksPath=${hooksPath} but it holds no executable hook`,
|
|
95
|
+
remedy: `chmod +x ${hooksPath}/*`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
return bodyProblems(id, label, [...hooks.values()]) ?? {
|
|
99
|
+
id,
|
|
100
|
+
label,
|
|
101
|
+
status: 'ok',
|
|
102
|
+
message: `${hooks.size} hook(s) active via ${hooksPath}`,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const states = cloneHookStates(root, ctx, frameworks);
|
|
107
|
+
const off = states.filter((s) => !s.enabled);
|
|
108
|
+
if (off.length > 0) {
|
|
109
|
+
return {
|
|
110
|
+
id,
|
|
111
|
+
label,
|
|
112
|
+
status: 'warning',
|
|
113
|
+
message: off.map((s) => `${s.framework.id} (${s.framework.declaredIn}): ${s.problem} — those hooks never run`).join('; '),
|
|
114
|
+
...(off[0]!.remedy ? { remedy: off[0]!.remedy } : {}),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const commands = frameworks.flatMap((f) => [...f.hooks.values()].flat());
|
|
119
|
+
const problem = bodyProblems(id, label, commands);
|
|
120
|
+
if (problem) return problem;
|
|
121
|
+
|
|
122
|
+
const dir = effectiveHooksDir(root);
|
|
123
|
+
const where = dir ? path.relative(root, dir) || dir : 'the hooks directory';
|
|
124
|
+
const count = frameworks.reduce((n, f) => n + f.hooks.size, 0);
|
|
125
|
+
return {
|
|
126
|
+
id,
|
|
127
|
+
label,
|
|
128
|
+
status: 'ok',
|
|
129
|
+
message: `${frameworks.map((f) => f.id).join(', ')}: ${count} hook(s) active in ${where}`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** A body that looks installed but does nothing here: gated on an unset variable, or calling a missing path. */
|
|
134
|
+
function bodyProblems(id: string, label: string, bodies: string[]): CheckResult | null {
|
|
135
|
+
const unset = unsetGatingVars(bodies);
|
|
136
|
+
if (unset.length > 0) {
|
|
137
|
+
return {
|
|
138
|
+
id,
|
|
139
|
+
label,
|
|
140
|
+
status: 'warning',
|
|
141
|
+
message: `hooks gated on unset ${unset.join(', ')} — those gates are no-ops`,
|
|
142
|
+
remedy: `export ${unset[0]}=... in your shell profile (see the tool that ships the hook)`,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
const missing = missingMachinePaths(bodies);
|
|
146
|
+
if (missing.length > 0) {
|
|
147
|
+
return {
|
|
148
|
+
id,
|
|
149
|
+
label,
|
|
150
|
+
status: 'warning',
|
|
151
|
+
message: `hooks call ${missing.join(', ')} — not on this machine, the commit fails or the gate is skipped`,
|
|
152
|
+
remedy: 'install what the hook calls here, or move the hook out of the repository (a personal hook in git config)',
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The checks an axis may name in `clone_check`. */
|
|
159
|
+
export function cloneCheck(name: 'git-hooks', root: string): CheckResult | null {
|
|
160
|
+
switch (name) {
|
|
161
|
+
case 'git-hooks':
|
|
162
|
+
return checkRepoHooks(root);
|
|
163
|
+
default:
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
}
|
package/src/conversations.ts
CHANGED
|
@@ -314,10 +314,13 @@ export function parseMachineReport(text: string): MachineReport {
|
|
|
314
314
|
return { home: text.match(/^home=(.+)$/m)?.[1] ?? null, sessions: parseLiveSessions(text) };
|
|
315
315
|
}
|
|
316
316
|
|
|
317
|
-
/**
|
|
318
|
-
|
|
317
|
+
/**
|
|
318
|
+
* "running on m3 now (t23b-program@m3, waiting)" — where a live session is, for
|
|
319
|
+
* a person choosing; `place` is the machine as they see it: "here", "on m3".
|
|
320
|
+
*/
|
|
321
|
+
export function describeLive(place: string, live: LiveSession): string {
|
|
319
322
|
const where = live.tmux ? live.tmux.replace(/:.*$/, '') : `pid ${live.pid}`;
|
|
320
|
-
return `running ${
|
|
323
|
+
return `running ${place} now (${where}${live.status ? `, ${live.status}` : ''})`;
|
|
321
324
|
}
|
|
322
325
|
|
|
323
326
|
/** "3 min ago", "yesterday 14:05", "2026-09-30 14:05" — for a person choosing. */
|
package/src/doctor.ts
CHANGED
|
@@ -5,6 +5,8 @@ import { fileURLToPath } from 'node:url';
|
|
|
5
5
|
import { execFileSync } from 'node:child_process';
|
|
6
6
|
import { effectiveAuthor, isGitRepo, rankAuthors, scanPool } from './author.ts';
|
|
7
7
|
import { authorPoolDirs, loadEnvironment, resolveEnvironmentPath, shellQuote, type Environment } from './ai.ts';
|
|
8
|
+
import { checkRepoHooks } from './clone-checks.ts';
|
|
9
|
+
import { checkPersonalHooks, defaultPersonalHookDeps, fixPersonalHooks, type PersonalHookDeps } from './personal-hooks.ts';
|
|
8
10
|
|
|
9
11
|
export type CheckStatus = 'ok' | 'warning' | 'missing';
|
|
10
12
|
|
|
@@ -380,134 +382,6 @@ export function checkClaudeCodePlugin(
|
|
|
380
382
|
return { ...base, status: 'ok', message: label };
|
|
381
383
|
}
|
|
382
384
|
|
|
383
|
-
/**
|
|
384
|
-
* Variables a hook body *gates on*: `[ -n "${VAR:-}" ]`, `[ -x "$VAR" ]` and
|
|
385
|
-
* friends. Restricted to test-expression positions on purpose — a bare `$VAR`
|
|
386
|
-
* anywhere in a script says nothing about whether the hook still does its job,
|
|
387
|
-
* while an unset variable inside the guard means the guarded block never runs.
|
|
388
|
-
*/
|
|
389
|
-
const GATING_VAR_RE =
|
|
390
|
-
/\[\s+-[nxfse]\s+"\$\{([A-Z][A-Z0-9_]*)(?::-[^}]*)?\}"|\[\s+-[nxfse]\s+"\$([A-Z][A-Z0-9_]*)"/g;
|
|
391
|
-
|
|
392
|
-
/**
|
|
393
|
-
* Git hooks: declared vs actually wired.
|
|
394
|
-
*
|
|
395
|
-
* A hook framework leaves two separable traces: files in the repository
|
|
396
|
-
* (tracked, same for every clone) and activation in `.git/config` (per-clone,
|
|
397
|
-
* never committed). Presence of the first says nothing about the second, and a
|
|
398
|
-
* hook that is present but inert fails exactly like success — nothing is
|
|
399
|
-
* printed, the commit goes through. Same for a hook whose body is gated on an
|
|
400
|
-
* environment variable nobody set: the guard short-circuits and the gate is a
|
|
401
|
-
* no-op.
|
|
402
|
-
*
|
|
403
|
-
* Returns null (row omitted) when the repo declares no hooks at all — there is
|
|
404
|
-
* nothing to be wrong about.
|
|
405
|
-
*/
|
|
406
|
-
function checkGitHooks(ctx: DoctorCtx): CheckResult | null {
|
|
407
|
-
const root = ctx.projectRoot;
|
|
408
|
-
if (root === null) return null;
|
|
409
|
-
if (!fs.existsSync(path.join(root, '.git'))) return null;
|
|
410
|
-
|
|
411
|
-
const id = 'git-hooks';
|
|
412
|
-
const label = 'git hooks';
|
|
413
|
-
|
|
414
|
-
let hooksPath: string | null = null;
|
|
415
|
-
try {
|
|
416
|
-
const out = execFileSync('git', ['-C', root, 'config', '--get', 'core.hooksPath'], {
|
|
417
|
-
encoding: 'utf8',
|
|
418
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
419
|
-
});
|
|
420
|
-
hooksPath = out.trim() || null;
|
|
421
|
-
} catch {
|
|
422
|
-
hooksPath = null;
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
// Directories a framework would have written into the repo.
|
|
426
|
-
const declaredDirs = ['.githooks', '.husky'].filter((d) =>
|
|
427
|
-
fs.existsSync(path.join(root, d)),
|
|
428
|
-
);
|
|
429
|
-
|
|
430
|
-
if (hooksPath === null) {
|
|
431
|
-
if (declaredDirs.length === 0) return null; // nothing declared — not applicable
|
|
432
|
-
return {
|
|
433
|
-
id,
|
|
434
|
-
label,
|
|
435
|
-
status: 'warning',
|
|
436
|
-
message: `${declaredDirs.join(', ')} present but core.hooksPath is unset — hooks never run`,
|
|
437
|
-
remedy: `git -C ${root} config core.hooksPath ${declaredDirs[0]}`,
|
|
438
|
-
};
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
const dir = path.resolve(root, hooksPath);
|
|
442
|
-
if (!fs.existsSync(dir)) {
|
|
443
|
-
return {
|
|
444
|
-
id,
|
|
445
|
-
label,
|
|
446
|
-
status: 'missing',
|
|
447
|
-
message: `core.hooksPath=${hooksPath} but that directory does not exist`,
|
|
448
|
-
remedy: `git -C ${root} config --unset core.hooksPath, or create ${hooksPath}/`,
|
|
449
|
-
};
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
let entries: string[] = [];
|
|
453
|
-
try {
|
|
454
|
-
entries = fs.readdirSync(dir).filter((f) => !f.startsWith('.') && !f.endsWith('.sample'));
|
|
455
|
-
} catch {
|
|
456
|
-
entries = [];
|
|
457
|
-
}
|
|
458
|
-
const executable = entries.filter((f) => {
|
|
459
|
-
try {
|
|
460
|
-
fs.accessSync(path.join(dir, f), fs.constants.X_OK);
|
|
461
|
-
return true;
|
|
462
|
-
} catch {
|
|
463
|
-
return false;
|
|
464
|
-
}
|
|
465
|
-
});
|
|
466
|
-
|
|
467
|
-
if (executable.length === 0) {
|
|
468
|
-
return {
|
|
469
|
-
id,
|
|
470
|
-
label,
|
|
471
|
-
status: 'warning',
|
|
472
|
-
message: `core.hooksPath=${hooksPath} but it holds no executable hook`,
|
|
473
|
-
remedy: `chmod +x ${hooksPath}/*`,
|
|
474
|
-
};
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
// A hook gated on an unset variable is inert while looking installed.
|
|
478
|
-
const unresolved = new Set<string>();
|
|
479
|
-
for (const f of executable) {
|
|
480
|
-
let body = '';
|
|
481
|
-
try {
|
|
482
|
-
body = fs.readFileSync(path.join(dir, f), 'utf8');
|
|
483
|
-
} catch {
|
|
484
|
-
continue;
|
|
485
|
-
}
|
|
486
|
-
for (const m of body.matchAll(GATING_VAR_RE)) {
|
|
487
|
-
const name = m[1] ?? m[2];
|
|
488
|
-
if (name && !process.env[name]) unresolved.add(name);
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
if (unresolved.size > 0) {
|
|
493
|
-
const names = [...unresolved].sort();
|
|
494
|
-
return {
|
|
495
|
-
id,
|
|
496
|
-
label,
|
|
497
|
-
status: 'warning',
|
|
498
|
-
message: `${executable.length} hook(s) via ${hooksPath}, but gated on unset ${names.join(', ')} — those gates are no-ops`,
|
|
499
|
-
remedy: `export ${names[0]}=... in your shell profile (see the tool that ships the hook)`,
|
|
500
|
-
};
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
return {
|
|
504
|
-
id,
|
|
505
|
-
label,
|
|
506
|
-
status: 'ok',
|
|
507
|
-
message: `${executable.length} hook(s) active via ${hooksPath}`,
|
|
508
|
-
};
|
|
509
|
-
}
|
|
510
|
-
|
|
511
385
|
/**
|
|
512
386
|
* The project's commit author. With `user.useConfigOnly` git refuses to commit
|
|
513
387
|
* in a repo that has none; the remedy names the likely one, as `vdx ai` does.
|
|
@@ -552,7 +426,8 @@ const CHECKS: Array<(ctx: DoctorCtx) => CheckResult | null> = [
|
|
|
552
426
|
checkMise,
|
|
553
427
|
checkNpmAuth,
|
|
554
428
|
checkContainerRuntime,
|
|
555
|
-
|
|
429
|
+
(ctx) => checkRepoHooks(ctx.projectRoot),
|
|
430
|
+
(ctx) => checkPersonalHooks(ctx.projectRoot),
|
|
556
431
|
checkGitAuthor,
|
|
557
432
|
];
|
|
558
433
|
|
|
@@ -568,6 +443,37 @@ export function runDoctor(ctx: DoctorCtx = resolveDoctorCtx()): DoctorReport {
|
|
|
568
443
|
return { cliVersion: readCliVersion(), checks, ok, warning, missing };
|
|
569
444
|
}
|
|
570
445
|
|
|
446
|
+
/**
|
|
447
|
+
* For the agent's own session (the vdx plugin's SessionStart hook): one line
|
|
448
|
+
* per hook that is declared but does not run — in this clone, or a personal
|
|
449
|
+
* hook on this machine. Nothing when they are in order; the fix is the user's
|
|
450
|
+
* call, so the line names it rather than applying it.
|
|
451
|
+
*/
|
|
452
|
+
export function runDoctorCheck(
|
|
453
|
+
ctx: DoctorCtx = resolveDoctorCtx(),
|
|
454
|
+
deps: PersonalHookDeps = defaultPersonalHookDeps(),
|
|
455
|
+
): string {
|
|
456
|
+
const rows = [checkRepoHooks(ctx.projectRoot), checkPersonalHooks(ctx.projectRoot, deps)].filter(
|
|
457
|
+
(c): c is CheckResult => c !== null && c.status !== 'ok',
|
|
458
|
+
);
|
|
459
|
+
return rows
|
|
460
|
+
.map(
|
|
461
|
+
(c) =>
|
|
462
|
+
`vdx doctor: ${c.label} — ${c.message}.` +
|
|
463
|
+
(c.remedy ? ` Fix, on the user's word: \`${c.remedy}\`.` : ''),
|
|
464
|
+
)
|
|
465
|
+
.join('\n');
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* `vdx doctor --fix`: what doctor can put right without installing anything —
|
|
470
|
+
* the profile's personal hooks in ~/.gitconfig. Repository hooks are switched
|
|
471
|
+
* on by the project's own install step; doctor names it and leaves it to you.
|
|
472
|
+
*/
|
|
473
|
+
export function runDoctorFix(deps: PersonalHookDeps = defaultPersonalHookDeps()): string[] {
|
|
474
|
+
return fixPersonalHooks(deps);
|
|
475
|
+
}
|
|
476
|
+
|
|
571
477
|
const PROJECT_MARKER_FILES = [
|
|
572
478
|
'package.json',
|
|
573
479
|
'composer.json',
|
package/src/evaluator.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { Predicate } from './rubric.ts';
|
|
2
2
|
import type { Ctx } from './facts.ts';
|
|
3
|
-
import { REGISTRY } from './predicates.ts';
|
|
3
|
+
import { REGISTRY, isUnknown, type Unknown, type Verdict } from './predicates.ts';
|
|
4
|
+
|
|
5
|
+
export { isUnknown, type Unknown, type Verdict };
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* Sugar shorthand: `{ has_task: "up" }` → `{ fn: "has_task", args: { task: "up" } }`
|
|
@@ -15,7 +17,20 @@ const SUGAR_ARG_KEY: Record<string, string> = {
|
|
|
15
17
|
phpstan_level_at_least: 'n',
|
|
16
18
|
};
|
|
17
19
|
|
|
18
|
-
export
|
|
20
|
+
export const COMBINATORS = ['all_of', 'any_of', 'at_least_n_of', 'not'] as const;
|
|
21
|
+
|
|
22
|
+
/** The first unknown among verdicts, for the reason a combination cannot be decided. */
|
|
23
|
+
function firstUnknown(vs: Verdict[]): Unknown | undefined {
|
|
24
|
+
return vs.find(isUnknown);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Three-valued: true, false, or unknown with the reason the fact could not be
|
|
29
|
+
* read (a CI system or a hosting vdx does not inspect). Unknown never turns
|
|
30
|
+
* into true: `any_of` is true only on a true branch, `all_of` false on any
|
|
31
|
+
* false branch, otherwise unknown.
|
|
32
|
+
*/
|
|
33
|
+
export function evalVerdict(p: Predicate, ctx: Ctx): Verdict {
|
|
19
34
|
if (p === null || p === undefined) return false;
|
|
20
35
|
if (typeof p === 'boolean') return p;
|
|
21
36
|
|
|
@@ -23,21 +38,27 @@ export function evalPredicate(p: Predicate, ctx: Ctx): boolean {
|
|
|
23
38
|
const obj = p as Record<string, any>;
|
|
24
39
|
|
|
25
40
|
if ('all_of' in obj) {
|
|
26
|
-
const
|
|
27
|
-
|
|
41
|
+
const vs = (obj.all_of as Predicate[]).map((q) => evalVerdict(q, ctx));
|
|
42
|
+
if (vs.some((v) => v === false)) return false;
|
|
43
|
+
return firstUnknown(vs) ?? true;
|
|
28
44
|
}
|
|
29
45
|
if ('any_of' in obj) {
|
|
30
|
-
const
|
|
31
|
-
|
|
46
|
+
const vs = (obj.any_of as Predicate[]).map((q) => evalVerdict(q, ctx));
|
|
47
|
+
if (vs.some((v) => v === true)) return true;
|
|
48
|
+
return firstUnknown(vs) ?? false;
|
|
32
49
|
}
|
|
33
50
|
if ('at_least_n_of' in obj) {
|
|
34
51
|
const { n, predicates } = obj.at_least_n_of as { n: number; predicates: Predicate[] };
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
52
|
+
const vs = predicates.map((q) => evalVerdict(q, ctx));
|
|
53
|
+
const matched = vs.filter((v) => v === true).length;
|
|
54
|
+
if (matched >= n) return true;
|
|
55
|
+
const open = vs.filter(isUnknown).length;
|
|
56
|
+
if (matched + open < n) return false;
|
|
57
|
+
return firstUnknown(vs)!;
|
|
38
58
|
}
|
|
39
59
|
if ('not' in obj) {
|
|
40
|
-
|
|
60
|
+
const v = evalVerdict(obj.not, ctx);
|
|
61
|
+
return isUnknown(v) ? v : !v;
|
|
41
62
|
}
|
|
42
63
|
if ('fn' in obj) {
|
|
43
64
|
const fn = REGISTRY[String(obj.fn)];
|
|
@@ -70,3 +91,32 @@ export function evalPredicate(p: Predicate, ctx: Ctx): boolean {
|
|
|
70
91
|
process.stderr.write(`warning: malformed predicate ${JSON.stringify(p)}\n`);
|
|
71
92
|
return false;
|
|
72
93
|
}
|
|
94
|
+
|
|
95
|
+
/** Two-valued view: unknown counts as not met (applies_when, flags, profile rules). */
|
|
96
|
+
export function evalPredicate(p: Predicate, ctx: Ctx): boolean {
|
|
97
|
+
return evalVerdict(p, ctx) === true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Predicate names a tree refers to, for load-time validation. Sugar keys and
|
|
102
|
+
* `fn:` both count; combinators are walked, not reported.
|
|
103
|
+
*/
|
|
104
|
+
export function predicateNames(p: Predicate): string[] {
|
|
105
|
+
const out: string[] = [];
|
|
106
|
+
const walk = (q: Predicate): void => {
|
|
107
|
+
if (q === null || q === undefined || typeof q !== 'object' || Array.isArray(q)) return;
|
|
108
|
+
const obj = q as Record<string, any>;
|
|
109
|
+
if ('all_of' in obj) return (obj.all_of as Predicate[]).forEach(walk);
|
|
110
|
+
if ('any_of' in obj) return (obj.any_of as Predicate[]).forEach(walk);
|
|
111
|
+
if ('at_least_n_of' in obj) return (obj.at_least_n_of?.predicates as Predicate[] ?? []).forEach(walk);
|
|
112
|
+
if ('not' in obj) return walk(obj.not);
|
|
113
|
+
if ('fn' in obj) {
|
|
114
|
+
out.push(String(obj.fn));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const keys = Object.keys(obj);
|
|
118
|
+
if (keys.length === 1) out.push(keys[0]!);
|
|
119
|
+
};
|
|
120
|
+
walk(p);
|
|
121
|
+
return out;
|
|
122
|
+
}
|
package/src/gha.ts
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Actions workflows as the `ci` axis reads them: which ones run on a
|
|
3
|
+
* push to the main branch, which steps run tests, which failures are masked,
|
|
4
|
+
* and which jobs ship something. Other CI systems are read as "present" (L1)
|
|
5
|
+
* and answer "unknown" above it — vdx does not inspect them yet.
|
|
6
|
+
*/
|
|
7
|
+
import * as fs from 'node:fs';
|
|
8
|
+
import * as path from 'node:path';
|
|
9
|
+
import YAML from 'js-yaml';
|
|
10
|
+
import { fileExists, readText, type Ctx } from './facts.ts';
|
|
11
|
+
import { projectTaskCalls } from './vocabulary.ts';
|
|
12
|
+
|
|
13
|
+
export interface GhaStep {
|
|
14
|
+
run?: string;
|
|
15
|
+
uses?: string;
|
|
16
|
+
continueOnError: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface GhaJob {
|
|
20
|
+
id: string;
|
|
21
|
+
name?: string;
|
|
22
|
+
needs: string[];
|
|
23
|
+
steps: GhaStep[];
|
|
24
|
+
continueOnError: boolean;
|
|
25
|
+
matrix: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface GhaWorkflow {
|
|
29
|
+
file: string;
|
|
30
|
+
pushesMain: boolean;
|
|
31
|
+
jobs: GhaJob[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const MAIN_BRANCHES = ['main', 'master'];
|
|
35
|
+
|
|
36
|
+
/** GitHub's branch filter glob: `**` crosses `/`, `*` does not. */
|
|
37
|
+
function filterMatches(pattern: string, branch: string): boolean {
|
|
38
|
+
const re = pattern
|
|
39
|
+
.split('**')
|
|
40
|
+
.map((part) =>
|
|
41
|
+
part
|
|
42
|
+
.split('*')
|
|
43
|
+
.map((s) => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&'))
|
|
44
|
+
.join('[^/]*'),
|
|
45
|
+
)
|
|
46
|
+
.join('.*');
|
|
47
|
+
return new RegExp(`^${re}$`).test(branch);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function branchSelected(filters: unknown, branch: string): boolean {
|
|
51
|
+
const list = Array.isArray(filters) ? filters : [filters];
|
|
52
|
+
let selected = false;
|
|
53
|
+
for (const f of list) {
|
|
54
|
+
if (typeof f !== 'string') continue;
|
|
55
|
+
if (f.startsWith('!')) {
|
|
56
|
+
if (filterMatches(f.slice(1), branch)) selected = false;
|
|
57
|
+
} else if (filterMatches(f, branch)) {
|
|
58
|
+
selected = true;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return selected;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Does `on:` start the workflow on a push to main/master? */
|
|
65
|
+
export function pushesMain(on: unknown): boolean {
|
|
66
|
+
if (on === 'push') return true;
|
|
67
|
+
if (Array.isArray(on)) return on.includes('push');
|
|
68
|
+
if (!on || typeof on !== 'object') return false;
|
|
69
|
+
if (!('push' in on)) return false;
|
|
70
|
+
const push = (on as Record<string, any>).push;
|
|
71
|
+
if (push === null || push === undefined || typeof push !== 'object') return true;
|
|
72
|
+
const hasBranchFilter = push.branches !== undefined || push['branches-ignore'] !== undefined;
|
|
73
|
+
if (!hasBranchFilter && (push.tags !== undefined || push['tags-ignore'] !== undefined)) return false;
|
|
74
|
+
return MAIN_BRANCHES.some((b) => {
|
|
75
|
+
if (push.branches !== undefined) return branchSelected(push.branches, b);
|
|
76
|
+
if (push['branches-ignore'] !== undefined) return !branchSelected(push['branches-ignore'], b);
|
|
77
|
+
return true;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function truthy(v: unknown): boolean {
|
|
82
|
+
return v === true || v === 'true';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function parseWorkflow(file: string, text: string): GhaWorkflow | null {
|
|
86
|
+
let data: any;
|
|
87
|
+
try {
|
|
88
|
+
data = YAML.load(text);
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
if (!data || typeof data !== 'object') return null;
|
|
93
|
+
const jobs: GhaJob[] = [];
|
|
94
|
+
for (const [id, job] of Object.entries((data.jobs ?? {}) as Record<string, any>)) {
|
|
95
|
+
if (!job || typeof job !== 'object') continue;
|
|
96
|
+
const needs = job.needs === undefined ? [] : Array.isArray(job.needs) ? job.needs : [job.needs];
|
|
97
|
+
const steps: GhaStep[] = (Array.isArray(job.steps) ? job.steps : [])
|
|
98
|
+
.filter((s: unknown) => s && typeof s === 'object')
|
|
99
|
+
.map((s: any) => ({
|
|
100
|
+
...(typeof s.run === 'string' ? { run: s.run } : {}),
|
|
101
|
+
...(typeof s.uses === 'string' ? { uses: s.uses } : {}),
|
|
102
|
+
continueOnError: truthy(s['continue-on-error']),
|
|
103
|
+
}));
|
|
104
|
+
jobs.push({
|
|
105
|
+
id,
|
|
106
|
+
...(typeof job.name === 'string' ? { name: job.name } : {}),
|
|
107
|
+
needs: needs.filter((n: unknown): n is string => typeof n === 'string'),
|
|
108
|
+
steps,
|
|
109
|
+
continueOnError: truthy(job['continue-on-error']),
|
|
110
|
+
matrix: Boolean(job.strategy?.matrix),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
return { file, pushesMain: pushesMain(data.on), jobs };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function readWorkflows(ctx: Ctx): GhaWorkflow[] {
|
|
117
|
+
const key = 'gha:workflows';
|
|
118
|
+
if (ctx.cache.has(key)) return ctx.cache.get(key) as GhaWorkflow[];
|
|
119
|
+
const dir = path.join(ctx.projectRoot, '.github', 'workflows');
|
|
120
|
+
const out: GhaWorkflow[] = [];
|
|
121
|
+
let files: string[] = [];
|
|
122
|
+
try {
|
|
123
|
+
files = fs.readdirSync(dir).filter((f) => /\.ya?ml$/.test(f)).sort();
|
|
124
|
+
} catch {
|
|
125
|
+
files = [];
|
|
126
|
+
}
|
|
127
|
+
for (const f of files) {
|
|
128
|
+
const rel = path.join('.github', 'workflows', f);
|
|
129
|
+
const text = readText(ctx, rel);
|
|
130
|
+
const wf = text === null ? null : parseWorkflow(rel, text);
|
|
131
|
+
if (wf) out.push(wf);
|
|
132
|
+
}
|
|
133
|
+
ctx.cache.set(key, out);
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** CI systems vdx sees but does not read beyond "a config exists". */
|
|
138
|
+
const OTHER_CI: Array<[string, string]> = [
|
|
139
|
+
['.gitlab-ci.yml', 'GitLab CI'],
|
|
140
|
+
['.circleci/config.yml', 'CircleCI'],
|
|
141
|
+
];
|
|
142
|
+
|
|
143
|
+
/** The CI systems a repository configures: `gha` for GitHub Actions workflows, others by name. */
|
|
144
|
+
export function ciSystems(ctx: Ctx): string[] {
|
|
145
|
+
const out: string[] = [];
|
|
146
|
+
if (readWorkflows(ctx).length > 0) out.push('gha');
|
|
147
|
+
for (const [file, name] of OTHER_CI) if (fileExists(ctx, file)) out.push(name);
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** `|| true` and its relatives: the step reports success whatever the command did. */
|
|
152
|
+
const MASK_RE = /\|\|\s*(true|:|exit\s+0)\b/;
|
|
153
|
+
|
|
154
|
+
export function stepMasked(step: GhaStep, job: GhaJob): boolean {
|
|
155
|
+
if (job.continueOnError || step.continueOnError) return true;
|
|
156
|
+
return step.run !== undefined && MASK_RE.test(step.run);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Unmasked `run:` steps of jobs in workflows started by a push to main. */
|
|
160
|
+
export function mainPushSteps(ctx: Ctx): Array<{ wf: GhaWorkflow; job: GhaJob; step: GhaStep }> {
|
|
161
|
+
const out: Array<{ wf: GhaWorkflow; job: GhaJob; step: GhaStep }> = [];
|
|
162
|
+
for (const wf of readWorkflows(ctx)) {
|
|
163
|
+
if (!wf.pushesMain) continue;
|
|
164
|
+
for (const job of wf.jobs) {
|
|
165
|
+
for (const step of job.steps) {
|
|
166
|
+
if (step.run === undefined || stepMasked(step, job)) continue;
|
|
167
|
+
out.push({ wf, job, step });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Project tasks CI calls on a push to main, unmasked. */
|
|
175
|
+
export function ciTaskCalls(ctx: Ctx): Set<string> {
|
|
176
|
+
const out = new Set<string>();
|
|
177
|
+
for (const { step } of mainPushSteps(ctx)) for (const t of projectTaskCalls(step.run!, ctx)) out.add(t);
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Jobs that check: an unmasked step runs tests (`pattern`) or a `test`/`check` task. */
|
|
182
|
+
export function checkingJobs(ctx: Ctx, pattern: RegExp): Set<GhaJob> {
|
|
183
|
+
const out = new Set<GhaJob>();
|
|
184
|
+
for (const { job, step } of mainPushSteps(ctx)) {
|
|
185
|
+
if (pattern.test(step.run!) || projectTaskCalls(step.run!, ctx).some((t) => /^(test|check)(:|$)/.test(t))) {
|
|
186
|
+
out.add(job);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const SHIP_RE = /deploy|release|publish/i;
|
|
193
|
+
|
|
194
|
+
/** Jobs that ship: deploy, release, publish — by id or name. */
|
|
195
|
+
export function shippingJobs(wf: GhaWorkflow): GhaJob[] {
|
|
196
|
+
return wf.jobs.filter((j) => SHIP_RE.test(j.id) || (j.name !== undefined && SHIP_RE.test(j.name)));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Every job `job` waits for, transitively, within its workflow. */
|
|
200
|
+
export function upstream(wf: GhaWorkflow, job: GhaJob): Set<GhaJob> {
|
|
201
|
+
const byId = new Map(wf.jobs.map((j) => [j.id, j] as const));
|
|
202
|
+
const seen = new Set<GhaJob>();
|
|
203
|
+
const queue = [...job.needs];
|
|
204
|
+
while (queue.length > 0) {
|
|
205
|
+
const j = byId.get(queue.shift()!);
|
|
206
|
+
if (!j || seen.has(j)) continue;
|
|
207
|
+
seen.add(j);
|
|
208
|
+
queue.push(...j.needs);
|
|
209
|
+
}
|
|
210
|
+
return seen;
|
|
211
|
+
}
|