@vodmal/vdx-cli 0.25.0 → 0.27.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/index.ts CHANGED
@@ -2,8 +2,9 @@
2
2
  import * as path from 'node:path';
3
3
  import { execFileSync } from 'node:child_process';
4
4
  import { loadRubric } from './rubric.ts';
5
- import { loadManifest, loadOverrides } from './manifest.ts';
5
+ import { loadManifest, loadManifestAt, loadOverrides } from './manifest.ts';
6
6
  import { autoDetectStack, type Ctx } from './facts.ts';
7
+ import { at, errorMessage } from './narrow.ts';
7
8
  import { LIFECYCLE_VERBS, type LifecycleVerb, resolveLifecycleVerb, renderResolveError } from './run.ts';
8
9
  import { audit } from './audit.ts';
9
10
  import {
@@ -285,7 +286,14 @@ function cmdAudit(opts: ParsedArgs): void {
285
286
  );
286
287
  }
287
288
 
288
- const manifest = loadManifest(projectRoot);
289
+ const found = loadManifestAt(projectRoot);
290
+ const manifest = found?.manifest ?? null;
291
+ if (found?.place === 'vdx') {
292
+ process.stderr.write(
293
+ 'note: the manifest is in [vdx] of mise.toml, and mise 2026.10 warns about it on every run; ' +
294
+ 'its place is [_.vdx], once every machine of the project runs vdx 0.26 or newer\n',
295
+ );
296
+ }
289
297
  const overrides = loadOverrides(projectRoot);
290
298
 
291
299
  const rubricPath = (opts.flags.rubric as string) || DEFAULT_RUBRIC;
@@ -346,8 +354,8 @@ function cmdInit(opts: ParsedArgs): void {
346
354
  try {
347
355
  writeInit(plan, { force: Boolean(opts.flags.force) });
348
356
  process.stderr.write(`\n✓ wrote ${plan.miseTomlPath}\n`);
349
- } catch (e: any) {
350
- process.stderr.write(`\nerror: ${e?.message ?? String(e)}\n`);
357
+ } catch (e) {
358
+ process.stderr.write(`\nerror: ${errorMessage(e)}\n`);
351
359
  process.exit(2);
352
360
  }
353
361
  }
