@vodmal/vdx-cli 0.26.0 → 0.28.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/package.json +10 -5
- package/rubric/vdx-rubric.yaml +8 -1
- package/src/ai.ts +26 -26
- package/src/author.ts +1 -1
- package/src/conversations.ts +3 -3
- package/src/doctor.ts +5 -5
- package/src/evaluator.ts +1 -1
- package/src/facts.ts +46 -15
- package/src/gha.ts +2 -2
- package/src/hooks.ts +3 -3
- package/src/init.ts +4 -9
- package/src/personal-hooks.ts +2 -8
- package/src/publish.ts +1 -1
- package/src/rubric.ts +4 -4
- package/src/vocabulary.ts +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vodmal/vdx-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.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",
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"LICENSE"
|
|
35
35
|
],
|
|
36
36
|
"scripts": {
|
|
37
|
+
"prepare": "git config core.hooksPath .githooks || true",
|
|
37
38
|
"vdx": "tsx src/index.ts",
|
|
38
39
|
"vdx-mcp": "tsx src/mcp-server.ts",
|
|
39
40
|
"audit": "tsx src/index.ts audit",
|
|
@@ -41,10 +42,13 @@
|
|
|
41
42
|
"typecheck": "tsc --noEmit",
|
|
42
43
|
"format:check": "prettier --check .",
|
|
43
44
|
"lint": "eslint .",
|
|
44
|
-
"
|
|
45
|
+
"knip": "knip",
|
|
46
|
+
"check": "npm run typecheck && npm run format:check && npm run lint && npm run knip",
|
|
47
|
+
"check:code:security": "npm audit --omit=dev",
|
|
45
48
|
"fix": "eslint --fix . && prettier --write .",
|
|
46
49
|
"test": "vitest run",
|
|
47
50
|
"test:unit": "vitest run tests/unit",
|
|
51
|
+
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
|
48
52
|
"coverage": "vitest run --coverage"
|
|
49
53
|
},
|
|
50
54
|
"dependencies": {
|
|
@@ -62,18 +66,19 @@
|
|
|
62
66
|
"@types/js-yaml": "^4.0.9",
|
|
63
67
|
"@types/marked-terminal": "^6.1.1",
|
|
64
68
|
"@types/node": "^22.0.0",
|
|
65
|
-
"@vitest/coverage-v8": "^4.1.
|
|
69
|
+
"@vitest/coverage-v8": "^4.1.11",
|
|
66
70
|
"eslint": "^10.12.0",
|
|
67
71
|
"eslint-config-prettier": "^10.1.8",
|
|
72
|
+
"knip": "^6.41.0",
|
|
68
73
|
"prettier": "^3.9.10",
|
|
69
74
|
"typescript": "^5.6.0",
|
|
70
75
|
"typescript-eslint": "^8.71.2",
|
|
71
|
-
"vitest": "^4.1.
|
|
76
|
+
"vitest": "^4.1.11"
|
|
72
77
|
},
|
|
73
78
|
"publishConfig": {
|
|
74
79
|
"access": "public"
|
|
75
80
|
},
|
|
76
81
|
"engines": {
|
|
77
|
-
"node": ">=
|
|
82
|
+
"node": ">=22"
|
|
78
83
|
}
|
|
79
84
|
}
|
package/rubric/vdx-rubric.yaml
CHANGED
|
@@ -11,7 +11,7 @@ schema_version: "0.3"
|
|
|
11
11
|
|
|
12
12
|
metadata:
|
|
13
13
|
name: vdx-rubric-vodmal
|
|
14
|
-
version: "1.
|
|
14
|
+
version: "1.5.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"
|
|
@@ -470,6 +470,10 @@ axes:
|
|
|
470
470
|
|
|
471
471
|
- id: observability
|
|
472
472
|
class: supporting
|
|
473
|
+
# v1.5.0: логгер, Sentry и релиз в Sentry — у процесса, который работает и
|
|
474
|
+
# падает без человека. Библиотека и CLI (lib-intent) пишут в stderr и код
|
|
475
|
+
# выхода — у них ось excluded. Определение lib-intent — в lifecycle-interface.L3.
|
|
476
|
+
applies_when: { not: *lib_intent }
|
|
473
477
|
storage: level
|
|
474
478
|
default_target: L4
|
|
475
479
|
fact_sources: [composer.json, package.json, .github/workflows/]
|
|
@@ -526,6 +530,9 @@ axes:
|
|
|
526
530
|
- id: mock-infra
|
|
527
531
|
class: supporting
|
|
528
532
|
applies_to: [php, node, go, python]
|
|
533
|
+
# v1.5.0: mock-сервер и проверка, что моков нет в проде, — у сервиса с
|
|
534
|
+
# зависимостями. У библиотеки и CLI (lib-intent) ось excluded.
|
|
535
|
+
applies_when: { not: *lib_intent }
|
|
529
536
|
storage: level
|
|
530
537
|
default_target: L4
|
|
531
538
|
fact_sources: [composer.json, package.json, docker-compose.yml, mock-server/]
|
package/src/ai.ts
CHANGED
|
@@ -49,12 +49,12 @@ import { at, errorMessage, isStringList, str } from './narrow.ts';
|
|
|
49
49
|
*/
|
|
50
50
|
|
|
51
51
|
/** A prompt a conditional flag is known to cause, and the tmux keys that answer it. */
|
|
52
|
-
|
|
52
|
+
interface ConfirmRule {
|
|
53
53
|
screen: string;
|
|
54
54
|
keys: string[];
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
interface WhenRule {
|
|
58
58
|
id: string;
|
|
59
59
|
description?: string;
|
|
60
60
|
if: Predicate;
|
|
@@ -71,7 +71,7 @@ export interface AgentProfile {
|
|
|
71
71
|
when?: WhenRule[];
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
|
|
74
|
+
type Multiplexer = 'tmux' | 'none';
|
|
75
75
|
|
|
76
76
|
/** A place a project's short name may come from: a structured file and a dot path into it. */
|
|
77
77
|
export interface NameSource {
|
|
@@ -80,7 +80,7 @@ export interface NameSource {
|
|
|
80
80
|
jsonpath: string;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
interface SessionProfile {
|
|
84
84
|
multiplexer?: Multiplexer;
|
|
85
85
|
name?: string;
|
|
86
86
|
/** Tried after `[_.vdx] name` (or the older `[vdx]`) in the project's mise.toml; the first non-empty string wins. */
|
|
@@ -107,7 +107,7 @@ export interface PersonalHook {
|
|
|
107
107
|
description?: string;
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
|
|
110
|
+
interface GitProfile {
|
|
111
111
|
/** Directories whose repos are the neighbours an author is proposed from; default — the project's parent. */
|
|
112
112
|
author_pool?: string[];
|
|
113
113
|
hooks?: PersonalHook[];
|
|
@@ -121,19 +121,19 @@ export interface Environment {
|
|
|
121
121
|
git?: GitProfile;
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
|
|
125
|
-
|
|
124
|
+
const ENVIRONMENT_ENV_VAR = 'VDX_ENVIRONMENT';
|
|
125
|
+
const HOST_ENV_VAR = 'VDX_HOST';
|
|
126
126
|
/** Set by `vdx ai@<host>` for the vdx it starts there: the machine the person sits at. */
|
|
127
|
-
|
|
127
|
+
const SEAT_ENV_VAR = 'VDX_AI_FROM';
|
|
128
128
|
/**
|
|
129
129
|
* `1` in the environment of an agent started with `vdx ai --focused`, and so of
|
|
130
130
|
* its hooks and MCP servers: nothing wakes this session or reads its inbox for it.
|
|
131
131
|
*/
|
|
132
|
-
|
|
133
|
-
|
|
132
|
+
const FOCUSED_ENV_VAR = 'VDX_FOCUSED';
|
|
133
|
+
const USER_ENVIRONMENT_FILE = '.vdx-environment.yaml';
|
|
134
134
|
/** Tried in order when no profile names an agent. Started plain, with no flags. */
|
|
135
|
-
|
|
136
|
-
|
|
135
|
+
const FALLBACK_AGENTS = ['claude', 'codex'] as const;
|
|
136
|
+
const DEFAULT_SESSION_NAME = '{project}';
|
|
137
137
|
|
|
138
138
|
export function resolveEnvironmentPath(
|
|
139
139
|
env: NodeJS.ProcessEnv = process.env,
|
|
@@ -401,7 +401,7 @@ export interface ProcInfo {
|
|
|
401
401
|
args: string;
|
|
402
402
|
}
|
|
403
403
|
|
|
404
|
-
|
|
404
|
+
const PANE_FORMAT = '#{session_name}\t#{pane_id}\t#{pane_pid}\t#{pane_current_path}';
|
|
405
405
|
|
|
406
406
|
export function parsePanes(out: string): PaneInfo[] {
|
|
407
407
|
const panes: PaneInfo[] = [];
|
|
@@ -462,7 +462,7 @@ function realpathOr(p: string): string {
|
|
|
462
462
|
}
|
|
463
463
|
}
|
|
464
464
|
|
|
465
|
-
|
|
465
|
+
function isInside(child: string, parent: string): boolean {
|
|
466
466
|
return child === parent || child.startsWith(parent.endsWith(path.sep) ? parent : parent + path.sep);
|
|
467
467
|
}
|
|
468
468
|
|
|
@@ -497,7 +497,7 @@ export function missingArgs(procArgs: string, required: string[]): string[] {
|
|
|
497
497
|
return required.filter((a) => (/\s/.test(a) ? !procArgs.includes(a) : !tokens.has(a)));
|
|
498
498
|
}
|
|
499
499
|
|
|
500
|
-
|
|
500
|
+
function resolveProjectRoot(p: string): string | null {
|
|
501
501
|
const abs = path.resolve(p);
|
|
502
502
|
try {
|
|
503
503
|
if (!fs.statSync(abs).isDirectory()) return null;
|
|
@@ -580,12 +580,12 @@ export function hostLabel(env: NodeJS.ProcessEnv = process.env): string {
|
|
|
580
580
|
}
|
|
581
581
|
|
|
582
582
|
/** The machine the person sits at: the one whose `vdx ai@<host>` started this vdx, else this one. */
|
|
583
|
-
|
|
583
|
+
function seatLabel(env: NodeJS.ProcessEnv = process.env): string {
|
|
584
584
|
return env[SEAT_ENV_VAR]?.trim() || hostLabel(env);
|
|
585
585
|
}
|
|
586
586
|
|
|
587
587
|
/** Machine labels compare without case: `M3` in the profile is `m3` in a transcript. */
|
|
588
|
-
|
|
588
|
+
function sameMachine(a: string | null, b: string | null): boolean {
|
|
589
589
|
return (a ?? '').toLowerCase() === (b ?? '').toLowerCase();
|
|
590
590
|
}
|
|
591
591
|
|
|
@@ -737,7 +737,7 @@ export interface AiDeps {
|
|
|
737
737
|
liveHere?: () => LiveSession[];
|
|
738
738
|
}
|
|
739
739
|
|
|
740
|
-
|
|
740
|
+
function runSsh(args: string[], capture: boolean): { status: number; stdout: string } | null {
|
|
741
741
|
const res = spawnSync('ssh', args, { stdio: capture ? ['ignore', 'pipe', 'ignore'] : 'inherit', encoding: 'utf8' });
|
|
742
742
|
if (at(res.error, 'code') === 'ENOENT') return null;
|
|
743
743
|
return { status: res.status ?? 1, stdout: res.stdout ?? '' };
|
|
@@ -748,7 +748,7 @@ export function sleepSync(ms: number): void {
|
|
|
748
748
|
}
|
|
749
749
|
|
|
750
750
|
/** Read one line from the terminal, synchronously: the prompt runs before tmux takes it. */
|
|
751
|
-
|
|
751
|
+
function askLine(prompt: string): string | null {
|
|
752
752
|
process.stderr.write(prompt);
|
|
753
753
|
const buf = Buffer.alloc(256);
|
|
754
754
|
let text = '';
|
|
@@ -798,9 +798,9 @@ export const EXIT_LAUNCH_FAILED = 4;
|
|
|
798
798
|
* a start with the resume args had nothing to resume (`claude --continue` exits
|
|
799
799
|
* 1 at once); the window covers answering the channel prompt that comes first.
|
|
800
800
|
*/
|
|
801
|
-
|
|
801
|
+
const NOTHING_TO_RESUME_MS = 30_000;
|
|
802
802
|
|
|
803
|
-
|
|
803
|
+
function renderPlan(
|
|
804
804
|
plan: LaunchPlan,
|
|
805
805
|
multiplexer: Multiplexer,
|
|
806
806
|
running: AgentPane[],
|
|
@@ -1083,7 +1083,7 @@ export function runAiRemote(opts: AiOptions, host: string, deps: AiDeps, from: s
|
|
|
1083
1083
|
* The Claude Code sessions alive on `host` right now; null when it does not
|
|
1084
1084
|
* answer over ssh (BatchMode: a password prompt counts as no answer).
|
|
1085
1085
|
*/
|
|
1086
|
-
|
|
1086
|
+
function askMachine(host: string, deps: AiDeps): MachineReport | null {
|
|
1087
1087
|
const res = (deps.ssh ?? runSsh)(
|
|
1088
1088
|
['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', host, `sh -c ${shellQuote(LIVE_SESSIONS_SCRIPT)}`],
|
|
1089
1089
|
true,
|
|
@@ -1092,7 +1092,7 @@ export function askMachine(host: string, deps: AiDeps): MachineReport | null {
|
|
|
1092
1092
|
}
|
|
1093
1093
|
|
|
1094
1094
|
/** What a start continues: resume args here, or a conversation on another machine. */
|
|
1095
|
-
|
|
1095
|
+
interface ConversationChoice {
|
|
1096
1096
|
args: string[];
|
|
1097
1097
|
/** Continue it on that machine (`vdx ai@<host>`); `reachable` — it answered over ssh just now. */
|
|
1098
1098
|
remote?: { host: string; id: string; reachable: boolean };
|
|
@@ -1106,7 +1106,7 @@ export interface ConversationChoice {
|
|
|
1106
1106
|
lines: string[];
|
|
1107
1107
|
}
|
|
1108
1108
|
|
|
1109
|
-
|
|
1109
|
+
function isClaude(command: string): boolean {
|
|
1110
1110
|
return path.basename(command) === 'claude';
|
|
1111
1111
|
}
|
|
1112
1112
|
|
|
@@ -1136,7 +1136,7 @@ const resumeId = (id: string) => ['--resume', id];
|
|
|
1136
1136
|
* takes the newest overall; without a terminal it is this machine's newest
|
|
1137
1137
|
* idle one, never another's.
|
|
1138
1138
|
*/
|
|
1139
|
-
|
|
1139
|
+
function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boolean): ConversationChoice {
|
|
1140
1140
|
if (!isClaude(plan.command)) return { args: plan.resumeArgs, lines: [] };
|
|
1141
1141
|
const host = plan.host;
|
|
1142
1142
|
const same = sameMachine;
|
|
@@ -1412,7 +1412,7 @@ export function processFocused(pid: number): boolean {
|
|
|
1412
1412
|
}
|
|
1413
1413
|
|
|
1414
1414
|
/** macOS guards these folders on their own; a tmux server can hold a denial for every session under it. */
|
|
1415
|
-
|
|
1415
|
+
const GUARDED_FOLDERS = ['Downloads', 'Desktop', 'Documents'];
|
|
1416
1416
|
|
|
1417
1417
|
/**
|
|
1418
1418
|
* The guarded folders an agent started here could not read. Through the tmux server
|
package/src/author.ts
CHANGED
|
@@ -81,7 +81,7 @@ const GENERIC = new Set([
|
|
|
81
81
|
'dmitry',
|
|
82
82
|
]);
|
|
83
83
|
|
|
84
|
-
|
|
84
|
+
function nameTokens(dir: string, remotePath: string): Set<string> {
|
|
85
85
|
return new Set(
|
|
86
86
|
`${path.basename(dir)} ${remotePath}`
|
|
87
87
|
.toLowerCase()
|
package/src/conversations.ts
CHANGED
|
@@ -29,7 +29,7 @@ import { at, dict, str } from './narrow.ts';
|
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
31
|
/** The opening of `vdx ai --check`'s text; the label is an ssh destination from the other machines. */
|
|
32
|
-
|
|
32
|
+
const MACHINE_LINE_PREFIX = 'vdx ai: this machine is';
|
|
33
33
|
const MACHINE_LINE_RE = /^vdx ai: this machine is `([^`]+)`/m;
|
|
34
34
|
/** A label read from a synced file goes to ssh: it must not be able to read as an option. */
|
|
35
35
|
const LABEL_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
@@ -112,7 +112,7 @@ export function readConversation(file: string, updated: Date): Conversation | nu
|
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
/** Which of `ids` had a prompt typed in them on this machine. */
|
|
115
|
-
|
|
115
|
+
function typedHere(env: NodeJS.ProcessEnv, home: string, ids: Set<string>): Set<string> {
|
|
116
116
|
const found = new Set<string>();
|
|
117
117
|
let text: string;
|
|
118
118
|
try {
|
|
@@ -235,7 +235,7 @@ export interface LiveSession {
|
|
|
235
235
|
}
|
|
236
236
|
|
|
237
237
|
/** Session files, one JSON object per line — a file read here, or the output of LIVE_SESSIONS_SCRIPT. */
|
|
238
|
-
|
|
238
|
+
function parseLiveSessions(text: string): LiveSession[] {
|
|
239
239
|
const out: LiveSession[] = [];
|
|
240
240
|
for (const line of text.split('\n')) {
|
|
241
241
|
if (!line.trim().startsWith('{')) continue;
|
package/src/doctor.ts
CHANGED
|
@@ -99,24 +99,24 @@ function checkNode(): CheckResult {
|
|
|
99
99
|
label: 'Node.js',
|
|
100
100
|
status: 'missing',
|
|
101
101
|
message: 'node binary not on PATH',
|
|
102
|
-
remedy: 'https://nodejs.org/en/download (
|
|
102
|
+
remedy: 'https://nodejs.org/en/download (vdx needs 22 or newer)',
|
|
103
103
|
};
|
|
104
104
|
}
|
|
105
105
|
const major = parseSemverMajor(v);
|
|
106
106
|
if (major === null) {
|
|
107
107
|
return { id: 'node', label: 'Node.js', status: 'warning', message: `unparseable version: ${v}` };
|
|
108
108
|
}
|
|
109
|
-
if (major <
|
|
109
|
+
if (major < 22) {
|
|
110
110
|
return {
|
|
111
111
|
id: 'node',
|
|
112
112
|
label: 'Node.js',
|
|
113
113
|
status: 'warning',
|
|
114
114
|
level: 1,
|
|
115
|
-
message: `${v} —
|
|
116
|
-
remedy: 'mise use -g node@
|
|
115
|
+
message: `${v} — vdx needs 22 or newer (engines in its package.json)`,
|
|
116
|
+
remedy: 'mise use -g node@24 (or upgrade via nvm/brew)',
|
|
117
117
|
};
|
|
118
118
|
}
|
|
119
|
-
return { id: 'node', label: 'Node.js', status: 'ok', level: major >=
|
|
119
|
+
return { id: 'node', label: 'Node.js', status: 'ok', level: major >= 24 ? 4 : 3, message: v };
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
function checkGit(): CheckResult {
|
package/src/evaluator.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { Ctx } from './facts.ts';
|
|
|
3
3
|
import { REGISTRY, isUnknown, type Args, type Unknown, type Verdict } from './predicates.ts';
|
|
4
4
|
import { at, isDict, list } from './narrow.ts';
|
|
5
5
|
|
|
6
|
-
export { isUnknown, type
|
|
6
|
+
export { isUnknown, type Verdict };
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Sugar shorthand: `{ has_task: "up" }` → `{ fn: "has_task", args: { task: "up" } }`
|
package/src/facts.ts
CHANGED
|
@@ -16,12 +16,43 @@ export interface Ctx {
|
|
|
16
16
|
fallbackRoot?: string;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
/**
|
|
20
|
-
function
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
19
|
+
/** Names in `dir`, read once per context; none when it cannot be read. */
|
|
20
|
+
function entries(ctx: Ctx, dir: string): string[] {
|
|
21
|
+
const key = `dir:${dir}`;
|
|
22
|
+
let names = ctx.cache.get(key) as string[] | undefined;
|
|
23
|
+
if (!names) {
|
|
24
|
+
try {
|
|
25
|
+
names = fs.readdirSync(dir);
|
|
26
|
+
} catch {
|
|
27
|
+
names = [];
|
|
28
|
+
}
|
|
29
|
+
ctx.cache.set(key, names);
|
|
30
|
+
}
|
|
31
|
+
return names;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `rel` under `root`, each segment in the case it is written. APFS and NTFS find a file
|
|
36
|
+
* under any case and ext4 does not: `tests/Fixtures` was there on a Mac that has only
|
|
37
|
+
* `tests/fixtures`, and the same project got another level on Linux.
|
|
38
|
+
*/
|
|
39
|
+
function existsAsWritten(ctx: Ctx, root: string, rel: string): boolean {
|
|
40
|
+
let dir = root;
|
|
41
|
+
for (const name of path.normalize(rel).split(path.sep)) {
|
|
42
|
+
if (name === '' || name === '.') continue;
|
|
43
|
+
if (name !== '..' && !entries(ctx, dir).includes(name)) return false;
|
|
44
|
+
dir = path.join(dir, name);
|
|
45
|
+
}
|
|
46
|
+
return fs.existsSync(dir);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Absolute path of `rel`: here, else in `fallbackRoot`; null when in neither. */
|
|
50
|
+
function locate(ctx: Ctx, rel: string): string | null {
|
|
51
|
+
if (existsAsWritten(ctx, ctx.projectRoot, rel)) return path.join(ctx.projectRoot, rel);
|
|
52
|
+
if (ctx.fallbackRoot !== undefined && existsAsWritten(ctx, ctx.fallbackRoot, rel)) {
|
|
53
|
+
return path.join(ctx.fallbackRoot, rel);
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
25
56
|
}
|
|
26
57
|
|
|
27
58
|
/** The same directory without the fallback: a project's tasks are the ones its own runner sees. */
|
|
@@ -36,13 +67,17 @@ function ownCtx(ctx: Ctx): Ctx {
|
|
|
36
67
|
}
|
|
37
68
|
|
|
38
69
|
export function fileExists(ctx: Ctx, rel: string): boolean {
|
|
39
|
-
return
|
|
70
|
+
return locate(ctx, rel) !== null;
|
|
40
71
|
}
|
|
41
72
|
|
|
42
73
|
export function readText(ctx: Ctx, rel: string): string | null {
|
|
43
74
|
const key = `file:${rel}`;
|
|
44
75
|
if (ctx.cache.has(key)) return ctx.cache.get(key) as string | null;
|
|
45
76
|
const abs = locate(ctx, rel);
|
|
77
|
+
if (abs === null) {
|
|
78
|
+
ctx.cache.set(key, null);
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
46
81
|
try {
|
|
47
82
|
const t = fs.readFileSync(abs, 'utf8');
|
|
48
83
|
ctx.cache.set(key, t);
|
|
@@ -89,7 +124,7 @@ export function readToml(ctx: Ctx, rel: string): unknown {
|
|
|
89
124
|
}
|
|
90
125
|
}
|
|
91
126
|
|
|
92
|
-
|
|
127
|
+
function readYaml(ctx: Ctx, rel: string): unknown {
|
|
93
128
|
const key = `yaml:${rel}`;
|
|
94
129
|
if (ctx.cache.has(key)) return ctx.cache.get(key);
|
|
95
130
|
const text = readText(ctx, rel);
|
|
@@ -183,13 +218,9 @@ export function expandPaths(paths: string[], ctx: Ctx): string[] {
|
|
|
183
218
|
const dir = m[1];
|
|
184
219
|
const ext = m[2];
|
|
185
220
|
const abs = locate(ctx, dir);
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
if (f.endsWith('.' + ext)) out.push(path.join(dir, f));
|
|
190
|
-
}
|
|
191
|
-
} catch {
|
|
192
|
-
/* dir missing — silent */
|
|
221
|
+
// A missing dir lists nothing.
|
|
222
|
+
for (const f of abs === null ? [] : entries(ctx, abs)) {
|
|
223
|
+
if (f.endsWith('.' + ext)) out.push(path.join(dir, f));
|
|
193
224
|
}
|
|
194
225
|
}
|
|
195
226
|
} else {
|
package/src/gha.ts
CHANGED
|
@@ -83,7 +83,7 @@ function truthy(v: unknown): boolean {
|
|
|
83
83
|
return v === true || v === 'true';
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
|
|
86
|
+
function parseWorkflow(file: string, text: string): GhaWorkflow | null {
|
|
87
87
|
let data: unknown;
|
|
88
88
|
try {
|
|
89
89
|
data = YAML.load(text);
|
|
@@ -162,7 +162,7 @@ export function ciSystems(ctx: Ctx): string[] {
|
|
|
162
162
|
/** `|| true` and its relatives: the step reports success whatever the command did. */
|
|
163
163
|
const MASK_RE = /\|\|\s*(true|:|exit\s+0)\b/;
|
|
164
164
|
|
|
165
|
-
|
|
165
|
+
function stepMasked(step: GhaStep, job: GhaJob): boolean {
|
|
166
166
|
if (job.continueOnError || step.continueOnError) return true;
|
|
167
167
|
return step.run !== undefined && MASK_RE.test(step.run);
|
|
168
168
|
}
|
package/src/hooks.ts
CHANGED
|
@@ -18,7 +18,7 @@ import YAML from 'js-yaml';
|
|
|
18
18
|
import { fileExists, isPackagePresent, readJson, readText, readToml, type Ctx } from './facts.ts';
|
|
19
19
|
import { at, dict, isDict, list } from './narrow.ts';
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
type FrameworkId = 'husky' | 'lefthook' | 'simple-git-hooks' | 'cghooks' | 'githooks';
|
|
22
22
|
|
|
23
23
|
export interface HookFramework {
|
|
24
24
|
id: FrameworkId;
|
|
@@ -69,7 +69,7 @@ interface InstallStep {
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
/** Yarn 2+ does not run a project's `prepare` (husky docs, How To → Yarn). */
|
|
72
|
-
|
|
72
|
+
function yarnBerry(ctx: Ctx): boolean {
|
|
73
73
|
const pm = at(readJson(ctx, 'package.json'), 'packageManager');
|
|
74
74
|
if (typeof pm === 'string') {
|
|
75
75
|
const m = pm.match(/^yarn@(\d+)/);
|
|
@@ -297,7 +297,7 @@ export function detectHookFrameworks(ctx: Ctx): HookFramework[] {
|
|
|
297
297
|
}
|
|
298
298
|
|
|
299
299
|
/** What a clone runs to switch hooks on: the install step of the project's package manager. */
|
|
300
|
-
|
|
300
|
+
function installCommand(fw: HookFramework, ctx: Ctx): string {
|
|
301
301
|
if (fw.installedBy === null) return fw.manualInstall;
|
|
302
302
|
if (fw.installedBy.startsWith('composer')) return 'composer install';
|
|
303
303
|
if (fw.installedBy === 'mise task up') return 'vdx up';
|
package/src/init.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { at } from './narrow.ts';
|
|
|
6
6
|
export const STANDARD_VERBS = ['up', 'down', 'build', 'test', 'check', 'fix'] as const;
|
|
7
7
|
export type Verb = (typeof STANDARD_VERBS)[number];
|
|
8
8
|
|
|
9
|
-
export const DEFAULT_BASELINE = 'github.com/VoDmAl/vdx-rubric-vodmal@v1.
|
|
9
|
+
export const DEFAULT_BASELINE = 'github.com/VoDmAl/vdx-rubric-vodmal@v1.5.0';
|
|
10
10
|
|
|
11
11
|
const VERB_ALIASES: Record<Verb, string[]> = {
|
|
12
12
|
up: ['docker:up', 'docker-up', 'docker:up:detached', 'start', 'dev', 'serve'],
|
|
@@ -78,9 +78,9 @@ const VERB_SUFFIX_GROUPS: Record<Verb, string[]> = {
|
|
|
78
78
|
fix: [':fix', '-fix', ':format'],
|
|
79
79
|
};
|
|
80
80
|
|
|
81
|
-
|
|
81
|
+
type MatchReason = 'exact' | 'alias' | 'prefix-group' | 'suffix-group' | 'not-found';
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
interface VerbMapping {
|
|
84
84
|
verb: Verb;
|
|
85
85
|
nativeTask: string | null;
|
|
86
86
|
runCommand: string | null;
|
|
@@ -219,11 +219,6 @@ export function selectVerbTask(verb: Verb, tasks: Set<string>): VerbMatch {
|
|
|
219
219
|
return { task: null, reason: 'not-found', alternatives: [] };
|
|
220
220
|
}
|
|
221
221
|
|
|
222
|
-
/** Kept for back-compat with potential external callers; prefer `selectVerbTask`. */
|
|
223
|
-
export function mapVerb(verb: Verb, tasks: Set<string>): string | null {
|
|
224
|
-
return selectVerbTask(verb, tasks).task;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
222
|
function collectTools(ctx: Ctx): Record<string, string> {
|
|
228
223
|
const tools: Record<string, string> = {};
|
|
229
224
|
const php = at(readJson(ctx, 'composer.json'), 'require', 'php');
|
|
@@ -253,7 +248,7 @@ function quoteToml(s: string): string {
|
|
|
253
248
|
return '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
|
|
254
249
|
}
|
|
255
250
|
|
|
256
|
-
|
|
251
|
+
function renderMiseToml(
|
|
257
252
|
tools: Record<string, string>,
|
|
258
253
|
mappings: VerbMapping[],
|
|
259
254
|
stack: string,
|
package/src/personal-hooks.ts
CHANGED
|
@@ -9,14 +9,13 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import * as fs from 'node:fs';
|
|
11
11
|
import * as os from 'node:os';
|
|
12
|
-
import * as path from 'node:path';
|
|
13
12
|
import { execFileSync } from 'node:child_process';
|
|
14
13
|
import type { CheckResult } from './doctor.ts';
|
|
15
14
|
import { loadEnvironment, resolveEnvironmentPath, type PersonalHook } from './ai.ts';
|
|
16
15
|
import { missingMachinePaths } from './hooks.ts';
|
|
17
16
|
|
|
18
17
|
/** Git reads hooks from config since 2.54; older git skips the keys without a word. */
|
|
19
|
-
|
|
18
|
+
const CONFIG_HOOKS_SINCE: [number, number] = [2, 54];
|
|
20
19
|
|
|
21
20
|
export interface PersonalHookDeps {
|
|
22
21
|
env: NodeJS.ProcessEnv;
|
|
@@ -62,7 +61,7 @@ function events(h: PersonalHook): string[] {
|
|
|
62
61
|
}
|
|
63
62
|
|
|
64
63
|
/** The hooks of the profile that apply on this machine; null — no profile, or none declared. */
|
|
65
|
-
|
|
64
|
+
function applicableHooks(deps: PersonalHookDeps): PersonalHook[] | null {
|
|
66
65
|
const file = resolveEnvironmentPath(deps.env, deps.home);
|
|
67
66
|
if (!file) return null;
|
|
68
67
|
let hooks: PersonalHook[];
|
|
@@ -185,8 +184,3 @@ export function fixPersonalHooks(deps: PersonalHookDeps = defaultPersonalHookDep
|
|
|
185
184
|
}
|
|
186
185
|
return done;
|
|
187
186
|
}
|
|
188
|
-
|
|
189
|
-
/** For messages: where the user config lives. */
|
|
190
|
-
export function userGitConfigPath(deps: PersonalHookDeps): string {
|
|
191
|
-
return deps.env['GIT_CONFIG_GLOBAL'] ?? path.join(deps.home, '.gitconfig');
|
|
192
|
-
}
|
package/src/publish.ts
CHANGED
|
@@ -383,7 +383,7 @@ export function preflightFixable(plan: PublishPlan): boolean {
|
|
|
383
383
|
}
|
|
384
384
|
|
|
385
385
|
/** Runs a command on this terminal (stdio inherited); throws on a non-zero exit. */
|
|
386
|
-
|
|
386
|
+
type CommandRunner = (cmd: string, args: string[], cwd: string) => void;
|
|
387
387
|
|
|
388
388
|
export interface PublishDeps {
|
|
389
389
|
run: CommandRunner;
|
package/src/rubric.ts
CHANGED
|
@@ -4,20 +4,20 @@ import { COMBINATORS, predicateNames } from './evaluator.ts';
|
|
|
4
4
|
import { REGISTRY, REMOVED_IN_0_3 } from './predicates.ts';
|
|
5
5
|
|
|
6
6
|
export type LevelName = 'L0' | 'L1' | 'L2' | 'L3' | 'L4';
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
type AxisClass = 'critical' | 'supporting';
|
|
8
|
+
type Storage = 'level' | 'flags';
|
|
9
9
|
|
|
10
10
|
// Predicate структура динамическая — оставляем any для дешёвого парсинга
|
|
11
11
|
// со сахаром. Валидация — отдельная история v0.2.
|
|
12
12
|
export type Predicate = unknown;
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
interface Flag {
|
|
15
15
|
id: string;
|
|
16
16
|
weight: number;
|
|
17
17
|
predicate: Predicate;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
interface StackImpl {
|
|
21
21
|
flags?: Flag[];
|
|
22
22
|
level_thresholds?: Partial<Record<LevelName, number>>;
|
|
23
23
|
}
|
package/src/vocabulary.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { listAllTasks, readJson, readToml, type Ctx } from './facts.ts';
|
|
8
8
|
import { at, dict, list } from './narrow.ts';
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
const LIFECYCLE_VOCABULARY = ['up', 'down', 'build', 'test', 'check', 'fix'] as const;
|
|
11
11
|
|
|
12
12
|
/** npm-family options that take a value; their value is not the script name. */
|
|
13
13
|
const VALUE_OPTIONS = new Set(['--prefix', '-C', '--dir', '-w', '--workspace', '--filter', '--cwd']);
|
|
@@ -98,7 +98,7 @@ export function isVocabularyTask(name: string, words: readonly string[] = ['test
|
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
/** Tasks a task's definition calls: composer `@refs`, `npm run X` inside a script, mise `depends`/`run`. */
|
|
101
|
-
|
|
101
|
+
function taskChildren(ctx: Ctx, name: string): string[] {
|
|
102
102
|
const out: string[] = [];
|
|
103
103
|
const entry = at(readJson(ctx, 'composer.json'), 'scripts', name);
|
|
104
104
|
if (entry !== undefined) {
|