@vodmal/vdx-cli 0.25.0 → 0.26.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/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
+ }
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';
package/src/publish.ts CHANGED
@@ -3,6 +3,7 @@ import * as path from 'node:path';
3
3
  import { execFileSync } from 'node:child_process';
4
4
  import type { AuditResult } from './audit.ts';
5
5
  import { stackForDir, type Ctx } from './facts.ts';
6
+ import { at, dict, errorMessage, stderrOf, str } from './narrow.ts';
6
7
 
7
8
  export type BumpKind = 'patch' | 'minor' | 'major';
8
9
 
@@ -115,7 +116,7 @@ const runGit: GitRunner = (args, cwd) =>
115
116
  * since the last fetch; nothing is fetched.
116
117
  */
117
118
  export function planPush(projectRoot: string, enabled: boolean, git: GitRunner = runGit): PushPlan {
118
- let upstream: string | null = null;
119
+ let upstream: string | null;
119
120
  try {
120
121
  upstream = git(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], projectRoot).trim() || null;
121
122
  } catch {
@@ -144,10 +145,10 @@ function fetchPublishedVersion(packageName: string, npm: NpmRunner): string | nu
144
145
  try {
145
146
  const out = npm(['view', packageName, 'version']);
146
147
  return out.trim() || null;
147
- } catch (e: any) {
148
- const stderr = String(e?.stderr ?? e?.message ?? '');
148
+ } catch (e) {
149
+ const stderr = stderrOf(e);
149
150
  if (stderr.includes('E404') || stderr.includes('404 Not Found')) return null;
150
- throw new Error(`npm view failed: ${stderr.split('\n')[0] ?? stderr}`);
151
+ throw new Error(`npm view failed: ${stderr.split('\n')[0] ?? stderr}`, { cause: e });
151
152
  }
152
153
  }
153
154
 
@@ -155,12 +156,10 @@ function fetchPublishedVersion(packageName: string, npm: NpmRunner): string | nu
155
156
  * The registry `npm publish` sends this package to, when it is not npm's
156
157
  * default: `publishConfig.registry`, else the scope's registry from npm config.
157
158
  */