@@ -380,8 +388,8 @@ function cmdPublish(opts: ParsedArgs): void {
380
388
  const replan = () => {
381
389
  try {
382
390
  return planPublish(planOpts, auditResult, ctx);
383
- } catch (e: any) {
384
- process.stderr.write(`error: ${e?.message ?? String(e)}\n`);
391
+ } catch (e) {
392
+ process.stderr.write(`error: ${errorMessage(e)}\n`);
385
393
  process.exit(2);
386
394
  }
387
395
  };
@@ -418,8 +426,8 @@ function cmdPublish(opts: ParsedArgs): void {
418
426
 
419
427
  try {
420
428
  executePublish(plan, deps);
421
- } catch (e: any) {
422
- process.stderr.write(`\nerror: ${e?.message ?? String(e)}\n`);
429
+ } catch (e) {
430
+ process.stderr.write(`\nerror: ${errorMessage(e)}\n`);
423
431
  process.exit(3);
424
432
  }
425
433
  }
@@ -434,14 +442,15 @@ function cmdRun(verb: LifecycleVerb): void {
434
442
 
435
443
  try {
436
444
  execFileSync('mise', ['run', verb], { cwd: projectRoot, stdio: 'inherit' });
437
- } catch (e: any) {
438
- if (e?.code === 'ENOENT') {
445
+ } catch (e) {
446
+ if (at(e, 'code') === 'ENOENT') {
439
447
  process.stderr.write(
440
448
  `vdx: \`mise\` binary not found on PATH\n` + `hint: install mise — https://mise.jdx.dev/getting-started.html\n`,
441
449
  );
442
450
  process.exit(127);
443
451
  }
444
- process.exit(typeof e?.status === 'number' ? e.status : 1);
452
+ const status = at(e, 'status');
453
+ process.exit(typeof status === 'number' ? status : 1);
445
454
  }
446
455
  }
447
456
 
@@ -462,8 +471,8 @@ function cmdDoctor(opts: ParsedArgs): void {
462
471
  process.stderr.write(
463
472
  done.length > 0 ? done.map((d) => `✓ ${d}`).join('\n') + '\n' : 'nothing to fix in ~/.gitconfig\n',
464
473
  );
465
- } catch (e: any) {
466
- process.stderr.write(`vdx doctor --fix: ${e?.message ?? String(e)}\n`);
474
+ } catch (e) {
475
+ process.stderr.write(`vdx doctor --fix: ${errorMessage(e)}\n`);
467
476
  process.exit(2);
468
477
  }
469
478
  }
package/src/init.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import { Ctx, readJson, listAllTasks, autoDetectStack, findSubPackages, type SubPackage } from './facts.ts';
4
+ import { at } from './narrow.ts';
4
5
 
5
6
  export const STANDARD_VERBS = ['up', 'down', 'build', 'test', 'check', 'fix'] as const;
6
7
  export type Verb = (typeof STANDARD_VERBS)[number];
7
8
 
8
- export const DEFAULT_BASELINE = 'github.com/VoDmAl/vdx-rubric-vodmal@v1.4.0';
9
+ export const DEFAULT_BASELINE = 'github.com/VoDmAl/vdx-rubric-vodmal@v1.4.2';
9
10
 
10
11
  const VERB_ALIASES: Record<Verb, string[]> = {
11
12
  up: ['docker:up', 'docker-up', 'docker:up:detached', 'start', 'dev', 'serve'],
@@ -77,9 +78,9 @@ const VERB_SUFFIX_GROUPS: Record<Verb, string[]> = {
77
78
  fix: [':fix', '-fix', ':format'],
78
79
  };
79
80
 
80
- export type MatchReason = 'exact' | 'alias' | 'prefix-group' | 'suffix-group' | 'not-found';
81
+ type MatchReason = 'exact' | 'alias' | 'prefix-group' | 'suffix-group' | 'not-found';
81
82
 
82
- export interface VerbMapping {
83
+ interface VerbMapping {
83
84
  verb: Verb;
84
85
  nativeTask: string | null;
85
86
  runCommand: string | null;
@@ -218,21 +219,12 @@ export function selectVerbTask(verb: Verb, tasks: Set<string>): VerbMatch {
218
219
  return { task: null, reason: 'not-found', alternatives: [] };
219
220
  }
220
221
 
221
- /** Kept for back-compat with potential external callers; prefer `selectVerbTask`. */
222
- export function mapVerb(verb: Verb, tasks: Set<string>): string | null {
223
- return selectVerbTask(verb, tasks).task;
224
- }
225
-
226
222
  function collectTools(ctx: Ctx): Record<string, string> {
227
223
  const tools: Record<string, string> = {};
228
- const composer = readJson(ctx, 'composer.json');
229
- if (composer?.require?.php && typeof composer.require.php === 'string') {
230
- tools.php = stripVersionConstraint(composer.require.php);
231
- }
232
- const pkg = readJson(ctx, 'package.json');
233
- if (pkg?.engines?.node && typeof pkg.engines.node === 'string') {
234
- tools.node = stripVersionConstraint(pkg.engines.node);
235
- }
224
+ const php = at(readJson(ctx, 'composer.json'), 'require', 'php');
225
+ if (typeof php === 'string' && php) tools.php = stripVersionConstraint(php);
226
+ const node = at(readJson(ctx, 'package.json'), 'engines', 'node');
227
+ if (typeof node === 'string' && node) tools.node = stripVersionConstraint(node);
236
228
  return tools;
237
229
  }
238
230
 
@@ -256,7 +248,7 @@ function quoteToml(s: string): string {
256
248
  return '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
257
249
  }
258
250
 
259
- export function renderMiseToml(
251
+ function renderMiseToml(
260
252
  tools: Record<string, string>,
261
253
  mappings: VerbMapping[],
262
254
  stack: string,
@@ -264,7 +256,7 @@ export function renderMiseToml(
264
256
  primarySubpackage: string | null = null,
265
257
  ): string {
266
258
  const out: string[] = [];
267
- out.push('# Generated by vdx init. The [vdx] block is metadata for vdx audit.');
259
+ out.push('# Generated by vdx init. The [_.vdx] block is metadata for vdx audit; mise leaves [_] alone.');
268
260
  out.push('# Lifecycle verbs follow the vdx contract: up/down/build/test/check/fix.');
269
261
  out.push('');
270
262
 
@@ -294,7 +286,7 @@ export function renderMiseToml(
294
286
  out.push('');
295
287
  }
296
288
 
297
- out.push('[vdx]');
289
+ out.push('[_.vdx]');
298
290
  out.push('schema_version = "0.2"');
299
291
  out.push(`baseline = ${quoteToml(baseline)}`);
300
292
  out.push(`stack = ${quoteToml(stack)}`);
@@ -337,7 +329,7 @@ export function planInit(projectRoot: string, opts: { baseline?: string; stack?:
337
329
  const list = subs.map((s) => `${s.relPath}(${s.stack})`).join(', ');
338
330
  warnings.push(
339
331
  `stack="meta", найдено несколько subpackages: ${list}. ` +
340
- 'primary_subpackage не выбран; укажи его руками в [vdx] блоке.',
332
+ 'primary_subpackage не выбран; укажи его руками в блоке [_.vdx].',
341
333
  );
342
334
  }
343
335
  } else if (!stackOverridden && (detectedStack === 'unknown' || detectedStack === 'monorepo')) {
package/src/manifest.ts CHANGED
@@ -2,6 +2,7 @@ import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import * as TOML from 'smol-toml';
4
4
  import YAML from 'js-yaml';
5
+ import { at, dict, type Dict } from './narrow.ts';
5
6
 
6
7
  export interface VdxManifest {
7
8
  schema_version?: string;
@@ -29,18 +30,38 @@ export interface Override {
29
30
  until?: string;
30
31
  }
31
32
 
32
- export function loadManifest(projectRoot: string): VdxManifest | null {
33
+ /**
34
+ * Where the manifest lives in mise.toml. `[_.vdx]` — mise documents the `[_]` table as never
35
+ * read, for other tools' data. `[vdx]` — where it lived before vdx 0.26; still read, but mise
36
+ * 2026.10 warns about an unknown field on every run.
37
+ */
38
+ export type ManifestPlace = '_.vdx' | 'vdx';
39
+
40
+ /** The manifest table of a parsed mise.toml and its place: `[_.vdx]`, else the older `[vdx]`. */
41
+ export function manifestTable(data: unknown): { table: Dict; place: ManifestPlace } | null {
42
+ const table = dict(at(data, '_', 'vdx'));
43
+ if (table) return { table, place: '_.vdx' };
44
+ const older = dict(at(data, 'vdx'));
45
+ return older ? { table: older, place: 'vdx' } : null;
46
+ }
47
+
48
+ /** The project's manifest and where in its mise.toml it was found. */
49
+ export function loadManifestAt(projectRoot: string): { manifest: VdxManifest; place: ManifestPlace } | null {
33
50
  const miseToml = path.join(projectRoot, 'mise.toml');
34
51
  if (!fs.existsSync(miseToml)) return null;
35
52
  try {
36
- const text = fs.readFileSync(miseToml, 'utf8');
37
- const data = TOML.parse(text) as { vdx?: VdxManifest };
38
- return data?.vdx ?? null;
53
+ const found = manifestTable(TOML.parse(fs.readFileSync(miseToml, 'utf8')));
54
+ // The fields are the project's to write; readers check what they use.
55
+ return found ? { manifest: found.table, place: found.place } : null;
39
56
  } catch {
40
57
  return null;
41
58
  }
42
59
  }
43
60
 
61
+ export function loadManifest(projectRoot: string): VdxManifest | null {
62
+ return loadManifestAt(projectRoot)?.manifest ?? null;
63
+ }
64
+
44
65
  export function loadOverrides(projectRoot: string): Override[] {
45
66
  const file = path.join(projectRoot, '.vdx-overrides.yml');
46
67
  if (!fs.existsSync(file)) return [];
package/src/mcp-server.ts CHANGED
@@ -8,12 +8,13 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
8
8
  import * as TOML from 'smol-toml';
9
9
 
10
10
  import { loadRubric } from './rubric.ts';
11
- import { loadManifest, loadOverrides } from './manifest.ts';
11
+ import { loadManifest, loadOverrides, manifestTable } from './manifest.ts';
12
12
  import { autoDetectStack, readToml, type Ctx } from './facts.ts';
13
13
  import { audit } from './audit.ts';
14
14
  import { reportJson, reportMarkdown } from './report.ts';
15
15
  import { DEFAULT_BASELINE, STANDARD_VERBS, type Verb } from './init.ts';
16
16
  import { resolveDefaultRubric } from './defaults.ts';
17
+ import { at, dict, errorMessage, isStringList, str, type Dict } from './narrow.ts';
17
18
 
18
19
  const DEFAULT_RUBRIC = resolveDefaultRubric();
19
20
 
@@ -30,18 +31,16 @@ function freshCtx(): Ctx {
30
31
  return { projectRoot: PROJECT_ROOT, stack, cache: new Map() };
31
32
  }
32
33
 
33
- function readMiseToml(): any {
34
+ function readMiseToml(): unknown {
34
35
  return readToml(freshCtx(), 'mise.toml');
35
36
  }
36
37
 
37
- function getVdxBlock(): any {
38
- const t = readMiseToml();
39
- return t?.vdx ?? null;
38
+ function getVdxBlock(): Dict | null {
39
+ return manifestTable(readMiseToml())?.table ?? null;
40
40
  }
41
41
 
42
- function getTasks(): Record<string, any> {
43
- const t = readMiseToml();
44
- return (t?.tasks as Record<string, any>) ?? {};
42
+ function getTasks(): Dict {
43
+ return dict(at(readMiseToml(), 'tasks')) ?? {};
45
44
  }
46
45
 
47
46
  interface RunResult {
@@ -61,10 +60,10 @@ function spawnVerb(verb: Verb, extraArgs: string[]): Promise<RunResult> {
61
60
  });
62
61
  let stdout = '';
63
62
  let stderr = '';
64
- child.stdout.on('data', (b) => {
63
+ child.stdout.on('data', (b: Buffer) => {
65
64
  stdout += b.toString();
66
65
  });
67
- child.stderr.on('data', (b) => {
66
+ child.stderr.on('data', (b: Buffer) => {
68
67
  stderr += b.toString();
69
68
  });
70
69
  child.on('error', (e) => {
@@ -91,32 +90,33 @@ function spawnVerb(verb: Verb, extraArgs: string[]): Promise<RunResult> {
91
90
  function buildCapabilities() {
92
91
  const vdxBlock = getVdxBlock();
93
92
  const tasks = getTasks();
94
- const verbsList = (vdxBlock?.verbs as string[] | undefined) ?? STANDARD_VERBS.slice();
93
+ const declared = vdxBlock?.['verbs'];
94
+ const verbsList = isStringList(declared) ? declared : STANDARD_VERBS.slice();
95
95
  const verbs = verbsList.map((name) => {
96
96
  const t = tasks[name];
97
- const nativeCommand = t && typeof t === 'object' && typeof t.run === 'string' ? t.run : null;
98
97
  return {
99
98
  name,
100
99
  has_manifest: Boolean(t),
101
- native_command: nativeCommand,
100
+ native_command: str(at(t, 'run')) ?? null,
102
101
  };
103
102
  });
104
103
 
105
- const sharedInfra = vdxBlock?.shared_infra
104
+ const infra = dict(vdxBlock?.['shared_infra']);
105
+ const sharedInfra = infra
106
106
  ? {
107
- provider: String(vdxBlock.shared_infra.provider ?? ''),
108
- precheck: Boolean(vdxBlock.shared_infra.precheck),
107
+ provider: str(infra['provider']) ?? '',
108
+ precheck: Boolean(infra['precheck']),
109
109
  is_running: false,
110
110
  }
111
111
  : null;
112
112
 
113
113
  return {
114
- schema_version: String(vdxBlock?.schema_version ?? '0.2'),
115
- stack: String(vdxBlock?.stack ?? autoDetectStack(PROJECT_ROOT)),
116
- baseline: String(vdxBlock?.baseline ?? `file://${DEFAULT_RUBRIC}`),
114
+ schema_version: str(vdxBlock?.['schema_version']) ?? '0.2',
115
+ stack: str(vdxBlock?.['stack']) ?? autoDetectStack(PROJECT_ROOT),
116
+ baseline: str(vdxBlock?.['baseline']) ?? `file://${DEFAULT_RUBRIC}`,
117
117
  verbs,
118
118
  shared_infra: sharedInfra,
119
- last_verified: vdxBlock?.success_path?.last_verified ?? null,
119
+ last_verified: at(vdxBlock, 'success_path', 'last_verified') ?? null,
120
120
  };
121
121
  }
122
122
 
@@ -138,40 +138,44 @@ function recordSuccessPath(input: {
138
138
  notes?: string;
139
139
  }): { ok: boolean; manifest_path: string; agents_md_updated: boolean } {
140
140
  const manifestPath = path.join(PROJECT_ROOT, 'mise.toml');
141
- let doc: any = {};
141
+ let doc: Dict = {};
142
142
  if (fs.existsSync(manifestPath)) {
143
143
  const text = fs.readFileSync(manifestPath, 'utf8');
144
144
  try {
145
145
  doc = TOML.parse(text);
146
- } catch (e: any) {
147
- throw new Error(`mise.toml parse error: ${e?.message}`);
146
+ } catch (e) {
147
+ throw new Error(`mise.toml parse error: ${errorMessage(e)}`, { cause: e });
148
148
  }
149
149
  }
150
150
 
151
- doc.tasks = doc.tasks ?? {};
151
+ const tasks = dict(doc['tasks']) ?? {};
152
152
  for (const v of input.verbs) {
153
- doc.tasks[v.name] = { run: v.command };
153
+ tasks[v.name] = { run: v.command };
154
154
  }
155
+ doc['tasks'] = tasks;
155
156
 
156
- doc.vdx = doc.vdx ?? {
157
+ // An existing manifest is updated where it is; a new one goes into [_.vdx].
158
+ const found = manifestTable(doc);
159
+ const vdx = found?.table ?? {
157
160
  schema_version: '0.2',
158
161
  baseline: DEFAULT_BASELINE,
159
162
  stack: autoDetectStack(PROJECT_ROOT),
160
163
  };
161
- doc.vdx.verbs = input.verbs.map((v) => v.name);
164
+ vdx['verbs'] = input.verbs.map((v) => v.name);
162
165
 
163
166
  if (input.shared_infra) {
164
- doc.vdx.shared_infra = {
167
+ vdx['shared_infra'] = {
165
168
  provider: input.shared_infra.provider,
166
169
  precheck: input.shared_infra.precheck,
167
170
  };
168
171
  }
169
172
 
170
- doc.vdx.success_path = {
173
+ vdx['success_path'] = {
171
174
  last_verified: new Date().toISOString().slice(0, 10),
172
175
  verified_by: 'vdx-mcp',
173
176
  ...(input.notes ? { notes: input.notes } : {}),
174
177
  };
178
+ if (!found) doc['_'] = { ...dict(doc['_']), vdx };
175
179
 
176
180
  const newText = TOML.stringify(doc);
177
181
  fs.writeFileSync(manifestPath, newText, 'utf8');
@@ -191,7 +195,7 @@ server.registerTool(
191
195
  'Возвращает manifest проекта: stack, baseline, доступные глаголы и их native команды, shared_infra и last_verified. Самый частый вызов агента, входящего в проект.',
192
196
  inputSchema: {},
193
197
  },
194
- async () => {
198
+ () => {
195
199
  const payload = buildCapabilities();
196
200
  return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
197
201
  },
@@ -221,7 +225,7 @@ server.registerTool(
221
225
  format: z.enum(['json', 'markdown']).optional(),
222
226
  },
223
227
  },
224
- async ({ baseline, format }) => {
228
+ ({ baseline, format }) => {
225
229
  const fmt = format ?? 'json';
226
230
  const payload = buildAuditPayload(baseline, fmt);
227
231
  return { content: [{ type: 'text', text: payload }] };
@@ -232,14 +236,14 @@ server.registerTool(
232
236
  'vdx_record_success_path',
233
237
  {
234
238
  description:
235
- 'Записывает обнаруженные команды в [tasks.X] секцию mise.toml + обновляет [vdx.success_path]. Используется после успешной верификации команд агентом.',
239
+ 'Записывает обнаруженные команды в [tasks.X] секцию mise.toml + обновляет [_.vdx.success_path] (у манифеста в старом [vdx] — там). Используется после успешной верификации команд агентом.',
236
240
  inputSchema: {
237
241
  verbs: z.array(z.object({ name: z.string(), command: z.string() })),
238
242
  shared_infra: z.object({ provider: z.string(), precheck: z.boolean() }).optional(),
239
243
  notes: z.string().optional(),
240
244
  },
241
245
  },
242
- async ({ verbs, shared_infra, notes }) => {
246
+ ({ verbs, shared_infra, notes }) => {
243
247
  const args: Parameters<typeof recordSuccessPath>[0] = { verbs };
244
248
  if (shared_infra) args.shared_infra = shared_infra;
245
249
  if (notes !== undefined) args.notes = notes;
@@ -254,7 +258,7 @@ async function main() {
254
258
  process.stderr.write(`vdx-mcp connected (project: ${PROJECT_ROOT})\n`);
255
259
  }
256
260
 
257
- main().catch((e) => {
258
- process.stderr.write(`vdx-mcp fatal: ${e?.message ?? e}\n`);
261
+ main().catch((e: unknown) => {
262
+ process.stderr.write(`vdx-mcp fatal: ${errorMessage(e)}\n`);
259
263
  process.exit(1);
260
264
  });
package/src/narrow.ts ADDED
@@ -0,0 +1,56 @@
1
+ /**
2
+ * What a parsed file (JSON, YAML, TOML) or a caught error holds is `unknown` until checked.
3
+ * These helpers check it on the way in, so the code past them works with real types.
4
+ */
5
+
6
+ export type Dict = Record<string, unknown>;
7
+
8
+ /** A plain object — not null, not an array. */
9
+ export function isDict(v: unknown): v is Dict {
10
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
11
+ }
12
+
13
+ /** `v` when it is a plain object, else undefined. */
14
+ export function dict(v: unknown): Dict | undefined {
15
+ return isDict(v) ? v : undefined;
16
+ }
17
+
18
+ /**
19
+ * The value under `keys`, one level per key — an object's property or an array's index ("0");
20
+ * undefined as soon as a level is not an object.
21
+ */
22
+ export function at(v: unknown, ...keys: string[]): unknown {
23
+ let cur = v;
24
+ for (const k of keys) {
25
+ if (typeof cur !== 'object' || cur === null) return undefined;
26
+ cur = (cur as Dict)[k];
27
+ }
28
+ return cur;
29
+ }
30
+
31
+ /** `v` when it is a string, else undefined. */
32
+ export function str(v: unknown): string | undefined {
33
+ return typeof v === 'string' ? v : undefined;
34
+ }
35
+
36
+ /** `v` when it is an array, else an empty one. */
37
+ export function list(v: unknown): unknown[] {
38
+ return Array.isArray(v) ? (v as unknown[]) : [];
39
+ }
40
+
41
+ export function isStringList(v: unknown): v is string[] {
42
+ return Array.isArray(v) && v.every((x) => typeof x === 'string');
43
+ }
44
+
45
+ /** The message of a caught value, whatever was thrown. */
46
+ export function errorMessage(e: unknown): string {
47
+ return e instanceof Error ? e.message : String(e);
48
+ }
49
+
50
+ /** What a failed child process said: its stderr, else the error's message. */
51
+ export function stderrOf(e: unknown): string {
52
+ const err = at(e, 'stderr');
53
+ if (typeof err === 'string' && err) return err;
54
+ if (Buffer.isBuffer(err) && err.length > 0) return err.toString();
55
+ return errorMessage(e);
56
+ }
@@ -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/predicates.ts CHANGED
@@ -15,8 +15,9 @@ import {
15
15
  import { checkingJobs, ciSystems, ciTaskCalls, mainPushSteps, readWorkflows, shippingJobs, upstream } from './gha.ts';
16
16
  import { detectHookFrameworks } from './hooks.ts';
17
17
  import { covers, expandTasks, isVocabularyTask, projectTaskCalls } from './vocabulary.ts';
18
+ import { at, isStringList, str } from './narrow.ts';
18
19
 
19
- type Args = Record<string, any>;
20
+ export type Args = Record<string, unknown>;
20
21
 
21
22
  /** A fact vdx could not read here — a CI system or a hosting it does not inspect. */
22
23
  export interface Unknown {
@@ -42,7 +43,7 @@ const DEFAULT_TEST_PATTERN =
42
43
 
43
44
  function testPattern(args: Args): RegExp {
44
45
  try {
45
- return new RegExp(String(args.pattern ?? DEFAULT_TEST_PATTERN));
46
+ return new RegExp(str(args.pattern) ?? DEFAULT_TEST_PATTERN);
46
47
  } catch {
47
48
  return new RegExp(DEFAULT_TEST_PATTERN);
48
49
  }
@@ -102,7 +103,7 @@ export const REGISTRY: Record<string, PredicateFn> = {
102
103
  has_file: (args, ctx) => fileExists(ctx, String(args.path)),
103
104
 
104
105
  file_contains: (args, ctx) => {
105
- const paths: string[] = Array.isArray(args.paths) ? args.paths : [String(args.path)];
106
+ const paths = isStringList(args.paths) ? args.paths : [String(args.path)];
106
107
  const expanded = expandPaths(paths, ctx);
107
108
  let re: RegExp;
108
109
  try {
@@ -119,7 +120,7 @@ export const REGISTRY: Record<string, PredicateFn> = {
119
120
 
120
121
  package_present: (args, ctx) => {
121
122
  if (typeof args === 'string') return isPackagePresent(args, 'any', ctx);
122
- return isPackagePresent(String(args.name), String(args.ecosystem ?? 'any'), ctx);
123
+ return isPackagePresent(String(args.name), str(args.ecosystem) ?? 'any', ctx);
123
124
  },
124
125
 
125
126
  tsc_flag: (args, ctx) => {
@@ -143,7 +144,7 @@ export const REGISTRY: Record<string, PredicateFn> = {
143
144
  gh_workflow_blocks_pr: (args, ctx) => {
144
145
  const dir = path.join(ctx.projectRoot, '.github', 'workflows');
145
146
  if (!fs.existsSync(dir)) return false;
146
- const wanted = args.check_name ? String(args.check_name) : null;
147
+ const wanted = str(args.check_name) || null;
147
148
  try {
148
149
  for (const f of fs.readdirSync(dir)) {
149
150
  if (!/\.ya?ml$/.test(f)) continue;
@@ -166,21 +167,19 @@ export const REGISTRY: Record<string, PredicateFn> = {
166
167
  const text = readText(ctx, f);
167
168
  if (text && new RegExp(`^${hook}:`, 'm').test(text)) return true;
168
169
  }
169
- const composer = readJson(ctx, 'composer.json');
170
- if (composer?.extra?.hooks?.[hook]) return true;
171
- return false;
170
+ return Boolean(at(readJson(ctx, 'composer.json'), 'extra', 'hooks', hook));
172
171
  },
173
172
 
174
173
  config_value: (args, ctx) => {
175
- const filePath = String(args.path ?? '');
176
- const jsonpath = String(args.jsonpath ?? '');
174
+ const filePath = str(args.path) ?? '';
175
+ const jsonpath = str(args.jsonpath) ?? '';
177
176
  if (!filePath || !jsonpath) return false;
178
177
  const data = readStructured(ctx, filePath);
179
178
  if (data === null || data === undefined) return false;
180
179
  const value = resolveConfigPath(data, jsonpath);
181
180
 
182
181
  // Operator resolution: explicit `op`, otherwise infer from arg shape.
183
- let op = args.op ? String(args.op) : null;
182
+ let op = str(args.op) || null;
184
183
  if (!op) {
185
184
  if (args.equals !== undefined) op = 'equals';
186
185
  else if (args.gte !== undefined) op = 'gte';