@vodmal/vdx-cli 0.2.1 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vodmal/vdx-cli",
3
- "version": "0.2.1",
3
+ "version": "0.3.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",
@@ -39,7 +39,9 @@
39
39
  "audit": "tsx src/index.ts audit",
40
40
  "smoke": "bash smoke.sh",
41
41
  "typecheck": "tsc --noEmit",
42
- "test": "tsc --noEmit"
42
+ "test": "vitest run",
43
+ "test:unit": "vitest run tests/unit",
44
+ "coverage": "vitest run --coverage"
43
45
  },
44
46
  "dependencies": {
45
47
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -51,7 +53,9 @@
51
53
  "devDependencies": {
52
54
  "@types/js-yaml": "^4.0.9",
53
55
  "@types/node": "^22.0.0",
54
- "typescript": "^5.6.0"
56
+ "@vitest/coverage-v8": "^4.1.7",
57
+ "typescript": "^5.6.0",
58
+ "vitest": "^4.1.7"
55
59
  },
56
60
  "publishConfig": {
57
61
  "access": "public"
@@ -11,7 +11,7 @@ schema_version: "0.2"
11
11
 
12
12
  metadata:
13
13
  name: vdx-rubric-vodmal
14
- version: "0.2.2"
14
+ version: "0.3.1"
15
15
  description: "Personal maturity rubric for multi-stack projects (vodmal)"
16
16
  owner: vodmal
17
17
  homepage: "https://github.com/vodmal/vdx-rubric"
@@ -534,3 +534,84 @@ axes:
534
534
  file_contains:
535
535
  paths: [misc/traefik-global/README.md, misc/traefik-global/start.sh]
536
536
  pattern: "@v\\d+\\.\\d+\\.\\d+" # semver pin
537
+
538
+ - id: release-artifact
539
+ class: supporting
540
+ description: "Publish-readiness: metadata completeness + entry points + registry signals (O34, v0.3.0). Применяется только к проектам, заявляющим publish-intent (O35, v0.3.1) — иначе excluded."
541
+ applies_to: [node, php, ruby, python]
542
+ # O35 (v0.3.1): ось применяется только если проект декларирует намерение
543
+ # публиковать артефакт. Чистые app'ы (private:true в package.json или
544
+ # composer type:project) → excluded.
545
+ applies_when:
546
+ any_of:
547
+ # Node-сторона: НЕ private И есть хоть один entry-point/publish-signal.
548
+ - all_of:
549
+ - has_file: package.json
550
+ - not:
551
+ config_value: { path: package.json, jsonpath: "private", equals: true }
552
+ - any_of:
553
+ - config_value: { path: package.json, jsonpath: "publishConfig", op: present }
554
+ - config_value: { path: package.json, jsonpath: "bin", op: present }
555
+ - config_value: { path: package.json, jsonpath: "main", op: present }
556
+ - config_value: { path: package.json, jsonpath: "exports", op: present }
557
+ - config_value: { path: package.json, jsonpath: "module", op: present }
558
+ # PHP-сторона: composer.json есть, name задан, type ≠ project (project = app).
559
+ - all_of:
560
+ - has_file: composer.json
561
+ - config_value: { path: composer.json, jsonpath: "name", op: present }
562
+ - not:
563
+ config_value: { path: composer.json, jsonpath: "type", op: equals, equals: "project" }
564
+ storage: level
565
+ default_target: L4
566
+ fact_sources:
567
+ - package.json
568
+ - composer.json
569
+ - LICENSE
570
+ - LICENSE.md
571
+ - LICENSE.txt
572
+ levels:
573
+ L1:
574
+ requires:
575
+ any_of:
576
+ - all_of:
577
+ - config_value: { path: package.json, jsonpath: "name", op: present }
578
+ - config_value: { path: package.json, jsonpath: "version", op: present }
579
+ - config_value: { path: package.json, jsonpath: "license", op: present }
580
+ - all_of:
581
+ - config_value: { path: composer.json, jsonpath: "name", op: present }
582
+ - config_value: { path: composer.json, jsonpath: "license", op: present }
583
+ L2:
584
+ requires:
585
+ any_of:
586
+ - all_of:
587
+ - config_value: { path: package.json, jsonpath: "description", op: present }
588
+ - config_value: { path: package.json, jsonpath: "repository", op: present }
589
+ - any_of:
590
+ - has_file: LICENSE
591
+ - has_file: LICENSE.md
592
+ - has_file: LICENSE.txt
593
+ - all_of:
594
+ - config_value: { path: composer.json, jsonpath: "description", op: present }
595
+ - any_of:
596
+ - has_file: LICENSE
597
+ - has_file: LICENSE.md
598
+ L3:
599
+ requires:
600
+ any_of:
601
+ - all_of:
602
+ - config_value: { path: package.json, jsonpath: "files", op: present }
603
+ - any_of:
604
+ - config_value: { path: package.json, jsonpath: "bin", op: present }
605
+ - config_value: { path: package.json, jsonpath: "main", op: present }
606
+ - config_value: { path: package.json, jsonpath: "exports", op: present }
607
+ - all_of:
608
+ - config_value: { path: composer.json, jsonpath: "autoload", op: present }
609
+ - config_value: { path: composer.json, jsonpath: "type", op: present }
610
+ L4:
611
+ requires:
612
+ any_of:
613
+ - all_of:
614
+ - config_value: { path: package.json, jsonpath: "publishConfig", op: present }
615
+ - config_value: { path: package.json, jsonpath: "homepage", op: present }
616
+ - config_value: { path: package.json, jsonpath: "bugs", op: present }
617
+ - config_value: { path: composer.json, jsonpath: "extra.publish", op: present }
package/src/audit.ts CHANGED
@@ -2,6 +2,7 @@ import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import type { Rubric, LevelName } from './rubric.ts';
4
4
  import { findSubPackages, stackForDir, type Ctx } from './facts.ts';
5
+ import { evalPredicate } from './evaluator.ts';
5
6
  import type { Override, VdxManifest } from './manifest.ts';
6
7
  import { evalAxis, projectLevel, type AxisResult } from './scoring.ts';
7
8
 
@@ -115,6 +116,16 @@ export function audit(
115
116
  });
116
117
  continue;
117
118
  }
