@vodmal/vdx-cli 0.2.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 ADDED
@@ -0,0 +1,142 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import type { Rubric, LevelName } from './rubric.ts';
4
+ import { findSubPackages, type Ctx } from './facts.ts';
5
+ import type { Override, VdxManifest } from './manifest.ts';
6
+ import { evalAxis, projectLevel, type AxisResult } from './scoring.ts';
7
+
8
+ function levelToInt(L: LevelName): number {
9
+ return parseInt(L.slice(1), 10);
10
+ }
11
+
12
+ export interface AuditResult {
13
+ baseline: string;
14
+ stack: string;
15
+ achieved_level: LevelName;
16
+ per_axis: Array<AxisResult & { suppressed?: boolean; override_target?: LevelName }>;
17
+ overrides: Override[];
18
+ expired_overrides: string[];
19
+ primary_subpackage?: string;
20
+ }
21
+
22
+ /**
23
+ * Резолвит ctx для stack-specific осей (с `applies_to`). Источник subpackage:
24
+ * 1. manifest.primary_subpackage (explicit) — если папка существует.
25
+ * 2. findSubPackages() auto-detect — если ровно один subpackage совпадает с ctx.stack.
26
+ * 3. fallback на root ctx.
27
+ *
28
+ * Возвращает `{ ctx, relPath }` где relPath — относительный путь либо null
29
+ * (если subpackage не разрезолвился — будем использовать root).
30
+ */
31
+ function resolveSubpackageCtx(
32
+ ctx: Ctx,
33
+ manifest: VdxManifest | null,
34
+ ): { ctx: Ctx; relPath: string | null } {
35
+ let relPath: string | null = null;
36
+
37
+ if (manifest?.primary_subpackage) {
38
+ const p = manifest.primary_subpackage;
39
+ const abs = path.join(ctx.projectRoot, p);
40
+ if (fs.existsSync(abs)) {
41
+ relPath = p;
42
+ } else {
43
+ process.stderr.write(
44
+ `warning: primary_subpackage "${p}" does not exist — falling back to root\n`,
45
+ );
46
+ }
47
+ } else {
48
+ const subs = findSubPackages(ctx.projectRoot);
49
+ const matching = subs.filter((s) => s.stack === ctx.stack);
50
+ if (matching.length === 1) {
51
+ relPath = matching[0]!.relPath;
52
+ }
53
+ }
54
+
55
+ if (!relPath) return { ctx, relPath: null };
56
+ return {
57
+ ctx: {
58
+ projectRoot: path.join(ctx.projectRoot, relPath),
59
+ stack: ctx.stack,
60
+ cache: new Map(),
61
+ },
62
+ relPath,
63
+ };
64
+ }
65
+
66
+ export function audit(
67
+ rubric: Rubric,
68
+ ctx: Ctx,
69
+ overrides: Override[],
70
+ baselineRef: string,
71
+ manifest: VdxManifest | null = null,
72
+ ): AuditResult {
73
+ const suppressed = new Set<string>();
74
+ const overrideByAxis = new Map<string, Override>();
75
+ const expired: string[] = [];
76
+ const today = new Date().toISOString().slice(0, 10);
77
+
78
+ for (const ov of overrides) {
79
+ overrideByAxis.set(ov.axis, ov);
80
+ if (ov.suppress) suppressed.add(ov.axis);
81
+ if (ov.until && ov.until < today) expired.push(ov.axis);
82
+ }
83
+
84
+ const { ctx: subpackageCtx, relPath: subpackagePath } = resolveSubpackageCtx(ctx, manifest);
85
+
86
+ const perAxis: AuditResult['per_axis'] = [];
87
+ for (const axis of rubric.axes) {
88
+ if (suppressed.has(axis.id)) {
89
+ perAxis.push({
90
+ axis_id: axis.id,
91
+ class: axis.class,
92
+ achieved: 'L0',
93
+ target: axis.default_target,
94
+ drift_kind: 'aligned',
95
+ suppressed: true,
96
+ });
97
+ continue;
98
+ }
99
+ if (axis.applies_to && !axis.applies_to.includes(ctx.stack)) {
100
+ perAxis.push({
101
+ axis_id: axis.id,
102
+ class: axis.class,
103
+ achieved: 'L0',
104
+ target: axis.default_target,
105
+ drift_kind: 'excluded',
106
+ });
107
+ continue;
108
+ }
109
+ const evalCtx = axis.applies_to ? subpackageCtx : ctx;
110
+ const achieved = evalAxis(axis, evalCtx);
111
+ const ov = overrideByAxis.get(axis.id);
112
+ const target = (ov?.target as LevelName) ?? axis.default_target;
113
+ const drift: 'aligned' | 'gap' | 'over' =
114
+ levelToInt(achieved) === levelToInt(target)
115
+ ? 'aligned'
116
+ : levelToInt(achieved) < levelToInt(target)
117
+ ? 'gap'
118
+ : 'over';
119
+ const entry: AuditResult['per_axis'][number] = {
120
+ axis_id: axis.id,
121
+ class: axis.class,
122
+ achieved,
123
+ target,
124
+ drift_kind: drift,
125
+ };
126
+ if (ov?.target) entry.override_target = ov.target as LevelName;
127
+ perAxis.push(entry);
128
+ }
129
+
130
+ const overall = projectLevel(perAxis, rubric, suppressed);
131
+
132
+ const result: AuditResult = {
133
+ baseline: baselineRef,
134
+ stack: ctx.stack,
135
+ achieved_level: overall,
136
+ per_axis: perAxis,
137
+ overrides,
138
+ expired_overrides: expired,
139
+ };
140
+ if (subpackagePath) result.primary_subpackage = subpackagePath;
141
+ return result;
142
+ }
@@ -0,0 +1,10 @@
1
+ import * as path from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+
4
+ const here = path.dirname(fileURLToPath(import.meta.url));
5
+
6
+ export const BUNDLED_RUBRIC_PATH = path.resolve(here, '..', 'rubric', 'vdx-rubric.yaml');
7
+
8
+ export function resolveDefaultRubric(): string {
9
+ return process.env['VDX_RUBRIC'] ?? BUNDLED_RUBRIC_PATH;
10
+ }
@@ -0,0 +1,72 @@
1
+ import type { Predicate } from './rubric.ts';
2
+ import type { Ctx } from './facts.ts';
3
+ import { REGISTRY } from './predicates.ts';
4
+
5
+ /**
6
+ * Sugar shorthand: `{ has_task: "up" }` → `{ fn: "has_task", args: { task: "up" } }`
7
+ * For predicates whose single primary arg has a known name.
8
+ */
9
+ const SUGAR_ARG_KEY: Record<string, string> = {
10
+ has_task: 'task',
11
+ has_task_matching: 'pattern',
12
+ has_file: 'path',
13
+ package_present: 'name',
14
+ git_hook_installed: 'hook',
15
+ phpstan_level_at_least: 'n',
16
+ };
17
+
18
+ export function evalPredicate(p: Predicate, ctx: Ctx): boolean {
19
+ if (p === null || p === undefined) return false;
20
+ if (typeof p === 'boolean') return p;
21
+
22
+ if (typeof p === 'object' && !Array.isArray(p)) {
23
+ const obj = p as Record<string, any>;
24
+
25
+ if ('all_of' in obj) {
26
+ const list = obj.all_of as Predicate[];
27
+ return list.every((q) => evalPredicate(q, ctx));
28
+ }
29
+ if ('any_of' in obj) {
30
+ const list = obj.any_of as Predicate[];
31
+ return list.some((q) => evalPredicate(q, ctx));
32
+ }
33
+ if ('at_least_n_of' in obj) {
34
+ const { n, predicates } = obj.at_least_n_of as { n: number; predicates: Predicate[] };
35
+ let matched = 0;
36
+ for (const q of predicates) if (evalPredicate(q, ctx)) matched += 1;
37
+ return matched >= n;
38
+ }
39
+ if ('not' in obj) {
40
+ return !evalPredicate(obj.not, ctx);
41
+ }
42
+ if ('fn' in obj) {
43
+ const fn = REGISTRY[String(obj.fn)];
44
+ if (!fn) {
45
+ process.stderr.write(`warning: unknown predicate "${obj.fn}"\n`);
46
+ return false;
47
+ }
48
+ return fn(obj.args ?? {}, ctx);
49
+ }
50
+
51
+ // Sugar form: single-key object where key is a registered predicate
52
+ const keys = Object.keys(obj);
53
+ if (keys.length === 1) {
54
+ const key = keys[0]!;
55
+ const fn = REGISTRY[key];
56
+ if (fn) {
57
+ const value = obj[key];
58
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
59
+ return fn(value, ctx);
60
+ }
61
+ const argKey = SUGAR_ARG_KEY[key];
62
+ if (argKey) {
63
+ return fn({ [argKey]: value }, ctx);
64
+ }
65
+ return fn({ value }, ctx);
66
+ }
67
+ }
68
+ }
69
+
70
+ process.stderr.write(`warning: malformed predicate ${JSON.stringify(p)}\n`);
71
+ return false;
72
+ }
package/src/facts.ts ADDED
@@ -0,0 +1,262 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import * as TOML from 'smol-toml';
4
+ import * as YAML from 'js-yaml';
5
+
6
+ export interface Ctx {
7
+ projectRoot: string;
8
+ stack: string;
9
+ cache: Map<string, unknown>;
10
+ }
11
+
12
+ export function fileExists(ctx: Ctx, rel: string): boolean {
13
+ return fs.existsSync(path.join(ctx.projectRoot, rel));
14
+ }
15
+
16
+ export function readText(ctx: Ctx, rel: string): string | null {
17
+ const key = `file:${rel}`;
18
+ if (ctx.cache.has(key)) return ctx.cache.get(key) as string | null;
19
+ const abs = path.join(ctx.projectRoot, rel);
20
+ try {
21
+ const t = fs.readFileSync(abs, 'utf8');
22
+ ctx.cache.set(key, t);
23
+ return t;
24
+ } catch {
25
+ ctx.cache.set(key, null);
26
+ return null;
27
+ }
28
+ }
29
+
30
+ export function readJson(ctx: Ctx, rel: string): any {
31
+ const key = `json:${rel}`;
32
+ if (ctx.cache.has(key)) return ctx.cache.get(key);
33
+ const text = readText(ctx, rel);
34
+ if (text === null) {
35
+ ctx.cache.set(key, null);
36
+ return null;
37
+ }
38
+ try {
39
+ const data = JSON.parse(text);
40
+ ctx.cache.set(key, data);
41
+ return data;
42
+ } catch {
43
+ ctx.cache.set(key, null);
44
+ return null;
45
+ }
46
+ }
47
+
48
+ export function readToml(ctx: Ctx, rel: string): any {
49
+ const key = `toml:${rel}`;
50
+ if (ctx.cache.has(key)) return ctx.cache.get(key);
51
+ const text = readText(ctx, rel);
52
+ if (text === null) {
53
+ ctx.cache.set(key, null);
54
+ return null;
55
+ }
56
+ try {
57
+ const data = TOML.parse(text);
58
+ ctx.cache.set(key, data);
59
+ return data;
60
+ } catch {
61
+ ctx.cache.set(key, null);
62
+ return null;
63
+ }
64
+ }
65
+
66
+ export function readYaml(ctx: Ctx, rel: string): any {
67
+ const key = `yaml:${rel}`;
68
+ if (ctx.cache.has(key)) return ctx.cache.get(key);
69
+ const text = readText(ctx, rel);
70
+ if (text === null) {
71
+ ctx.cache.set(key, null);
72
+ return null;
73
+ }
74
+ try {
75
+ const data = YAML.load(text);
76
+ ctx.cache.set(key, data);
77
+ return data;
78
+ } catch {
79
+ ctx.cache.set(key, null);
80
+ return null;
81
+ }
82
+ }
83
+
84
+ /** Parse a structured file by extension. Returns parsed object/array or null. */
85
+ export function readStructured(ctx: Ctx, rel: string): any {
86
+ const lower = rel.toLowerCase();
87
+ if (lower.endsWith('.json')) return readJson(ctx, rel);
88
+ if (lower.endsWith('.toml')) return readToml(ctx, rel);
89
+ if (lower.endsWith('.yaml') || lower.endsWith('.yml')) return readYaml(ctx, rel);
90
+ return null;
91
+ }
92
+
93
+ /**
94
+ * Resolve a dot-notation path inside a parsed config object.
95
+ * Supports plain dots only — array indices and bracket notation are not used by
96
+ * the current rubric. Returns `undefined` if any segment is missing.
97
+ */
98
+ export function resolveConfigPath(root: unknown, jsonpath: string): unknown {
99
+ if (root === null || root === undefined) return undefined;
100
+ const parts = jsonpath.split('.');
101
+ let cur: any = root;
102
+ for (const part of parts) {
103
+ if (cur === null || cur === undefined) return undefined;
104
+ if (typeof cur !== 'object') return undefined;
105
+ cur = (cur as Record<string, unknown>)[part];
106
+ }
107
+ return cur;
108
+ }
109
+
110
+ /** Aggregated task names from mise.toml, composer scripts, npm scripts, Makefile. */
111
+ export function listAllTasks(ctx: Ctx): Set<string> {
112
+ const key = 'tasks:all';
113
+ if (ctx.cache.has(key)) return ctx.cache.get(key) as Set<string>;
114
+ const tasks = new Set<string>();
115
+
116
+ const miseT = readToml(ctx, 'mise.toml');
117
+ if (miseT?.tasks && typeof miseT.tasks === 'object') {
118
+ for (const k of Object.keys(miseT.tasks as object)) tasks.add(k);
119
+ }
120
+
121
+ const composer = readJson(ctx, 'composer.json');
122
+ if (composer?.scripts && typeof composer.scripts === 'object') {
123
+ for (const k of Object.keys(composer.scripts)) tasks.add(k);
124
+ }
125
+
126
+ const pkg = readJson(ctx, 'package.json');
127
+ if (pkg?.scripts && typeof pkg.scripts === 'object') {
128
+ for (const k of Object.keys(pkg.scripts)) tasks.add(k);
129
+ }
130
+
131
+ // monorepo-ish fallback: server/package.json (см. bookmap)
132
+ const serverPkg = readJson(ctx, 'server/package.json');
133
+ if (serverPkg?.scripts && typeof serverPkg.scripts === 'object') {
134
+ for (const k of Object.keys(serverPkg.scripts)) tasks.add(`server:${k}`);
135
+ }
136
+
137
+ const make = readText(ctx, 'Makefile');
138
+ if (make) {
139
+ const lines = make.split('\n');
140
+ for (const line of lines) {
141
+ const m = line.match(/^([a-zA-Z0-9_.\-:]+)\s*:[^=]/);
142
+ if (m && m[1] && !m[1].startsWith('.')) tasks.add(m[1]);
143
+ }
144
+ }
145
+
146
+ ctx.cache.set(key, tasks);
147
+ return tasks;
148
+ }
149
+
150
+ export function isPackagePresent(name: string, ecosystem: string, ctx: Ctx): boolean {
151
+ const eco = ecosystem ?? 'any';
152
+ if (eco === 'any' || eco === 'composer') {
153
+ const composer = readJson(ctx, 'composer.json');
154
+ if (composer) {
155
+ if (composer.require?.[name]) return true;
156
+ if (composer['require-dev']?.[name]) return true;
157
+ }
158
+ }
159
+ if (eco === 'any' || eco === 'npm') {
160
+ for (const rel of ['package.json', 'server/package.json']) {
161
+ const pkg = readJson(ctx, rel);
162
+ if (pkg) {
163
+ if (pkg.dependencies?.[name]) return true;
164
+ if (pkg.devDependencies?.[name]) return true;
165
+ }
166
+ }
167
+ }
168
+ return false;
169
+ }
170
+
171
+ export function expandPaths(paths: string[], ctx: Ctx): string[] {
172
+ const out: string[] = [];
173
+ for (const p of paths) {
174
+ if (p.includes('*')) {
175
+ const m = p.match(/^([^*]+)\/\*\.(.+)$/);
176
+ if (m && m[1] && m[2]) {
177
+ const dir = m[1];
178
+ const ext = m[2];
179
+ const abs = path.join(ctx.projectRoot, dir);
180
+ try {
181
+ const files = fs.readdirSync(abs);
182
+ for (const f of files) {
183
+ if (f.endsWith('.' + ext)) out.push(path.join(dir, f));
184
+ }
185
+ } catch {
186
+ /* dir missing — silent */
187
+ }
188
+ }
189
+ } else {
190
+ out.push(p);
191
+ }
192
+ }
193
+ return out;
194
+ }
195
+
196
+ export function readTsconfig(ctx: Ctx): any {
197
+ let cfg = readJson(ctx, 'tsconfig.json');
198
+ if (!cfg) cfg = readJson(ctx, 'server/tsconfig.json');
199
+ if (!cfg) return null;
200
+ // basic one-level `extends` resolution
201
+ if (cfg.extends && typeof cfg.extends === 'string' && cfg.extends.startsWith('.')) {
202
+ const baseRel = cfg.extends.endsWith('.json') ? cfg.extends : `${cfg.extends}.json`;
203
+ const base = readJson(ctx, baseRel);
204
+ if (base?.compilerOptions) {
205
+ cfg.compilerOptions = { ...base.compilerOptions, ...(cfg.compilerOptions ?? {}) };
206
+ }
207
+ }
208
+ return cfg;
209
+ }
210
+
211
+ const MANIFEST_TO_STACK: Array<[string, string]> = [
212
+ ['composer.json', 'php'],
213
+ ['package.json', 'node'],
214
+ ['go.mod', 'go'],
215
+ ['pyproject.toml', 'python'],
216
+ ['requirements.txt', 'python'],
217
+ ];
218
+
219
+ function stackForDir(dir: string): string | null {
220
+ for (const [manifest, stack] of MANIFEST_TO_STACK) {
221
+ if (fs.existsSync(path.join(dir, manifest))) return stack;
222
+ }
223
+ return null;
224
+ }
225
+
226
+ const SUBPACKAGE_SCAN_IGNORE = new Set([
227
+ 'node_modules', 'vendor', 'dist', 'build', 'out', 'target',
228
+ '.git', '.next', '__pycache__', '.venv', 'venv', '.cache', 'coverage',
229
+ ]);
230
+
231
+ export interface SubPackage {
232
+ relPath: string;
233
+ stack: string;
234
+ }
235
+
236
+ export function findSubPackages(projectRoot: string): SubPackage[] {
237
+ const found: SubPackage[] = [];
238
+ let entries: fs.Dirent[];
239
+ try {
240
+ entries = fs.readdirSync(projectRoot, { withFileTypes: true });
241
+ } catch {
242
+ return found;
243
+ }
244
+ for (const entry of entries) {
245
+ if (!entry.isDirectory()) continue;
246
+ if (entry.name.startsWith('.')) continue;
247
+ if (SUBPACKAGE_SCAN_IGNORE.has(entry.name)) continue;
248
+ const stack = stackForDir(path.join(projectRoot, entry.name));
249
+ if (stack) found.push({ relPath: entry.name, stack });
250
+ }
251
+ return found;
252
+ }
253
+
254
+ export function autoDetectStack(projectRoot: string): string {
255
+ const rootStack = stackForDir(projectRoot);
256
+ if (rootStack) return rootStack;
257
+ const subs = findSubPackages(projectRoot);
258
+ if (subs.length === 0) return 'unknown';
259
+ const stacks = new Set(subs.map((s) => s.stack));
260
+ if (stacks.size === 1) return subs[0]!.stack;
261
+ return 'monorepo';
262
+ }
package/src/index.ts ADDED
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env node
2
+ import * as path from 'node:path';
3
+ import { loadRubric } from './rubric.ts';
4
+ import { loadManifest, loadOverrides } from './manifest.ts';
5
+ import { autoDetectStack, type Ctx } from './facts.ts';
6
+ import { audit } from './audit.ts';
7
+ import { reportMarkdown, reportJson } from './report.ts';
8
+ import { planInit, writeInit, renderPlanSummary } from './init.ts';
9
+ import { resolveDefaultRubric } from './defaults.ts';
10
+
11
+ const DEFAULT_RUBRIC = resolveDefaultRubric();
12
+
13
+ function usage(): never {
14
+ process.stderr.write(
15
+ `Usage:
16
+ vdx audit <project_path> [--rubric <path>] [--stack <stack>] [--json]
17
+ vdx init <project_path> [--baseline <ref>] [--dry-run] [--force]
18
+ `,
19
+ );
20
+ process.exit(1);
21
+ }
22
+
23
+ interface ParsedArgs {
24
+ cmd: string;
25
+ positionals: string[];
26
+ flags: Record<string, string | boolean>;
27
+ }
28
+
29
+ function parseArgs(argv: string[]): ParsedArgs {
30
+ const cmd = argv[2] ?? '';
31
+ const rest = argv.slice(3);
32
+ const positionals: string[] = [];
33
+ const flags: Record<string, string | boolean> = {};
34
+ for (let i = 0; i < rest.length; i++) {
35
+ const a = rest[i]!;
36
+ if (a.startsWith('--')) {
37
+ const name = a.slice(2);
38
+ const next = rest[i + 1];
39
+ if (next !== undefined && !next.startsWith('--')) {
40
+ flags[name] = next;
41
+ i++;
42
+ } else {
43
+ flags[name] = true;
44
+ }
45
+ } else {
46
+ positionals.push(a);
47
+ }
48
+ }
49
+ return { cmd, positionals, flags };
50
+ }
51
+
52
+ function cmdAudit(opts: ParsedArgs): void {
53
+ const projectArg = opts.positionals[0];
54
+ if (!projectArg) usage();
55
+ const projectRoot = path.resolve(projectArg);
56
+
57
+ const manifest = loadManifest(projectRoot);
58
+ const overrides = loadOverrides(projectRoot);
59
+
60
+ const rubricPath = (opts.flags.rubric as string) || DEFAULT_RUBRIC;
61
+ const rubric = loadRubric(rubricPath);
62
+
63
+ const stack =
64
+ (opts.flags.stack as string) || manifest?.stack || autoDetectStack(projectRoot);
65
+
66
+ const ctx: Ctx = {
67
+ projectRoot,
68
+ stack,
69
+ cache: new Map(),
70
+ };
71
+
72
+ const baselineRef = manifest?.baseline ?? `file://${rubricPath}`;
73
+ const result = audit(rubric, ctx, overrides, baselineRef, manifest);
74
+
75
+ if (opts.flags.json) {
76
+ process.stdout.write(reportJson(result) + '\n');
77
+ } else {
78
+ process.stdout.write(reportMarkdown(result));
79
+ }
80
+ }
81
+
82
+ function cmdInit(opts: ParsedArgs): void {
83
+ const projectArg = opts.positionals[0];
84
+ if (!projectArg) usage();
85
+ const projectRoot = path.resolve(projectArg);
86
+
87
+ const baselineFlag = opts.flags.baseline;
88
+ const baseline = typeof baselineFlag === 'string' ? baselineFlag : undefined;
89
+ const plan = planInit(projectRoot, baseline ? { baseline } : {});
90
+
91
+ process.stdout.write(renderPlanSummary(plan));
92
+
93
+ if (opts.flags['dry-run']) {
94
+ process.stderr.write('\n[dry-run] mise.toml / AGENTS.md не записаны.\n');
95
+ return;
96
+ }
97
+ try {
98
+ writeInit(plan, { force: Boolean(opts.flags.force) });
99
+ process.stderr.write(
100
+ `\n✓ Записаны:\n ${plan.miseTomlPath}\n ${plan.agentsMdPath}\n`,
101
+ );
102
+ } catch (e: any) {
103
+ process.stderr.write(`\nerror: ${e?.message ?? String(e)}\n`);
104
+ process.exit(2);
105
+ }
106
+ }
107
+
108
+ const parsed = parseArgs(process.argv);
109
+ if (parsed.cmd === 'audit') cmdAudit(parsed);
110
+ else if (parsed.cmd === 'init') cmdInit(parsed);
111
+ else usage();