@vodmal/vdx-cli 0.31.2 → 0.32.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 +1 -1
- package/package.json +1 -1
- package/src/ai.ts +1 -1
- package/src/audit.ts +2 -1
- package/src/doctor.ts +59 -2
- package/src/evaluator.ts +10 -5
- package/src/hosting.ts +29 -3
- package/src/index.ts +29 -7
- package/src/predicates.ts +15 -2
- package/src/publish.ts +65 -13
- package/src/report.ts +8 -0
- package/src/scoring.ts +7 -0
package/README.md
CHANGED
|
@@ -48,7 +48,7 @@ vdx publish <patch|minor|major> [--dry-run] [--force] [--no-push]
|
|
|
48
48
|
# Push the branch; one that requires checks on GitHub takes the commit once they pass
|
|
49
49
|
vdx push [project-path] [--dry-run] # default: cwd
|
|
50
50
|
|
|
51
|
-
# Environment self-check (Node / git / mise / npm auth / docker / Claude Code plugin; git hooks of the project)
|
|
51
|
+
# Environment self-check (Node / git / mise / npm auth / docker / Claude Code plugin; git hooks of the project; the GitHub token vdx asks with)
|
|
52
52
|
vdx doctor [project-path] [--format=ansi|markdown|json] # default: cwd
|
|
53
53
|
vdx doctor --check [project-path] # one line per hook declared but not running here — the plugin's SessionStart hook tells the agent
|
|
54
54
|
vdx doctor --fix # write the profile's personal hooks (git.hooks) into ~/.gitconfig
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vodmal/vdx-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.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",
|
package/src/ai.ts
CHANGED
|
@@ -762,7 +762,7 @@ export function sleepSync(ms: number): void {
|
|
|
762
762
|
}
|
|
763
763
|
|
|
764
764
|
/** Read one line from the terminal, synchronously: the prompt runs before tmux takes it. */
|
|
765
|
-
function askLine(prompt: string): string | null {
|
|
765
|
+
export function askLine(prompt: string): string | null {
|
|
766
766
|
process.stderr.write(prompt);
|
|
767
767
|
const buf = Buffer.alloc(256);
|
|
768
768
|
let text = '';
|
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/doctor.ts
CHANGED
|
@@ -3,10 +3,11 @@ import * as os from 'node:os';
|
|
|
3
3
|
import * as path from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { execFileSync } from 'node:child_process';
|
|
6
|
-
import { effectiveAuthor, isGitRepo, rankAuthors, scanPool } from './author.ts';
|
|
6
|
+
import { effectiveAuthor, isGitRepo, rankAuthors, remoteParts, scanPool } from './author.ts';
|
|
7
7
|
import { authorPoolDirs, loadEnvironment, resolveEnvironmentPath, shellQuote, type Environment } from './ai.ts';
|
|
8
8
|
import { checkRepoHooks } from './clone-checks.ts';
|
|
9
|
-
import {
|
|
9
|
+
import { githubToken, machineIo, type HostingIo } from './hosting.ts';
|
|
10
|
+
import { at, dict, errorMessage, str } from './narrow.ts';
|
|
10
11
|
import {
|
|
11
12
|
checkPersonalHooks,
|
|
12
13
|
defaultPersonalHookDeps,
|
|
@@ -417,6 +418,61 @@ export function checkGitAuthor(ctx: DoctorCtx): CheckResult | null {
|
|
|
417
418
|
};
|
|
418
419
|
}
|
|
419
420
|
|
|
421
|
+
/**
|
|
422
|
+
* The token vdx asks GitHub with, for a repository whose origin is there — the audit
|
|
423
|
+
* (`branch-protection`) and `vdx push` ask. Without one GitHub answers 60 requests an
|
|
424
|
+
* hour; a token that no longer works is worse than none: every answer is a 401.
|
|
425
|
+
* `/rate_limit` tells both apart and costs nothing of the limit.
|
|
426
|
+
*/
|
|
427
|
+
export function checkGithubToken(
|
|
428
|
+
ctx: DoctorCtx,
|
|
429
|
+
io: HostingIo | null = ctx.projectRoot ? machineIo(ctx.projectRoot) : null,
|
|
430
|
+
ghOnPath: boolean = findOnPath('gh') !== null,
|
|
431
|
+
platform: NodeJS.Platform = process.platform,
|
|
432
|
+
): CheckResult | null {
|
|
433
|
+
if (ctx.projectRoot === null || io === null) return null;
|
|
434
|
+
const url = io.git(['remote', 'get-url', 'origin']);
|
|
435
|
+
if (!url || remoteParts(url).host !== 'github.com') return null;
|
|
436
|
+
const id = 'github-token';
|
|
437
|
+
const label = 'GitHub token';
|
|
438
|
+
if (io.env.VDX_OFFLINE) return { id, label, status: 'ok', message: 'VDX_OFFLINE is set — vdx does not ask GitHub' };
|
|
439
|
+
const t = githubToken(io);
|
|
440
|
+
let status: number;
|
|
441
|
+
let body: unknown;
|
|
442
|
+
try {
|
|
443
|
+
({ status, body } = io.get('/rate_limit', t?.token ?? null));
|
|
444
|
+
} catch (e) {
|
|
445
|
+
return { id, label, status: 'warning', message: `GitHub did not answer: ${errorMessage(e)}` };
|
|
446
|
+
}
|
|
447
|
+
if (status === 401 && t) {
|
|
448
|
+
return {
|
|
449
|
+
id,
|
|
450
|
+
label,
|
|
451
|
+
status: 'warning',
|
|
452
|
+
message: `the token from ${t.source} does not work (GitHub: ${str(at(body, 'message')) ?? '401'}) — every answer is a 401`,
|
|
453
|
+
remedy: t.source === 'gh' ? 'gh auth login -h github.com' : `a working ${t.source}, or unset it`,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
if (status !== 200) return { id, label, status: 'warning', message: `GitHub answered ${status} for /rate_limit` };
|
|
457
|
+
const limit = at(body, 'resources', 'core', 'limit');
|
|
458
|
+
const left = at(body, 'resources', 'core', 'remaining');
|
|
459
|
+
const hour = typeof limit === 'number' ? `${limit} requests an hour` : 'its limit';
|
|
460
|
+
const remaining = typeof left === 'number' ? `, ${left} left` : '';
|
|
461
|
+
if (t) return { id, label, status: 'ok', message: `from ${t.source}: ${hour}${remaining}` };
|
|
462
|
+
return {
|
|
463
|
+
id,
|
|
464
|
+
label,
|
|
465
|
+
status: 'warning',
|
|
466
|
+
message: `none — GitHub answers ${hour} without one${remaining}; an audit takes about 3, vdx push about 10`,
|
|
467
|
+
remedy: !ghOnPath
|
|
468
|
+
? 'install gh (https://cli.github.com) and gh auth login, or set GH_TOKEN'
|
|
469
|
+
: io.env.SSH_CONNECTION && platform === 'darwin'
|
|
470
|
+
? // m3, 2026-10-19: logged in, and `ssh m3 gh` saw no token, while tmux there did.
|
|
471
|
+
'over ssh gh cannot read the macOS keychain where it keeps the token — in a local terminal or tmux it can; else gh auth login -h github.com'
|
|
472
|
+
: 'gh auth login -h github.com',
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
|
|
420
476
|
const CHECKS: Array<(ctx: DoctorCtx) => CheckResult | null> = [
|
|
421
477
|
checkVdxVersion,
|
|
422
478
|
checkVdx,
|
|
@@ -430,6 +486,7 @@ const CHECKS: Array<(ctx: DoctorCtx) => CheckResult | null> = [
|
|
|
430
486
|
(ctx) => checkRepoHooks(ctx.projectRoot),
|
|
431
487
|
(ctx) => checkPersonalHooks(ctx.projectRoot),
|
|
432
488
|
checkGitAuthor,
|
|
489
|
+
(ctx) => checkGithubToken(ctx),
|
|
433
490
|
];
|
|
434
491
|
|
|
435
492
|
export function resolveDoctorCtx(cwd: string = process.cwd()): DoctorCtx {
|
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/hosting.ts
CHANGED
|
@@ -115,13 +115,25 @@ function judge(io: HostingIo, repo: string, token: string | null): Verdict {
|
|
|
115
115
|
const refusedBranch = refusal(b, repo, token);
|
|
116
116
|
if (refusedBranch) return refusedBranch;
|
|
117
117
|
const classic = at(b.body, 'protection', 'required_status_checks');
|
|
118
|
-
|
|
118
|
+
const level = at(classic, 'enforcement_level');
|
|
119
|
+
const named = checkCount(classic) > 0;
|
|
120
|
+
if (level === 'everyone' && named) return true;
|
|
121
|
+
// Why not, for the report (DL #11): what classic protection lacks.
|
|
122
|
+
const classicWhy =
|
|
123
|
+
at(b.body, 'protected') !== true
|
|
124
|
+
? `${branch} is not protected`
|
|
125
|
+
: level === 'non_admins' && named
|
|
126
|
+
? `${branch} requires checks of everyone but admins — and an agent pushes as its owner, an admin (DL #3)`
|
|
127
|
+
: level === 'everyone'
|
|
128
|
+
? `${branch} is protected, but names no check it requires`
|
|
129
|
+
: `${branch} is protected without required checks`;
|
|
119
130
|
|
|
120
131
|
// Rulesets: a rule that requires checks, in a ruleset nobody bypasses.
|
|
121
132
|
const rules = io.get(`/repos/${repo}/rules/branches/${ref}`, token);
|
|
122
133
|
const refusedRules = refusal(rules, repo, token);
|
|
123
134
|
if (refusedRules) return refusedRules;
|
|
124
135
|
let hidden = false;
|
|
136
|
+
let bypassers = 0;
|
|
125
137
|
for (const rule of list(rules.body)) {
|
|
126
138
|
if (at(rule, 'type') !== 'required_status_checks') continue;
|
|
127
139
|
if (list(at(rule, 'parameters', 'required_status_checks')).length === 0) continue;
|
|
@@ -130,13 +142,19 @@ function judge(io: HostingIo, repo: string, token: string | null): Verdict {
|
|
|
130
142
|
const bypass = ruleset?.status === 200 ? at(ruleset.body, 'bypass_actors') : undefined;
|
|
131
143
|
if (!Array.isArray(bypass)) hidden = true;
|
|
132
144
|
else if (bypass.length === 0) return true;
|
|
145
|
+
else bypassers = Math.max(bypassers, bypass.length);
|
|
133
146
|
}
|
|
134
147
|
if (hidden) {
|
|
135
148
|
return {
|
|
136
149
|
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
150
|
};
|
|
138
151
|
}
|
|
139
|
-
return
|
|
152
|
+
return {
|
|
153
|
+
no:
|
|
154
|
+
bypassers > 0
|
|
155
|
+
? `${classicWhy}; a ruleset requires checks, but ${bypassers} actor(s) may bypass it`
|
|
156
|
+
: `${classicWhy}, and no ruleset requires checks`,
|
|
157
|
+
};
|
|
140
158
|
}
|
|
141
159
|
|
|
142
160
|
/**
|
|
@@ -170,7 +188,15 @@ export function githubRepo(io: HostingIo, remote: string): { repo: string; token
|
|
|
170
188
|
// The host only: a URL can carry a token.
|
|
171
189
|
if (host !== 'github.com')
|
|
172
190
|
return { unknown: `${remote} is on ${host || 'a host vdx cannot tell'} — vdx reads GitHub only` };
|
|
173
|
-
return { repo, token: io
|
|
191
|
+
return { repo, token: githubToken(io)?.token ?? null };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The token vdx asks GitHub with and where it comes from — `GH_TOKEN`, `GITHUB_TOKEN`, then gh, as gh itself picks. */
|
|
195
|
+
export function githubToken(io: HostingIo): { token: string; source: 'GH_TOKEN' | 'GITHUB_TOKEN' | 'gh' } | null {
|
|
196
|
+
if (io.env.GH_TOKEN) return { token: io.env.GH_TOKEN, source: 'GH_TOKEN' };
|
|
197
|
+
if (io.env.GITHUB_TOKEN) return { token: io.env.GITHUB_TOKEN, source: 'GITHUB_TOKEN' };
|
|
198
|
+
const gh = io.ghToken();
|
|
199
|
+
return gh ? { token: gh, source: 'gh' } : null;
|
|
174
200
|
}
|
|
175
201
|
|
|
176
202
|
function isUnknownRepo(v: { repo: string } | Unknown): v is Unknown {
|
package/src/index.ts
CHANGED
|
@@ -35,7 +35,7 @@ import {
|
|
|
35
35
|
} from './publish.ts';
|
|
36
36
|
import { resolveDefaultRubric } from './defaults.ts';
|
|
37
37
|
import { defaultGateDeps, GATE_PREFIX, planGate, pushThroughGate } from './gate.ts';
|
|
38
|
-
import { runAi, runAiCheck, runAiRemote, defaultDeps, hostLabel, REMOTE_HOST_RE } from './ai.ts';
|
|
38
|
+
import { askLine, runAi, runAiCheck, runAiRemote, defaultDeps, hostLabel, REMOTE_HOST_RE } from './ai.ts';
|
|
39
39
|
import { isConversationId } from './conversations.ts';
|
|
40
40
|
|
|
41
41
|
const DEFAULT_RUBRIC = resolveDefaultRubric();
|
|
@@ -156,7 +156,10 @@ mise.toml is overwritten only with --force.
|
|
|
156
156
|
git commit and tag, then \`git push --follow-tags\` to the branch's upstream —
|
|
157
157
|
through its checks when it requires some, as \`vdx push\` does: the pre-flight
|
|
158
158
|
then refuses local commits not pushed yet, npm would get them before CI;
|
|
159
|
-
a wait until the registry shows the new version (up to
|
|
159
|
+
a wait until the registry shows the new version (up to 10 min from npm publish;
|
|
160
|
+
Ctrl-C there stops only the wait).
|
|
161
|
+
Nothing in the package's directory changed since the tag of its version: the
|
|
162
|
+
same package under a new number — at a terminal a question, Enter releases nothing.
|
|
160
163
|
--dry-run runs the pre-flight and prints the plan; --force goes on past a failed
|
|
161
164
|
pre-flight; --no-push leaves the commit and the tag local.
|
|
162
165
|
`,
|
|
@@ -193,7 +196,9 @@ or GitHub stopped answering; a plain git push exits with git's own code.
|
|
|
193
196
|
help: `Checks this machine for what vdx needs (Node, git, mise, npm auth, docker, the
|
|
194
197
|
Claude Code plugin) and, inside a project (default: cwd), its commit author and
|
|
195
198
|
its git hooks: are the hooks the repository declares on in this clone, and do
|
|
196
|
-
the personal hooks of your profile (git.hooks) run on this machine
|
|
199
|
+
the personal hooks of your profile (git.hooks) run on this machine; with origin on
|
|
200
|
+
GitHub, the token vdx asks GitHub with ($GH_TOKEN, $GITHUB_TOKEN or gh) and
|
|
201
|
+
whether it works — without one GitHub answers 60 requests an hour. Reads only.
|
|
197
202
|
Exit 2 when something is missing.
|
|
198
203
|
|
|
199
204
|
--check for the agent's own session (the vdx plugin's SessionStart hook): one
|
|
@@ -438,7 +443,8 @@ function cmdPublish(opts: ParsedArgs): void {
|
|
|
438
443
|
const deps = defaultPublishDeps();
|
|
439
444
|
const interactive = process.stdin.isTTY === true && process.stdout.isTTY === true;
|
|
440
445
|
if (!plan.preflightPassed && !planOpts.force && interactive && preflightFixable(plan)) {
|
|
441
|
-
|
|
446
|
+
const fixes = plan.preflight.filter((c) => !c.ok && c.fix);
|
|
447
|
+
for (const c of fixes) {
|
|
442
448
|
process.stderr.write(`\n→ ${c.name}: ${c.fix!.cmd} ${c.fix!.args.join(' ')}\n`);
|
|
443
449
|
try {
|
|
444
450
|
deps.run(c.fix!.cmd, c.fix!.args, projectRoot);
|
|
@@ -447,11 +453,27 @@ function cmdPublish(opts: ParsedArgs): void {
|
|
|
447
453
|
process.exit(2);
|
|
448
454
|
}
|
|
449
455
|
}
|
|
450
|
-
|
|
451
|
-
|
|
456
|
+
if (fixes.length > 0) {
|
|
457
|
+
plan = replan();
|
|
458
|
+
process.stdout.write('\n' + renderPublishPlan(plan));
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// A failure the person may waive (nothing changed since the release): a question, Enter no.
|
|
463
|
+
let waived = false;
|
|
464
|
+
const left = plan.preflight.filter((c) => !c.ok);
|
|
465
|
+
if (!planOpts.force && interactive && left.length > 0 && left.every((c) => c.waive)) {
|
|
466
|
+
for (const c of left) {
|
|
467
|
+
process.stderr.write(`\n${c.name}: ${c.message}\n`);
|
|
468
|
+
if (!/^y(es)?$/i.test(askLine(c.waive!) ?? '')) {
|
|
469
|
+
process.stderr.write('nothing released\n');
|
|
470
|
+
process.exit(2);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
waived = true;
|
|
452
474
|
}
|
|
453
475
|
|
|
454
|
-
if (!plan.preflightPassed && !planOpts.force) {
|
|
476
|
+
if (!plan.preflightPassed && !planOpts.force && !waived) {
|
|
455
477
|
process.stderr.write('\nerror: pre-flight failed (use --force to bypass)\n');
|
|
456
478
|
process.exit(2);
|
|
457
479
|
}
|
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/publish.ts
CHANGED
|
@@ -24,6 +24,8 @@ export interface PreflightCheck {
|
|
|
24
24
|
message: string;
|
|
25
25
|
/** A failed check a command at the terminal can fix — `npm login` for an expired session. */
|
|
26
26
|
fix?: { cmd: string; args: string[] };
|
|
27
|
+
/** A failed check the person at the terminal may waive: the question, Enter answering no. */
|
|
28
|
+
waive?: string;
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
/** Where the release goes after the tag. */
|
|
@@ -311,6 +313,9 @@ export function planPublish(
|
|
|
311
313
|
});
|
|
312
314
|
|
|
313
315
|
preflight.push(checkNpmAuth(publishRegistry(pkg, npm), npm));
|
|
316
|
+
preflight.push(
|
|
317
|
+
checkChangesSinceRelease(opts.projectRoot, path.dirname(pkgJsonPath), currentVersion, newVersion, git),
|
|
318
|
+
);
|
|
314
319
|
|
|
315
320
|
let published: string | null = null;
|
|
316
321
|
let registryError: string | null = null;
|
|
@@ -397,10 +402,50 @@ export function planPublish(
|
|
|
397
402
|
};
|
|
398
403
|
}
|
|
399
404
|
|
|
400
|
-
/**
|
|
405
|
+
/**
|
|
406
|
+
* Anything to release? 0.30.0 went out on 2026-10-18 with nothing since v0.29.0 but
|
|
407
|
+
* its release commit (hosting-extension Sidetrack #6). Nothing in the package's
|
|
408
|
+
* directory since the tag of the current version — the same package under a new
|
|
409
|
+
* number; a terminal asks, Enter answering no.
|
|
410
|
+
*/
|
|
411
|
+
export function checkChangesSinceRelease(
|
|
412
|
+
projectRoot: string,
|
|
413
|
+
packageDir: string,
|
|
414
|
+
current: string,
|
|
415
|
+
next: string,
|
|
416
|
+
git: GitRunner,
|
|
417
|
+
): PreflightCheck {
|
|
418
|
+
const name = 'changes-since-release';
|
|
419
|
+
const tag = `v${current}`;
|
|
420
|
+
try {
|
|
421
|
+
git(['rev-parse', '--verify', '--quiet', `refs/tags/${tag}`], projectRoot);
|
|
422
|
+
} catch {
|
|
423
|
+
return { name, ok: true, message: `OK (no tag ${tag} to compare with)` };
|
|
424
|
+
}
|
|
425
|
+
const where = path.relative(projectRoot, packageDir) || '.';
|
|
426
|
+
const shown = where === '.' ? `${path.basename(packageDir)}/` : `${where}/`;
|
|
427
|
+
let count: number;
|
|
428
|
+
try {
|
|
429
|
+
count = parseInt(git(['rev-list', '--count', `${tag}..HEAD`, '--', where], projectRoot).trim(), 10) || 0;
|
|
430
|
+
} catch {
|
|
431
|
+
return { name, ok: true, message: `OK (git could not compare with ${tag})` };
|
|
432
|
+
}
|
|
433
|
+
if (count > 0) return { name, ok: true, message: `OK (${count} commit(s) in ${shown} since ${tag})` };
|
|
434
|
+
return {
|
|
435
|
+
name,
|
|
436
|
+
ok: false,
|
|
437
|
+
message: `nothing in ${shown} has changed since ${tag}: ${next} would be the package ${current} is`,
|
|
438
|
+
waive: `Release ${next} anyway, the same package under a new number? [y/N] `,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Every failed check is resolved at a terminal: a command that fixes it (run, then
|
|
444
|
+
* plan again) or a question the person may answer yes to.
|
|
445
|
+
*/
|
|
401
446
|
export function preflightFixable(plan: PublishPlan): boolean {
|
|
402
447
|
const failed = plan.preflight.filter((c) => !c.ok);
|
|
403
|
-
return failed.length > 0 && failed.every((c) => c.fix);
|
|
448
|
+
return failed.length > 0 && failed.every((c) => c.fix || c.waive);
|
|
404
449
|
}
|
|
405
450
|
|
|
406
451
|
/** Runs a command on this terminal (stdio inherited); throws on a non-zero exit. */
|
|
@@ -431,7 +476,7 @@ export function defaultPublishDeps(): PublishDeps {
|
|
|
431
476
|
npm: runNpm,
|
|
432
477
|
log: (line) => process.stderr.write(line + '\n'),
|
|
433
478
|
sleep: sleepSync,
|
|
434
|
-
registryWaitMs:
|
|
479
|
+
registryWaitMs: 10 * 60_000,
|
|
435
480
|
progress: process.stderr.isTTY ? (text) => process.stderr.write(`\r${text}\x1b[K`) : undefined,
|
|
436
481
|
gatePush: (target, projectRoot) => pushThroughGate(target, defaultGateDeps(projectRoot)),
|
|
437
482
|
};
|
|
@@ -447,19 +492,22 @@ export function bumpLockText(text: string, version: string): string {
|
|
|
447
492
|
}
|
|
448
493
|
|
|
449
494
|
/**
|
|
450
|
-
* npm answers `202 Accepted` and the version shows up
|
|
451
|
-
*
|
|
452
|
-
*
|
|
453
|
-
*
|
|
495
|
+
* npm answers `202 Accepted` and the version shows up minutes later — 0.30.0
|
|
496
|
+
* about 6 min after publish, 0.31.2 past 5 (hosting-extension Sidetrack #9); until
|
|
497
|
+
* then `npm view` and `npx` still see the previous one. Waiting here turns "is it
|
|
498
|
+
* out?" into an answer. The time counts from `npm publish` (`since`): the push
|
|
499
|
+
* through CI before the wait is part of it. A version that does not show up in
|
|
500
|
+
* time is not a failure: npm took it.
|
|
454
501
|
*/
|
|
455
|
-
export function waitForRegistry(name: string, version: string, deps: PublishDeps): boolean {
|
|
456
|
-
const started =
|
|
502
|
+
export function waitForRegistry(name: string, version: string, deps: PublishDeps, since = Date.now()): boolean {
|
|
503
|
+
const started = since;
|
|
457
504
|
const deadline = started + deps.registryWaitMs;
|
|
458
505
|
const limit = Math.round(deps.registryWaitMs / 1000);
|
|
459
506
|
const seconds = () => Math.round((Date.now() - started) / 1000);
|
|
460
507
|
deps.log(
|
|
461
508
|
`→ waiting for the registry to show ${name}@${version} — npm has taken it; ` +
|
|
462
|
-
`the registry
|
|
509
|
+
`the registry has taken up to 6 min to show a version (giving it ${limit} s from publish; ` +
|
|
510
|
+
`Ctrl-C stops only this wait)`,
|
|
463
511
|
);
|
|
464
512
|
const shown = () => {
|
|
465
513
|
try {
|
|
@@ -556,6 +604,7 @@ export function executePublish(plan: PublishPlan, deps: PublishDeps = defaultPub
|
|
|
556
604
|
} finally {
|
|
557
605
|
for (const s of signals) process.off(s, holdOut);
|
|
558
606
|
}
|
|
607
|
+
const publishedAt = Date.now();
|
|
559
608
|
deps.log(`✓ published ${plan.packageName}@${plan.newVersion}`);
|
|
560
609
|
|
|
561
610
|
// 3. git add + commit + tag (at project root)
|
|
@@ -597,7 +646,7 @@ export function executePublish(plan: PublishPlan, deps: PublishDeps = defaultPub
|
|
|
597
646
|
}
|
|
598
647
|
|
|
599
648
|
// 5. registry
|
|
600
|
-
waitForRegistry(plan.packageName, plan.newVersion, deps);
|
|
649
|
+
waitForRegistry(plan.packageName, plan.newVersion, deps, publishedAt);
|
|
601
650
|
}
|
|
602
651
|
|
|
603
652
|
export function renderPublishPlan(plan: PublishPlan): string {
|
|
@@ -643,7 +692,10 @@ export function renderPublishPlan(plan: PublishPlan): string {
|
|
|
643
692
|
const fixes = plan.preflight
|
|
644
693
|
.filter((c) => !c.ok && c.fix)
|
|
645
694
|
.map((c) => `\`${c.fix!.cmd} ${c.fix!.args.join(' ')}\``);
|
|
646
|
-
lines.push(`0. ${fixes.join(', ')} — at a terminal, then the pre-flight again`);
|
|
695
|
+
if (fixes.length > 0) lines.push(`0. ${fixes.join(', ')} — at a terminal, then the pre-flight again`);
|
|
696
|
+
for (const c of plan.preflight.filter((c) => !c.ok && c.waive)) {
|
|
697
|
+
lines.push(`0. At a terminal: ${c.waive!.trim()} — Enter releases nothing`);
|
|
698
|
+
}
|
|
647
699
|
}
|
|
648
700
|
lines.push(`1. Set \`version\` in ${files} → \`${plan.newVersion}\``);
|
|
649
701
|
lines.push(`2. \`npm publish\` (cwd = directory containing package.json)`);
|
|
@@ -658,7 +710,7 @@ export function renderPublishPlan(plan: PublishPlan): string {
|
|
|
658
710
|
? `5. \`git push --follow-tags\` → ${push.upstream}${push.ahead ? ` (with ${push.ahead} local commit(s) not pushed yet)` : ''}`
|
|
659
711
|
: '5. Not pushed: the branch has no upstream',
|
|
660
712
|
);
|
|
661
|
-
lines.push(`6. Wait until the registry shows ${plan.newVersion} (up to
|
|
713
|
+
lines.push(`6. Wait until the registry shows ${plan.newVersion} (up to 10 min from publish)`);
|
|
662
714
|
} else {
|
|
663
715
|
lines.push('> Pre-flight failed. Use `--force` to bypass at your own risk.');
|
|
664
716
|
}
|
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
|
|