@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/narrow.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a parsed file (JSON, YAML, TOML) or a caught error holds is `unknown` until checked.
|
|
3
|
+
* These helpers check it on the way in, so the code past them works with real types.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type Dict = Record<string, unknown>;
|
|
7
|
+
|
|
8
|
+
/** A plain object — not null, not an array. */
|
|
9
|
+
export function isDict(v: unknown): v is Dict {
|
|
10
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** `v` when it is a plain object, else undefined. */
|
|
14
|
+
export function dict(v: unknown): Dict | undefined {
|
|
15
|
+
return isDict(v) ? v : undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The value under `keys`, one level per key — an object's property or an array's index ("0");
|
|
20
|
+
* undefined as soon as a level is not an object.
|
|
21
|
+
*/
|
|
22
|
+
export function at(v: unknown, ...keys: string[]): unknown {
|
|
23
|
+
let cur = v;
|
|
24
|
+
for (const k of keys) {
|
|
25
|
+
if (typeof cur !== 'object' || cur === null) return undefined;
|
|
26
|
+
cur = (cur as Dict)[k];
|
|
27
|
+
}
|
|
28
|
+
return cur;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** `v` when it is a string, else undefined. */
|
|
32
|
+
export function str(v: unknown): string | undefined {
|
|
33
|
+
return typeof v === 'string' ? v : undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** `v` when it is an array, else an empty one. */
|
|
37
|
+
export function list(v: unknown): unknown[] {
|
|
38
|
+
return Array.isArray(v) ? (v as unknown[]) : [];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function isStringList(v: unknown): v is string[] {
|
|
42
|
+
return Array.isArray(v) && v.every((x) => typeof x === 'string');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The message of a caught value, whatever was thrown. */
|
|
46
|
+
export function errorMessage(e: unknown): string {
|
|
47
|
+
return e instanceof Error ? e.message : String(e);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** What a failed child process said: its stderr, else the error's message. */
|
|
51
|
+
export function stderrOf(e: unknown): string {
|
|
52
|
+
const err = at(e, 'stderr');
|
|
53
|
+
if (typeof err === 'string' && err) return err;
|
|
54
|
+
if (Buffer.isBuffer(err) && err.length > 0) return err.toString();
|
|
55
|
+
return errorMessage(e);
|
|
56
|
+
}
|
package/src/personal-hooks.ts
CHANGED
|
@@ -92,17 +92,25 @@ function stateOf(h: PersonalHook, root: string | null, deps: PersonalHookDeps):
|
|
|
92
92
|
let drift: string | null = null;
|
|
93
93
|
if (have.length === 0 && command === null) drift = 'not in ~/.gitconfig';
|
|
94
94
|
else if (command !== h.command) drift = `its command in ~/.gitconfig differs from the profile`;
|
|
95
|
-
else if (events(h).some((e) => !have.includes(e)))
|
|
95
|
+
else if (events(h).some((e) => !have.includes(e)))
|
|
96
|
+
drift = `not bound to ${events(h)
|
|
97
|
+
.filter((e) => !have.includes(e))
|
|
98
|
+
.join(', ')}`;
|
|
96
99
|
|
|
97
100
|
let local: string | null = null;
|
|
98
101
|
if (root !== null) {
|
|
99
|
-
if (deps.git(['-C', root, 'config', '--local', '--get', `${key}.enabled`]) === 'false')
|
|
100
|
-
|
|
102
|
+
if (deps.git(['-C', root, 'config', '--local', '--get', `${key}.enabled`]) === 'false')
|
|
103
|
+
local = 'off in this repository';
|
|
104
|
+
else if (deps.git(['-C', root, 'config', '--local', '--get', `${key}.command`]) !== null)
|
|
105
|
+
local = 'own command in this repository';
|
|
101
106
|
}
|
|
102
107
|
return { hook: h, drift, local };
|
|
103
108
|
}
|
|
104
109
|
|
|
105
|
-
export function checkPersonalHooks(
|
|
110
|
+
export function checkPersonalHooks(
|
|
111
|
+
projectRoot: string | null,
|
|
112
|
+
deps: PersonalHookDeps = defaultPersonalHookDeps(),
|
|
113
|
+
): CheckResult | null {
|
|
106
114
|
const hooks = applicableHooks(deps);
|
|
107
115
|
if (!hooks) return null;
|
|
108
116
|
const id = 'personal-hooks';
|
|
@@ -132,7 +140,10 @@ export function checkPersonalHooks(projectRoot: string | null, deps: PersonalHoo
|
|
|
132
140
|
};
|
|
133
141
|
}
|
|
134
142
|
|
|
135
|
-
const missing = missingMachinePaths(
|
|
143
|
+
const missing = missingMachinePaths(
|
|
144
|
+
hooks.map((h) => h.command),
|
|
145
|
+
deps.home,
|
|
146
|
+
);
|
|
136
147
|
if (missing.length > 0) {
|
|
137
148
|
return {
|
|
138
149
|
id,
|
package/src/predicates.ts
CHANGED
|
@@ -15,8 +15,9 @@ import {
|
|
|
15
15
|
import { checkingJobs, ciSystems, ciTaskCalls, mainPushSteps, readWorkflows, shippingJobs, upstream } from './gha.ts';
|
|
16
16
|
import { detectHookFrameworks } from './hooks.ts';
|
|
17
17
|
import { covers, expandTasks, isVocabularyTask, projectTaskCalls } from './vocabulary.ts';
|
|
18
|
+
import { at, isStringList, str } from './narrow.ts';
|
|
18
19
|
|
|
19
|
-
type Args = Record<string,
|
|
20
|
+
export type Args = Record<string, unknown>;
|
|
20
21
|
|
|
21
22
|
/** A fact vdx could not read here — a CI system or a hosting it does not inspect. */
|
|
22
23
|
export interface Unknown {
|
|
@@ -42,7 +43,7 @@ const DEFAULT_TEST_PATTERN =
|
|
|
42
43
|
|
|
43
44
|
function testPattern(args: Args): RegExp {
|
|
44
45
|
try {
|
|
45
|
-
return new RegExp(
|
|
46
|
+
return new RegExp(str(args.pattern) ?? DEFAULT_TEST_PATTERN);
|
|
46
47
|
} catch {
|
|
47
48
|
return new RegExp(DEFAULT_TEST_PATTERN);
|
|
48
49
|
}
|
|
@@ -102,7 +103,7 @@ export const REGISTRY: Record<string, PredicateFn> = {
|
|
|
102
103
|
has_file: (args, ctx) => fileExists(ctx, String(args.path)),
|
|
103
104
|
|
|
104
105
|
file_contains: (args, ctx) => {
|
|
105
|
-
const paths
|
|
106
|
+
const paths = isStringList(args.paths) ? args.paths : [String(args.path)];
|
|
106
107
|
const expanded = expandPaths(paths, ctx);
|
|
107
108
|
let re: RegExp;
|
|
108
109
|
try {
|
|
@@ -119,7 +120,7 @@ export const REGISTRY: Record<string, PredicateFn> = {
|
|
|
119
120
|
|
|
120
121
|
package_present: (args, ctx) => {
|
|
121
122
|
if (typeof args === 'string') return isPackagePresent(args, 'any', ctx);
|
|
122
|
-
return isPackagePresent(String(args.name),
|
|
123
|
+
return isPackagePresent(String(args.name), str(args.ecosystem) ?? 'any', ctx);
|
|
123
124
|
},
|
|
124
125
|
|
|
125
126
|
tsc_flag: (args, ctx) => {
|
|
@@ -132,9 +133,7 @@ export const REGISTRY: Record<string, PredicateFn> = {
|
|
|
132
133
|
|
|
133
134
|
phpstan_level_at_least: (args, ctx) => {
|
|
134
135
|
const text =
|
|
135
|
-
readText(ctx, 'phpstan.neon') ||
|
|
136
|
-
readText(ctx, 'phpstan.dist.neon') ||
|
|
137
|
-
readText(ctx, 'phpstan.neon.dist');
|
|
136
|
+
readText(ctx, 'phpstan.neon') || readText(ctx, 'phpstan.dist.neon') || readText(ctx, 'phpstan.neon.dist');
|
|
138
137
|
if (!text) return false;
|
|
139
138
|
const m = text.match(/^\s*level:\s*(\d+|max)/m);
|
|
140
139
|
if (!m || !m[1]) return false;
|
|
@@ -145,7 +144,7 @@ export const REGISTRY: Record<string, PredicateFn> = {
|
|
|
145
144
|
gh_workflow_blocks_pr: (args, ctx) => {
|
|
146
145
|
const dir = path.join(ctx.projectRoot, '.github', 'workflows');
|
|
147
146
|
if (!fs.existsSync(dir)) return false;
|
|
148
|
-
const wanted =
|
|
147
|
+
const wanted = str(args.check_name) || null;
|
|
149
148
|
try {
|
|
150
149
|
for (const f of fs.readdirSync(dir)) {
|
|
151
150
|
if (!/\.ya?ml$/.test(f)) continue;
|
|
@@ -168,21 +167,19 @@ export const REGISTRY: Record<string, PredicateFn> = {
|
|
|
168
167
|
const text = readText(ctx, f);
|
|
169
168
|
if (text && new RegExp(`^${hook}:`, 'm').test(text)) return true;
|
|
170
169
|
}
|
|
171
|
-
|
|
172
|
-
if (composer?.extra?.hooks?.[hook]) return true;
|
|
173
|
-
return false;
|
|
170
|
+
return Boolean(at(readJson(ctx, 'composer.json'), 'extra', 'hooks', hook));
|
|
174
171
|
},
|
|
175
172
|
|
|
176
173
|
config_value: (args, ctx) => {
|
|
177
|
-
const filePath =
|
|
178
|
-
const jsonpath =
|
|
174
|
+
const filePath = str(args.path) ?? '';
|
|
175
|
+
const jsonpath = str(args.jsonpath) ?? '';
|
|
179
176
|
if (!filePath || !jsonpath) return false;
|
|
180
177
|
const data = readStructured(ctx, filePath);
|
|
181
178
|
if (data === null || data === undefined) return false;
|
|
182
179
|
const value = resolveConfigPath(data, jsonpath);
|
|
183
180
|
|
|
184
181
|
// Operator resolution: explicit `op`, otherwise infer from arg shape.
|
|
185
|
-
let op =
|
|
182
|
+
let op = str(args.op) || null;
|
|
186
183
|
if (!op) {
|
|
187
184
|
if (args.equals !== undefined) op = 'equals';
|
|
188
185
|
else if (args.gte !== undefined) op = 'gte';
|
|
@@ -236,8 +233,7 @@ export const REGISTRY: Record<string, PredicateFn> = {
|
|
|
236
233
|
}),
|
|
237
234
|
|
|
238
235
|
/** L4 (library): the testing job runs across a matrix. */
|
|
239
|
-
gha_test_matrix: (args, ctx) =>
|
|
240
|
-
ciVerdict(ctx, () => [...checkingJobs(ctx, testPattern(args))].some((j) => j.matrix)),
|
|
236
|
+
gha_test_matrix: (args, ctx) => ciVerdict(ctx, () => [...checkingJobs(ctx, testPattern(args))].some((j) => j.matrix)),
|
|
241
237
|
|
|
242
238
|
/** L4 (service): whatever CI ships on a push to main waits, via `needs:`, for a checking job. */
|
|
243
239
|
gha_ship_needs_checks: (args, ctx) =>
|
package/src/publish.ts
CHANGED
|
@@ -3,6 +3,7 @@ import * as path from 'node:path';
|
|
|
3
3
|
import { execFileSync } from 'node:child_process';
|
|
4
4
|
import type { AuditResult } from './audit.ts';
|
|
5
5
|
import { stackForDir, type Ctx } from './facts.ts';
|
|
6
|
+
import { at, dict, errorMessage, stderrOf, str } from './narrow.ts';
|
|
6
7
|
|
|
7
8
|
export type BumpKind = 'patch' | 'minor' | 'major';
|
|
8
9
|
|
|
@@ -72,7 +73,10 @@ export function bumpSemver(current: string, kind: BumpKind): string {
|
|
|
72
73
|
|
|
73
74
|
export function compareSemver(a: string, b: string): number {
|
|
74
75
|
const norm = (s: string) =>
|
|
75
|
-
s
|
|
76
|
+
s
|
|
77
|
+
.replace(/^v/, '')
|
|
78
|
+
.split(/[.\-+]/)
|
|
79
|
+
.map((p) => parseInt(p, 10) || 0);
|
|
76
80
|
const pa = norm(a);
|
|
77
81
|
const pb = norm(b);
|
|
78
82
|
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
@@ -112,7 +116,7 @@ const runGit: GitRunner = (args, cwd) =>
|
|
|
112
116
|
* since the last fetch; nothing is fetched.
|
|
113
117
|
*/
|
|
114
118
|
export function planPush(projectRoot: string, enabled: boolean, git: GitRunner = runGit): PushPlan {
|
|
115
|
-
let upstream: string | null
|
|
119
|
+
let upstream: string | null;
|
|
116
120
|
try {
|
|
117
121
|
upstream = git(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], projectRoot).trim() || null;
|
|
118
122
|
} catch {
|
|
@@ -141,10 +145,10 @@ function fetchPublishedVersion(packageName: string, npm: NpmRunner): string | nu
|
|
|
141
145
|
try {
|
|
142
146
|
const out = npm(['view', packageName, 'version']);
|
|
143
147
|
return out.trim() || null;
|
|
144
|
-
} catch (e
|
|
145
|
-
const stderr =
|
|
148
|
+
} catch (e) {
|
|
149
|
+
const stderr = stderrOf(e);
|
|
146
150
|
if (stderr.includes('E404') || stderr.includes('404 Not Found')) return null;
|
|
147
|
-
throw new Error(`npm view failed: ${stderr.split('\n')[0] ?? stderr}
|
|
151
|
+
throw new Error(`npm view failed: ${stderr.split('\n')[0] ?? stderr}`, { cause: e });
|
|
148
152
|
}
|
|
149
153
|
}
|
|
150
154
|
|
|
@@ -152,12 +156,10 @@ function fetchPublishedVersion(packageName: string, npm: NpmRunner): string | nu
|
|
|
152
156
|
* The registry `npm publish` sends this package to, when it is not npm's
|
|
153
157
|
* default: `publishConfig.registry`, else the scope's registry from npm config.
|
|
154
158
|
*/
|
|
155
|
-
export function publishRegistry(
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
if (pkg.publishConfig?.registry) return pkg.publishConfig.registry;
|
|
160
|
-
const scope = pkg.name?.match(/^(@[^/]+)\//)?.[1];
|
|
159
|
+
export function publishRegistry(pkg: unknown, npm: NpmRunner): string | null {
|
|
160
|
+
const registry = str(at(pkg, 'publishConfig', 'registry'));
|
|
161
|
+
if (registry) return registry;
|
|
162
|
+
const scope = str(at(pkg, 'name'))?.match(/^(@[^/]+)\//)?.[1];
|
|
161
163
|
if (!scope) return null;
|
|
162
164
|
try {
|
|
163
165
|
const value = npm(['config', 'get', `${scope}:registry`]).trim();
|
|
@@ -181,8 +183,8 @@ export function checkNpmAuth(registry: string | null, npm: NpmRunner): Preflight
|
|
|
181
183
|
const who = npm(registry ? ['whoami', '--registry', registry] : ['whoami']).trim();
|
|
182
184
|
if (who) return { name: 'npm-auth', ok: true, message: `OK (logged in to ${where} as ${who})` };
|
|
183
185
|
return { name: 'npm-auth', ok: false, message: `npm whoami answered nothing for ${where}. Run: ${login}`, fix };
|
|
184
|
-
} catch (e
|
|
185
|
-
const stderr =
|
|
186
|
+
} catch (e) {
|
|
187
|
+
const stderr = stderrOf(e);
|
|
186
188
|
if (/E401|ENEEDAUTH/.test(stderr)) {
|
|
187
189
|
return {
|
|
188
190
|
name: 'npm-auth',
|
|
@@ -249,9 +251,7 @@ export function planPublish(
|
|
|
249
251
|
// Resolve effective stack: prefer subpackage stack if primary_subpackage is set
|
|
250
252
|
// (meta-projects publishing a nested lib, e.g. vdx itself with primary_subpackage=cli).
|
|
251
253
|
const subpkg = audit.primary_subpackage;
|
|
252
|
-
const effectiveStack = subpkg
|
|
253
|
-
? stackForDir(path.join(opts.projectRoot, subpkg))
|
|
254
|
-
: ctx.stack;
|
|
254
|
+
const effectiveStack = subpkg ? stackForDir(path.join(opts.projectRoot, subpkg)) : ctx.stack;
|
|
255
255
|
|
|
256
256
|
if (effectiveStack !== 'node') {
|
|
257
257
|
throw new Error(
|
|
@@ -267,9 +267,9 @@ export function planPublish(
|
|
|
267
267
|
throw new Error(`vdx publish: package.json not found at ${pkgJsonPath}`);
|
|
268
268
|
}
|
|
269
269
|
|
|
270
|
-
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
|
|
271
|
-
const packageName
|
|
272
|
-
const currentVersion
|
|
270
|
+
const pkg: unknown = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
|
|
271
|
+
const packageName = str(at(pkg, 'name')) ?? '(unnamed)';
|
|
272
|
+
const currentVersion = str(at(pkg, 'version')) ?? '0.0.0';
|
|
273
273
|
const newVersion = bumpSemver(currentVersion, opts.bump);
|
|
274
274
|
|
|
275
275
|
const preflight: PreflightCheck[] = [];
|
|
@@ -311,8 +311,8 @@ export function planPublish(
|
|
|
311
311
|
let registryError: string | null = null;
|
|
312
312
|
try {
|
|
313
313
|
published = fetchPublishedVersion(packageName, npm);
|
|
314
|
-
} catch (e
|
|
315
|
-
registryError =
|
|
314
|
+
} catch (e) {
|
|
315
|
+
registryError = errorMessage(e);
|
|
316
316
|
}
|
|
317
317
|
if (registryError) {
|
|
318
318
|
preflight.push({
|
|
@@ -415,9 +415,10 @@ export function defaultPublishDeps(): PublishDeps {
|
|
|
415
415
|
|
|
416
416
|
/** Set `version` in package-lock.json — the root and its own entry under `packages`. */
|
|
417
417
|
export function bumpLockText(text: string, version: string): string {
|
|
418
|
-
const lock = JSON.parse(text);
|
|
419
|
-
lock
|
|
420
|
-
|
|
418
|
+
const lock = dict(JSON.parse(text)) ?? {};
|
|
419
|
+
lock['version'] = version;
|
|
420
|
+
const self = dict(at(lock, 'packages', ''));
|
|
421
|
+
if (self) self['version'] = version;
|
|
421
422
|
return JSON.stringify(lock, null, 2) + '\n';
|
|
422
423
|
}
|
|
423
424
|
|
|
@@ -501,8 +502,8 @@ export function executePublish(plan: PublishPlan, deps: PublishDeps = defaultPub
|
|
|
501
502
|
fs.writeFileSync(file, next(text), 'utf8');
|
|
502
503
|
};
|
|
503
504
|
rewrite(plan.packageJsonPath, (text) => {
|
|
504
|
-
const pkg = JSON.parse(text);
|
|
505
|
-
pkg
|
|
505
|
+
const pkg = dict(JSON.parse(text)) ?? {};
|
|
506
|
+
pkg['version'] = plan.newVersion;
|
|
506
507
|
return JSON.stringify(pkg, null, 2) + '\n';
|
|
507
508
|
});
|
|
508
509
|
if (plan.lockPath) rewrite(plan.lockPath, (text) => bumpLockText(text, plan.newVersion));
|
|
@@ -520,11 +521,13 @@ export function executePublish(plan: PublishPlan, deps: PublishDeps = defaultPub
|
|
|
520
521
|
for (const s of signals) process.on(s, holdOut);
|
|
521
522
|
try {
|
|
522
523
|
deps.run('npm', ['publish'], path.dirname(plan.packageJsonPath));
|
|
523
|
-
} catch (e
|
|
524
|
+
} catch (e) {
|
|
524
525
|
for (const [file, text] of originals) fs.writeFileSync(file, text, 'utf8');
|
|
525
|
-
const
|
|
526
|
+
const signal = str(at(e, 'signal'));
|
|
527
|
+
const status = at(e, 'status');
|
|
528
|
+
const what = signal ? `interrupted (${signal})` : `failed (exit ${typeof status === 'number' ? status : '?'})`;
|
|
526
529
|
deps.log(`✗ npm publish ${what}; reverted ${bumped.map((f) => path.basename(f)).join(' and ')}`);
|
|
527
|
-
throw new Error(`npm publish ${
|
|
530
|
+
throw new Error(`npm publish ${signal ? 'interrupted' : 'failed'}`, { cause: e });
|
|
528
531
|
} finally {
|
|
529
532
|
for (const s of signals) process.off(s, holdOut);
|
|
530
533
|
}
|
|
@@ -543,7 +546,9 @@ export function executePublish(plan: PublishPlan, deps: PublishDeps = defaultPub
|
|
|
543
546
|
try {
|
|
544
547
|
deps.run('git', ['push', '--follow-tags'], plan.projectRoot);
|
|
545
548
|
} catch {
|
|
546
|
-
deps.log(
|
|
549
|
+
deps.log(
|
|
550
|
+
`✗ git push failed — ${plan.packageName}@${plan.newVersion} is published and tagged here; push it: git push --follow-tags`,
|
|
551
|
+
);
|
|
547
552
|
throw new Error('git push failed');
|
|
548
553
|
}
|
|
549
554
|
deps.log(`✓ pushed to ${push.upstream} with tag v${plan.newVersion}`);
|
|
@@ -599,7 +604,9 @@ export function renderPublishPlan(plan: PublishPlan): string {
|
|
|
599
604
|
lines.push('## Pipeline (would execute on `vdx publish`)');
|
|
600
605
|
lines.push('');
|
|
601
606
|
if (fixable) {
|
|
602
|
-
const fixes = plan.preflight
|
|
607
|
+
const fixes = plan.preflight
|
|
608
|
+
.filter((c) => !c.ok && c.fix)
|
|
609
|
+
.map((c) => `\`${c.fix!.cmd} ${c.fix!.args.join(' ')}\``);
|
|
603
610
|
lines.push(`0. ${fixes.join(', ')} — at a terminal, then the pre-flight again`);
|
|
604
611
|
}
|
|
605
612
|
lines.push(`1. Set \`version\` in ${files} → \`${plan.newVersion}\``);
|
package/src/report.ts
CHANGED
|
@@ -116,9 +116,7 @@ export function reportDoctorMarkdown(r: DoctorReport): string {
|
|
|
116
116
|
const lines: string[] = [];
|
|
117
117
|
lines.push('# vdx doctor report');
|
|
118
118
|
lines.push('');
|
|
119
|
-
lines.push(
|
|
120
|
-
`**OK**: ${r.ok} **Warning**: ${r.warning} **Missing**: ${r.missing}`,
|
|
121
|
-
);
|
|
119
|
+
lines.push(`**OK**: ${r.ok} **Warning**: ${r.warning} **Missing**: ${r.missing}`);
|
|
122
120
|
lines.push('');
|
|
123
121
|
lines.push('| Check | Status | Detail | Remedy |');
|
|
124
122
|
lines.push('|-------|:------:|--------|--------|');
|
package/src/rubric.ts
CHANGED
|
@@ -124,8 +124,8 @@ export function parseRubric(text: string, source: string): Rubric {
|
|
|
124
124
|
}
|
|
125
125
|
// нормализуем default_target (если кто-то напишет "L4" со spaces)
|
|
126
126
|
for (const ax of data.axes) {
|
|
127
|
-
ax.default_target =
|
|
128
|
-
ax.storage =
|
|
127
|
+
ax.default_target = ax.default_target ?? 'L4';
|
|
128
|
+
ax.storage = ax.storage ?? 'level';
|
|
129
129
|
}
|
|
130
130
|
if (compareSchema(schema, '0.3')! >= 0) {
|
|
131
131
|
const bad = invalidPredicates(data);
|
package/src/run.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as fs from 'node:fs';
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
import * as TOML from 'smol-toml';
|
|
4
|
+
import { errorMessage } from './narrow.ts';
|
|
4
5
|
|
|
5
6
|
export const LIFECYCLE_VERBS = ['up', 'down', 'build', 'test', 'check', 'fix'] as const;
|
|
6
7
|
export type LifecycleVerb = (typeof LIFECYCLE_VERBS)[number];
|
|
@@ -13,10 +14,7 @@ export type VerbResolveResult =
|
|
|
13
14
|
detail?: string;
|
|
14
15
|
};
|
|
15
16
|
|
|
16
|
-
export function resolveLifecycleVerb(
|
|
17
|
-
projectRoot: string,
|
|
18
|
-
verb: string,
|
|
19
|
-
): VerbResolveResult {
|
|
17
|
+
export function resolveLifecycleVerb(projectRoot: string, verb: string): VerbResolveResult {
|
|
20
18
|
const miseTomlPath = path.join(projectRoot, 'mise.toml');
|
|
21
19
|
if (!fs.existsSync(miseTomlPath)) {
|
|
22
20
|
return { ok: false, reason: 'no-mise-toml' };
|
|
@@ -24,8 +22,8 @@ export function resolveLifecycleVerb(
|
|
|
24
22
|
let parsed: unknown;
|
|
25
23
|
try {
|
|
26
24
|
parsed = TOML.parse(fs.readFileSync(miseTomlPath, 'utf8'));
|
|
27
|
-
} catch (e
|
|
28
|
-
return { ok: false, reason: 'parse-error', detail:
|
|
25
|
+
} catch (e) {
|
|
26
|
+
return { ok: false, reason: 'parse-error', detail: errorMessage(e) };
|
|
29
27
|
}
|
|
30
28
|
const tasks = (parsed as { tasks?: Record<string, unknown> })?.tasks;
|
|
31
29
|
if (!tasks || typeof tasks !== 'object' || !(verb in tasks)) {
|
|
@@ -42,8 +40,7 @@ export function renderResolveError(
|
|
|
42
40
|
switch (res.reason) {
|
|
43
41
|
case 'no-mise-toml':
|
|
44
42
|
return (
|
|
45
|
-
`vdx: no mise.toml in ${projectRoot}\n` +
|
|
46
|
-
`hint: run \`vdx init\` to generate one, or define it manually.\n`
|
|
43
|
+
`vdx: no mise.toml in ${projectRoot}\n` + `hint: run \`vdx init\` to generate one, or define it manually.\n`
|
|
47
44
|
);
|
|
48
45
|
case 'parse-error':
|
|
49
46
|
return `vdx: failed to parse mise.toml: ${res.detail ?? 'unknown error'}\n`;
|
package/src/scoring.ts
CHANGED
|
@@ -95,15 +95,9 @@ function evalAxisFlags(axis: Axis, ctx: Ctx): LevelName {
|
|
|
95
95
|
return achieved;
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
export function projectLevel(
|
|
99
|
-
results: AxisResult[],
|
|
100
|
-
rubric: Rubric,
|
|
101
|
-
suppressed: Set<string>,
|
|
102
|
-
): LevelName {
|
|
98
|
+
export function projectLevel(results: AxisResult[], rubric: Rubric, suppressed: Set<string>): LevelName {
|
|
103
99
|
const supportingThreshold = rubric.scoring.supporting_threshold ?? 0.8;
|
|
104
|
-
const visible = results.filter(
|
|
105
|
-
(r) => !suppressed.has(r.axis_id) && r.drift_kind !== 'excluded',
|
|
106
|
-
);
|
|
100
|
+
const visible = results.filter((r) => !suppressed.has(r.axis_id) && r.drift_kind !== 'excluded');
|
|
107
101
|
const critical = visible.filter((r) => r.class === 'critical');
|
|
108
102
|
const supporting = visible.filter((r) => r.class === 'supporting');
|
|
109
103
|
|
package/src/vocabulary.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* predicates (what a hook runs), so both read "the project's task" the same way.
|
|
6
6
|
*/
|
|
7
7
|
import { listAllTasks, readJson, readToml, type Ctx } from './facts.ts';
|
|
8
|
+
import { at, dict, list } from './narrow.ts';
|
|
8
9
|
|
|
9
10
|
export const LIFECYCLE_VOCABULARY = ['up', 'down', 'build', 'test', 'check', 'fix'] as const;
|
|
10
11
|
|
|
@@ -39,7 +40,11 @@ export function runnerCalls(command: string): string[] {
|
|
|
39
40
|
const toks = tokenize(raw.trim());
|
|
40
41
|
let i = 0;
|
|
41
42
|
// `FOO=1 npm test`, `exec npm test`, `npx --no -- vdx test`
|
|
42
|
-
while (
|
|
43
|
+
while (
|
|
44
|
+
i < toks.length &&
|
|
45
|
+
(/^[A-Za-z_][A-Za-z0-9_]*=/.test(toks[i]!) || ['exec', 'time', 'command'].includes(toks[i]!))
|
|
46
|
+
)
|
|
47
|
+
i++;
|
|
43
48
|
const head = toks[i];
|
|
44
49
|
if (!head) continue;
|
|
45
50
|
const runner = head.split('/').pop()!;
|
|
@@ -95,11 +100,9 @@ export function isVocabularyTask(name: string, words: readonly string[] = ['test
|
|
|
95
100
|
/** Tasks a task's definition calls: composer `@refs`, `npm run X` inside a script, mise `depends`/`run`. */
|
|
96
101
|
export function taskChildren(ctx: Ctx, name: string): string[] {
|
|
97
102
|
const out: string[] = [];
|
|
98
|
-
const
|
|
99
|
-
const entry = composer?.scripts?.[name];
|
|
103
|
+
const entry = at(readJson(ctx, 'composer.json'), 'scripts', name);
|
|
100
104
|
if (entry !== undefined) {
|
|
101
|
-
const
|
|
102
|
-
for (const e of list) {
|
|
105
|
+
for (const e of Array.isArray(entry) ? (entry as unknown[]) : [entry]) {
|
|
103
106
|
if (typeof e !== 'string') continue;
|
|
104
107
|
if (e.startsWith('@')) {
|
|
105
108
|
const [ref, ...rest] = e.slice(1).trim().split(/\s+/);
|
|
@@ -110,14 +113,12 @@ export function taskChildren(ctx: Ctx, name: string): string[] {
|
|
|
110
113
|
}
|
|
111
114
|
}
|
|
112
115
|
}
|
|
113
|
-
const
|
|
114
|
-
const script = pkg?.scripts?.[name];
|
|
116
|
+
const script = at(readJson(ctx, 'package.json'), 'scripts', name);
|
|
115
117
|
if (typeof script === 'string') out.push(...runnerCalls(script));
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
const runs = Array.isArray(task.run) ? task.run : [task.run];
|
|
118
|
+
const task = dict(at(readToml(ctx, 'mise.toml'), 'tasks', name));
|
|
119
|
+
if (task) {
|
|
120
|
+
out.push(...list(task['depends']).filter((d): d is string => typeof d === 'string'));
|
|
121
|
+
const runs = Array.isArray(task['run']) ? (task['run'] as unknown[]) : [task['run']];
|
|
121
122
|
for (const r of runs) if (typeof r === 'string') out.push(...runnerCalls(r));
|
|
122
123
|
}
|
|
123
124
|
const tasks = listAllTasks(ctx);
|