@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/README.md CHANGED
@@ -91,7 +91,7 @@ vdx ai@m3
91
91
  `vdx ai` reads a personal profile, never a bundled one: `$VDX_ENVIRONMENT`, else
92
92
  `~/.vdx-environment.yaml`, else a plain `claude`/`codex` with no flags. Format:
93
93
  [docs/specs/environment-format.md](../docs/specs/environment-format.md).
94
- The session's `{project}` is `[vdx] name` from the project's `mise.toml`, else the
94
+ The session's `{project}` is `[_.vdx] name` (or the older `[vdx]`) from the project's `mise.toml`, else the
95
95
  first file in the profile's `session.project_names` that has one, else the repo
96
96
  name. `vdx ai@<host>` runs the same command on `<host>` over ssh — that machine's vdx,
97
97
  profile and tmux, the path taken under its home directory; `--detach` only
@@ -126,7 +126,7 @@ npm run typecheck
126
126
  ## Structure
127
127
 
128
128
  - `src/rubric.ts` — types + YAML loader
129
- - `src/manifest.ts` — parser for `[vdx]` block in `mise.toml` + `.vdx-overrides.yml`
129
+ - `src/manifest.ts` — parser for the `[_.vdx]` block in `mise.toml` (the older `[vdx]` too) + `.vdx-overrides.yml`
130
130
  - `src/facts.ts` — fact source loaders (tasks, packages, configs, sub-package detection)
131
131
  - `src/predicates.ts` — predicate registry; true / false / unknown with a reason (schema 0.3)
132
132
  - `src/evaluator.ts` — recursive evaluator + sugar notation
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vodmal/vdx-cli",
3
- "version": "0.25.0",
3
+ "version": "0.26.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",
@@ -40,8 +40,9 @@
40
40
  "smoke": "bash smoke.sh",
41
41
  "typecheck": "tsc --noEmit",
42
42
  "format:check": "prettier --check .",
43
- "check": "npm run typecheck && npm run format:check",
44
- "fix": "prettier --write .",
43
+ "lint": "eslint .",
44
+ "check": "npm run typecheck && npm run format:check && npm run lint",
45
+ "fix": "eslint --fix . && prettier --write .",
45
46
  "test": "vitest run",
46
47
  "test:unit": "vitest run tests/unit",
47
48
  "coverage": "vitest run --coverage"
@@ -57,12 +58,16 @@
57
58
  "zod": "^3.23.0"
58
59
  },
59
60
  "devDependencies": {
61
+ "@eslint/js": "^10.0.1",
60
62
  "@types/js-yaml": "^4.0.9",
61
63
  "@types/marked-terminal": "^6.1.1",
62
64
  "@types/node": "^22.0.0",
63
65
  "@vitest/coverage-v8": "^4.1.7",
66
+ "eslint": "^10.12.0",
67
+ "eslint-config-prettier": "^10.1.8",
64
68
  "prettier": "^3.9.10",
65
69
  "typescript": "^5.6.0",
70
+ "typescript-eslint": "^8.71.2",
66
71
  "vitest": "^4.1.7"
67
72
  },
