@vodmal/vdx-cli 0.21.0 → 0.22.0

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/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
- checkGitHooks,
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 function evalPredicate(p: Predicate, ctx: Ctx): boolean {
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 list = obj.all_of as Predicate[];
27
- return list.every((q) => evalPredicate(q, ctx));
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 list = obj.any_of as Predicate[];
31
- return list.some((q) => evalPredicate(q, ctx));
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
- let matched = 0;
36
- for (const q of predicates) if (evalPredicate(q, ctx)) matched += 1;
37
- return matched >= n;
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
- return !evalPredicate(obj.not, ctx);
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
+ }