119
+ if (axis.applies_when && !evalPredicate(axis.applies_when, evalCtx)) {
120
+ perAxis.push({
121
+ axis_id: axis.id,
122
+ class: axis.class,
123
+ achieved: 'L0',
124
+ target: axis.default_target,
125
+ drift_kind: 'excluded',
126
+ });
127
+ continue;
128
+ }
118
129
  const achieved = evalAxis(axis, evalCtx);
119
130
  const ov = overrideByAxis.get(axis.id);
120
131
  const target = (ov?.target as LevelName) ?? axis.default_target;
package/src/index.ts CHANGED
@@ -14,7 +14,7 @@ function usage(): never {
14
14
  process.stderr.write(
15
15
  `Usage:
16
16
  vdx audit <project_path> [--rubric <path>] [--stack <stack>] [--json]
17
- vdx init <project_path> [--baseline <ref>] [--dry-run] [--force]
17
+ vdx init <project_path> [--stack <id>] [--baseline <ref>] [--dry-run] [--force]
18
18
  `,
19
19
  );
20
20
  process.exit(1);
@@ -86,10 +86,19 @@ function cmdInit(opts: ParsedArgs): void {
86
86
 
87
87
  const baselineFlag = opts.flags.baseline;
88
88
  const baseline = typeof baselineFlag === 'string' ? baselineFlag : undefined;
89
- const plan = planInit(projectRoot, baseline ? { baseline } : {});
89
+ const stackFlag = opts.flags.stack;
90
+ const stackOverride = typeof stackFlag === 'string' ? stackFlag : undefined;
91
+ const plan = planInit(projectRoot, {
92
+ ...(baseline ? { baseline } : {}),
93
+ ...(stackOverride ? { stack: stackOverride } : {}),
94
+ });
90
95
 
91
96
  process.stdout.write(renderPlanSummary(plan));
92
97
 
98
+ for (const w of plan.warnings) {
99
+ process.stderr.write(`\n⚠ ${w}\n`);
100
+ }
101
+
93
102
  if (opts.flags['dry-run']) {
94
103
  process.stderr.write('\n[dry-run] mise.toml / AGENTS.md не записаны.\n');
95
104
  return;
package/src/init.ts CHANGED
@@ -7,12 +7,14 @@ import {
7
7
  readText,
8
8
  listAllTasks,
9
9
  autoDetectStack,
10
+ findSubPackages,
11
+ type SubPackage,
10
12
  } from './facts.ts';
11
13
 
12
14
  export const STANDARD_VERBS = ['up', 'down', 'build', 'test', 'check', 'fix'] as const;
13
15
  export type Verb = (typeof STANDARD_VERBS)[number];
14
16
 
15
- export const DEFAULT_BASELINE = 'github.com/VoDmAl/vdx-rubric-vodmal@v0.2.2';
17
+ export const DEFAULT_BASELINE = 'github.com/VoDmAl/vdx-rubric-vodmal@v0.3.1';
16
18
 
17
19
  const VERB_ALIASES: Record<Verb, string[]> = {
18
20
  up: ['docker:up', 'docker-up', 'docker:up:detached', 'start'],
@@ -41,6 +43,10 @@ export interface VerbMapping {
41
43
  export interface InitPlan {
42
44
  projectRoot: string;
43
45
  stack: string;
46
+ /** Was `stack` derived from autoDetectStack (false) or supplied via --stack (true). */
47
+ stackOverridden: boolean;
48
+ /** Non-null only when stack === 'meta' and resolved a single nested manifest. */
49
+ primarySubpackage: string | null;
44
50
  pkgManager: 'composer' | 'npm' | 'pnpm' | 'yarn' | null;
45
51
  tools: Record<string, string>;
46
52
  mappings: VerbMapping[];
@@ -49,6 +55,8 @@ export interface InitPlan {
49
55
  agentsMdPath: string;
50
56
  agentsMdContent: string;
51
57
  baseline: string;
58
+ /** Human-readable advisories for the user — printed to stderr by the CLI. */
59
+ warnings: string[];
52
60
  }
53
61
 
54
62
  function detectNodePackageManager(projectRoot: string): 'pnpm' | 'yarn' | 'npm' {
@@ -67,6 +75,15 @@ function renderRunCommand(task: string, stack: string, projectRoot: string): str
67
75
  return task;
68
76
  }
69
77
 
78
+ function renderRunCommandInSubpackage(
79
+ task: string,
80
+ subStack: string,
81
+ subAbs: string,
82
+ subRel: string,
83
+ ): string {
84
+ return `cd ${subRel} && ${renderRunCommand(task, subStack, subAbs)}`;
85
+ }
86
+
70
87
  function pickPrefixGroup(prefixes: string[], tasks: Set<string>, verb: Verb): string | null {
71
88
  if (prefixes.length === 0) return null;
72
89
  // Prefer entries that look like a canonical "prod/dist/release" first, then any.
@@ -135,12 +152,23 @@ export function renderMiseToml(
135
152
  mappings: VerbMapping[],
136
153
  stack: string,
137
154
  baseline: string,
155
+ primarySubpackage: string | null = null,
138
156
  ): string {
139
157
  const out: string[] = [];
140
158
  out.push('# Generated by vdx init. The [vdx] block is metadata for vdx audit.');
141
159
  out.push('# Lifecycle verbs follow the vdx contract: up/down/build/test/check/fix.');
142
160
  out.push('');
143
161
 
162
+ if (stack === 'unknown' || stack === 'monorepo') {
163
+ out.push(
164
+ '# TODO(vdx): stack auto-detection вернул "' +
165
+ stack +
166
+ '". Замени на php/node/go/python/meta',
167
+ );
168
+ out.push('# и заполни [tasks.*] блоки руками, либо перегенери: vdx init . --stack <id>.');
169
+ out.push('');
170
+ }
171
+
144
172
  if (Object.keys(tools).length > 0) {
145
173
  out.push('[tools]');
146
174
  for (const [k, v] of Object.entries(tools)) {
@@ -161,6 +189,9 @@ export function renderMiseToml(
161
189
  out.push('schema_version = "0.2"');
162
190
  out.push(`baseline = ${quoteToml(baseline)}`);
163
191
  out.push(`stack = ${quoteToml(stack)}`);
192
+ if (primarySubpackage) {
193
+ out.push(`primary_subpackage = ${quoteToml(primarySubpackage)}`);
194
+ }
164
195
  const verbsList = mappings
165
196
  .filter((m) => m.runCommand)
166
197
  .map((m) => quoteToml(m.verb))
@@ -217,36 +248,93 @@ export function buildAgentsMd(existing: string | null, section: string): string
217
248
  return existing + sep + section + '\n';
218
249
  }
219
250
 
220
- export function planInit(projectRoot: string, opts: { baseline?: string } = {}): InitPlan {
221
- const stack = autoDetectStack(projectRoot);
222
- const ctx: Ctx = { projectRoot, stack, cache: new Map() };
223
- const tasks = listAllTasks(ctx);
251
+ export function planInit(
252
+ projectRoot: string,
253
+ opts: { baseline?: string; stack?: string } = {},
254
+ ): InitPlan {
255
+ const detectedStack = autoDetectStack(projectRoot);
256
+ const stackOverridden = opts.stack !== undefined && opts.stack !== detectedStack;
257
+ const stack = opts.stack ?? detectedStack;
258
+ const warnings: string[] = [];
259
+
260
+ // For meta-stack — try to resolve a single nested subpackage and scan it for tasks.
261
+ let primarySubpackage: string | null = null;
262
+ let scanRoot = projectRoot;
263
+ let scanStack = stack;
264
+
265
+ if (stack === 'meta') {
266
+ const subs: SubPackage[] = findSubPackages(projectRoot);
267
+ if (subs.length === 1) {
268
+ primarySubpackage = subs[0]!.relPath;
269
+ scanRoot = path.join(projectRoot, primarySubpackage);
270
+ scanStack = subs[0]!.stack;
271
+ } else if (subs.length === 0) {
272
+ warnings.push(
273
+ 'stack="meta", но nested manifest не найден. mise.toml будет иметь пустой verbs[]; ' +
274
+ 'заполни [tasks.*] руками.',
275
+ );
276
+ } else {
277
+ const list = subs.map((s) => `${s.relPath}(${s.stack})`).join(', ');
278
+ warnings.push(
279
+ `stack="meta", найдено несколько subpackages: ${list}. ` +
280
+ 'primary_subpackage не выбран; укажи его руками в [vdx] блоке.',
281
+ );
282
+ }
283
+ } else if (!stackOverridden && (detectedStack === 'unknown' || detectedStack === 'monorepo')) {
284
+ if (detectedStack === 'unknown') {
285
+ warnings.push(
286
+ 'autoDetectStack вернул "unknown" — в корне нет manifest и нет subpackages. ' +
287
+ 'Передай --stack <id> (php|node|go|python|meta) либо отредактируй mise.toml вручную.',
288
+ );
289
+ } else {
290
+ const subs = findSubPackages(projectRoot);
291
+ const list = subs.map((s) => `${s.relPath}(${s.stack})`).join(', ');
292
+ warnings.push(
293
+ `autoDetectStack вернул "monorepo": ${list}. ` +
294
+ 'Передай --stack <id> чтобы выбрать конкретный стек, либо --stack meta + укажи primary_subpackage.',
295
+ );
296
+ }
297
+ }
298
+
299
+ const scanCtx: Ctx = { projectRoot: scanRoot, stack: scanStack, cache: new Map() };
300
+ const tasks = listAllTasks(scanCtx);
224
301
 
225
302
  const mappings: VerbMapping[] = STANDARD_VERBS.map((verb) => {
226
303
  const native = mapVerb(verb, tasks);
227
- return {
228
- verb,
229
- nativeTask: native,
230
- runCommand: native ? renderRunCommand(native, stack, projectRoot) : null,
231
- };
304
+ let runCommand: string | null = null;
305
+ if (native) {
306
+ runCommand =
307
+ primarySubpackage !== null
308
+ ? renderRunCommandInSubpackage(native, scanStack, scanRoot, primarySubpackage)
309
+ : renderRunCommand(native, stack, projectRoot);
310
+ }
311
+ return { verb, nativeTask: native, runCommand };
232
312
  });
233
313
 
234
- const tools = collectTools(ctx);
314
+ // tools читаем из root независимо: для PHP/Node корневой manifest часто
315
+ // содержит engines/require php; для meta-проектов вынос tool-версий в
316
+ // root mise.toml — общая практика.
317
+ const rootCtx: Ctx = { projectRoot, stack, cache: new Map() };
318
+ const tools = collectTools(rootCtx);
235
319
  const baseline = opts.baseline ?? DEFAULT_BASELINE;
236
- const miseTomlContent = renderMiseToml(tools, mappings, stack, baseline);
320
+ const miseTomlContent = renderMiseToml(tools, mappings, stack, baseline, primarySubpackage);
237
321
 
238
322
  const agentsMdPath = path.join(projectRoot, 'AGENTS.md');
239
- const existingAgents = fileExists(ctx, 'AGENTS.md') ? readText(ctx, 'AGENTS.md') : null;
323
+ const existingAgents = fileExists(rootCtx, 'AGENTS.md') ? readText(rootCtx, 'AGENTS.md') : null;
240
324
  const section = renderAgentsCommandsSection(mappings, baseline);
241
325
  const agentsMdContent = buildAgentsMd(existingAgents, section);
242
326
 
243
327
  let pkgManager: InitPlan['pkgManager'] = null;
244
- if (stack === 'php') pkgManager = 'composer';
245
- else if (stack === 'node') pkgManager = detectNodePackageManager(projectRoot);
328
+ const pmStack = primarySubpackage ? scanStack : stack;
329
+ const pmRoot = primarySubpackage ? scanRoot : projectRoot;
330
+ if (pmStack === 'php') pkgManager = 'composer';
331
+ else if (pmStack === 'node') pkgManager = detectNodePackageManager(pmRoot);
246
332
 
247
333
  return {
248
334
  projectRoot,
249
335
  stack,
336
+ stackOverridden,
337
+ primarySubpackage,
250
338
  pkgManager,
251
339
  tools,
252
340
  mappings,
@@ -255,6 +343,7 @@ export function planInit(projectRoot: string, opts: { baseline?: string } = {}):
255
343
  agentsMdPath,
256
344
  agentsMdContent,
257
345
  baseline,
346
+ warnings,
258
347
  };
259
348
  }
260
349
 
@@ -272,10 +361,23 @@ export function renderPlanSummary(plan: InitPlan): string {
272
361
  const lines: string[] = [];
273
362
  lines.push(`# vdx init plan`);
274
363
  lines.push('');
275
- lines.push(`- **Project**: ${plan.projectRoot}`);
276
- lines.push(`- **Stack**: ${plan.stack}`);
364
+ lines.push(
365
+ `- **Project**: ${plan.projectRoot}`,
366
+ );
367
+ lines.push(
368
+ `- **Stack**: ${plan.stack}${plan.stackOverridden ? ' (via --stack)' : ''}`,
369
+ );
370
+ if (plan.primarySubpackage) {
371
+ lines.push(`- **Primary subpackage**: ${plan.primarySubpackage}`);
372
+ }
277
373
  if (plan.pkgManager) lines.push(`- **Package manager**: ${plan.pkgManager}`);
278
374
  lines.push(`- **Baseline**: ${plan.baseline}`);
375
+ if (plan.warnings.length > 0) {
376
+ lines.push('');
377
+ lines.push('## ⚠ Warnings');
378
+ lines.push('');
379
+ for (const w of plan.warnings) lines.push(`- ${w}`);
380
+ }
279
381
  if (Object.keys(plan.tools).length > 0) {
280
382
  const toolStr = Object.entries(plan.tools)
281
383
  .map(([k, v]) => `${k}=${v}`)
package/src/rubric.ts CHANGED
@@ -30,6 +30,7 @@ export interface Axis {
30
30
  levels?: Partial<Record<LevelName, { requires: Predicate }>>;
31
31
  stack_implementations?: Record<string, StackImpl>;
32
32
  applies_to?: string[];
33
+ applies_when?: Predicate;
33
34
  }
34
35
 
35
36
  export interface Rubric {