158
- export function publishRegistry(
159
- pkg: { name?: string; publishConfig?: { registry?: string } },
160
- npm: NpmRunner,
161
- ): string | null {
162
- if (pkg.publishConfig?.registry) return pkg.publishConfig.registry;
163
- const scope = pkg.name?.match(/^(@[^/]+)\//)?.[1];
159
+ export function publishRegistry(pkg: unknown, npm: NpmRunner): string | null {
160
+ const registry = str(at(pkg, 'publishConfig', 'registry'));
161
+ if (registry) return registry;
162
+ const scope = str(at(pkg, 'name'))?.match(/^(@[^/]+)\//)?.[1];
164
163
  if (!scope) return null;
165
164
  try {
166
165
  const value = npm(['config', 'get', `${scope}:registry`]).trim();
@@ -184,8 +183,8 @@ export function checkNpmAuth(registry: string | null, npm: NpmRunner): Preflight
184
183
  const who = npm(registry ? ['whoami', '--registry', registry] : ['whoami']).trim();
185
184
  if (who) return { name: 'npm-auth', ok: true, message: `OK (logged in to ${where} as ${who})` };
186
185
  return { name: 'npm-auth', ok: false, message: `npm whoami answered nothing for ${where}. Run: ${login}`, fix };
187
- } catch (e: any) {
188
- const stderr = String(e?.stderr ?? e?.message ?? '');
186
+ } catch (e) {
187
+ const stderr = stderrOf(e);
189
188
  if (/E401|ENEEDAUTH/.test(stderr)) {
190
189
  return {
191
190
  name: 'npm-auth',
@@ -268,9 +267,9 @@ export function planPublish(
268
267
  throw new Error(`vdx publish: package.json not found at ${pkgJsonPath}`);
269
268
  }
270
269
 
271
- const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
272
- const packageName: string = pkg.name ?? '(unnamed)';
273
- const currentVersion: string = pkg.version ?? '0.0.0';
270
+ const pkg: unknown = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
271
+ const packageName = str(at(pkg, 'name')) ?? '(unnamed)';
272
+ const currentVersion = str(at(pkg, 'version')) ?? '0.0.0';
274
273
  const newVersion = bumpSemver(currentVersion, opts.bump);
275
274
 
276
275
  const preflight: PreflightCheck[] = [];
@@ -312,8 +311,8 @@ export function planPublish(
312
311
  let registryError: string | null = null;
313
312
  try {
314
313
  published = fetchPublishedVersion(packageName, npm);
315
- } catch (e: any) {
316
- registryError = e?.message ?? String(e);
314
+ } catch (e) {
315
+ registryError = errorMessage(e);
317
316
  }
318
317
  if (registryError) {
319
318
  preflight.push({
@@ -416,9 +415,10 @@ export function defaultPublishDeps(): PublishDeps {
416
415
 
417
416
  /** Set `version` in package-lock.json — the root and its own entry under `packages`. */
418
417
  export function bumpLockText(text: string, version: string): string {
419
- const lock = JSON.parse(text);
420
- lock.version = version;
421
- if (lock.packages?.['']) lock.packages[''].version = version;
418
+ const lock = dict(JSON.parse(text)) ?? {};
419
+ lock['version'] = version;
420
+ const self = dict(at(lock, 'packages', ''));
421
+ if (self) self['version'] = version;
422
422
  return JSON.stringify(lock, null, 2) + '\n';
423
423
  }
424
424
 
@@ -502,8 +502,8 @@ export function executePublish(plan: PublishPlan, deps: PublishDeps = defaultPub
502
502
  fs.writeFileSync(file, next(text), 'utf8');
503
503
  };
504
504
  rewrite(plan.packageJsonPath, (text) => {
505
- const pkg = JSON.parse(text);
506
- pkg.version = plan.newVersion;
505
+ const pkg = dict(JSON.parse(text)) ?? {};
506
+ pkg['version'] = plan.newVersion;
507
507
  return JSON.stringify(pkg, null, 2) + '\n';
508
508
  });
509
509
  if (plan.lockPath) rewrite(plan.lockPath, (text) => bumpLockText(text, plan.newVersion));
@@ -521,11 +521,13 @@ export function executePublish(plan: PublishPlan, deps: PublishDeps = defaultPub
521
521
  for (const s of signals) process.on(s, holdOut);
522
522
  try {
523
523
  deps.run('npm', ['publish'], path.dirname(plan.packageJsonPath));
524
- } catch (e: any) {
524
+ } catch (e) {
525
525
  for (const [file, text] of originals) fs.writeFileSync(file, text, 'utf8');
526
- const what = e?.signal ? `interrupted (${e.signal})` : `failed (exit ${e?.status ?? '?'})`;
526
+ const signal = str(at(e, 'signal'));
527
+ const status = at(e, 'status');
528
+ const what = signal ? `interrupted (${signal})` : `failed (exit ${typeof status === 'number' ? status : '?'})`;
527
529
  deps.log(`✗ npm publish ${what}; reverted ${bumped.map((f) => path.basename(f)).join(' and ')}`);
528
- throw new Error(`npm publish ${e?.signal ? 'interrupted' : 'failed'}`);
530
+ throw new Error(`npm publish ${signal ? 'interrupted' : 'failed'}`, { cause: e });
529
531
  } finally {
530
532
  for (const s of signals) process.off(s, holdOut);
531
533
  }
package/src/rubric.ts CHANGED
@@ -124,8 +124,8 @@ export function parseRubric(text: string, source: string): Rubric {
124
124
  }
125
125
  // нормализуем default_target (если кто-то напишет "L4" со spaces)
126
126
  for (const ax of data.axes) {
127
- ax.default_target = (ax.default_target ?? 'L4') as LevelName;
128
- ax.storage = (ax.storage ?? 'level') as Storage;
127
+ ax.default_target = ax.default_target ?? 'L4';
128
+ ax.storage = ax.storage ?? 'level';
129
129
  }
130
130
  if (compareSchema(schema, '0.3')! >= 0) {
131
131
  const bad = invalidPredicates(data);
package/src/run.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import * as TOML from 'smol-toml';
4
+ import { errorMessage } from './narrow.ts';
4
5
 
5
6
  export const LIFECYCLE_VERBS = ['up', 'down', 'build', 'test', 'check', 'fix'] as const;
6
7
  export type LifecycleVerb = (typeof LIFECYCLE_VERBS)[number];
@@ -21,8 +22,8 @@ export function resolveLifecycleVerb(projectRoot: string, verb: string): VerbRes
21
22
  let parsed: unknown;
22
23
  try {
23
24
  parsed = TOML.parse(fs.readFileSync(miseTomlPath, 'utf8'));
24
- } catch (e: any) {
25
- return { ok: false, reason: 'parse-error', detail: e?.message ?? String(e) };
25
+ } catch (e) {
26
+ return { ok: false, reason: 'parse-error', detail: errorMessage(e) };
26
27
  }
27
28
  const tasks = (parsed as { tasks?: Record<string, unknown> })?.tasks;
28
29
  if (!tasks || typeof tasks !== 'object' || !(verb in tasks)) {
package/src/vocabulary.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * predicates (what a hook runs), so both read "the project's task" the same way.
6
6
  */
7
7
  import { listAllTasks, readJson, readToml, type Ctx } from './facts.ts';
8
+ import { at, dict, list } from './narrow.ts';
8
9
 
9
10
  export const LIFECYCLE_VOCABULARY = ['up', 'down', 'build', 'test', 'check', 'fix'] as const;
10
11
 
@@ -99,11 +100,9 @@ export function isVocabularyTask(name: string, words: readonly string[] = ['test
99
100
  /** Tasks a task's definition calls: composer `@refs`, `npm run X` inside a script, mise `depends`/`run`. */
100
101
  export function taskChildren(ctx: Ctx, name: string): string[] {
101
102
  const out: string[] = [];
102
- const composer = readJson(ctx, 'composer.json');
103
- const entry = composer?.scripts?.[name];
103
+ const entry = at(readJson(ctx, 'composer.json'), 'scripts', name);
104
104
  if (entry !== undefined) {
105
- const list = Array.isArray(entry) ? entry : [entry];
106
- for (const e of list) {
105
+ for (const e of Array.isArray(entry) ? (entry as unknown[]) : [entry]) {
107
106
  if (typeof e !== 'string') continue;
108
107
  if (e.startsWith('@')) {
109
108
  const [ref, ...rest] = e.slice(1).trim().split(/\s+/);
@@ -114,15 +113,12 @@ export function taskChildren(ctx: Ctx, name: string): string[] {
114
113
  }
115
114
  }
116
115
  }
117
- const pkg = readJson(ctx, 'package.json');
118
- const script = pkg?.scripts?.[name];
116
+ const script = at(readJson(ctx, 'package.json'), 'scripts', name);
119
117
  if (typeof script === 'string') out.push(...runnerCalls(script));
120
- const mise = readToml(ctx, 'mise.toml');
121
- const task = mise?.tasks?.[name];
122
- if (task && typeof task === 'object') {
123
- if (Array.isArray(task.depends))
124
- out.push(...task.depends.filter((d: unknown): d is string => typeof d === 'string'));
125
- const runs = Array.isArray(task.run) ? task.run : [task.run];
118
+ const task = dict(at(readToml(ctx, 'mise.toml'), 'tasks', name));
119
+ if (task) {
120
+ out.push(...list(task['depends']).filter((d): d is string => typeof d === 'string'));
121
+ const runs = Array.isArray(task['run']) ? (task['run'] as unknown[]) : [task['run']];
126
122
  for (const r of runs) if (typeof r === 'string') out.push(...runnerCalls(r));
127
123
  }
128
124
  const tasks = listAllTasks(ctx);