@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/README.md +5 -4
- package/package.json +10 -1
- package/rubric/vdx-rubric.yaml +76 -32
- package/src/ai.ts +221 -76
- package/src/audit.ts +19 -9
- package/src/author.ts +27 -8
- package/src/clone-checks.ts +14 -9
- package/src/conversations.ts +48 -22
- package/src/doctor.ts +14 -15
- package/src/evaluator.ts +21 -20
- package/src/facts.ts +71 -56
- package/src/gha.ts +38 -27
- package/src/hooks.ts +78 -43
- package/src/index.ts +56 -41
- package/src/init.ts +22 -61
- package/src/manifest.ts +25 -4
- package/src/mcp-server.ts +40 -39
- package/src/narrow.ts +56 -0
- package/src/personal-hooks.ts +16 -5
- package/src/predicates.ts +12 -16
- package/src/publish.ts +38 -31
- package/src/report.ts +1 -3
- package/src/rubric.ts +2 -2
- package/src/run.ts +5 -8
- package/src/scoring.ts +2 -8
- package/src/vocabulary.ts +13 -12
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 :
|
|
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',
|
|
65
|
-
'
|
|
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
|
|
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
|
-
.
|
|
182
|
-
|
|
183
|
-
|
|
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 {
|
package/src/clone-checks.ts
CHANGED
|
@@ -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
|
|
99
|
-
id,
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
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:
|
|
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;
|
package/src/conversations.ts
CHANGED
|
@@ -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:
|
|
90
|
+
let entry: unknown;
|
|
90
91
|
try {
|
|
91
92
|
entry = JSON.parse(line);
|
|
92
93
|
} catch {
|
|
93
94
|
continue;
|
|
94
95
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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(
|
|
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(
|
|
199
|
-
|
|
200
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
232
|
-
conversation
|
|
233
|
-
tmux:
|
|
234
|
-
status:
|
|
235
|
-
cwd:
|
|
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
|
|
266
|
-
return e
|
|
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(
|
|
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 {
|
|
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):
|
|
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
|
|
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
|
|
378
|
+
const version = at(installed, 'plugins', key, '0', 'version');
|
|
378
379
|
const label = `${key}${typeof version === 'string' ? ` ${version}` : ''}`;
|
|
379
|
-
if (readJson('settings.json')
|
|
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 (
|
|
38
|
-
const obj = p
|
|
38
|
+
if (isDict(p)) {
|
|
39
|
+
const obj = p;
|
|
39
40
|
|
|
40
41
|
if ('all_of' in obj) {
|
|
41
|
-
const vs = (obj
|
|
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
|
|
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
|
|
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
|
|
61
|
+
const v = evalVerdict(obj['not'], ctx);
|
|
61
62
|
return isUnknown(v) ? v : !v;
|
|
62
63
|
}
|
|
63
64
|
if ('fn' in obj) {
|
|
64
|
-
const
|
|
65
|
+
const name = String(obj['fn']);
|
|
66
|
+
const fn = REGISTRY[name];
|
|
65
67
|
if (!fn) {
|
|
66
|
-
process.stderr.write(`warning: unknown predicate "${
|
|
68
|
+
process.stderr.write(`warning: unknown predicate "${name}"\n`);
|
|
67
69
|
return false;
|
|
68
70
|
}
|
|
69
|
-
|
|
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 (
|
|
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 (
|
|
108
|
-
const obj = q
|
|
109
|
-
if ('all_of' in obj) return (obj
|
|
110
|
-
if ('any_of' in obj) return (obj
|
|
111
|
-
if ('at_least_n_of' in obj) return (obj
|
|
112
|
-
if ('not' in obj) return walk(obj
|
|
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
|
|
115
|
+
out.push(String(obj['fn']));
|
|
115
116
|
return;
|
|
116
117
|
}
|
|
117
118
|
const keys = Object.keys(obj);
|