68
73
  "publishConfig": {
@@ -11,7 +11,7 @@ schema_version: "0.3"
11
11
 
12
12
  metadata:
13
13
  name: vdx-rubric-vodmal
14
- version: "1.4.0"
14
+ version: "1.4.2"
15
15
  description: "Personal maturity rubric for multi-stack projects (vodmal)"
16
16
  owner: vodmal
17
17
  homepage: "https://github.com/vodmal/vdx-rubric"
@@ -193,17 +193,24 @@ axes:
193
193
  predicate: { tsc_flag: { name: noImplicitOverride, equals: true } }
194
194
  - id: eslint_flat
195
195
  weight: 1.0
196
- predicate:
196
+ # Имена flat-конфига — те, что ищет ESLint 10 (lib/config/config-loader.js).
197
+ # Одно определение на сет: code-style ссылается сюда (v1.4.1).
198
+ predicate: &eslint_flat_config
197
199
  any_of:
198
200
  - has_file: eslint.config.js
199
201
  - has_file: eslint.config.mjs
202
+ - has_file: eslint.config.cjs
200
203
  - has_file: eslint.config.ts
204
+ - has_file: eslint.config.mts
205
+ - has_file: eslint.config.cts
201
206
  - id: eslint_type_checked
202
207
  weight: 1.0
203
208
  predicate:
204
209
  file_contains:
205
- paths: [eslint.config.js, eslint.config.mjs, eslint.config.ts]
206
- pattern: "recommended-type-checked"
210
+ paths: [eslint.config.js, eslint.config.mjs, eslint.config.cjs, eslint.config.ts, eslint.config.mts, eslint.config.cts]
211
+ # typescript-eslint во flat-конфиге — recommendedTypeChecked / strictTypeChecked,
212
+ # в .eslintrc было recommended-type-checked (v1.4.1).
213
+ pattern: "(recommended|strict)-?[Tt]ype-?[Cc]hecked"
207
214
  level_thresholds: { L4: 0.85, L3: 0.65, L2: 0.35, L1: 0.10 }
208
215
 
209
216
  - id: ci
@@ -316,9 +323,31 @@ axes:
316
323
  - has_file: ecs.php
317
324
  - has_file: .php-cs-fixer.php
318
325
  - has_file: phpcs.xml.dist
319
- - has_file: .prettierrc
320
- - has_file: biome.json
321
- - has_file: eslint.config.js
326
+ # Форматтер: имена конфига — те, что ищет Prettier 3.9, и Biome (v1.4.1).
327
+ - &formatter_config
328
+ any_of:
329
+ - has_file: .prettierrc
330
+ - has_file: .prettierrc.json
331
+ - has_file: .prettierrc.yaml
332
+ - has_file: .prettierrc.yml
333
+ - has_file: .prettierrc.json5
334
+ - has_file: .prettierrc.toml
335
+ - has_file: .prettierrc.js
336
+ - has_file: .prettierrc.mjs
337
+ - has_file: .prettierrc.cjs
338
+ - has_file: .prettierrc.ts
339
+ - has_file: .prettierrc.mts
340
+ - has_file: .prettierrc.cts
341
+ - has_file: prettier.config.js
342
+ - has_file: prettier.config.mjs
343
+ - has_file: prettier.config.cjs
344
+ - has_file: prettier.config.ts
345
+ - has_file: prettier.config.mts
346
+ - has_file: prettier.config.cts
347
+ - config_value: { path: package.json, jsonpath: "prettier", op: present }
348
+ - has_file: biome.json
349
+ - has_file: biome.jsonc
350
+ - *eslint_flat_config
322
351
  L3:
323
352
  requires:
324
353
  any_of:
@@ -333,8 +362,8 @@ axes:
333
362
  - has_file: ecs.php
334
363
  - has_file: phpcs.xml.dist
335
364
  - all_of:
336
- - any_of: [{ has_file: .prettierrc }, { has_file: biome.json }]
337
- - has_file: eslint.config.js
365
+ - *formatter_config
366
+ - *eslint_flat_config
338
367
 
339
368
  - id: dependency-hygiene
340
369
  class: supporting
@@ -544,10 +573,17 @@ axes:
544
573
  requires:
545
574
  any_of:
546
575
  - file_contains: { path: scripts/docker-up.sh, pattern: "(traefik|nginx-proxy)" }
576
+ # Манифест vdx — в [_.vdx] (vdx 0.26+) или в старом [vdx]; оба места — с v1.4.2.
577
+ - config_value: { path: mise.toml, jsonpath: "_.vdx.shared_infra.precheck", equals: true }
547
578
  - config_value: { path: mise.toml, jsonpath: "vdx.shared_infra.precheck", equals: true }
548
579
  L4:
549
580
  requires:
550
581
  any_of:
582
+ - config_value:
583
+ path: mise.toml
584
+ jsonpath: "_.vdx.shared_infra.provider"
585
+ op: matches
586
+ pattern: "github.com/.+@v\\d+"
551
587
  - config_value:
552
588
  path: mise.toml
553
589
  jsonpath: "vdx.shared_infra.provider"
package/src/ai.ts CHANGED
@@ -6,7 +6,7 @@ import YAML from 'js-yaml';
6
6
  import { evalPredicate } from './evaluator.ts';
7
7
  import { GIT_HOOK_EVENTS } from './hooks.ts';
8
8
  import { autoDetectStack, readStructured, resolveConfigPath, type Ctx } from './facts.ts';
9
- import { loadManifest } from './manifest.ts';
9
+ import { loadManifestAt } from './manifest.ts';
10
10
  import { type Author, effectiveAuthor, isGitRepo, rankAuthors, scanPool, writeAuthor } from './author.ts';
11
11
  import type { Predicate } from './rubric.ts';
12
12
  import {
@@ -28,6 +28,7 @@ import {
28
28
  parseMachineReport,
29
29
  runsInProject,
30
30
  } from './conversations.ts';
31
+ import { at, errorMessage, isStringList, str } from './narrow.ts';
31
32
 
32
33
  /**
33
34
  * `vdx ai` — start the person's agent in a project, with the flags their
@@ -82,7 +83,7 @@ export interface NameSource {
82
83
  export interface SessionProfile {
83
84
  multiplexer?: Multiplexer;
84
85
  name?: string;
85
- /** Tried after `[vdx] name` in the project's mise.toml; the first non-empty string wins. */
86
+ /** Tried after `[_.vdx] name` (or the older `[vdx]`) in the project's mise.toml; the first non-empty string wins. */
86
87
  project_names?: NameSource[];
87
88
  /** The machines a project's agent may run on, as ssh destinations; vdx asks the others what runs there. */
88
89
  machines?: string[];
@@ -144,13 +145,9 @@ export function resolveEnvironmentPath(
144
145
  return fs.existsSync(user) ? user : null;
145
146
  }
146
147
 
147
- function isStringList(v: unknown): v is string[] {
148
- return Array.isArray(v) && v.every((x) => typeof x === 'string');
149
- }
150
-
151
148
  /** Parse and validate a profile. Throws with the offending key named. */
152
149
  export function parseEnvironment(text: string, source: string): Environment {
153
- const data = YAML.load(text) as unknown;
150
+ const data: unknown = YAML.load(text);
154
151
  if (data === null || data === undefined) return {};
155
152
  if (typeof data !== 'object' || Array.isArray(data)) {
156
153
  throw new Error(`${source}: expected a YAML mapping at the top level`);
@@ -271,8 +268,8 @@ export function loadEnvironment(file: string): Environment {
271
268
  let text: string;
272
269
  try {
273
270
  text = fs.readFileSync(file, 'utf8');
274
- } catch (e: any) {
275
- throw new Error(`cannot read profile ${file}: ${e?.code ?? e?.message ?? e}`);
271
+ } catch (e) {
272
+ throw new Error(`cannot read profile ${file}: ${str(at(e, 'code')) ?? errorMessage(e)}`, { cause: e });
276
273
  }
277
274
  return parseEnvironment(text, file);
278
275
  }
@@ -372,7 +369,7 @@ export function planLaunch(input: {
372
369
  }
373
370
 
374
371
  export function shellQuote(s: string): string {
375
- if (s !== '' && /^[A-Za-z0-9_\/.:@%+=,-]+$/.test(s)) return s;
372
+ if (s !== '' && /^[A-Za-z0-9_/.:@%+=,-]+$/.test(s)) return s;
376
373
  return `'${s.replace(/'/g, `'\\''`)}'`;
377
374
  }
378
375
 
@@ -544,7 +541,7 @@ export interface ProjectName {
544
541
  }
545
542
 
546
543
  /**
547
- * `{project}`: `[vdx] name` in the project's mise.toml, else the first profile
544
+ * `{project}`: `[_.vdx] name` (or the older `[vdx]`) in the project's mise.toml, else the first profile
548
545
  * source holding a non-empty string, else the repository name. A source that is
549
546
  * missing or unreadable is skipped — a name is a label, not a reason to fail.
550
547
  */
@@ -555,8 +552,9 @@ export function resolveProjectName(input: {
555
552
  home: string;
556
553
  profileDir: string;
557
554
  }): ProjectName {
558
- const own = loadManifest(input.projectRoot)?.name;
559
- if (typeof own === 'string' && own.trim()) return { name: own.trim(), source: 'mise.toml [vdx] name' };
555
+ const found = loadManifestAt(input.projectRoot);
556
+ const own = found?.manifest.name;
557
+ if (typeof own === 'string' && own.trim()) return { name: own.trim(), source: `mise.toml [${found!.place}] name` };
560
558
  for (const src of input.sources) {
561
559
  const file = path.resolve(
562
560
  input.profileDir,
@@ -741,7 +739,7 @@ export interface AiDeps {
741
739
 
742
740
  export function runSsh(args: string[], capture: boolean): { status: number; stdout: string } | null {
743
741
  const res = spawnSync('ssh', args, { stdio: capture ? ['ignore', 'pipe', 'ignore'] : 'inherit', encoding: 'utf8' });
744
- if ((res.error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') return null;
742
+ if (at(res.error, 'code') === 'ENOENT') return null;
745
743
  return { status: res.status ?? 1, stdout: res.stdout ?? '' };
746
744
  }
747
745
 
@@ -758,8 +756,8 @@ export function askLine(prompt: string): string | null {
758
756
  let n: number;
759
757
  try {
760
758
  n = fs.readSync(0, buf, 0, buf.length, null);
761
- } catch (e: any) {
762
- if (e?.code === 'EAGAIN') {
759
+ } catch (e) {
760
+ if (at(e, 'code') === 'EAGAIN') {
763
761
  sleepSync(50);
764
762
  continue;
765
763
  }
@@ -1351,8 +1349,8 @@ function prepare(pathArg: string, deps: AiDeps, focused = false): Prepared | num
1351
1349
  if (profilePath) {
1352
1350
  try {
1353
1351
  environment = loadEnvironment(profilePath);
1354
- } catch (e: any) {
1355
- log(`vdx ai: ${e?.message ?? e}`);
1352
+ } catch (e) {
1353
+ log(`vdx ai: ${errorMessage(e)}`);
1356
1354
  return EXIT_USAGE;
1357
1355
  }
1358
1356
  }
@@ -1390,8 +1388,8 @@ function prepare(pathArg: string, deps: AiDeps, focused = false): Prepared | num
1390
1388
  let plan: LaunchPlan;
1391
1389
  try {
1392
1390
  plan = replan(focused);
1393
- } catch (e: any) {
1394
- log(`vdx ai: ${profilePath ?? 'profile'}: ${e?.message ?? e}`);
1391
+ } catch (e) {
1392
+ log(`vdx ai: ${profilePath ?? 'profile'}: ${errorMessage(e)}`);
1395
1393
  return EXIT_USAGE;
1396
1394
  }
1397
1395
  return { projectRoot, environment, profilePath, profileDir, plan, replan };
@@ -1445,8 +1443,9 @@ export function unreadableFolders(tmux: Tmux | null, home: string): string[] {
1445
1443
  try {
1446
1444
  fs.readdirSync(d);
1447
1445
  return false;
1448
- } catch (e: any) {
1449
- return e?.code === 'EPERM' || e?.code === 'EACCES';
1446
+ } catch (e) {
1447
+ const code = at(e, 'code');
1448
+ return code === 'EPERM' || code === 'EACCES';
1450
1449
  }
1451
1450
  });
1452
1451
  }
@@ -1672,7 +1671,7 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1672
1671
  const env = plan.focused ? { ...process.env, [FOCUSED_ENV_VAR]: '1' } : undefined;
1673
1672
  const startedAt = Date.now();
1674
1673
  let res = spawnSync(plan.command, [...plan.args, ...plan.resumeArgs], { cwd: projectRoot, stdio: 'inherit', env });
1675
- if ((res.error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') {
1674
+ if (at(res.error, 'code') === 'ENOENT') {
1676
1675
  log(`vdx ai: \`${plan.command}\` not found on PATH`);
1677
1676
  return 127;
1678
1677
  }
@@ -34,7 +34,7 @@ function isGitWorkTree(root: string): boolean {
34
34
 
35
35
  function executableHooks(dir: string): Map<string, string> {
36
36
  const out = new Map<string, string>();
37
- let names: string[] = [];
37
+ let names: string[];
38
38
  try {
39
39
  names = fs.readdirSync(dir).filter((f) => !f.startsWith('.') && !f.endsWith('.sample'));
40
40
  } catch {
@@ -1,5 +1,6 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
+ import { at, dict, str } from './narrow.ts';
3
4
 
4
5
  /**
5
6
  * Claude Code conversations of a project, and the machine each one was last
@@ -86,17 +87,20 @@ export function readConversation(file: string, updated: Date): Conversation | nu
86
87
  const isTitle = line.includes('"ai-title"') || line.includes('"last-prompt"');
87
88
  if (!turns && line.includes('"type":"user"')) turns = true;
88
89
  if (!isHook && !isTitle) continue;
89
- let entry: any;
90
+ let entry: unknown;
90
91
  try {
91
92
  entry = JSON.parse(line);
92
93
  } catch {
93
94
  continue;
94
95
  }
95
- if (entry?.type === 'ai-title' && typeof entry.aiTitle === 'string') aiTitle = entry.aiTitle;
96
- else if (entry?.type === 'last-prompt' && typeof entry.lastPrompt === 'string') lastPrompt = entry.lastPrompt;
97
- else if (entry?.type === 'attachment' && entry.attachment?.type === 'hook_additional_context') {
98
- const content = entry.attachment.content;
99
- for (const part of Array.isArray(content) ? content : [content]) {
96
+ const type = at(entry, 'type');
97
+ const title = str(at(entry, 'aiTitle'));
98
+ const prompt = str(at(entry, 'lastPrompt'));
99
+ if (type === 'ai-title' && title !== undefined) aiTitle = title;
100
+ else if (type === 'last-prompt' && prompt !== undefined) lastPrompt = prompt;
101
+ else if (type === 'attachment' && at(entry, 'attachment', 'type') === 'hook_additional_context') {
102
+ const content = at(entry, 'attachment', 'content');
103
+ for (const part of Array.isArray(content) ? (content as unknown[]) : [content]) {
100
104
  const label = typeof part === 'string' ? part.match(MACHINE_LINE_RE)?.[1] : undefined;
101
105
  if (label && LABEL_RE.test(label)) machine = label;
102
106
  }
@@ -197,9 +201,11 @@ export function latestPerMachine(
197
201
  */
198
202
  export function liveConversationId(env: NodeJS.ProcessEnv, home: string, pid: number): string | null {
199
203
  try {
200
- const data = JSON.parse(fs.readFileSync(path.join(claudeConfigDir(env, home), 'sessions', `${pid}.json`), 'utf8'));
201
- const id = data?.sessionId;
202
- return typeof id === 'string' && isConversationId(id) ? id : null;
204
+ const data: unknown = JSON.parse(
205
+ fs.readFileSync(path.join(claudeConfigDir(env, home), 'sessions', `${pid}.json`), 'utf8'),
206
+ );
207
+ const id = str(at(data, 'sessionId'));
208
+ return id !== undefined && isConversationId(id) ? id : null;
203
209
  } catch {
204
210
  return null;
205
211
  }
@@ -209,9 +215,8 @@ export function liveConversationId(env: NodeJS.ProcessEnv, home: string, pid: nu
209
215
  export function liveStartedAt(env: NodeJS.ProcessEnv, home: string, pid: number): number | null {
210
216
  try {
211
217
  const file = path.join(claudeConfigDir(env, home), 'sessions', `${pid}.json`);
212
- const data: unknown = JSON.parse(fs.readFileSync(file, 'utf8'));
213
- const at = typeof data === 'object' && data !== null ? (data as { startedAt?: unknown }).startedAt : undefined;
214
- return typeof at === 'number' ? at : null;
218
+ const started = at(JSON.parse(fs.readFileSync(file, 'utf8')), 'startedAt');
219
+ return typeof started === 'number' ? started : null;
215
220
  } catch {
216
221
  return null;
217
222
  }
@@ -234,20 +239,22 @@ export function parseLiveSessions(text: string): LiveSession[] {
234
239
  const out: LiveSession[] = [];
235
240
  for (const line of text.split('\n')) {
236
241
  if (!line.trim().startsWith('{')) continue;
237
- let data: any;
242
+ let data: Record<string, unknown> | undefined;
238
243
  try {
239
- data = JSON.parse(line);
244
+ data = dict(JSON.parse(line));
240
245
  } catch {
241
246
  continue;
242
247
  }
243
- if (!Number.isInteger(data?.pid) || typeof data?.sessionId !== 'string' || !isConversationId(data.sessionId))
244
- continue;
248
+ const pid = data?.['pid'];
249
+ const conversation = str(data?.['sessionId']);
250
+ if (typeof pid !== 'number' || !Number.isInteger(pid) || conversation === undefined) continue;
251
+ if (!isConversationId(conversation)) continue;
245
252
  out.push({
246
- pid: data.pid,
247
- conversation: data.sessionId,
248
- tmux: typeof data.tmux === 'string' ? data.tmux : null,
249
- status: typeof data.status === 'string' ? data.status : null,
250
- cwd: typeof data.cwd === 'string' ? data.cwd : null,
253
+ pid,
254
+ conversation,
255
+ tmux: str(data?.['tmux']) ?? null,
256
+ status: str(data?.['status']) ?? null,
257
+ cwd: str(data?.['cwd']) ?? null,
251
258
  });
252
259
  }
253
260
  return out;
@@ -277,8 +284,8 @@ function pidAlive(pid: number): boolean {
277
284
  try {
278
285
  process.kill(pid, 0);
279
286
  return true;
280
- } catch (e: any) {
281
- return e?.code === 'EPERM';
287
+ } catch (e) {
288
+ return at(e, 'code') === 'EPERM';
282
289
  }
283
290
  }
284
291
 
package/src/doctor.ts CHANGED
@@ -6,6 +6,7 @@ import { execFileSync } from 'node:child_process';
6
6
  import { effectiveAuthor, isGitRepo, rankAuthors, scanPool } from './author.ts';
7
7
  import { authorPoolDirs, loadEnvironment, resolveEnvironmentPath, shellQuote, type Environment } from './ai.ts';
8
8
  import { checkRepoHooks } from './clone-checks.ts';
9
+ import { at, dict } from './narrow.ts';
9
10
  import {
10
11
  checkPersonalHooks,
11
12
  defaultPersonalHookDeps,
@@ -354,7 +355,7 @@ export function checkClaudeCodePlugin(
354
355
  claudeDir: string = process.env['CLAUDE_CONFIG_DIR'] || path.join(os.homedir(), '.claude'),
355
356
  ): CheckResult {
356
357
  const base = { id: 'claude-plugin', label: 'Claude Code vdx plugin' };
357
- const readJson = (file: string): any => {
358
+ const readJson = (file: string): unknown => {
358
359
  try {
359
360
  return JSON.parse(fs.readFileSync(path.join(claudeDir, file), 'utf8'));
360
361
  } catch {
@@ -370,13 +371,13 @@ export function checkClaudeCodePlugin(
370
371
  remedy: PLUGIN_INSTALL_HINT,
371
372
  };
372
373
  }
373
- const key = Object.keys(installed.plugins ?? {}).find((k) => k.startsWith('vdx@'));
374
+ const key = Object.keys(dict(at(installed, 'plugins')) ?? {}).find((k) => k.startsWith('vdx@'));
374
375
  if (!key) {
375
376
  return { ...base, status: 'warning', message: 'not installed', remedy: PLUGIN_INSTALL_HINT };
376
377
  }
377
- const version = installed.plugins[key]?.[0]?.version;
378
+ const version = at(installed, 'plugins', key, '0', 'version');
378
379
  const label = `${key}${typeof version === 'string' ? ` ${version}` : ''}`;
379
- if (readJson('settings.json')?.enabledPlugins?.[key] === false) {
380
+ if (at(readJson('settings.json'), 'enabledPlugins', key) === false) {
380
381
  return { ...base, status: 'warning', message: `${label} installed but disabled`, remedy: `/plugin enable ${key}` };
381
382
  }
382
383
  return { ...base, status: 'ok', message: label };
package/src/evaluator.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Predicate } from './rubric.ts';
2
2
  import type { Ctx } from './facts.ts';
3
- import { REGISTRY, isUnknown, type Unknown, type Verdict } from './predicates.ts';
3
+ import { REGISTRY, isUnknown, type Args, type Unknown, type Verdict } from './predicates.ts';
4
+ import { at, isDict, list } from './narrow.ts';
4
5
 
5
6
  export { isUnknown, type Unknown, type Verdict };
6
7
 
@@ -34,21 +35,21 @@ export function evalVerdict(p: Predicate, ctx: Ctx): Verdict {
34
35
  if (p === null || p === undefined) return false;
35
36
  if (typeof p === 'boolean') return p;
36
37
 
37
- if (typeof p === 'object' && !Array.isArray(p)) {
38
- const obj = p as Record<string, any>;
38
+ if (isDict(p)) {
39
+ const obj = p;
39
40
 
40
41
  if ('all_of' in obj) {
41
- const vs = (obj.all_of as Predicate[]).map((q) => evalVerdict(q, ctx));
42
+ const vs = list(obj['all_of']).map((q) => evalVerdict(q, ctx));
42
43
  if (vs.some((v) => v === false)) return false;
43
44
  return firstUnknown(vs) ?? true;
44
45
  }
45
46
  if ('any_of' in obj) {
46
- const vs = (obj.any_of as Predicate[]).map((q) => evalVerdict(q, ctx));
47
+ const vs = list(obj['any_of']).map((q) => evalVerdict(q, ctx));
47
48
  if (vs.some((v) => v === true)) return true;
48
49
  return firstUnknown(vs) ?? false;
49
50
  }
50
51
  if ('at_least_n_of' in obj) {
51
- const { n, predicates } = obj.at_least_n_of as { n: number; predicates: Predicate[] };
52
+ const { n, predicates } = obj['at_least_n_of'] as { n: number; predicates: Predicate[] };
52
53
  const vs = predicates.map((q) => evalVerdict(q, ctx));
53
54
  const matched = vs.filter((v) => v === true).length;
54
55
  if (matched >= n) return true;
@@ -57,16 +58,18 @@ export function evalVerdict(p: Predicate, ctx: Ctx): Verdict {
57
58
  return firstUnknown(vs)!;
58
59
  }
59
60
  if ('not' in obj) {
60
- const v = evalVerdict(obj.not, ctx);
61
+ const v = evalVerdict(obj['not'], ctx);
61
62
  return isUnknown(v) ? v : !v;
62
63
  }
63
64
  if ('fn' in obj) {
64
- const fn = REGISTRY[String(obj.fn)];
65
+ const name = String(obj['fn']);
66
+ const fn = REGISTRY[name];
65
67
  if (!fn) {
66
- process.stderr.write(`warning: unknown predicate "${obj.fn}"\n`);
68
+ process.stderr.write(`warning: unknown predicate "${name}"\n`);
67
69
  return false;
68
70
  }
69
- return fn(obj.args ?? {}, ctx);
71
+ // `args` is the predicate's to read: package_present takes a bare string too.
72
+ return fn((obj['args'] ?? {}) as Args, ctx);
70
73
  }
71
74
 
72
75
  // Sugar form: single-key object where key is a registered predicate
@@ -76,9 +79,7 @@ export function evalVerdict(p: Predicate, ctx: Ctx): Verdict {
76
79
  const fn = REGISTRY[key];
77
80
  if (fn) {
78
81
  const value = obj[key];
79
- if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
80
- return fn(value, ctx);
81
- }
82
+ if (isDict(value)) return fn(value, ctx);
82
83
  const argKey = SUGAR_ARG_KEY[key];
83
84
  if (argKey) {
84
85
  return fn({ [argKey]: value }, ctx);
@@ -104,14 +105,14 @@ export function evalPredicate(p: Predicate, ctx: Ctx): boolean {
104
105
  export function predicateNames(p: Predicate): string[] {
105
106
  const out: string[] = [];
106
107
  const walk = (q: Predicate): void => {
107
- if (q === null || q === undefined || typeof q !== 'object' || Array.isArray(q)) return;
108
- const obj = q as Record<string, any>;
109
- if ('all_of' in obj) return (obj.all_of as Predicate[]).forEach(walk);
110
- if ('any_of' in obj) return (obj.any_of as Predicate[]).forEach(walk);
111
- if ('at_least_n_of' in obj) return ((obj.at_least_n_of?.predicates as Predicate[]) ?? []).forEach(walk);
112
- if ('not' in obj) return walk(obj.not);
108
+ if (!isDict(q)) return;
109
+ const obj = q;
110
+ if ('all_of' in obj) return list(obj['all_of']).forEach(walk);
111
+ if ('any_of' in obj) return list(obj['any_of']).forEach(walk);
112
+ if ('at_least_n_of' in obj) return list(at(obj, 'at_least_n_of', 'predicates')).forEach(walk);
113
+ if ('not' in obj) return walk(obj['not']);
113
114
  if ('fn' in obj) {
114
- out.push(String(obj.fn));
115
+ out.push(String(obj['fn']));
115
116
  return;
116
117
  }
117
118
  const keys = Object.keys(obj);