@vodmal/vdx-cli 0.24.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/audit.ts CHANGED
@@ -28,12 +28,11 @@ export interface AuditResult {
28
28
  * 3. fallback на root ctx.
29
29
  *
30
30
  * Возвращает `{ ctx, relPath }` где relPath — относительный путь либо null
31
- * (если subpackage не разрезолвился — будем использовать root).
31
+ * (если subpackage не разрезолвился — будем использовать root). Файл, которого
32
+ * нет в subpackage, ищется в корне (`fallbackRoot`): `.github/` и `.gitignore`
33
+ * у репо одни.
32
34
  */
33
- function resolveSubpackageCtx(
34
- ctx: Ctx,
35
- manifest: VdxManifest | null,
36
- ): { ctx: Ctx; relPath: string | null } {
35
+ function resolveSubpackageCtx(ctx: Ctx, manifest: VdxManifest | null): { ctx: Ctx; relPath: string | null } {
37
36
  let relPath: string | null = null;
38
37
  let subpackageStack: string | null = null;
39
38
 
@@ -44,9 +43,7 @@ function resolveSubpackageCtx(
44
43
  relPath = p;
45
44
  subpackageStack = stackForDir(abs);
46
45
  } else {
47
- process.stderr.write(
48
- `warning: primary_subpackage "${p}" does not exist — falling back to root\n`,
49
- );
46
+ process.stderr.write(`warning: primary_subpackage "${p}" does not exist — falling back to root\n`);
50
47
  }
51
48
  } else {
52
49
  const subs = findSubPackages(ctx.projectRoot);
@@ -68,11 +65,23 @@ function resolveSubpackageCtx(
68
65
  projectRoot: path.join(ctx.projectRoot, relPath),
69
66
  stack: subpackageStack ?? ctx.stack,
70
67
  cache: new Map(),
68
+ fallbackRoot: ctx.projectRoot,
71
69
  },
72
70
  relPath,
73
71
  };
74
72
  }
75
73
 
74
+ /**
75
+ * Ctx осей без `applies_to`. Корень мета-репо (без своего манифеста стека)
76
+ * дочитывает недостающие файлы из subpackage: `engines` в `cli/package.json`
77
+ * — тоже про воспроизводимость всего репо. У корня со своим манифестом
78
+ * подстановки нет.
79
+ */
80
+ function rootAxisCtx(ctx: Ctx, subpackage: Ctx, relPath: string | null): Ctx {
81
+ if (!relPath || stackForDir(ctx.projectRoot) !== null) return ctx;
82
+ return { ...ctx, cache: new Map(), fallbackRoot: subpackage.projectRoot };
83
+ }
84
+
76
85
  export function audit(
77
86
  rubric: Rubric,
78
87
  ctx: Ctx,
@@ -92,6 +101,7 @@ export function audit(
92
101
  }
93
102
 
94
103
  const { ctx: subpackageCtx, relPath: subpackagePath } = resolveSubpackageCtx(ctx, manifest);
104
+ const rootCtx = rootAxisCtx(ctx, subpackageCtx, subpackagePath);
95
105
 
96
106
  const perAxis: AuditResult['per_axis'] = [];
97
107
  for (const axis of rubric.axes) {
@@ -106,7 +116,7 @@ export function audit(
106
116
  });
107
117
  continue;
108
118
  }
109
- const evalCtx = axis.applies_to ? subpackageCtx : ctx;
119
+ const evalCtx = axis.applies_to ? subpackageCtx : rootCtx;
110
120
  if (axis.applies_to && !axis.applies_to.includes(evalCtx.stack)) {
111
121
  perAxis.push({
112
122
  axis_id: axis.id,
package/src/author.ts CHANGED
@@ -61,8 +61,24 @@ export function remoteParts(url: string): { host: string; group: string; path: s
61
61
 
62
62
  /** Words that say nothing about whose repo it is. */
63
63
  const GENERIC = new Set([
64
- 'git', 'com', 'org', 'name', 'www', 'ssh', 'http', 'https', 'the', 'app', 'mcp',
65
- 'github', 'gitlab', 'dev', 'net', 'vorobyev', 'vodmal', 'dmitry',
64
+ 'git',
65
+ 'com',
66
+ 'org',
67
+ 'name',
68
+ 'www',
69
+ 'ssh',
70
+ 'http',
71
+ 'https',
72
+ 'the',
73
+ 'app',
74
+ 'mcp',
75
+ 'github',
76
+ 'gitlab',
77
+ 'dev',
78
+ 'net',
79
+ 'vorobyev',
80
+ 'vodmal',
81
+ 'dmitry',
66
82
  ]);
67
83
 
68
84
  export function nameTokens(dir: string, remotePath: string): Set<string> {
@@ -77,7 +93,11 @@ export function nameTokens(dir: string, remotePath: string): Set<string> {
77
93
  function describe(dir: string): PoolRepo {
78
94
  const cfg = git(dir, ['config', '--local', '--get-regexp', '^(user\\.(name|email)|remote\\.origin\\.url)$']) ?? '';
79
95
  const val = (key: string) =>
80
- cfg.split('\n').find((l) => l.toLowerCase().startsWith(`${key} `))?.slice(key.length + 1).trim() ?? '';
96
+ cfg
97
+ .split('\n')
98
+ .find((l) => l.toLowerCase().startsWith(`${key} `))
99
+ ?.slice(key.length + 1)
100
+ .trim() ?? '';
81
101
  const email = val('user.email');
82
102
  const remote = remoteParts(val('remote.origin.url'));
83
103
  return {
@@ -177,11 +197,10 @@ export function rankAuthors(dir: string, pool: PoolRepo[]): AuthorCandidate[] {
177
197
  }
178
198
 
179
199
  const fallbackName = [...names.values()].flatMap((m) => [...m.entries()]).sort((a, b) => b[1] - a[1])[0]?.[0] ?? '';
180
- return ranked
181
- .map(([email, s]) => {
182
- const byName = [...(names.get(email)?.entries() ?? [])].sort((a, b) => b[1] - a[1]);
183
- return { email, name: byName[0]?.[0] ?? fallbackName, score: s, why: why.get(email) ?? [] };
184
- });
200
+ return ranked.map(([email, s]) => {
201
+ const byName = [...(names.get(email)?.entries() ?? [])].sort((a, b) => b[1] - a[1]);
202
+ return { email, name: byName[0]?.[0] ?? fallbackName, score: s, why: why.get(email) ?? [] };
203
+ });
185
204
  }
186
205
 
187
206
  export function writeAuthor(dir: string, author: Author): boolean {
@@ -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 {
@@ -95,12 +95,14 @@ export function checkRepoHooks(root: string | null): CheckResult | null {
95
95
  remedy: `chmod +x ${hooksPath}/*`,
96
96
  };
97
97
  }
98
- return bodyProblems(id, label, [...hooks.values()]) ?? {
99
- id,
100
- label,
101
- status: 'ok',
102
- message: `${hooks.size} hook(s) active via ${hooksPath}`,
103
- };
98
+ return (
99
+ bodyProblems(id, label, [...hooks.values()]) ?? {
100
+ id,
101
+ label,
102
+ status: 'ok',
103
+ message: `${hooks.size} hook(s) active via ${hooksPath}`,
104
+ }
105
+ );
104
106
  }
105
107
 
106
108
  const states = cloneHookStates(root, ctx, frameworks);
@@ -110,7 +112,9 @@ export function checkRepoHooks(root: string | null): CheckResult | null {
110
112
  id,
111
113
  label,
112
114
  status: 'warning',
113
- message: off.map((s) => `${s.framework.id} (${s.framework.declaredIn}): ${s.problem} — those hooks never run`).join('; '),
115
+ message: off
116
+ .map((s) => `${s.framework.id} (${s.framework.declaredIn}): ${s.problem} — those hooks never run`)
117
+ .join('; '),
114
118
  ...(off[0]!.remedy ? { remedy: off[0]!.remedy } : {}),
115
119
  };
116
120
  }
@@ -149,7 +153,8 @@ function bodyProblems(id: string, label: string, bodies: string[]): CheckResult
149
153
  label,
150
154
  status: 'warning',
151
155
  message: `hooks call ${missing.join(', ')} — not on this machine, the commit fails or the gate is skipped`,
152
- remedy: 'install what the hook calls here, or move the hook out of the repository (a personal hook in git config)',
156
+ remedy:
157
+ 'install what the hook calls here, or move the hook out of the repository (a personal hook in git config)',
153
158
  };
154
159
  }
155
160
  return null;
@@ -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
  }
@@ -179,7 +183,10 @@ export function machineOf(c: Conversation, host: string): string | null {
179
183
  }
180
184
 
181
185
  /** The newest conversation of each machine (unknown counts as one), newest first. */
182
- export function latestPerMachine(conversations: Conversation[], machine: (c: Conversation) => string | null): Conversation[] {
186
+ export function latestPerMachine(
187
+ conversations: Conversation[],
188
+ machine: (c: Conversation) => string | null,
189
+ ): Conversation[] {
183
190
  const out: Conversation[] = [];
184
191
  for (const c of [...conversations].sort((a, b) => b.updated.getTime() - a.updated.getTime())) {
185
192
  if (!out.some((o) => sameMachine(machine(o), machine(c)))) out.push(c);
@@ -187,7 +194,6 @@ export function latestPerMachine(conversations: Conversation[], machine: (c: Con
187
194
  return out;
188
195
  }
189
196
 
190
-
191
197
  /**
192
198
  * The conversation a running Claude Code process is in, from its session file
193
199
  * `<config>/sessions/<pid>.json` — it follows /clear. That folder is per
@@ -195,9 +201,22 @@ export function latestPerMachine(conversations: Conversation[], machine: (c: Con
195
201
  */
196
202
  export function liveConversationId(env: NodeJS.ProcessEnv, home: string, pid: number): string | null {
197
203
  try {
198
- const data = JSON.parse(fs.readFileSync(path.join(claudeConfigDir(env, home), 'sessions', `${pid}.json`), 'utf8'));
199
- const id = data?.sessionId;
200
- 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;
209
+ } catch {
210
+ return null;
211
+ }
212
+ }
213
+
214
+ /** When a running Claude Code process started (ms), from the same session file; null — not known. */
215
+ export function liveStartedAt(env: NodeJS.ProcessEnv, home: string, pid: number): number | null {
216
+ try {
217
+ const file = path.join(claudeConfigDir(env, home), 'sessions', `${pid}.json`);
218
+ const started = at(JSON.parse(fs.readFileSync(file, 'utf8')), 'startedAt');
219
+ return typeof started === 'number' ? started : null;
201
220
  } catch {
202
221
  return null;
203
222
  }
@@ -220,19 +239,22 @@ export function parseLiveSessions(text: string): LiveSession[] {
220
239
  const out: LiveSession[] = [];
221
240
  for (const line of text.split('\n')) {
222
241
  if (!line.trim().startsWith('{')) continue;
223
- let data: any;
242
+ let data: Record<string, unknown> | undefined;
224
243
  try {
225
- data = JSON.parse(line);
244
+ data = dict(JSON.parse(line));
226
245
  } catch {
227
246
  continue;
228
247
  }
229
- if (!Number.isInteger(data?.pid) || typeof data?.sessionId !== 'string' || !isConversationId(data.sessionId)) 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;
230
252
  out.push({
231
- pid: data.pid,
232
- conversation: data.sessionId,
233
- tmux: typeof data.tmux === 'string' ? data.tmux : null,
234
- status: typeof data.status === 'string' ? data.status : null,
235
- 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,
236
258
  });
237
259
  }
238
260
  return out;
@@ -262,8 +284,8 @@ function pidAlive(pid: number): boolean {
262
284
  try {
263
285
  process.kill(pid, 0);
264
286
  return true;
265
- } catch (e: any) {
266
- return e?.code === 'EPERM';
287
+ } catch (e) {
288
+ return at(e, 'code') === 'EPERM';
267
289
  }
268
290
  }
269
291
 
@@ -282,7 +304,11 @@ export const LIVE_SESSIONS_SCRIPT =
282
304
  'kill -0 "$p" 2>/dev/null && tr -d "\\n" < "$f" && echo; done; exit 0';
283
305
 
284
306
  /** Claude Code processes alive on this machine. */
285
- export function liveSessionsHere(env: NodeJS.ProcessEnv, home: string, alive: (pid: number) => boolean = pidAlive): LiveSession[] {
307
+ export function liveSessionsHere(
308
+ env: NodeJS.ProcessEnv,
309
+ home: string,
310
+ alive: (pid: number) => boolean = pidAlive,
311
+ ): LiveSession[] {
286
312
  const dir = path.join(claudeConfigDir(env, home), 'sessions');
287
313
  let names: string[];
288
314
  try {
package/src/doctor.ts CHANGED
@@ -6,7 +6,13 @@ 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 { checkPersonalHooks, defaultPersonalHookDeps, fixPersonalHooks, type PersonalHookDeps } from './personal-hooks.ts';
9
+ import { at, dict } from './narrow.ts';
10
+ import {
11
+ checkPersonalHooks,
12
+ defaultPersonalHookDeps,
13
+ fixPersonalHooks,
14
+ type PersonalHookDeps,
15
+ } from './personal-hooks.ts';
10
16
 
11
17
  export type CheckStatus = 'ok' | 'warning' | 'missing';
12
18
 
@@ -39,11 +45,7 @@ export interface DoctorReport {
39
45
 
40
46
  export function readCliVersion(): string {
41
47
  try {
42
- const pkgPath = path.resolve(
43
- path.dirname(fileURLToPath(import.meta.url)),
44
- '..',
45
- 'package.json',
46
- );
48
+ const pkgPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
47
49
  const raw = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as { version?: string };
48
50
  return raw.version ?? 'unknown';
49
51
  } catch {
@@ -315,8 +317,7 @@ function checkVdx(): CheckResult {
315
317
  status: 'ok',
316
318
  level,
317
319
  message: `${tag} — works inside this project only (${found})`,
318
- remedy:
319
- 'npm i -g @vodmal/vdx-cli OR add `alias vdx="npx -y -p @vodmal/vdx-cli vdx"` to your shell rc',
320
+ remedy: 'npm i -g @vodmal/vdx-cli OR add `alias vdx="npx -y -p @vodmal/vdx-cli vdx"` to your shell rc',
320
321
  };
321
322
  }
322
323
 
@@ -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 };
@@ -458,9 +459,7 @@ export function runDoctorCheck(
458
459
  );
459
460
  return rows
460
461
  .map(
461
- (c) =>
462
- `vdx doctor: ${c.label} — ${c.message}.` +
463
- (c.remedy ? ` Fix, on the user's word: \`${c.remedy}\`.` : ''),
462
+ (c) => `vdx doctor: ${c.label} — ${c.message}.` + (c.remedy ? ` Fix, on the user's word: \`${c.remedy}\`.` : ''),
464
463
  )
465
464
  .join('\n');
466
465
  }
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);