@vodmal/vdx-cli 0.31.3 → 0.32.1
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 +1 -1
- package/src/audit.ts +2 -1
- package/src/evaluator.ts +10 -5
- package/src/gate.ts +17 -3
- package/src/hosting.ts +23 -4
- package/src/predicates.ts +15 -2
- package/src/report.ts +8 -0
- package/src/scoring.ts +7 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vodmal/vdx-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.1",
|
|
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",
|
package/src/audit.ts
CHANGED
|
@@ -137,7 +137,7 @@ export function audit(
|
|
|
137
137
|
});
|
|
138
138
|
continue;
|
|
139
139
|
}
|
|
140
|
-
const { achieved, not_required, unknown } = evalAxisDetailed(axis, evalCtx);
|
|
140
|
+
const { achieved, not_required, unknown, not_met } = evalAxisDetailed(axis, evalCtx);
|
|
141
141
|
const ov = overrideByAxis.get(axis.id);
|
|
142
142
|
const target = (ov?.target as LevelName) ?? axis.default_target;
|
|
143
143
|
const drift: 'aligned' | 'gap' | 'over' =
|
|
@@ -156,6 +156,7 @@ export function audit(
|
|
|
156
156
|
if (ov?.target) entry.override_target = ov.target as LevelName;
|
|
157
157
|
if (not_required) entry.not_required = not_required;
|
|
158
158
|
if (unknown) entry.unknown = unknown;
|
|
159
|
+
if (not_met) entry.not_met = not_met;
|
|
159
160
|
if (axis.clone_check) {
|
|
160
161
|
const c = cloneCheck(axis.clone_check, ctx.projectRoot);
|
|
161
162
|
if (c && c.status !== 'ok') entry.note = c.remedy ? `${c.message} → ${c.remedy}` : c.message;
|
package/src/evaluator.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Predicate } from './rubric.ts';
|
|
2
2
|
import type { Ctx } from './facts.ts';
|
|
3
|
-
import { REGISTRY, isUnknown, type Args, type Unknown, type Verdict } from './predicates.ts';
|
|
3
|
+
import { REGISTRY, isFalse, isNo, isUnknown, type Args, type No, type Unknown, type Verdict } from './predicates.ts';
|
|
4
4
|
import { at, isDict, list } from './narrow.ts';
|
|
5
5
|
|
|
6
6
|
export { isUnknown, type Verdict };
|
|
@@ -25,6 +25,11 @@ function firstUnknown(vs: Verdict[]): Unknown | undefined {
|
|
|
25
25
|
return vs.find(isUnknown);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/** Not met, with the first reason a branch gave, if one did. */
|
|
29
|
+
function no(vs: Verdict[]): false | No {
|
|
30
|
+
return vs.find(isNo) ?? false;
|
|
31
|
+
}
|
|
32
|
+
|
|
28
33
|
/**
|
|
29
34
|
* Three-valued: true, false, or unknown with the reason the fact could not be
|
|
30
35
|
* read (a CI system or a hosting vdx does not inspect). Unknown never turns
|
|
@@ -40,13 +45,13 @@ export function evalVerdict(p: Predicate, ctx: Ctx): Verdict {
|
|
|
40
45
|
|
|
41
46
|
if ('all_of' in obj) {
|
|
42
47
|
const vs = list(obj['all_of']).map((q) => evalVerdict(q, ctx));
|
|
43
|
-
if (vs.some(
|
|
48
|
+
if (vs.some(isFalse)) return no(vs);
|
|
44
49
|
return firstUnknown(vs) ?? true;
|
|
45
50
|
}
|
|
46
51
|
if ('any_of' in obj) {
|
|
47
52
|
const vs = list(obj['any_of']).map((q) => evalVerdict(q, ctx));
|
|
48
53
|
if (vs.some((v) => v === true)) return true;
|
|
49
|
-
return firstUnknown(vs) ??
|
|
54
|
+
return firstUnknown(vs) ?? no(vs);
|
|
50
55
|
}
|
|
51
56
|
if ('at_least_n_of' in obj) {
|
|
52
57
|
const { n, predicates } = obj['at_least_n_of'] as { n: number; predicates: Predicate[] };
|
|
@@ -54,12 +59,12 @@ export function evalVerdict(p: Predicate, ctx: Ctx): Verdict {
|
|
|
54
59
|
const matched = vs.filter((v) => v === true).length;
|
|
55
60
|
if (matched >= n) return true;
|
|
56
61
|
const open = vs.filter(isUnknown).length;
|
|
57
|
-
if (matched + open < n) return
|
|
62
|
+
if (matched + open < n) return no(vs);
|
|
58
63
|
return firstUnknown(vs)!;
|
|
59
64
|
}
|
|
60
65
|
if ('not' in obj) {
|
|
61
66
|
const v = evalVerdict(obj['not'], ctx);
|
|
62
|
-
return isUnknown(v) ? v :
|
|
67
|
+
return isUnknown(v) ? v : v !== true;
|
|
63
68
|
}
|
|
64
69
|
if ('fn' in obj) {
|
|
65
70
|
const name = String(obj['fn']);
|
package/src/gate.ts
CHANGED
|
@@ -119,11 +119,16 @@ export function pushThroughGate(t: GateTarget, d: GateDeps): void {
|
|
|
119
119
|
const names = t.checks.join(', ');
|
|
120
120
|
const link = `https://github.com/${t.repo}/commit/${sha}/checks`;
|
|
121
121
|
const again = `once they pass: vdx push (${link})`;
|
|
122
|
-
|
|
122
|
+
// No answer at all — a timeout, the network — is not a refusal: the wait asks again
|
|
123
|
+
// (0.32.0's release push stopped on one ETIMEDOUT at a load of ~7, 2026-10-20).
|
|
124
|
+
let silent: string | null = null;
|
|
125
|
+
const ask = (): CheckState[] | Unknown | null => {
|
|
123
126
|
try {
|
|
127
|
+
silent = null;
|
|
124
128
|
return commitChecks(d.io, t.repo, sha, t.checks, t.token);
|
|
125
129
|
} catch (e) {
|
|
126
|
-
|
|
130
|
+
silent = `GitHub did not answer: ${errorMessage(e)}`;
|
|
131
|
+
return null;
|
|
127
132
|
}
|
|
128
133
|
};
|
|
129
134
|
const toBranch = () => {
|
|
@@ -137,7 +142,7 @@ export function pushThroughGate(t: GateTarget, d: GateDeps): void {
|
|
|
137
142
|
// A commit not pushed yet is not asked about — GitHub would only answer 422.
|
|
138
143
|
if (d.io.git(['rev-parse', '--verify', '--quiet', `refs/remotes/${t.remote}/${gate}`]) === sha) {
|
|
139
144
|
const before = ask();
|
|
140
|
-
if (!isUnknown(before) && passed(before)) {
|
|
145
|
+
if (before !== null && !isUnknown(before) && passed(before)) {
|
|
141
146
|
d.log(`✓ ${short} has passed ${names}`);
|
|
142
147
|
toBranch();
|
|
143
148
|
return;
|
|
@@ -165,6 +170,15 @@ export function pushThroughGate(t: GateTarget, d: GateDeps): void {
|
|
|
165
170
|
}
|
|
166
171
|
const states = ask();
|
|
167
172
|
d.progress?.('');
|
|
173
|
+
if (states === null) {
|
|
174
|
+
if (d.now() - started >= d.timeoutMs) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
`${silent} after ${Math.round(d.timeoutMs / 60_000)} min — ${t.branch} did not take ${short}; ${again}`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
d.log(` … ${seconds()} s — ${silent}; asking again`);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
168
182
|
if (isUnknown(states)) throw new Error(`${states.unknown} — ${t.branch} did not take ${short}; ${again}`);
|
|
169
183
|
const failed = states.filter((s) => s.state === 'failed');
|
|
170
184
|
if (failed.length > 0) {
|
package/src/hosting.ts
CHANGED
|
@@ -28,7 +28,7 @@ const GET = `
|
|
|
28
28
|
const headers = { accept: 'application/vnd.github+json', 'x-github-api-version': '2022-11-28', 'user-agent': 'vdx' };
|
|
29
29
|
if (process.env.VDX_GITHUB_TOKEN) headers.authorization = 'Bearer ' + process.env.VDX_GITHUB_TOKEN;
|
|
30
30
|
try {
|
|
31
|
-
const r = await fetch(process.argv[1], { headers, signal: AbortSignal.timeout(
|
|
31
|
+
const r = await fetch(process.argv[1], { headers, signal: AbortSignal.timeout(15000) });
|
|
32
32
|
process.stdout.write(JSON.stringify({ status: r.status, text: await r.text() }));
|
|
33
33
|
} catch (e) {
|
|
34
34
|
process.stdout.write(JSON.stringify({ error: String(e?.cause?.code ?? e?.cause?.message ?? e?.message ?? e) }));
|
|
@@ -43,7 +43,8 @@ function get(apiPath: string, token: string | null): Answer {
|
|
|
43
43
|
execFileSync(process.execPath, ['--input-type=module', '-e', GET, `https://api.github.com${apiPath}`], {
|
|
44
44
|
encoding: 'utf8',
|
|
45
45
|
env,
|
|
46
|
-
|
|
46
|
+
// node's own start counts too: at a load of ~7 on lft 10 s ran out (2026-10-20).
|
|
47
|
+
timeout: 20_000,
|
|
47
48
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
48
49
|
}),
|
|
49
50
|
);
|
|
@@ -115,13 +116,25 @@ function judge(io: HostingIo, repo: string, token: string | null): Verdict {
|
|
|
115
116
|
const refusedBranch = refusal(b, repo, token);
|
|
116
117
|
if (refusedBranch) return refusedBranch;
|
|
117
118
|
const classic = at(b.body, 'protection', 'required_status_checks');
|
|
118
|
-
|
|
119
|
+
const level = at(classic, 'enforcement_level');
|
|
120
|
+
const named = checkCount(classic) > 0;
|
|
121
|
+
if (level === 'everyone' && named) return true;
|
|
122
|
+
// Why not, for the report (DL #11): what classic protection lacks.
|
|
123
|
+
const classicWhy =
|
|
124
|
+
at(b.body, 'protected') !== true
|
|
125
|
+
? `${branch} is not protected`
|
|
126
|
+
: level === 'non_admins' && named
|
|
127
|
+
? `${branch} requires checks of everyone but admins — and an agent pushes as its owner, an admin (DL #3)`
|
|
128
|
+
: level === 'everyone'
|
|
129
|
+
? `${branch} is protected, but names no check it requires`
|
|
130
|
+
: `${branch} is protected without required checks`;
|
|
119
131
|
|
|
120
132
|
// Rulesets: a rule that requires checks, in a ruleset nobody bypasses.
|
|
121
133
|
const rules = io.get(`/repos/${repo}/rules/branches/${ref}`, token);
|
|
122
134
|
const refusedRules = refusal(rules, repo, token);
|
|
123
135
|
if (refusedRules) return refusedRules;
|
|
124
136
|
let hidden = false;
|
|
137
|
+
let bypassers = 0;
|
|
125
138
|
for (const rule of list(rules.body)) {
|
|
126
139
|
if (at(rule, 'type') !== 'required_status_checks') continue;
|
|
127
140
|
if (list(at(rule, 'parameters', 'required_status_checks')).length === 0) continue;
|
|
@@ -130,13 +143,19 @@ function judge(io: HostingIo, repo: string, token: string | null): Verdict {
|
|
|
130
143
|
const bypass = ruleset?.status === 200 ? at(ruleset.body, 'bypass_actors') : undefined;
|
|
131
144
|
if (!Array.isArray(bypass)) hidden = true;
|
|
132
145
|
else if (bypass.length === 0) return true;
|
|
146
|
+
else bypassers = Math.max(bypassers, bypass.length);
|
|
133
147
|
}
|
|
134
148
|
if (hidden) {
|
|
135
149
|
return {
|
|
136
150
|
unknown: `a ruleset requires checks on ${branch}, but GitHub shows who bypasses it only to ${token ? 'a repository admin' : 'a token; gh auth login, or GH_TOKEN'}`,
|
|
137
151
|
};
|
|
138
152
|
}
|
|
139
|
-
return
|
|
153
|
+
return {
|
|
154
|
+
no:
|
|
155
|
+
bypassers > 0
|
|
156
|
+
? `${classicWhy}; a ruleset requires checks, but ${bypassers} actor(s) may bypass it`
|
|
157
|
+
: `${classicWhy}, and no ruleset requires checks`,
|
|
158
|
+
};
|
|
140
159
|
}
|
|
141
160
|
|
|
142
161
|
/**
|
package/src/predicates.ts
CHANGED
|
@@ -24,11 +24,24 @@ export type Args = Record<string, unknown>;
|
|
|
24
24
|
export interface Unknown {
|
|
25
25
|
unknown: string;
|
|
26
26
|
}
|
|
27
|
-
|
|
27
|
+
/** Not met, and why — from a predicate that knows (DL #11 of docs/tasks/hosting-extension). */
|
|
28
|
+
export interface No {
|
|
29
|
+
no: string;
|
|
30
|
+
}
|
|
31
|
+
export type Verdict = boolean | Unknown | No;
|
|
28
32
|
type PredicateFn = (args: Args, ctx: Ctx) => Verdict;
|
|
29
33
|
|
|
30
34
|
export function isUnknown(v: Verdict): v is Unknown {
|
|
31
|
-
return typeof v === 'object' && v !== null;
|
|
35
|
+
return typeof v === 'object' && v !== null && 'unknown' in v;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function isNo(v: Verdict): v is No {
|
|
39
|
+
return typeof v === 'object' && v !== null && 'no' in v;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Not met: `false`, or `false` with its reason. */
|
|
43
|
+
export function isFalse(v: Verdict): v is false | No {
|
|
44
|
+
return v === false || isNo(v);
|
|
32
45
|
}
|
|
33
46
|
|
|
34
47
|
/**
|
package/src/report.ts
CHANGED
|
@@ -47,6 +47,14 @@ export function reportMarkdown(r: AuditResult): string {
|
|
|
47
47
|
lines.push(`| \`${a.axis_id}\` | ${cls} | ${achievedCell(a)} | ${a.target} | ${drift} |`);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
const notMet = r.per_axis.filter((a) => a.not_met && !a.suppressed && a.drift_kind !== 'excluded');
|
|
51
|
+
if (notMet.length > 0) {
|
|
52
|
+
lines.push('');
|
|
53
|
+
lines.push(`## Not met`);
|
|
54
|
+
lines.push('');
|
|
55
|
+
for (const a of notMet) lines.push(`- \`${a.axis_id}\` ${a.not_met!.level}: ${a.not_met!.reason}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
50
58
|
const unknown = r.per_axis.filter((a) => a.unknown);
|
|
51
59
|
if (unknown.length > 0) {
|
|
52
60
|
lines.push('');
|
package/src/scoring.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Axis, LevelName, Rubric } from './rubric.ts';
|
|
2
2
|
import type { Ctx } from './facts.ts';
|
|
3
3
|
import { evalPredicate, evalVerdict, isUnknown } from './evaluator.ts';
|
|
4
|
+
import { isNo } from './predicates.ts';
|
|
4
5
|
|
|
5
6
|
const LEVELS: LevelName[] = ['L1', 'L2', 'L3', 'L4'];
|
|
6
7
|
|
|
@@ -18,6 +19,8 @@ export interface AxisResult {
|
|
|
18
19
|
not_required?: LevelName[];
|
|
19
20
|
/** The level whose requirement could not be checked, and why; the ascent stopped there. */
|
|
20
21
|
unknown?: { level: LevelName; reason: string };
|
|
22
|
+
/** The level whose requirement is not met, and why — when the predicate knows. */
|
|
23
|
+
not_met?: { level: LevelName; reason: string };
|
|
21
24
|
/** A clone check printed next to the axis; does not change the level. */
|
|
22
25
|
note?: string;
|
|
23
26
|
}
|
|
@@ -26,6 +29,7 @@ export interface AxisEvaluation {
|
|
|
26
29
|
achieved: LevelName;
|
|
27
30
|
not_required?: LevelName[];
|
|
28
31
|
unknown?: { level: LevelName; reason: string };
|
|
32
|
+
not_met?: { level: LevelName; reason: string };
|
|
29
33
|
}
|
|
30
34
|
|
|
31
35
|
export function evalAxis(axis: Axis, ctx: Ctx): LevelName {
|
|
@@ -41,6 +45,7 @@ function evalAxisLevels(axis: Axis, ctx: Ctx): AxisEvaluation {
|
|
|
41
45
|
let achieved: LevelName = 'L0';
|
|
42
46
|
const notRequired: LevelName[] = [];
|
|
43
47
|
let unknown: AxisEvaluation['unknown'];
|
|
48
|
+
let notMet: AxisEvaluation['not_met'];
|
|
44
49
|
for (const L of LEVELS) {
|
|
45
50
|
const lvl = axis.levels?.[L];
|
|
46
51
|
if (!lvl) break;
|
|
@@ -55,6 +60,7 @@ function evalAxisLevels(axis: Axis, ctx: Ctx): AxisEvaluation {
|
|
|
55
60
|
} else {
|
|
56
61
|
// delta-style: discontinuity → stop ascent; unknown is "not met", named
|
|
57
62
|
if (isUnknown(v)) unknown = { level: L, reason: v.unknown };
|
|
63
|
+
else if (isNo(v)) notMet = { level: L, reason: v.no };
|
|
58
64
|
break;
|
|
59
65
|
}
|
|
60
66
|
}
|
|
@@ -62,6 +68,7 @@ function evalAxisLevels(axis: Axis, ctx: Ctx): AxisEvaluation {
|
|
|
62
68
|
achieved,
|
|
63
69
|
...(notRequired.length > 0 ? { not_required: notRequired } : {}),
|
|
64
70
|
...(unknown ? { unknown } : {}),
|
|
71
|
+
...(notMet ? { not_met: notMet } : {}),
|
|
65
72
|
};
|
|
66
73
|
}
|
|
67
74
|
|