@vodmal/vdx-cli 0.21.1 → 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/README.md +17 -7
- package/package.json +1 -1
- package/rubric/vdx-rubric.yaml +57 -34
- package/src/ai.ts +39 -0
- package/src/audit.ts +9 -2
- package/src/clone-checks.ts +166 -0
- 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
package/README.md
CHANGED
|
@@ -26,8 +26,9 @@ ephemeral environment.
|
|
|
26
26
|
|
|
27
27
|
**First command after install: `vdx doctor`** — it inspects your environment
|
|
28
28
|
(Node version, `vdx` on PATH, git, mise, npm auth, container runtime, Claude
|
|
29
|
-
Code plugin; in a project — git hooks
|
|
30
|
-
|
|
29
|
+
Code plugin; in a project — whether its git hooks are on in this clone, and the
|
|
30
|
+
commit author; with a profile — whether your personal hooks run on this machine)
|
|
31
|
+
and points to remedies for everything that's missing or sub-optimal.
|
|
31
32
|
|
|
32
33
|
## Use
|
|
33
34
|
|
|
@@ -44,8 +45,10 @@ vdx init [project-path] [--baseline github.com/org/repo@vX.Y] [--stack <id>] [-
|
|
|
44
45
|
# Publish a library (Node MVP; PHP/Python coming in Y.3)
|
|
45
46
|
vdx publish <patch|minor|major> [--dry-run] [--force] [--no-push]
|
|
46
47
|
|
|
47
|
-
# Environment self-check (Node / git / mise / npm auth / docker / Claude Code plugin)
|
|
48
|
-
vdx doctor [--format=ansi|markdown|json]
|
|
48
|
+
# Environment self-check (Node / git / mise / npm auth / docker / Claude Code plugin; git hooks of the project)
|
|
49
|
+
vdx doctor [project-path] [--format=ansi|markdown|json] # default: cwd
|
|
50
|
+
vdx doctor --check [project-path] # one line per hook declared but not running here — the plugin's SessionStart hook tells the agent
|
|
51
|
+
vdx doctor --fix # write the profile's personal hooks (git.hooks) into ~/.gitconfig
|
|
49
52
|
|
|
50
53
|
# Start your agent in a project per your profile (~/.vdx-environment.yaml or $VDX_ENVIRONMENT)
|
|
51
54
|
vdx ai[@host] [project-path] [--new | --conversation <id>] [--restart] [--detach] [--dry-run] # default: cwd; @host: over ssh, in tmux there
|
|
@@ -123,9 +126,14 @@ npm run typecheck
|
|
|
123
126
|
- `src/rubric.ts` — types + YAML loader
|
|
124
127
|
- `src/manifest.ts` — parser for `[vdx]` block in `mise.toml` + `.vdx-overrides.yml`
|
|
125
128
|
- `src/facts.ts` — fact source loaders (tasks, packages, configs, sub-package detection)
|
|
126
|
-
- `src/predicates.ts` — predicate registry (
|
|
129
|
+
- `src/predicates.ts` — predicate registry; true / false / unknown with a reason (schema 0.3)
|
|
127
130
|
- `src/evaluator.ts` — recursive evaluator + sugar notation
|
|
128
|
-
- `src/scoring.ts` — delta-style levels, flags for orthogonal axes
|
|
131
|
+
- `src/scoring.ts` — delta-style levels, `not_required` levels, flags for orthogonal axes
|
|
132
|
+
- `src/gha.ts` — GitHub Actions workflows as the `ci` axis reads them
|
|
133
|
+
- `src/vocabulary.ts` — which project tasks a command calls through the project's runner
|
|
134
|
+
- `src/hooks.ts` — one recognizer of hook frameworks for audit and doctor
|
|
135
|
+
- `src/clone-checks.ts` — clone checks printed by doctor and next to an axis in audit
|
|
136
|
+
- `src/personal-hooks.ts` — the profile's personal hooks in ~/.gitconfig: check and `--fix`
|
|
129
137
|
- `src/audit.ts` — orchestrator: overrides + applies_to filter + subpackage-ctx
|
|
130
138
|
- `src/init.ts` — `vdx init` planner (`selectVerbTask` + mise.toml renderer)
|
|
131
139
|
- `src/run.ts` — `resolveLifecycleVerb` + error renderer (pure logic for `vdx <verb>`)
|
|
@@ -139,7 +147,9 @@ npm run typecheck
|
|
|
139
147
|
|
|
140
148
|
## Not implemented yet
|
|
141
149
|
|
|
142
|
-
- `
|
|
150
|
+
- Above L1 the `ci` axis reads GitHub Actions only; other CI systems answer
|
|
151
|
+
"unknown". `branch-protection` is "unknown" everywhere until a hosting
|
|
152
|
+
extension exists.
|
|
143
153
|
- Baseline loading from a git ref is documented but evaluator still reads file
|
|
144
154
|
paths only — `baseline:` in `mise.toml` is recorded but does not auto-fetch.
|
|
145
155
|
- Watermark drift (phase 2 of `drift-algorithm.md`).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vodmal/vdx-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"description": "vdx — unified lifecycle interface (up/down/build/test/check/fix) + versioned maturity audit with drift detection. Pairs with the vdx Claude Code plugin.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"vdx",
|
package/rubric/vdx-rubric.yaml
CHANGED
|
@@ -7,11 +7,11 @@
|
|
|
7
7
|
# дельту относительно L(N-1). Уровень оси = max L, для которого истинны
|
|
8
8
|
# requires(L1), ..., requires(L) подряд (без разрывов).
|
|
9
9
|
|
|
10
|
-
schema_version: "0.
|
|
10
|
+
schema_version: "0.3"
|
|
11
11
|
|
|
12
12
|
metadata:
|
|
13
13
|
name: vdx-rubric-vodmal
|
|
14
|
-
version: "0.
|
|
14
|
+
version: "1.0.0"
|
|
15
15
|
description: "Personal maturity rubric for multi-stack projects (vodmal)"
|
|
16
16
|
owner: vodmal
|
|
17
17
|
homepage: "https://github.com/vodmal/vdx-rubric"
|
|
@@ -24,8 +24,8 @@ levels:
|
|
|
24
24
|
L0: { name: chaos, description: "Не поднимается, знание в голове" }
|
|
25
25
|
L1: { name: reproducible, description: "Поднимается одной задокументированной командой" }
|
|
26
26
|
L2: { name: testable, description: "Тесты есть; единый словарь up/test/build" }
|
|
27
|
-
L3: { name: gated, description: "Статанализ +
|
|
28
|
-
L4: { name: exemplar, description: "Полная глубина:
|
|
27
|
+
L3: { name: gated, description: "Статанализ + стиль; CI зовёт словарь проекта; main на хостинге принимает только проверенное" }
|
|
28
|
+
L4: { name: exemplar, description: "Полная глубина: хуки повторяют CI, mock-инфра, observability, версионированная общая инфра" }
|
|
29
29
|
|
|
30
30
|
axes:
|
|
31
31
|
|
|
@@ -183,13 +183,15 @@ axes:
|
|
|
183
183
|
|
|
184
184
|
- id: ci
|
|
185
185
|
class: critical
|
|
186
|
-
description: "
|
|
186
|
+
description: "Сигнал: тесты на каждый push в основную ветку, падение не замаскировано. Гейт до слияния — ось branch-protection"
|
|
187
187
|
storage: level
|
|
188
188
|
default_target: L4
|
|
189
189
|
fact_sources:
|
|
190
190
|
- .github/workflows/
|
|
191
191
|
- .gitlab-ci.yml
|
|
192
192
|
- .circleci/config.yml
|
|
193
|
+
# Выше L1 vdx читает только GitHub Actions; у прочих CI-систем L2+ —
|
|
194
|
+
# «неизвестно», в общем уровне это «не выполнено».
|
|
193
195
|
levels:
|
|
194
196
|
L1:
|
|
195
197
|
requires:
|
|
@@ -197,28 +199,45 @@ axes:
|
|
|
197
199
|
- has_file: .github/workflows
|
|
198
200
|
- has_file: .gitlab-ci.yml
|
|
199
201
|
- has_file: .circleci/config.yml
|
|
202
|
+
# Тесты на push в main/master (фильтр веток или без него). Шаг с `|| true`
|
|
203
|
+
# или continue-on-error не засчитывается. Только PR-триггер — тоже: в репо
|
|
204
|
+
# с пушами в main такой CI не запускается никогда.
|
|
200
205
|
L2:
|
|
201
206
|
requires:
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
207
|
+
gha_tests_on_push:
|
|
208
|
+
pattern: "\\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"
|
|
209
|
+
# Паритет: CI вызывает задачи проекта check и test (или check:* / test:*)
|
|
210
|
+
# через его раннер — то же, что человек и агент запускают локально.
|
|
206
211
|
L3:
|
|
207
212
|
requires:
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
- file_contains: { path: .gitlab-ci.yml, pattern: "rules:.+merge_request" }
|
|
212
|
-
- not:
|
|
213
|
-
file_contains:
|
|
214
|
-
paths: [.github/workflows/*.yml, .gitlab-ci.yml]
|
|
215
|
-
pattern: "\\|\\| true"
|
|
213
|
+
gha_runs_tasks: { tasks: [check, test] }
|
|
214
|
+
# Ширина или конвейер: проверяющая задача идёт по matrix, либо всё, что CI
|
|
215
|
+
# выкладывает или выпускает (deploy/release/publish), ждёт её через needs:.
|
|
216
216
|
L4:
|
|
217
217
|
requires:
|
|
218
218
|
any_of:
|
|
219
|
-
-
|
|
220
|
-
|
|
221
|
-
-
|
|
219
|
+
- gha_test_matrix:
|
|
220
|
+
pattern: "\\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"
|
|
221
|
+
- gha_ship_needs_checks:
|
|
222
|
+
pattern: "\\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"
|
|
223
|
+
|
|
224
|
+
- id: branch-protection
|
|
225
|
+
class: critical
|
|
226
|
+
description: "Основная ветка на хостинге принимает изменения только после обязательной проверки"
|
|
227
|
+
storage: level
|
|
228
|
+
default_target: L4
|
|
229
|
+
fact_sources: [hosting API]
|
|
230
|
+
# Гейт — свойство L3 общей лестницы; ниже ось ничего не требует и уровень
|
|
231
|
+
# не ограничивает. Знает о защите только хостинг: пока расширения для него
|
|
232
|
+
# нет, L3 — «неизвестно» у всех. L4 — позже: force-push, удаление ветки,
|
|
233
|
+
# ревью (как Branch-Protection у Scorecard).
|
|
234
|
+
levels:
|
|
235
|
+
L1: { not_required: true }
|
|
236
|
+
L2: { not_required: true }
|
|
237
|
+
L3:
|
|
238
|
+
requires:
|
|
239
|
+
branch_requires_checks: {}
|
|
240
|
+
L4: { not_required: true }
|
|
222
241
|
|
|
223
242
|
# =========================================================================
|
|
224
243
|
# SUPPORTING
|
|
@@ -364,32 +383,36 @@ axes:
|
|
|
364
383
|
|
|
365
384
|
- id: git-hygiene
|
|
366
385
|
class: supporting
|
|
386
|
+
description: "Хуки репозитория: ставятся сами, зовут словарь проекта, повторяют проверки CI"
|
|
367
387
|
storage: level
|
|
368
388
|
default_target: L4
|
|
369
|
-
fact_sources: [.husky/,
|
|
389
|
+
fact_sources: [.gitignore, .husky/, lefthook.yml, .lefthook.yml, .githooks/, package.json, composer.json, mise.toml]
|
|
390
|
+
# Включены ли хуки в этом клоне — пометка рядом с осью в `vdx audit` и строка
|
|
391
|
+
# `vdx doctor`; уровень не меняет: тот же репо и тот же тег дают тот же отчёт
|
|
392
|
+
# в любом клоне.
|
|
393
|
+
clone_check: git-hooks
|
|
370
394
|
levels:
|
|
371
395
|
L1:
|
|
372
396
|
requires:
|
|
373
397
|
has_file: .gitignore
|
|
398
|
+
# Репо ставит свои хуки сам на обычной установке: husky в prepare/postinstall
|
|
399
|
+
# (под Yarn 2+ — только postinstall), пакеты lefthook / simple-git-hooks,
|
|
400
|
+
# `cghooks add` в post-install-cmd, `git config core.hooksPath` в шаге
|
|
401
|
+
# установки или в задаче up. Объявленный, но не ставящийся сам — не L2.
|
|
374
402
|
L2:
|
|
375
403
|
requires:
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
- has_file: lefthook.yml
|
|
380
|
-
- package_present: { name: brainmaestro/composer-git-hooks, ecosystem: composer }
|
|
381
|
-
- package_present: { name: husky, ecosystem: npm }
|
|
404
|
+
git_hooks_arranged: {}
|
|
405
|
+
# pre-commit или pre-push зовёт задачу словаря (test, check, check:*, test:*)
|
|
406
|
+
# через раннер проекта.
|
|
382
407
|
L3:
|
|
383
408
|
requires:
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
409
|
+
git_hook_runs_task: { tasks: [test, check] }
|
|
410
|
+
# pre-push вызывает все задачи словаря, которые вызывает CI на push в main
|
|
411
|
+
# (@-ссылки composer и npm run раскрываются): пуш, прошедший хук, не падает
|
|
412
|
+
# в CI на проверках.
|
|
388
413
|
L4:
|
|
389
414
|
requires:
|
|
390
|
-
|
|
391
|
-
- file_contains: { path: composer.json, pattern: "\"(post-commit|commit-msg|post-merge)\"" }
|
|
392
|
-
- file_contains: { paths: [.lefthook.yml, lefthook.yml], pattern: "(post-commit|commit-msg|post-merge):" }
|
|
415
|
+
git_hook_covers_ci: { tasks: [test, check] }
|
|
393
416
|
|
|
394
417
|
- id: observability
|
|
395
418
|
class: supporting
|
package/src/ai.ts
CHANGED
|
@@ -4,6 +4,7 @@ import * as path from 'node:path';
|
|
|
4
4
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
5
5
|
import YAML from 'js-yaml';
|
|
6
6
|
import { evalPredicate } from './evaluator.ts';
|
|
7
|
+
import { GIT_HOOK_EVENTS } from './hooks.ts';
|
|
7
8
|
import { autoDetectStack, readStructured, resolveConfigPath, type Ctx } from './facts.ts';
|
|
8
9
|
import { loadManifest } from './manifest.ts';
|
|
9
10
|
import { type Author, effectiveAuthor, isGitRepo, rankAuthors, scanPool, writeAuthor } from './author.ts';
|
|
@@ -83,9 +84,26 @@ export interface SessionProfile {
|
|
|
83
84
|
machines?: string[];
|
|
84
85
|
}
|
|
85
86
|
|
|
87
|
+
/**
|
|
88
|
+
* A personal hook: the owner's own gate, kept in git's user config
|
|
89
|
+
* (`hook.<name>.event` / `.command`, Git 2.54) instead of in every repository.
|
|
90
|
+
* `vdx doctor` checks it is there, `vdx doctor --fix` writes it.
|
|
91
|
+
*/
|
|
92
|
+
export interface PersonalHook {
|
|
93
|
+
name: string;
|
|
94
|
+
/** Git hook event(s): `pre-commit`, … */
|
|
95
|
+
event: string | string[];
|
|
96
|
+
/** Run by git through the shell; `$HOME` and quotes work as in a hook script. */
|
|
97
|
+
command: string;
|
|
98
|
+
/** The hook applies on a machine where this path exists (the tool is installed); `~` is home. */
|
|
99
|
+
when_exists?: string;
|
|
100
|
+
description?: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
86
103
|
export interface GitProfile {
|
|
87
104
|
/** Directories whose repos are the neighbours an author is proposed from; default — the project's parent. */
|
|
88
105
|
author_pool?: string[];
|
|
106
|
+
hooks?: PersonalHook[];
|
|
89
107
|
}
|
|
90
108
|
|
|
91
109
|
export interface Environment {
|
|
@@ -202,6 +220,27 @@ export function parseEnvironment(text: string, source: string): Environment {
|
|
|
202
220
|
if (gitDoc.author_pool !== undefined && !isStringList(gitDoc.author_pool)) {
|
|
203
221
|
fail('git.author_pool', 'must be a list of directories');
|
|
204
222
|
}
|
|
223
|
+
if (gitDoc.hooks !== undefined) {
|
|
224
|
+
if (!Array.isArray(gitDoc.hooks)) fail('git.hooks', 'must be a list');
|
|
225
|
+
const names = new Set<string>();
|
|
226
|
+
gitDoc.hooks.forEach((h, i) => {
|
|
227
|
+
const at = `git.hooks[${i}]`;
|
|
228
|
+
if (typeof h !== 'object' || h === null) fail(at, 'must be a mapping');
|
|
229
|
+
if (typeof h.name !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(h.name)) {
|
|
230
|
+
fail(`${at}.name`, 'must be a name of letters, digits, . _ - (the git config subsection)');
|
|
231
|
+
}
|
|
232
|
+
if (names.has(h.name)) fail(`${at}.name`, `"${h.name}" is declared twice`);
|
|
233
|
+
names.add(h.name);
|
|
234
|
+
const events = Array.isArray(h.event) ? h.event : [h.event];
|
|
235
|
+
if (events.length === 0 || !events.every((e) => typeof e === 'string' && GIT_HOOK_EVENTS.has(e))) {
|
|
236
|
+
fail(`${at}.event`, 'must be a git hook event (pre-commit, pre-push, …) or a list of them');
|
|
237
|
+
}
|
|
238
|
+
if (typeof h.command !== 'string' || h.command.trim() === '') fail(`${at}.command`, 'must be a non-empty string');
|
|
239
|
+
if (h.when_exists !== undefined && (typeof h.when_exists !== 'string' || h.when_exists === '')) {
|
|
240
|
+
fail(`${at}.when_exists`, 'must be a path');
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
}
|
|
205
244
|
}
|
|
206
245
|
return envDoc;
|
|
207
246
|
}
|
package/src/audit.ts
CHANGED
|
@@ -4,7 +4,8 @@ import type { Rubric, LevelName } from './rubric.ts';
|
|
|
4
4
|
import { findSubPackages, stackForDir, type Ctx } from './facts.ts';
|
|
5
5
|
import { evalPredicate } from './evaluator.ts';
|
|
6
6
|
import type { Override, VdxManifest } from './manifest.ts';
|
|
7
|
-
import {
|
|
7
|
+
import { evalAxisDetailed, projectLevel, type AxisResult } from './scoring.ts';
|
|
8
|
+
import { cloneCheck } from './clone-checks.ts';
|
|
8
9
|
|
|
9
10
|
function levelToInt(L: LevelName): number {
|
|
10
11
|
return parseInt(L.slice(1), 10);
|
|
@@ -126,7 +127,7 @@ export function audit(
|
|
|
126
127
|
});
|
|
127
128
|
continue;
|
|
128
129
|
}
|
|
129
|
-
const achieved =
|
|
130
|
+
const { achieved, not_required, unknown } = evalAxisDetailed(axis, evalCtx);
|
|
130
131
|
const ov = overrideByAxis.get(axis.id);
|
|
131
132
|
const target = (ov?.target as LevelName) ?? axis.default_target;
|
|
132
133
|
const drift: 'aligned' | 'gap' | 'over' =
|
|
@@ -143,6 +144,12 @@ export function audit(
|
|
|
143
144
|
drift_kind: drift,
|
|
144
145
|
};
|
|
145
146
|
if (ov?.target) entry.override_target = ov.target as LevelName;
|
|
147
|
+
if (not_required) entry.not_required = not_required;
|
|
148
|
+
if (unknown) entry.unknown = unknown;
|
|
149
|
+
if (axis.clone_check) {
|
|
150
|
+
const c = cloneCheck(axis.clone_check, ctx.projectRoot);
|
|
151
|
+
if (c && c.status !== 'ok') entry.note = c.remedy ? `${c.message} → ${c.remedy}` : c.message;
|
|
152
|
+
}
|
|
146
153
|
perAxis.push(entry);
|
|
147
154
|
}
|
|
148
155
|
|
|
@@ -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/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',
|