@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vodmal/vdx-cli",
3
- "version": "0.26.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
- "check": "npm run typecheck && npm run format:check && npm run lint",
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.7",
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.7"
76
+ "vitest": "^4.1.11"
72
77
  },
73
78
  "publishConfig": {
74
79
  "access": "public"
75
80
  },
76
81
  "engines": {
77
- "node": ">=20.11"
82
+ "node": ">=22"
78
83
  }
79
84
  }
@@ -11,7 +11,7 @@ schema_version: "0.3"
11
11
 
12
12
  metadata:
13
13
  name: vdx-rubric-vodmal
14
- version: "1.4.2"
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
- export interface ConfirmRule {
52
+ interface ConfirmRule {
53
53
  screen: string;
54
54
  keys: string[];
55
55
  }
56
56
 
57
- export interface WhenRule {
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
- export type Multiplexer = 'tmux' | 'none';
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
- export interface SessionProfile {
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
- export interface GitProfile {
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
- export const ENVIRONMENT_ENV_VAR = 'VDX_ENVIRONMENT';
125
- export const HOST_ENV_VAR = 'VDX_HOST';
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
- export const SEAT_ENV_VAR = 'VDX_AI_FROM';
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
- export const FOCUSED_ENV_VAR = 'VDX_FOCUSED';
133
- export const USER_ENVIRONMENT_FILE = '.vdx-environment.yaml';
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
- export const FALLBACK_AGENTS = ['claude', 'codex'] as const;
136
- export const DEFAULT_SESSION_NAME = '{project}';
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
- export const PANE_FORMAT = '#{session_name}\t#{pane_id}\t#{pane_pid}\t#{pane_current_path}';
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
- export function isInside(child: string, parent: string): boolean {
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
- export function resolveProjectRoot(p: string): string | null {
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
- export function seatLabel(env: NodeJS.ProcessEnv = process.env): string {
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
- export function sameMachine(a: string | null, b: string | null): boolean {
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
- export function runSsh(args: string[], capture: boolean): { status: number; stdout: string } | null {
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
- export function askLine(prompt: string): string | null {
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
- export const NOTHING_TO_RESUME_MS = 30_000;
801
+ const NOTHING_TO_RESUME_MS = 30_000;
802
802
 
803
- export function renderPlan(
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
- export function askMachine(host: string, deps: AiDeps): MachineReport | null {
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
- export interface ConversationChoice {
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
- export function isClaude(command: string): boolean {
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
- export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boolean): ConversationChoice {
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
- export const GUARDED_FOLDERS = ['Downloads', 'Desktop', 'Documents'];
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
- export function nameTokens(dir: string, remotePath: string): Set<string> {
84
+ function nameTokens(dir: string, remotePath: string): Set<string> {
85
85
  return new Set(
86
86
  `${path.basename(dir)} ${remotePath}`
87
87
  .toLowerCase()
@@ -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
- export const MACHINE_LINE_PREFIX = 'vdx ai: this machine is';
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
- export function typedHere(env: NodeJS.ProcessEnv, home: string, ids: Set<string>): Set<string> {
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
- export function parseLiveSessions(text: string): LiveSession[] {
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 (LTS ≥ 20 recommended)',
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 < 20) {
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} — below recommended (≥ 20 LTS)`,
116
- remedy: 'mise use -g node@20 (or upgrade via nvm/brew)',
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 >= 22 ? 4 : 3, message: v };
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 Unknown, type Verdict };
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
- /** Absolute path of `rel`: here, else in `fallbackRoot` when it is there. */
20
- function locate(ctx: Ctx, rel: string): string {
21
- const here = path.join(ctx.projectRoot, rel);
22
- if (ctx.fallbackRoot === undefined || fs.existsSync(here)) return here;
23
- const there = path.join(ctx.fallbackRoot, rel);
24
- return fs.existsSync(there) ? there : here;
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 fs.existsSync(locate(ctx, rel));
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
- export function readYaml(ctx: Ctx, rel: string): unknown {
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
- try {
187
- const files = fs.readdirSync(abs);
188
- for (const f of files) {
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
- export function parseWorkflow(file: string, text: string): GhaWorkflow | null {
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
- export function stepMasked(step: GhaStep, job: GhaJob): boolean {
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
- export type FrameworkId = 'husky' | 'lefthook' | 'simple-git-hooks' | 'cghooks' | 'githooks';
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
- export function yarnBerry(ctx: Ctx): boolean {
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
- export function installCommand(fw: HookFramework, ctx: Ctx): string {
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.4.2';
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
- export type MatchReason = 'exact' | 'alias' | 'prefix-group' | 'suffix-group' | 'not-found';
81
+ type MatchReason = 'exact' | 'alias' | 'prefix-group' | 'suffix-group' | 'not-found';
82
82
 
83
- export interface VerbMapping {
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
- export function renderMiseToml(
251
+ function renderMiseToml(
257
252
  tools: Record<string, string>,
258
253
  mappings: VerbMapping[],
259
254
  stack: string,
@@ -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
- export const CONFIG_HOOKS_SINCE: [number, number] = [2, 54];
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
- export function applicableHooks(deps: PersonalHookDeps): PersonalHook[] | null {
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
- export type CommandRunner = (cmd: string, args: string[], cwd: string) => void;
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
- export type AxisClass = 'critical' | 'supporting';
8
- export type Storage = 'level' | 'flags';
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
- export interface Flag {
14
+ interface Flag {
15
15
  id: string;
16
16
  weight: number;
17
17
  predicate: Predicate;
18
18
  }
19
19
 
20
- export interface StackImpl {
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
- export const LIFECYCLE_VOCABULARY = ['up', 'down', 'build', 'test', 'check', 'fix'] as const;
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
- export function taskChildren(ctx: Ctx, name: string): string[] {
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) {