@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.
@@ -0,0 +1,160 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import {
4
+ Ctx,
5
+ fileExists,
6
+ readText,
7
+ readJson,
8
+ listAllTasks,
9
+ isPackagePresent,
10
+ expandPaths,
11
+ readTsconfig,
12
+ readStructured,
13
+ resolveConfigPath,
14
+ } from './facts.ts';
15
+
16
+ type Args = Record<string, any>;
17
+ type PredicateFn = (args: Args, ctx: Ctx) => boolean;
18
+
19
+ const warned = new Set<string>();
20
+ function warnOnce(name: string) {
21
+ if (warned.has(name)) return;
22
+ warned.add(name);
23
+ process.stderr.write(`warning: predicate "${name}" not implemented in v0.1 — returning false\n`);
24
+ }
25
+
26
+ export const REGISTRY: Record<string, PredicateFn> = {
27
+ always_true: () => true,
28
+
29
+ has_task: (args, ctx) => listAllTasks(ctx).has(String(args.task)),
30
+
31
+ has_task_matching: (args, ctx) => {
32
+ try {
33
+ const re = new RegExp(String(args.pattern));
34
+ for (const t of listAllTasks(ctx)) if (re.test(t)) return true;
35
+ } catch {
36
+ return false;
37
+ }
38
+ return false;
39
+ },
40
+
41
+ has_file: (args, ctx) => fileExists(ctx, String(args.path)),
42
+
43
+ file_contains: (args, ctx) => {
44
+ const paths: string[] = Array.isArray(args.paths) ? args.paths : [String(args.path)];
45
+ const expanded = expandPaths(paths, ctx);
46
+ let re: RegExp;
47
+ try {
48
+ re = new RegExp(String(args.pattern));
49
+ } catch {
50
+ return false;
51
+ }
52
+ for (const p of expanded) {
53
+ const text = readText(ctx, p);
54
+ if (text !== null && re.test(text)) return true;
55
+ }
56
+ return false;
57
+ },
58
+
59
+ package_present: (args, ctx) => {
60
+ if (typeof args === 'string') return isPackagePresent(args, 'any', ctx);
61
+ return isPackagePresent(String(args.name), String(args.ecosystem ?? 'any'), ctx);
62
+ },
63
+
64
+ tsc_flag: (args, ctx) => {
65
+ const cfg = readTsconfig(ctx);
66
+ const co = cfg?.compilerOptions;
67
+ if (!co) return false;
68
+ const target = args.equals === undefined ? true : args.equals;
69
+ return co[String(args.name)] === target;
70
+ },
71
+
72
+ phpstan_level_at_least: (args, ctx) => {
73
+ const text =
74
+ readText(ctx, 'phpstan.neon') ||
75
+ readText(ctx, 'phpstan.dist.neon') ||
76
+ readText(ctx, 'phpstan.neon.dist');
77
+ if (!text) return false;
78
+ const m = text.match(/^\s*level:\s*(\d+|max)/m);
79
+ if (!m || !m[1]) return false;
80
+ const lvl = m[1] === 'max' ? 9 : parseInt(m[1], 10);
81
+ return lvl >= Number(args.n);
82
+ },
83
+
84
+ gh_workflow_blocks_pr: (args, ctx) => {
85
+ const dir = path.join(ctx.projectRoot, '.github', 'workflows');
86
+ if (!fs.existsSync(dir)) return false;
87
+ const wanted = args.check_name ? String(args.check_name) : null;
88
+ try {
89
+ for (const f of fs.readdirSync(dir)) {
90
+ if (!/\.ya?ml$/.test(f)) continue;
91
+ const text = readText(ctx, path.join('.github', 'workflows', f));
92
+ if (!text) continue;
93
+ if (!/on:[\s\S]*?(pull_request|pull-request)/.test(text)) continue;
94
+ if (wanted === null) return true;
95
+ if (text.includes(wanted)) return true;
96
+ }
97
+ } catch {
98
+ /* ignore */
99
+ }
100
+ return false;
101
+ },
102
+
103
+ git_hook_installed: (args, ctx) => {
104
+ const hook = String(args.hook);
105
+ if (fileExists(ctx, `.husky/${hook}`)) return true;
106
+ for (const f of ['.lefthook.yml', 'lefthook.yml']) {
107
+ const text = readText(ctx, f);
108
+ if (text && new RegExp(`^${hook}:`, 'm').test(text)) return true;
109
+ }
110
+ const composer = readJson(ctx, 'composer.json');
111
+ if (composer?.extra?.hooks?.[hook]) return true;
112
+ return false;
113
+ },
114
+
115
+ config_value: (args, ctx) => {
116
+ const filePath = String(args.path ?? '');
117
+ const jsonpath = String(args.jsonpath ?? '');
118
+ if (!filePath || !jsonpath) return false;
119
+ const data = readStructured(ctx, filePath);
120
+ if (data === null || data === undefined) return false;
121
+ const value = resolveConfigPath(data, jsonpath);
122
+
123
+ // Operator resolution: explicit `op`, otherwise infer from arg shape.
124
+ let op = args.op ? String(args.op) : null;
125
+ if (!op) {
126
+ if (args.equals !== undefined) op = 'equals';
127
+ else if (args.gte !== undefined) op = 'gte';
128
+ else if (args.pattern !== undefined) op = 'matches';
129
+ else op = 'present';
130
+ }
131
+
132
+ switch (op) {
133
+ case 'present':
134
+ return value !== undefined && value !== null;
135
+ case 'equals':
136
+ return value === args.equals;
137
+ case 'gte': {
138
+ const v = typeof value === 'number' ? value : Number(value);
139
+ const target = Number(args.gte);
140
+ if (Number.isNaN(v) || Number.isNaN(target)) return false;
141
+ return v >= target;
142
+ }
143
+ case 'matches': {
144
+ if (typeof value !== 'string') return false;
145
+ try {
146
+ return new RegExp(String(args.pattern)).test(value);
147
+ } catch {
148
+ return false;
149
+ }
150
+ }
151
+ default:
152
+ process.stderr.write(`warning: config_value: unknown op "${op}"\n`);
153
+ return false;
154
+ }
155
+ },
156
+ command_succeeds: () => {
157
+ warnOnce('command_succeeds');
158
+ return false;
159
+ },
160
+ };
package/src/report.ts ADDED
@@ -0,0 +1,49 @@
1
+ import type { AuditResult } from './audit.ts';
2
+
3
+ const SYMBOL: Record<string, string> = {
4
+ aligned: '✅',
5
+ gap: '⚠️ ',
6
+ over: '🔵',
7
+ excluded: '➖',
8
+ };
9
+
10
+ export function reportMarkdown(r: AuditResult): string {
11
+ const lines: string[] = [];
12
+ lines.push(`# vdx audit report`);
13
+ lines.push('');
14
+ lines.push(`- **Baseline**: \`${r.baseline}\``);
15
+ lines.push(`- **Stack**: \`${r.stack}\``);
16
+ lines.push(`- **Achieved level**: **${r.achieved_level}**`);
17
+ lines.push('');
18
+ lines.push(`## Axes`);
19
+ lines.push('');
20
+ lines.push(`| Axis | Class | Achieved | Target | Drift |`);
21
+ lines.push(`|------|:-----:|:--------:|:------:|:------|`);
22
+ for (const a of r.per_axis) {
23
+ const cls = a.class === 'critical' ? '**C**' : 's';
24
+ const drift = a.suppressed ? '— suppressed —' : `${SYMBOL[a.drift_kind] ?? ''} ${a.drift_kind}`;
25
+ lines.push(`| \`${a.axis_id}\` | ${cls} | ${a.achieved} | ${a.target} | ${drift} |`);
26
+ }
27
+
28
+ if (r.overrides.length > 0) {
29
+ lines.push('');
30
+ lines.push(`## Overrides`);
31
+ for (const o of r.overrides) {
32
+ const kind = o.suppress ? 'suppress' : `target=${o.target}`;
33
+ const until = o.until ? `, until ${o.until}` : '';
34
+ lines.push(`- \`${o.axis}\` — ${kind}, reason: ${o.reason}${until}`);
35
+ }
36
+ }
37
+
38
+ if (r.expired_overrides.length > 0) {
39
+ lines.push('');
40
+ lines.push(`## ⚠️ Expired overrides`);
41
+ for (const x of r.expired_overrides) lines.push(`- \`${x}\``);
42
+ }
43
+
44
+ return lines.join('\n') + '\n';
45
+ }
46
+
47
+ export function reportJson(r: AuditResult): string {
48
+ return JSON.stringify(r, null, 2);
49
+ }
package/src/rubric.ts ADDED
@@ -0,0 +1,64 @@
1
+ import * as fs from 'node:fs';
2
+ import YAML from 'js-yaml';
3
+
4
+ export type LevelName = 'L0' | 'L1' | 'L2' | 'L3' | 'L4';
5
+ export type AxisClass = 'critical' | 'supporting';
6
+ export type Storage = 'level' | 'flags';
7
+
8
+ // Predicate структура динамическая — оставляем any для дешёвого парсинга
9
+ // со сахаром. Валидация — отдельная история v0.2.
10
+ export type Predicate = unknown;
11
+
12
+ export interface Flag {
13
+ id: string;
14
+ weight: number;
15
+ predicate: Predicate;
16
+ }
17
+
18
+ export interface StackImpl {
19
+ flags?: Flag[];
20
+ level_thresholds?: Partial<Record<LevelName, number>>;
21
+ }
22
+
23
+ export interface Axis {
24
+ id: string;
25
+ class: AxisClass;
26
+ description?: string;
27
+ storage: Storage;
28
+ default_target: LevelName;
29
+ fact_sources?: string[];
30
+ levels?: Partial<Record<LevelName, { requires: Predicate }>>;
31
+ stack_implementations?: Record<string, StackImpl>;
32
+ applies_to?: string[];
33
+ }
34
+
35
+ export interface Rubric {
36
+ schema_version: string;
37
+ metadata: {
38
+ name: string;
39
+ version: string;
40
+ owner?: string;
41
+ description?: string;
42
+ homepage?: string;
43
+ };
44
+ scoring: {
45
+ formula: string;
46
+ supporting_threshold: number;
47
+ };
48
+ levels: Record<LevelName, { name: string; description: string }>;
49
+ axes: Axis[];
50
+ }
51
+
52
+ export function loadRubric(yamlPath: string): Rubric {
53
+ const text = fs.readFileSync(yamlPath, 'utf8');
54
+ const data = YAML.load(text) as Rubric;
55
+ if (!data || !data.schema_version || !data.axes) {
56
+ throw new Error(`Invalid rubric at ${yamlPath}: missing schema_version or axes`);
57
+ }
58
+ // нормализуем default_target (если кто-то напишет "L4" со spaces)
59
+ for (const ax of data.axes) {
60
+ ax.default_target = (ax.default_target ?? 'L4') as LevelName;
61
+ ax.storage = (ax.storage ?? 'level') as Storage;
62
+ }
63
+ return data;
64
+ }
package/src/scoring.ts ADDED
@@ -0,0 +1,79 @@
1
+ import type { Axis, LevelName, Rubric } from './rubric.ts';
2
+ import type { Ctx } from './facts.ts';
3
+ import { evalPredicate } from './evaluator.ts';
4
+
5
+ const LEVELS: LevelName[] = ['L1', 'L2', 'L3', 'L4'];
6
+
7
+ function levelToInt(L: LevelName): number {
8
+ return parseInt(L.slice(1), 10);
9
+ }
10
+
11
+ export interface AxisResult {
12
+ axis_id: string;
13
+ class: 'critical' | 'supporting';
14
+ achieved: LevelName;
15
+ target: LevelName;
16
+ drift_kind: 'aligned' | 'gap' | 'over' | 'excluded';
17
+ }
18
+
19
+ export function evalAxis(axis: Axis, ctx: Ctx): LevelName {
20
+ if (axis.storage === 'flags') return evalAxisFlags(axis, ctx);
21
+ return evalAxisLevels(axis, ctx);
22
+ }
23
+
24
+ function evalAxisLevels(axis: Axis, ctx: Ctx): LevelName {
25
+ let achieved: LevelName = 'L0';
26
+ for (const L of LEVELS) {
27
+ const lvl = axis.levels?.[L];
28
+ if (!lvl) break;
29
+ if (evalPredicate(lvl.requires, ctx)) {
30
+ achieved = L;
31
+ } else {
32
+ break; // delta-style: discontinuity → stop ascent
33
+ }
34
+ }
35
+ return achieved;
36
+ }
37
+
38
+ function evalAxisFlags(axis: Axis, ctx: Ctx): LevelName {
39
+ const impl = axis.stack_implementations?.[ctx.stack];
40
+ if (!impl?.flags || !impl.level_thresholds) return 'L0';
41
+ let implemented = 0;
42
+ let total = 0;
43
+ for (const f of impl.flags) {
44
+ total += f.weight;
45
+ if (evalPredicate(f.predicate, ctx)) implemented += f.weight;
46
+ }
47
+ const ratio = total === 0 ? 0 : implemented / total;
48
+ let achieved: LevelName = 'L0';
49
+ for (const L of LEVELS) {
50
+ const th = impl.level_thresholds[L];
51
+ if (th === undefined) continue;
52
+ if (ratio >= th) achieved = L;
53
+ else break;
54
+ }
55
+ return achieved;
56
+ }
57
+
58
+ export function projectLevel(
59
+ results: AxisResult[],
60
+ rubric: Rubric,
61
+ suppressed: Set<string>,
62
+ ): LevelName {
63
+ const supportingThreshold = rubric.scoring.supporting_threshold ?? 0.8;
64
+ const visible = results.filter(
65
+ (r) => !suppressed.has(r.axis_id) && r.drift_kind !== 'excluded',
66
+ );
67
+ const critical = visible.filter((r) => r.class === 'critical');
68
+ const supporting = visible.filter((r) => r.class === 'supporting');
69
+
70
+ for (const L of [...LEVELS].reverse()) {
71
+ const Lint = levelToInt(L);
72
+ const critOk = critical.every((r) => levelToInt(r.achieved) >= Lint);
73
+ if (!critOk) continue;
74
+ if (supporting.length === 0) return L;
75
+ const supRatio = supporting.filter((r) => levelToInt(r.achieved) >= Lint).length / supporting.length;
76
+ if (supRatio >= supportingThreshold) return L;
77
+ }
78
+ return 'L0';
79
+ }