@vodmal/vdx-cli 0.2.1 → 0.4.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.4.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
@@ -6,6 +6,13 @@ import { autoDetectStack, type Ctx } from './facts.ts';
6
6
  import { audit } from './audit.ts';
7
7
  import { reportMarkdown, reportJson } from './report.ts';
8
8
  import { planInit, writeInit, renderPlanSummary } from './init.ts';
9
+ import {
10
+ planPublish,
11
+ renderPublishPlan,
12
+ executePublish,
13
+ type BumpKind,
14
+ type PublishOptions,
15
+ } from './publish.ts';
9
16
  import { resolveDefaultRubric } from './defaults.ts';
10
17
 
11
18
  const DEFAULT_RUBRIC = resolveDefaultRubric();
@@ -13,8 +20,9 @@ const DEFAULT_RUBRIC = resolveDefaultRubric();
13
20
  function usage(): never {
14
21
  process.stderr.write(
15
22
  `Usage:
16
- vdx audit <project_path> [--rubric <path>] [--stack <stack>] [--json]
17
- vdx init <project_path> [--baseline <ref>] [--dry-run] [--force]
23
+ vdx audit <project_path> [--rubric <path>] [--stack <stack>] [--json]
24
+ vdx init <project_path> [--stack <id>] [--baseline <ref>] [--dry-run] [--force]
25
+ vdx publish <patch|minor|major> [--dry-run] [--force]
18
26
  `,
19
27
  );
20
28
  process.exit(1);
@@ -86,10 +94,19 @@ function cmdInit(opts: ParsedArgs): void {
86
94
 
87
95
  const baselineFlag = opts.flags.baseline;
88
96
  const baseline = typeof baselineFlag === 'string' ? baselineFlag : undefined;
89
- const plan = planInit(projectRoot, baseline ? { baseline } : {});
97
+ const stackFlag = opts.flags.stack;
98
+ const stackOverride = typeof stackFlag === 'string' ? stackFlag : undefined;
99
+ const plan = planInit(projectRoot, {
100
+ ...(baseline ? { baseline } : {}),
101
+ ...(stackOverride ? { stack: stackOverride } : {}),
102
+ });
90
103
 
91
104
  process.stdout.write(renderPlanSummary(plan));
92
105
 
106
+ for (const w of plan.warnings) {
107
+ process.stderr.write(`\n⚠ ${w}\n`);
108
+ }
109
+
93
110
  if (opts.flags['dry-run']) {
94
111
  process.stderr.write('\n[dry-run] mise.toml / AGENTS.md не записаны.\n');
95
112
  return;
@@ -105,7 +122,62 @@ function cmdInit(opts: ParsedArgs): void {
105
122
  }
106
123
  }
107
124
 
125
+ function cmdPublish(opts: ParsedArgs): void {
126
+ const bumpArg = opts.positionals[0];
127
+ if (!bumpArg || !['patch', 'minor', 'major'].includes(bumpArg)) {
128
+ process.stderr.write(
129
+ 'Usage: vdx publish <patch|minor|major> [--dry-run] [--force]\n',
130
+ );
131
+ process.exit(1);
132
+ }
133
+ const bump = bumpArg as BumpKind;
134
+ const projectRoot = process.cwd();
135
+
136
+ const manifest = loadManifest(projectRoot);
137
+ const overrides = loadOverrides(projectRoot);
138
+ const rubric = loadRubric(DEFAULT_RUBRIC);
139
+ const stack = manifest?.stack || autoDetectStack(projectRoot);
140
+ const ctx: Ctx = { projectRoot, stack, cache: new Map() };
141
+ const baselineRef = manifest?.baseline ?? `file://${DEFAULT_RUBRIC}`;
142
+ const auditResult = audit(rubric, ctx, overrides, baselineRef, manifest);
143
+
144
+ const planOpts: PublishOptions = {
145
+ projectRoot,
146
+ bump,
147
+ dryRun: Boolean(opts.flags['dry-run']),
148
+ force: Boolean(opts.flags.force),
149
+ };
150
+
151
+ let plan;
152
+ try {
153
+ plan = planPublish(planOpts, auditResult, ctx);
154
+ } catch (e: any) {
155
+ process.stderr.write(`error: ${e?.message ?? String(e)}\n`);
156
+ process.exit(2);
157
+ }
158
+
159
+ process.stdout.write(renderPublishPlan(plan));
160
+
161
+ if (planOpts.dryRun) {
162
+ process.stderr.write('\n[dry-run] не выполнено.\n');
163
+ return;
164
+ }
165
+
166
+ if (!plan.preflightPassed && !planOpts.force) {
167
+ process.stderr.write('\nerror: pre-flight failed (use --force to bypass)\n');
168
+ process.exit(2);
169
+ }
170
+
171
+ try {
172
+ executePublish(plan);
173
+ } catch (e: any) {
174
+ process.stderr.write(`\nerror: ${e?.message ?? String(e)}\n`);
175
+ process.exit(3);
176
+ }
177
+ }
178
+
108
179
  const parsed = parseArgs(process.argv);
109
180
  if (parsed.cmd === 'audit') cmdAudit(parsed);
110
181
  else if (parsed.cmd === 'init') cmdInit(parsed);
182
+ else if (parsed.cmd === 'publish') cmdPublish(parsed);
111
183
  else usage();
package/src/init.ts CHANGED
@@ -7,20 +7,72 @@ 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
- up: ['docker:up', 'docker-up', 'docker:up:detached', 'start'],
20
+ up: ['docker:up', 'docker-up', 'docker:up:detached', 'start', 'dev', 'serve'],
19
21
  down: ['docker:down', 'docker-down', 'stop'],
20
- build: ['docker:build', 'build:prod', 'build-prod', 'build:dev', 'build-dev', 'build:assets'],
21
- test: ['phpunit', 'test:unit', 'test-unit', 'test:unit:phpunit', 'jest', 'vitest'],
22
- check: ['lint', 'check:config', 'check:before:commit', 'check:code:static', 'check:code:lint'],
23
- fix: ['fix:ecs', 'format', 'lint:fix'],
22
+ build: [
23
+ 'docker:build',
24
+ 'build:prod',
25
+ 'build-prod',
26
+ 'build:dev',
27
+ 'build-dev',
28
+ 'build:assets',
29
+ 'compile',
30
+ 'dist',
31
+ ],
32
+ test: [
33
+ 'phpunit',
34
+ 'pest',
35
+ 'test:unit',
36
+ 'test-unit',
37
+ 'test:unit:phpunit',
38
+ 'tests',
39
+ 'unit',
40
+ 'jest',
41
+ 'vitest',
42
+ 'mocha',
43
+ 'ava',
44
+ 'spec',
45
+ 'coverage',
46
+ ],
47
+ check: [
48
+ 'lint',
49
+ 'check:before:commit',
50
+ 'check:before:push',
51
+ 'check:config',
52
+ 'check:code:static',
53
+ 'check:code:lint',
54
+ 'typecheck',
55
+ 'tsc',
56
+ 'eslint',
57
+ 'prettier:check',
58
+ 'format:check',
59
+ 'qa',
60
+ 'phpstan',
61
+ 'psalm',
62
+ ],
63
+ fix: [
64
+ 'fix:ecs',
65
+ 'fix:rector',
66
+ 'format',
67
+ 'lint:fix',
68
+ 'eslint:fix',
69
+ 'prettier',
70
+ 'prettier:write',
71
+ 'prettier:fix',
72
+ 'format:write',
73
+ 'cs-fix',
74
+ 'cs:fix',
75
+ ],
24
76
  };
25
77
 
26
78
  const VERB_PREFIX_GROUPS: Record<Verb, string[]> = {
@@ -32,15 +84,41 @@ const VERB_PREFIX_GROUPS: Record<Verb, string[]> = {
32
84
  fix: ['fix:', 'fix-'],
33
85
  };
34
86
 
87
+ // Suffix-based monorepo fallback: lets us pick up `server:test`, `api:lint`, etc.
88
+ // when no exact/alias/prefix-group match exists.
89
+ const VERB_SUFFIX_GROUPS: Record<Verb, string[]> = {
90
+ up: [':up', '-up'],
91
+ down: [':down', '-down'],
92
+ build: [':build', '-build'],
93
+ test: [':test', '-test'],
94
+ check: [':check', '-check', ':lint', ':typecheck'],
95
+ fix: [':fix', '-fix', ':format'],
96
+ };
97
+
98
+ export type MatchReason = 'exact' | 'alias' | 'prefix-group' | 'suffix-group' | 'not-found';
99
+
35
100
  export interface VerbMapping {
36
101
  verb: Verb;
37
102
  nativeTask: string | null;
38
103
  runCommand: string | null;
104
+ reason: MatchReason;
105
+ /** Other candidate tasks that also matched but weren't chosen — useful for tuning. */
106
+ alternatives: string[];
107
+ }
108
+
109
+ export interface VerbMatch {
110
+ task: string | null;
111
+ reason: MatchReason;
112
+ alternatives: string[];
39
113
  }
40
114
 
41
115
  export interface InitPlan {
42
116
  projectRoot: string;
43
117
  stack: string;
118
+ /** Was `stack` derived from autoDetectStack (false) or supplied via --stack (true). */
119
+ stackOverridden: boolean;
120
+ /** Non-null only when stack === 'meta' and resolved a single nested manifest. */
121
+ primarySubpackage: string | null;
44
122
  pkgManager: 'composer' | 'npm' | 'pnpm' | 'yarn' | null;
45
123
  tools: Record<string, string>;
46
124
  mappings: VerbMapping[];
@@ -49,6 +127,8 @@ export interface InitPlan {
49
127
  agentsMdPath: string;
50
128
  agentsMdContent: string;
51
129
  baseline: string;
130
+ /** Human-readable advisories for the user — printed to stderr by the CLI. */
131
+ warnings: string[];
52
132
  }
53
133
 
54
134
  function detectNodePackageManager(projectRoot: string): 'pnpm' | 'yarn' | 'npm' {
@@ -67,6 +147,15 @@ function renderRunCommand(task: string, stack: string, projectRoot: string): str
67
147
  return task;
68
148
  }
69
149
 
150
+ function renderRunCommandInSubpackage(
151
+ task: string,
152
+ subStack: string,
153
+ subAbs: string,
154
+ subRel: string,
155
+ ): string {
156
+ return `cd ${subRel} && ${renderRunCommand(task, subStack, subAbs)}`;
157
+ }
158
+
70
159
  function pickPrefixGroup(prefixes: string[], tasks: Set<string>, verb: Verb): string | null {
71
160
  if (prefixes.length === 0) return null;
72
161
  // Prefer entries that look like a canonical "prod/dist/release" first, then any.
@@ -89,12 +178,74 @@ function pickPrefixGroup(prefixes: string[], tasks: Set<string>, verb: Verb): st
89
178
  return candidates[0] ?? null;
90
179
  }
91
180
 
92
- export function mapVerb(verb: Verb, tasks: Set<string>): string | null {
93
- if (tasks.has(verb)) return verb;
94
- for (const alt of VERB_ALIASES[verb]) {
95
- if (tasks.has(alt)) return alt;
181
+ function collectPrefixCandidates(prefixes: string[], tasks: Set<string>, verb: Verb): string[] {
182
+ if (prefixes.length === 0) return [];
183
+ const candidates: string[] = [];
184
+ for (const t of tasks) {
185
+ if (t === verb) continue;
186
+ for (const pfx of prefixes) {
187
+ if (t.startsWith(pfx)) {
188
+ candidates.push(t);
189
+ break;
190
+ }
191
+ }
96
192
  }
97
- return pickPrefixGroup(VERB_PREFIX_GROUPS[verb], tasks, verb);
193
+ return candidates;
194
+ }
195
+
196
+ function collectSuffixCandidates(suffixes: string[], tasks: Set<string>, verb: Verb): string[] {
197
+ if (suffixes.length === 0) return [];
198
+ const candidates: string[] = [];
199
+ for (const t of tasks) {
200
+ if (t === verb) continue;
201
+ for (const sfx of suffixes) {
202
+ if (t.endsWith(sfx)) {
203
+ candidates.push(t);
204
+ break;
205
+ }
206
+ }
207
+ }
208
+ return candidates;
209
+ }
210
+
211
+ export function selectVerbTask(verb: Verb, tasks: Set<string>): VerbMatch {
212
+ // 1) exact match wins; siblings (aliases / prefix-group) reported as alternatives.
213
+ if (tasks.has(verb)) {
214
+ const alts = new Set<string>();
215
+ for (const a of VERB_ALIASES[verb]) if (tasks.has(a)) alts.add(a);
216
+ for (const c of collectPrefixCandidates(VERB_PREFIX_GROUPS[verb], tasks, verb)) alts.add(c);
217
+ return { task: verb, reason: 'exact', alternatives: [...alts].sort() };
218
+ }
219
+
220
+ // 2) alias hit (curated common alternates per stack).
221
+ const aliasHits = VERB_ALIASES[verb].filter((a) => tasks.has(a));
222
+ if (aliasHits.length > 0) {
223
+ const [chosen, ...rest] = aliasHits;
224
+ return { task: chosen!, reason: 'alias', alternatives: rest.slice().sort() };
225
+ }
226
+
227
+ // 3) prefix-group with canonical hint (prod/release/all preferred, then shortest).
228
+ const prefixCandidates = collectPrefixCandidates(VERB_PREFIX_GROUPS[verb], tasks, verb);
229
+ if (prefixCandidates.length > 0) {
230
+ const chosen = pickPrefixGroup(VERB_PREFIX_GROUPS[verb], tasks, verb)!;
231
+ const alts = prefixCandidates.filter((c) => c !== chosen).sort();
232
+ return { task: chosen, reason: 'prefix-group', alternatives: alts };
233
+ }
234
+
235
+ // 4) suffix-group (monorepo fallback: `server:test` -> verb 'test').
236
+ const suffixCandidates = collectSuffixCandidates(VERB_SUFFIX_GROUPS[verb], tasks, verb);
237
+ if (suffixCandidates.length > 0) {
238
+ suffixCandidates.sort((a, b) => a.length - b.length);
239
+ const [chosen, ...rest] = suffixCandidates;
240
+ return { task: chosen!, reason: 'suffix-group', alternatives: rest.slice().sort() };
241
+ }
242
+
243
+ return { task: null, reason: 'not-found', alternatives: [] };
244
+ }
245
+
246
+ /** Kept for back-compat with potential external callers; prefer `selectVerbTask`. */
247
+ export function mapVerb(verb: Verb, tasks: Set<string>): string | null {
248
+ return selectVerbTask(verb, tasks).task;
98
249
  }
99
250
 
100
251
  function collectTools(ctx: Ctx): Record<string, string> {
@@ -135,12 +286,23 @@ export function renderMiseToml(
135
286
  mappings: VerbMapping[],
136
287
  stack: string,
137
288
  baseline: string,
289
+ primarySubpackage: string | null = null,
138
290
  ): string {
139
291
  const out: string[] = [];
140
292
  out.push('# Generated by vdx init. The [vdx] block is metadata for vdx audit.');
141
293
  out.push('# Lifecycle verbs follow the vdx contract: up/down/build/test/check/fix.');
142
294
  out.push('');
143
295
 
296
+ if (stack === 'unknown' || stack === 'monorepo') {
297
+ out.push(
298
+ '# TODO(vdx): stack auto-detection вернул "' +
299
+ stack +
300
+ '". Замени на php/node/go/python/meta',
301
+ );
302
+ out.push('# и заполни [tasks.*] блоки руками, либо перегенери: vdx init . --stack <id>.');
303
+ out.push('');
304
+ }
305
+
144
306
  if (Object.keys(tools).length > 0) {
145
307
  out.push('[tools]');
146
308
  for (const [k, v] of Object.entries(tools)) {
@@ -151,6 +313,10 @@ export function renderMiseToml(
151
313
 
152
314
  for (const m of mappings) {
153
315
  if (!m.runCommand) continue;
316
+ if (m.reason !== 'exact' || m.alternatives.length > 0) {
317
+ const altPart = m.alternatives.length > 0 ? `; alt: ${m.alternatives.join(', ')}` : '';
318
+ out.push(`# vdx: matched "${m.nativeTask}" via ${m.reason}${altPart}`);
319
+ }
154
320
  out.push(`[tasks.${m.verb}]`);
155
321
  out.push(`description = ${quoteToml(VERB_DESCRIPTIONS[m.verb])}`);
156
322
  out.push(`run = ${quoteToml(m.runCommand)}`);
@@ -161,6 +327,9 @@ export function renderMiseToml(
161
327
  out.push('schema_version = "0.2"');
162
328
  out.push(`baseline = ${quoteToml(baseline)}`);
163
329
  out.push(`stack = ${quoteToml(stack)}`);
330
+ if (primarySubpackage) {
331
+ out.push(`primary_subpackage = ${quoteToml(primarySubpackage)}`);
332
+ }
164
333
  const verbsList = mappings
165
334
  .filter((m) => m.runCommand)
166
335
  .map((m) => quoteToml(m.verb))
@@ -217,36 +386,99 @@ export function buildAgentsMd(existing: string | null, section: string): string
217
386
  return existing + sep + section + '\n';
218
387
  }
219
388
 
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);
389
+ export function planInit(
390
+ projectRoot: string,
391
+ opts: { baseline?: string; stack?: string } = {},
392
+ ): InitPlan {
393
+ const detectedStack = autoDetectStack(projectRoot);
394
+ const stackOverridden = opts.stack !== undefined && opts.stack !== detectedStack;
395
+ const stack = opts.stack ?? detectedStack;
396
+ const warnings: string[] = [];
397
+
398
+ // For meta-stack — try to resolve a single nested subpackage and scan it for tasks.
399
+ let primarySubpackage: string | null = null;
400
+ let scanRoot = projectRoot;
401
+ let scanStack = stack;
402
+
403
+ if (stack === 'meta') {
404
+ const subs: SubPackage[] = findSubPackages(projectRoot);
405
+ if (subs.length === 1) {
406
+ primarySubpackage = subs[0]!.relPath;
407
+ scanRoot = path.join(projectRoot, primarySubpackage);
408
+ scanStack = subs[0]!.stack;
409
+ } else if (subs.length === 0) {
410
+ warnings.push(
411
+ 'stack="meta", но nested manifest не найден. mise.toml будет иметь пустой verbs[]; ' +
412
+ 'заполни [tasks.*] руками.',
413
+ );
414
+ } else {
415
+ const list = subs.map((s) => `${s.relPath}(${s.stack})`).join(', ');
416
+ warnings.push(
417
+ `stack="meta", найдено несколько subpackages: ${list}. ` +
418
+ 'primary_subpackage не выбран; укажи его руками в [vdx] блоке.',
419
+ );
420
+ }
421
+ } else if (!stackOverridden && (detectedStack === 'unknown' || detectedStack === 'monorepo')) {
422
+ if (detectedStack === 'unknown') {
423
+ warnings.push(
424
+ 'autoDetectStack вернул "unknown" — в корне нет manifest и нет subpackages. ' +
425
+ 'Передай --stack <id> (php|node|go|python|meta) либо отредактируй mise.toml вручную.',
426
+ );
427
+ } else {
428
+ const subs = findSubPackages(projectRoot);
429
+ const list = subs.map((s) => `${s.relPath}(${s.stack})`).join(', ');
430
+ warnings.push(
431
+ `autoDetectStack вернул "monorepo": ${list}. ` +
432
+ 'Передай --stack <id> чтобы выбрать конкретный стек, либо --stack meta + укажи primary_subpackage.',
433
+ );
434
+ }
435
+ }
436
+
437
+ const scanCtx: Ctx = { projectRoot: scanRoot, stack: scanStack, cache: new Map() };
438
+ const tasks = listAllTasks(scanCtx);
224
439
 
225
440
  const mappings: VerbMapping[] = STANDARD_VERBS.map((verb) => {
226
- const native = mapVerb(verb, tasks);
441
+ const sel = selectVerbTask(verb, tasks);
442
+ let runCommand: string | null = null;
443
+ if (sel.task) {
444
+ runCommand =
445
+ primarySubpackage !== null
446
+ ? renderRunCommandInSubpackage(sel.task, scanStack, scanRoot, primarySubpackage)
447
+ : renderRunCommand(sel.task, stack, projectRoot);
448
+ }
227
449
  return {
228
450
  verb,
229
- nativeTask: native,
230
- runCommand: native ? renderRunCommand(native, stack, projectRoot) : null,
451
+ nativeTask: sel.task,
452
+ runCommand,
453
+ reason: sel.reason,
454
+ alternatives: sel.alternatives,
231
455
  };
232
456
  });
233
457
 
234
- const tools = collectTools(ctx);
458
+ // tools читаем из root независимо: для PHP/Node корневой manifest часто
459
+ // содержит engines/require php; для meta-проектов вынос tool-версий в
460
+ // root mise.toml — общая практика.
461
+ const rootCtx: Ctx = { projectRoot, stack, cache: new Map() };
462
+ const tools = collectTools(rootCtx);
235
463
  const baseline = opts.baseline ?? DEFAULT_BASELINE;
236
- const miseTomlContent = renderMiseToml(tools, mappings, stack, baseline);
464
+ const miseTomlContent = renderMiseToml(tools, mappings, stack, baseline, primarySubpackage);
237
465
 
238
466
  const agentsMdPath = path.join(projectRoot, 'AGENTS.md');
239
- const existingAgents = fileExists(ctx, 'AGENTS.md') ? readText(ctx, 'AGENTS.md') : null;
467
+ const existingAgents = fileExists(rootCtx, 'AGENTS.md') ? readText(rootCtx, 'AGENTS.md') : null;
240
468
  const section = renderAgentsCommandsSection(mappings, baseline);
241
469
  const agentsMdContent = buildAgentsMd(existingAgents, section);
242
470
 
243
471
  let pkgManager: InitPlan['pkgManager'] = null;
244
- if (stack === 'php') pkgManager = 'composer';
245
- else if (stack === 'node') pkgManager = detectNodePackageManager(projectRoot);
472
+ const pmStack = primarySubpackage ? scanStack : stack;
473
+ const pmRoot = primarySubpackage ? scanRoot : projectRoot;
474
+ if (pmStack === 'php') pkgManager = 'composer';
475
+ else if (pmStack === 'node') pkgManager = detectNodePackageManager(pmRoot);
246
476
 
247
477
  return {
248
478
  projectRoot,
249
479
  stack,
480
+ stackOverridden,
481
+ primarySubpackage,
250
482
  pkgManager,
251
483
  tools,
252
484
  mappings,
@@ -255,6 +487,7 @@ export function planInit(projectRoot: string, opts: { baseline?: string } = {}):
255
487
  agentsMdPath,
256
488
  agentsMdContent,
257
489
  baseline,
490
+ warnings,
258
491
  };
259
492
  }
260
493
 
@@ -272,10 +505,23 @@ export function renderPlanSummary(plan: InitPlan): string {
272
505
  const lines: string[] = [];
273
506
  lines.push(`# vdx init plan`);
274
507
  lines.push('');
275
- lines.push(`- **Project**: ${plan.projectRoot}`);
276
- lines.push(`- **Stack**: ${plan.stack}`);
508
+ lines.push(
509
+ `- **Project**: ${plan.projectRoot}`,
510
+ );
511
+ lines.push(
512
+ `- **Stack**: ${plan.stack}${plan.stackOverridden ? ' (via --stack)' : ''}`,
513
+ );
514
+ if (plan.primarySubpackage) {
515
+ lines.push(`- **Primary subpackage**: ${plan.primarySubpackage}`);
516
+ }
277
517
  if (plan.pkgManager) lines.push(`- **Package manager**: ${plan.pkgManager}`);
278
518
  lines.push(`- **Baseline**: ${plan.baseline}`);
519
+ if (plan.warnings.length > 0) {
520
+ lines.push('');
521
+ lines.push('## ⚠ Warnings');
522
+ lines.push('');
523
+ for (const w of plan.warnings) lines.push(`- ${w}`);
524
+ }
279
525
  if (Object.keys(plan.tools).length > 0) {
280
526
  const toolStr = Object.entries(plan.tools)
281
527
  .map(([k, v]) => `${k}=${v}`)
@@ -285,19 +531,33 @@ export function renderPlanSummary(plan: InitPlan): string {
285
531
  lines.push('');
286
532
  lines.push('## Verb mappings');
287
533
  lines.push('');
288
- lines.push('| Verb | Native task | Generated `run` |');
289
- lines.push('|------|-------------|-----------------|');
534
+ lines.push('| Verb | Native task | Reason | Generated `run` |');
535
+ lines.push('|------|-------------|--------|-----------------|');
290
536
  for (const m of plan.mappings) {
291
537
  if (m.runCommand) {
292
- lines.push(`| \`${m.verb}\` | \`${m.nativeTask}\` | \`${m.runCommand}\` |`);
538
+ lines.push(
539
+ `| \`${m.verb}\` | \`${m.nativeTask}\` | ${m.reason} | \`${m.runCommand}\` |`,
540
+ );
293
541
  } else {
294
- lines.push(`| \`${m.verb}\` | _(not found)_ | _(skipped)_ |`);
542
+ lines.push(`| \`${m.verb}\` | _(not found)_ | not-found | _(skipped)_ |`);
295
543
  }
296
544
  }
297
545
  lines.push('');
546
+ const withAlts = plan.mappings.filter((m) => m.alternatives.length > 0);
547
+ if (withAlts.length > 0) {
548
+ lines.push('### Alternatives considered');
549
+ lines.push('');
550
+ for (const m of withAlts) {
551
+ const alts = m.alternatives.map((a) => `\`${a}\``).join(', ');
552
+ lines.push(
553
+ `- \`${m.verb}\` chose \`${m.nativeTask}\` (${m.reason}); also matched: ${alts}`,
554
+ );
555
+ }
556
+ lines.push('');
557
+ }
298
558
  const unmatched = plan.mappings.filter((m) => !m.runCommand).map((m) => m.verb);
299
559
  if (unmatched.length > 0) {
300
- lines.push(`> Not mapped: ${unmatched.join(', ')}. Add тиски в \`mise.toml\` вручную.`);
560
+ lines.push(`> Not mapped: ${unmatched.join(', ')}. Добавь тиски в \`mise.toml\` вручную.`);
301
561
  lines.push('');
302
562
  }
303
563
  lines.push('## Generated mise.toml');
package/src/mcp-server.ts CHANGED
@@ -156,7 +156,7 @@ function recordSuccessPath(input: {
156
156
 
157
157
  doc.vdx = doc.vdx ?? {
158
158
  schema_version: '0.2',
159
- baseline: 'github.com/VoDmAl/vdx-rubric-vodmal@v0.2.1',
159
+ baseline: 'github.com/VoDmAl/vdx-rubric-vodmal@v0.3.1',
160
160
  stack: autoDetectStack(PROJECT_ROOT),
161
161
  };
162
162
  doc.vdx.verbs = input.verbs.map((v) => v.name);
package/src/publish.ts ADDED
@@ -0,0 +1,364 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { execFileSync } from 'node:child_process';
4
+ import type { AuditResult } from './audit.ts';
5
+ import { stackForDir, type Ctx } from './facts.ts';
6
+
7
+ export type BumpKind = 'patch' | 'minor' | 'major';
8
+
9
+ export interface PublishOptions {
10
+ projectRoot: string;
11
+ bump: BumpKind;
12
+ dryRun: boolean;
13
+ force: boolean;
14
+ }
15
+
16
+ export interface PreflightCheck {
17
+ name: string;
18
+ ok: boolean;
19
+ message: string;
20
+ }
21
+
22
+ export interface PublishPlan {
23
+ projectRoot: string;
24
+ stack: string;
25
+ packageName: string;
26
+ currentVersion: string;
27
+ newVersion: string;
28
+ bump: BumpKind;
29
+ packageJsonPath: string;
30
+ preflight: PreflightCheck[];
31
+ preflightPassed: boolean;
32
+ delegatedToMise: boolean;
33
+ }
34
+
35
+ export function bumpSemver(current: string, kind: BumpKind): string {
36
+ const m = current.match(/^(\d+)\.(\d+)\.(\d+)/);
37
+ if (!m) throw new Error(`Cannot parse semver "${current}"`);
38
+ let major = parseInt(m[1]!, 10);
39
+ let minor = parseInt(m[2]!, 10);
40
+ let patch = parseInt(m[3]!, 10);
41
+ if (kind === 'major') {
42
+ major += 1;
43
+ minor = 0;
44
+ patch = 0;
45
+ } else if (kind === 'minor') {
46
+ minor += 1;
47
+ patch = 0;
48
+ } else {
49
+ patch += 1;
50
+ }
51
+ return `${major}.${minor}.${patch}`;
52
+ }
53
+
54
+ export function compareSemver(a: string, b: string): number {
55
+ const norm = (s: string) =>
56
+ s.replace(/^v/, '').split(/[.\-+]/).map((p) => parseInt(p, 10) || 0);
57
+ const pa = norm(a);
58
+ const pb = norm(b);
59
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
60
+ const av = pa[i] ?? 0;
61
+ const bv = pb[i] ?? 0;
62
+ if (av !== bv) return av < bv ? -1 : 1;
63
+ }
64
+ return 0;
65
+ }
66
+
67
+ function isGitWorkingTreeClean(projectRoot: string): { ok: boolean; detail: string } {
68
+ try {
69
+ const out = execFileSync('git', ['status', '--porcelain'], {
70
+ cwd: projectRoot,
71
+ encoding: 'utf8',
72
+ });
73
+ if (out.trim() === '') return { ok: true, detail: 'OK' };
74
+ const lines = out.trim().split('\n');
75
+ const sample = lines.slice(0, 3).join(', ');
76
+ return { ok: false, detail: `${lines.length} uncommitted file(s) — ${sample}` };
77
+ } catch {
78
+ return { ok: false, detail: 'not a git repo (or git not available)' };
79
+ }
80
+ }
81
+
82
+ function fetchPublishedVersion(packageName: string): string | null {
83
+ try {
84
+ const out = execFileSync('npm', ['view', packageName, 'version'], {
85
+ encoding: 'utf8',
86
+ stdio: ['ignore', 'pipe', 'pipe'],
87
+ });
88
+ return out.trim() || null;
89
+ } catch (e: any) {
90
+ const stderr = String(e?.stderr ?? e?.message ?? '');
91
+ if (stderr.includes('E404') || stderr.includes('404 Not Found')) return null;
92
+ throw new Error(`npm view failed: ${stderr.split('\n')[0] ?? stderr}`);
93
+ }
94
+ }
95
+
96
+ function checkMiseOverride(projectRoot: string): boolean {
97
+ const miseToml = path.join(projectRoot, 'mise.toml');
98
+ if (!fs.existsSync(miseToml)) return false;
99
+ try {
100
+ const text = fs.readFileSync(miseToml, 'utf8');
101
+ // Lightweight check — a [tasks.publish] section header is unambiguous
102
+ // enough for the delegation gate; no need for full TOML parsing here.
103
+ return /^\[tasks\.publish\b/m.test(text);
104
+ } catch {
105
+ return false;
106
+ }
107
+ }
108
+
109
+ function resolvePackageJsonPath(projectRoot: string, audit: AuditResult): string {
110
+ const subpkg = audit.primary_subpackage;
111
+ const root = subpkg ? path.join(projectRoot, subpkg) : projectRoot;
112
+ return path.join(root, 'package.json');
113
+ }
114
+
115
+ export function planPublish(
116
+ opts: PublishOptions,
117
+ audit: AuditResult,
118
+ ctx: Ctx,
119
+ ): PublishPlan {
120
+ if (checkMiseOverride(opts.projectRoot)) {
121
+ return {
122
+ projectRoot: opts.projectRoot,
123
+ stack: ctx.stack,
124
+ packageName: '(delegated)',
125
+ currentVersion: '',
126
+ newVersion: '',
127
+ bump: opts.bump,
128
+ packageJsonPath: '',
129
+ preflight: [
130
+ {
131
+ name: 'mise-override',
132
+ ok: true,
133
+ message: '[tasks.publish] present in mise.toml — delegating to mise',
134
+ },
135
+ ],
136
+ preflightPassed: true,
137
+ delegatedToMise: true,
138
+ };
139
+ }
140
+
141
+ // Resolve effective stack: prefer subpackage stack if primary_subpackage is set
142
+ // (meta-projects publishing a nested lib, e.g. vdx itself with primary_subpackage=cli).
143
+ const subpkg = audit.primary_subpackage;
144
+ const effectiveStack = subpkg
145
+ ? stackForDir(path.join(opts.projectRoot, subpkg))
146
+ : ctx.stack;
147
+
148
+ if (effectiveStack !== 'node') {
149
+ throw new Error(
150
+ `vdx publish: only "node" stack is supported in the MVP (Шаг X). ` +
151
+ `Effective stack: "${effectiveStack}" (root: "${ctx.stack}"${
152
+ subpkg ? `, subpackage: "${subpkg}"` : ''
153
+ }). PHP/Python come in Шаг X.2.`,
154
+ );
155
+ }
156
+
157
+ const pkgJsonPath = resolvePackageJsonPath(opts.projectRoot, audit);
158
+ if (!fs.existsSync(pkgJsonPath)) {
159
+ throw new Error(`vdx publish: package.json not found at ${pkgJsonPath}`);
160
+ }
161
+
162
+ const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
163
+ const packageName: string = pkg.name ?? '(unnamed)';
164
+ const currentVersion: string = pkg.version ?? '0.0.0';
165
+ const newVersion = bumpSemver(currentVersion, opts.bump);
166
+
167
+ const preflight: PreflightCheck[] = [];
168
+
169
+ const tree = isGitWorkingTreeClean(opts.projectRoot);
170
+ preflight.push({
171
+ name: 'working-tree-clean',
172
+ ok: tree.ok,
173
+ message: tree.detail,
174
+ });
175
+
176
+ const raAxis = audit.per_axis.find((a) => a.axis_id === 'release-artifact');
177
+ const isExcluded = raAxis?.drift_kind === 'excluded';
178
+ preflight.push({
179
+ name: 'lib-intent (applies_when)',
180
+ ok: !!raAxis && !isExcluded,
181
+ message: !raAxis
182
+ ? 'release-artifact axis missing from rubric'
183
+ : isExcluded
184
+ ? 'release-artifact axis excluded — this looks like an app, not a library. ' +
185
+ 'Use [tasks.publish] in mise.toml if you really need vdx publish here.'
186
+ : 'OK (axis applies → lib-intent)',
187
+ });
188
+
189
+ const raLevel = raAxis?.achieved ?? 'L0';
190
+ const raLevelInt = parseInt(raLevel.slice(1), 10) || 0;
191
+ preflight.push({
192
+ name: 'release-artifact >= L3',
193
+ ok: raLevelInt >= 3,
194
+ message:
195
+ raLevelInt >= 3
196
+ ? `OK (release-artifact ${raLevel})`
197
+ : `release-artifact at ${raLevel}, need >= L3. Add files[], main/exports, license in package.json.`,
198
+ });
199
+
200
+ let published: string | null = null;
201
+ let registryError: string | null = null;
202
+ try {
203
+ published = fetchPublishedVersion(packageName);
204
+ } catch (e: any) {
205
+ registryError = e?.message ?? String(e);
206
+ }
207
+ if (registryError) {
208
+ preflight.push({
209
+ name: 'registry-collision',
210
+ ok: false,
211
+ message: `could not query npm registry: ${registryError}`,
212
+ });
213
+ } else if (published === null) {
214
+ preflight.push({
215
+ name: 'registry-collision',
216
+ ok: true,
217
+ message: `first publish (no prior version on registry)`,
218
+ });
219
+ } else {
220
+ const cmp = compareSemver(newVersion, published);
221
+ preflight.push({
222
+ name: 'registry-collision',
223
+ ok: cmp > 0,
224
+ message:
225
+ cmp > 0
226
+ ? `OK (registry @ ${published}, new ${newVersion})`
227
+ : `registry @ ${published} ≥ new ${newVersion}; pick a higher bump`,
228
+ });
229
+ }
230
+
231
+ const preflightPassed = preflight.every((c) => c.ok);
232
+
233
+ return {
234
+ projectRoot: opts.projectRoot,
235
+ stack: ctx.stack,
236
+ packageName,
237
+ currentVersion,
238
+ newVersion,
239
+ bump: opts.bump,
240
+ packageJsonPath: pkgJsonPath,
241
+ preflight,
242
+ preflightPassed,
243
+ delegatedToMise: false,
244
+ };
245
+ }
246
+
247
+ /**
248
+ * Execute the publish pipeline. Order is transactional:
249
+ * 1. bump package.json in-place (reversible by file restore).
250
+ * 2. `npm publish` (irreversible — first because npm errors are easier
251
+ * to recover from than a published-but-uncommitted state).
252
+ * 3. git add + commit + tag (reversible by reset/tag delete, but only
253
+ * a problem if step 2 succeeded — uncommitted bump after success
254
+ * is recoverable manually).
255
+ * 4. NOT pushed — that's the user's call.
256
+ *
257
+ * On npm publish failure → restore original package.json contents.
258
+ * Git failures after a successful npm publish leave the user in a
259
+ * partially-released state; they fix it manually (commit/tag what's
260
+ * needed) — we don't try to unpublish.
261
+ */
262
+ export function executePublish(plan: PublishPlan): void {
263
+ if (plan.delegatedToMise) {
264
+ process.stderr.write('→ delegating to `mise run publish`...\n');
265
+ execFileSync('mise', ['run', 'publish'], {
266
+ cwd: plan.projectRoot,
267
+ stdio: 'inherit',
268
+ });
269
+ return;
270
+ }
271
+
272
+ // 1. Bump package.json in-place (keep original text for revert)
273
+ const originalText = fs.readFileSync(plan.packageJsonPath, 'utf8');
274
+ const pkg = JSON.parse(originalText);
275
+ pkg.version = plan.newVersion;
276
+ fs.writeFileSync(
277
+ plan.packageJsonPath,
278
+ JSON.stringify(pkg, null, 2) + '\n',
279
+ 'utf8',
280
+ );
281
+ process.stderr.write(
282
+ `✓ bumped ${path.relative(plan.projectRoot, plan.packageJsonPath)}: ${plan.currentVersion} → ${plan.newVersion}\n`,
283
+ );
284
+
285
+ // 2. npm publish (interactive — OTP prompt may appear)
286
+ process.stderr.write(`→ running \`npm publish\` (OTP prompt may appear)...\n`);
287
+ const pkgDir = path.dirname(plan.packageJsonPath);
288
+ try {
289
+ execFileSync('npm', ['publish'], { cwd: pkgDir, stdio: 'inherit' });
290
+ } catch (e: any) {
291
+ fs.writeFileSync(plan.packageJsonPath, originalText, 'utf8');
292
+ process.stderr.write(
293
+ `✗ npm publish failed (exit ${e?.status ?? '?'}); reverted package.json\n`,
294
+ );
295
+ throw new Error('npm publish failed');
296
+ }
297
+ process.stderr.write(`✓ published ${plan.packageName}@${plan.newVersion}\n`);
298
+
299
+ // 3. git add + commit + tag (at project root)
300
+ execFileSync('git', ['add', plan.packageJsonPath], {
301
+ cwd: plan.projectRoot,
302
+ stdio: 'inherit',
303
+ });
304
+ execFileSync('git', ['commit', '-m', `release: v${plan.newVersion}`], {
305
+ cwd: plan.projectRoot,
306
+ stdio: 'inherit',
307
+ });
308
+ execFileSync(
309
+ 'git',
310
+ ['tag', '-a', `v${plan.newVersion}`, '-m', `v${plan.newVersion}`],
311
+ { cwd: plan.projectRoot, stdio: 'inherit' },
312
+ );
313
+ process.stderr.write(`✓ committed + tagged v${plan.newVersion}\n`);
314
+ process.stderr.write(
315
+ `\n! NOT pushed — your call: \`git push --follow-tags\`\n`,
316
+ );
317
+ }
318
+
319
+ export function renderPublishPlan(plan: PublishPlan): string {
320
+ const lines: string[] = [];
321
+ lines.push('# vdx publish plan');
322
+ lines.push('');
323
+ if (plan.delegatedToMise) {
324
+ lines.push('- **Mode**: delegated to `[tasks.publish]` from mise.toml');
325
+ lines.push('');
326
+ lines.push('## Pre-flight checks');
327
+ lines.push('');
328
+ lines.push('| Check | Status | Message |');
329
+ lines.push('|-------|:------:|---------|');
330
+ for (const c of plan.preflight) {
331
+ lines.push(`| ${c.name} | ${c.ok ? 'OK' : 'FAIL'} | ${c.message} |`);
332
+ }
333
+ lines.push('');
334
+ lines.push('> vdx will exec `mise run publish` — no vdx-native pipeline involved.');
335
+ return lines.join('\n') + '\n';
336
+ }
337
+
338
+ lines.push(`- **Package**: ${plan.packageName}`);
339
+ lines.push(`- **Stack**: ${plan.stack}`);
340
+ lines.push(`- **Bump**: ${plan.bump}`);
341
+ lines.push(`- **Version**: ${plan.currentVersion} → ${plan.newVersion}`);
342
+ lines.push(`- **package.json**: ${plan.packageJsonPath}`);
343
+ lines.push('');
344
+ lines.push('## Pre-flight checks');
345
+ lines.push('');
346
+ lines.push('| Check | Status | Message |');
347
+ lines.push('|-------|:------:|---------|');
348
+ for (const c of plan.preflight) {
349
+ lines.push(`| ${c.name} | ${c.ok ? 'OK' : 'FAIL'} | ${c.message} |`);
350
+ }
351
+ lines.push('');
352
+ if (plan.preflightPassed) {
353
+ lines.push('## Pipeline (would execute on `vdx publish`)');
354
+ lines.push('');
355
+ lines.push(`1. Set \`version\` in package.json → \`${plan.newVersion}\``);
356
+ lines.push(`2. \`npm publish\` (cwd = directory containing package.json)`);
357
+ lines.push(`3. \`git add package.json && git commit -m "release: v${plan.newVersion}"\``);
358
+ lines.push(`4. \`git tag -a v${plan.newVersion} -m "v${plan.newVersion}"\``);
359
+ lines.push(`5. (NOT pushed — your call: \`git push --follow-tags\`)`);
360
+ } else {
361
+ lines.push('> Pre-flight failed. Use `--force` to bypass at your own risk.');
362
+ }
363
+ return lines.join('\n') + '\n';
364
+ }
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 {