@vodmal/vdx-cli 0.27.0 → 0.29.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 CHANGED
@@ -112,6 +112,13 @@ By default the bundled `rubric/vdx-rubric.yaml` is used (a mirror of canonical
112
112
  [vdx-rubric-vodmal](https://github.com/VoDmAl/vdx-rubric-vodmal) at the time of
113
113
  each CLI release). Override via `--rubric <path>` or the env var `VDX_RUBRIC`.
114
114
 
115
+ `branch-protection` is the one axis the files cannot answer: `vdx audit` asks
116
+ GitHub whether the default branch of `origin` takes changes only after a check
117
+ required of everyone, admins included. A public repo needs no token; a private
118
+ one takes it from `GH_TOKEN`, `GITHUB_TOKEN` or `gh auth token`. Another
119
+ hosting, no network or no token where one is needed leave the axis unknown,
120
+ with the reason in the report; `VDX_OFFLINE=1` does not ask at all.
121
+
115
122
  ## Dev
116
123
 
117
124
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vodmal/vdx-cli",
3
- "version": "0.27.0",
3
+ "version": "0.29.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",
@@ -11,7 +11,7 @@ schema_version: "0.3"
11
11
 
12
12
  metadata:
13
13
  name: vdx-rubric-vodmal
14
- version: "1.4.2"
14
+ version: "1.5.0"
15
15
  description: "Personal maturity rubric for multi-stack projects (vodmal)"
16
16
  owner: vodmal
17
17
  homepage: "https://github.com/vodmal/vdx-rubric"
@@ -470,6 +470,10 @@ axes:
470
470
 
471
471
  - id: observability
472
472
  class: supporting
473
+ # v1.5.0: логгер, Sentry и релиз в Sentry — у процесса, который работает и
474
+ # падает без человека. Библиотека и CLI (lib-intent) пишут в stderr и код
475
+ # выхода — у них ось excluded. Определение lib-intent — в lifecycle-interface.L3.
476
+ applies_when: { not: *lib_intent }
473
477
  storage: level
474
478
  default_target: L4
475
479
  fact_sources: [composer.json, package.json, .github/workflows/]
@@ -526,6 +530,9 @@ axes:
526
530
  - id: mock-infra
527
531
  class: supporting
528
532
  applies_to: [php, node, go, python]
533
+ # v1.5.0: mock-сервер и проверка, что моков нет в проде, — у сервиса с
534
+ # зависимостями. У библиотеки и CLI (lib-intent) ось excluded.
535
+ applies_when: { not: *lib_intent }
529
536
  storage: level
530
537
  default_target: L4
531
538
  fact_sources: [composer.json, package.json, docker-compose.yml, mock-server/]
package/src/facts.ts CHANGED
@@ -16,12 +16,43 @@ export interface Ctx {
16
16
  fallbackRoot?: string;
17
17
  }
18
18
 
19
- /** Absolute path of `rel`: here, else in `fallbackRoot` when it is there. */
20
- function locate(ctx: Ctx, rel: string): string {
21
- const here = path.join(ctx.projectRoot, rel);
22
- if (ctx.fallbackRoot === undefined || fs.existsSync(here)) return here;
23
- const there = path.join(ctx.fallbackRoot, rel);
24
- return fs.existsSync(there) ? there : here;
19
+ /** Names in `dir`, read once per context; none when it cannot be read. */
20
+ function entries(ctx: Ctx, dir: string): string[] {
21
+ const key = `dir:${dir}`;
22
+ let names = ctx.cache.get(key) as string[] | undefined;
23
+ if (!names) {
24
+ try {
25
+ names = fs.readdirSync(dir);
26
+ } catch {
27
+ names = [];
28
+ }
29
+ ctx.cache.set(key, names);
30
+ }
31
+ return names;
32
+ }
33
+
34
+ /**
35
+ * `rel` under `root`, each segment in the case it is written. APFS and NTFS find a file
36
+ * under any case and ext4 does not: `tests/Fixtures` was there on a Mac that has only
37
+ * `tests/fixtures`, and the same project got another level on Linux.
38
+ */
39
+ function existsAsWritten(ctx: Ctx, root: string, rel: string): boolean {
40
+ let dir = root;
41
+ for (const name of path.normalize(rel).split(path.sep)) {
42
+ if (name === '' || name === '.') continue;
43
+ if (name !== '..' && !entries(ctx, dir).includes(name)) return false;
44
+ dir = path.join(dir, name);
45
+ }
46
+ return fs.existsSync(dir);
47
+ }
48
+
49
+ /** Absolute path of `rel`: here, else in `fallbackRoot`; null when in neither. */
50
+ function locate(ctx: Ctx, rel: string): string | null {
51
+ if (existsAsWritten(ctx, ctx.projectRoot, rel)) return path.join(ctx.projectRoot, rel);
52
+ if (ctx.fallbackRoot !== undefined && existsAsWritten(ctx, ctx.fallbackRoot, rel)) {
53
+ return path.join(ctx.fallbackRoot, rel);
54
+ }
55
+ return null;
25
56
  }
26
57
 
27
58
  /** The same directory without the fallback: a project's tasks are the ones its own runner sees. */
@@ -36,13 +67,17 @@ function ownCtx(ctx: Ctx): Ctx {
36
67
  }
37
68
 
38
69
  export function fileExists(ctx: Ctx, rel: string): boolean {
39
- return fs.existsSync(locate(ctx, rel));
70
+ return locate(ctx, rel) !== null;
40
71
  }
41
72
 
42
73
  export function readText(ctx: Ctx, rel: string): string | null {
43
74
  const key = `file:${rel}`;
44
75
  if (ctx.cache.has(key)) return ctx.cache.get(key) as string | null;
45
76
  const abs = locate(ctx, rel);
77
+ if (abs === null) {
78
+ ctx.cache.set(key, null);
79
+ return null;
80
+ }
46
81
  try {
47
82
  const t = fs.readFileSync(abs, 'utf8');
48
83
  ctx.cache.set(key, t);
@@ -183,13 +218,9 @@ export function expandPaths(paths: string[], ctx: Ctx): string[] {
183
218
  const dir = m[1];
184
219
  const ext = m[2];
185
220
  const abs = locate(ctx, dir);
186
- try {
187
- const files = fs.readdirSync(abs);
188
- for (const f of files) {
189
- if (f.endsWith('.' + ext)) out.push(path.join(dir, f));
190
- }
191
- } catch {
192
- /* dir missing — silent */
221
+ // A missing dir lists nothing.
222
+ for (const f of abs === null ? [] : entries(ctx, abs)) {
223
+ if (f.endsWith('.' + ext)) out.push(path.join(dir, f));
193
224
  }
194
225
  }
195
226
  } else {
package/src/hosting.ts ADDED
@@ -0,0 +1,168 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { remoteParts } from './author.ts';
3
+ import type { Ctx } from './facts.ts';
4
+ import { at, errorMessage, list, str } from './narrow.ts';
5
+ import type { Unknown, Verdict } from './predicates.ts';
6
+
7
+ /** One answer of the GitHub API: its status and its body, parsed (null when not JSON). */
8
+ export interface Answer {
9
+ status: number;
10
+ body: unknown;
11
+ }
12
+
13
+ /** What the check needs from outside — the machine's own below, recorded answers in the tests. */
14
+ export interface HostingIo {
15
+ env: NodeJS.ProcessEnv;
16
+ /** `git <args>` in the project, trimmed; null when git fails. */
17
+ git: (args: string[]) => string | null;
18
+ /** The token `gh` keeps; null without gh or a login. */
19
+ ghToken: () => string | null;
20
+ /** GET of an api.github.com path. Throws when no answer came at all. */
21
+ get: (apiPath: string, token: string | null) => Answer;
22
+ }
23
+
24
+ // A child node runs the request: the audit is synchronous and fetch is not
25
+ // (DL #6 of docs/tasks/hosting-extension). The token travels in its
26
+ // environment, not its arguments — those are visible in ps.
27
+ const GET = `
28
+ const headers = { accept: 'application/vnd.github+json', 'x-github-api-version': '2022-11-28', 'user-agent': 'vdx' };
29
+ if (process.env.VDX_GITHUB_TOKEN) headers.authorization = 'Bearer ' + process.env.VDX_GITHUB_TOKEN;
30
+ try {
31
+ const r = await fetch(process.argv[1], { headers, signal: AbortSignal.timeout(8000) });
32
+ process.stdout.write(JSON.stringify({ status: r.status, text: await r.text() }));
33
+ } catch (e) {
34
+ process.stdout.write(JSON.stringify({ error: String(e?.cause?.code ?? e?.cause?.message ?? e?.message ?? e) }));
35
+ }
36
+ `;
37
+
38
+ function get(apiPath: string, token: string | null): Answer {
39
+ const env = { ...process.env };
40
+ delete env.VDX_GITHUB_TOKEN;
41
+ if (token) env.VDX_GITHUB_TOKEN = token;
42
+ const out: unknown = JSON.parse(
43
+ execFileSync(process.execPath, ['--input-type=module', '-e', GET, `https://api.github.com${apiPath}`], {
44
+ encoding: 'utf8',
45
+ env,
46
+ timeout: 10_000,
47
+ stdio: ['ignore', 'pipe', 'ignore'],
48
+ }),
49
+ );
50
+ const error = str(at(out, 'error'));
51
+ if (error !== undefined) throw new Error(error);
52
+ const status = at(out, 'status');
53
+ if (typeof status !== 'number') throw new Error('no status in the answer');
54
+ let body: unknown = null;
55
+ try {
56
+ body = JSON.parse(str(at(out, 'text')) ?? '');
57
+ } catch {
58
+ // not JSON: the status says enough
59
+ }
60
+ return { status, body };
61
+ }
62
+
63
+ function machineIo(root: string): HostingIo {
64
+ const run = (cmd: string, args: string[]): string | null => {
65
+ try {
66
+ return (
67
+ execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5_000 }).trim() ||
68
+ null
69
+ );
70
+ } catch {
71
+ return null;
72
+ }
73
+ };
74
+ return {
75
+ env: process.env,
76
+ git: (args) => run('git', ['--no-optional-locks', '-C', root, ...args]),
77
+ ghToken: () => run('gh', ['auth', 'token']),
78
+ get,
79
+ };
80
+ }
81
+
82
+ /** A refusal of the API as the reason the answer is unknown; null for a 200. */
83
+ function refusal(a: Answer, repo: string, token: string | null): Unknown | null {
84
+ if (a.status === 200) return null;
85
+ const message = str(at(a.body, 'message')) ?? '';
86
+ if ((a.status === 403 || a.status === 429) && message.startsWith('API rate limit exceeded')) {
87
+ return {
88
+ unknown: `GitHub's rate limit is spent${token ? '' : ' — 60 requests an hour without a token; gh auth login, or GH_TOKEN'}`,
89
+ };
90
+ }
91
+ if (a.status === 404 && !token) {
92
+ return { unknown: `GitHub does not show ${repo} without a token — private? gh auth login, or GH_TOKEN` };
93
+ }
94
+ return { unknown: `GitHub answered ${a.status} for ${repo}${message ? `: ${message}` : ''}` };
95
+ }
96
+
97
+ /** Checks a required-status-checks setting names: `checks`, else the older `contexts`. */
98
+ function checkCount(setting: unknown): number {
99
+ return Math.max(list(at(setting, 'checks')).length, list(at(setting, 'contexts')).length);
100
+ }
101
+
102
+ function judge(io: HostingIo, repo: string, token: string | null): Verdict {
103
+ let branch = io.git(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])?.replace(/^origin\//, '');
104
+ if (!branch) {
105
+ const r = io.get(`/repos/${repo}`, token);
106
+ const refused = refusal(r, repo, token);
107
+ if (refused) return refused;
108
+ branch = str(at(r.body, 'default_branch'));
109
+ if (!branch) return { unknown: `GitHub names no default branch for ${repo}` };
110
+ }
111
+ const ref = encodeURIComponent(branch);
112
+
113
+ // Classic protection: the checks are required of everyone, admins too (DL #3).
114
+ const b = io.get(`/repos/${repo}/branches/${ref}`, token);
115
+ const refusedBranch = refusal(b, repo, token);
116
+ if (refusedBranch) return refusedBranch;
117
+ const classic = at(b.body, 'protection', 'required_status_checks');
118
+ if (at(classic, 'enforcement_level') === 'everyone' && checkCount(classic) > 0) return true;
119
+
120
+ // Rulesets: a rule that requires checks, in a ruleset nobody bypasses.
121
+ const rules = io.get(`/repos/${repo}/rules/branches/${ref}`, token);
122
+ const refusedRules = refusal(rules, repo, token);
123
+ if (refusedRules) return refusedRules;
124
+ let hidden = false;
125
+ for (const rule of list(rules.body)) {
126
+ if (at(rule, 'type') !== 'required_status_checks') continue;
127
+ if (list(at(rule, 'parameters', 'required_status_checks')).length === 0) continue;
128
+ const id = at(rule, 'ruleset_id');
129
+ const ruleset = typeof id === 'number' ? io.get(`/repos/${repo}/rulesets/${id}`, token) : null;
130
+ const bypass = ruleset?.status === 200 ? at(ruleset.body, 'bypass_actors') : undefined;
131
+ if (!Array.isArray(bypass)) hidden = true;
132
+ else if (bypass.length === 0) return true;
133
+ }
134
+ if (hidden) {
135
+ return {
136
+ 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
+ };
138
+ }
139
+ return false;
140
+ }
141
+
142
+ /**
143
+ * Whether the default branch on the hosting of `origin` takes changes only after a check
144
+ * required of everyone. GitHub only; what vdx cannot ask is unknown, with the reason.
145
+ */
146
+ export function branchRequiresChecks(ctx: Ctx, io: HostingIo = machineIo(ctx.projectRoot)): Verdict {
147
+ const key = 'hosting:branch-requires-checks';
148
+ if (ctx.cache.has(key)) return ctx.cache.get(key) as Verdict;
149
+ const verdict = ask(io);
150
+ ctx.cache.set(key, verdict);
151
+ return verdict;
152
+ }
153
+
154
+ function ask(io: HostingIo): Verdict {
155
+ if (io.env.VDX_OFFLINE) return { unknown: 'VDX_OFFLINE is set — the hosting was not asked' };
156
+ const url = io.git(['remote', 'get-url', 'origin']);
157
+ if (!url) return { unknown: 'no origin remote — the hosting is not known' };
158
+ const { host, path: repo } = remoteParts(url);
159
+ // The host only: a URL can carry a token.
160
+ if (host !== 'github.com')
161
+ return { unknown: `origin is on ${host || 'a host vdx cannot tell'} — vdx reads GitHub only` };
162
+ const token = io.env.GH_TOKEN || io.env.GITHUB_TOKEN || io.ghToken();
163
+ try {
164
+ return judge(io, repo, token);
165
+ } catch (e) {
166
+ return { unknown: `GitHub did not answer: ${errorMessage(e)}` };
167
+ }
168
+ }
package/src/index.ts CHANGED
@@ -125,6 +125,9 @@ const COMMANDS: Record<string, CommandSpec> = {
125
125
  usage: 'vdx audit [project_path] [--rubric <path>] [--stack <stack>] [--format=ansi|markdown|json] [--json]',
126
126
  help: `Scores the project (default: cwd) against the rubric and prints the report.
127
127
  Reads only. The rubric is the bundled one unless --rubric or $VDX_RUBRIC names another.
128
+ For branch-protection it asks GitHub whether the default branch of origin requires
129
+ a check, with a token from $GH_TOKEN, $GITHUB_TOKEN or gh when there is one;
130
+ VDX_OFFLINE=1 does not ask, and the axis says unknown.
128
131
  `,
129
132
  bools: ['json'],
130
133
  values: ['rubric', 'stack', 'format'],
package/src/init.ts CHANGED
@@ -6,7 +6,7 @@ import { at } from './narrow.ts';
6
6
  export const STANDARD_VERBS = ['up', 'down', 'build', 'test', 'check', 'fix'] as const;
7
7
  export type Verb = (typeof STANDARD_VERBS)[number];
8
8
 
9
- export const DEFAULT_BASELINE = 'github.com/VoDmAl/vdx-rubric-vodmal@v1.4.2';
9
+ export const DEFAULT_BASELINE = 'github.com/VoDmAl/vdx-rubric-vodmal@v1.5.0';
10
10
 
11
11
  const VERB_ALIASES: Record<Verb, string[]> = {
12
12
  up: ['docker:up', 'docker-up', 'docker:up:detached', 'start', 'dev', 'serve'],
package/src/predicates.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  } from './facts.ts';
15
15
  import { checkingJobs, ciSystems, ciTaskCalls, mainPushSteps, readWorkflows, shippingJobs, upstream } from './gha.ts';
16
16
  import { detectHookFrameworks } from './hooks.ts';
17
+ import { branchRequiresChecks } from './hosting.ts';
17
18
  import { covers, expandTasks, isVocabularyTask, projectTaskCalls } from './vocabulary.ts';
18
19
  import { at, isStringList, str } from './narrow.ts';
19
20
 
@@ -248,10 +249,8 @@ export const REGISTRY: Record<string, PredicateFn> = {
248
249
 
249
250
  // --- branch-protection: only the hosting knows (schema 0.3) ---
250
251
 
251
- /** L3: main takes changes only after a required check. No hosting extension yet — unknown everywhere (DL #12). */
252
- branch_requires_checks: () => ({
253
- unknown: 'only the hosting knows whether main is protected — vdx has no extension for it yet',
254
- }),
252
+ /** L3: the default branch takes changes only after a check required of everyone — asked of GitHub (docs/tasks/hosting-extension). */
253
+ branch_requires_checks: (_args, ctx) => branchRequiresChecks(ctx),
255
254
 
256
255
  // --- git-hygiene: the hook recognizer (schema 0.3) ---
257
256