@vodmal/vdx-cli 0.21.1 → 0.22.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/src/evaluator.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import type { Predicate } from './rubric.ts';
2
2
  import type { Ctx } from './facts.ts';
3
- import { REGISTRY } from './predicates.ts';
3
+ import { REGISTRY, isUnknown, type Unknown, type Verdict } from './predicates.ts';
4
+
5
+ export { isUnknown, type Unknown, type Verdict };
4
6
 
5
7
  /**
6
8
  * Sugar shorthand: `{ has_task: "up" }` → `{ fn: "has_task", args: { task: "up" } }`
@@ -15,7 +17,20 @@ const SUGAR_ARG_KEY: Record<string, string> = {
15
17
  phpstan_level_at_least: 'n',
16
18
  };
17
19
 
18
- export function evalPredicate(p: Predicate, ctx: Ctx): boolean {
20
+ export const COMBINATORS = ['all_of', 'any_of', 'at_least_n_of', 'not'] as const;
21
+
22
+ /** The first unknown among verdicts, for the reason a combination cannot be decided. */
23
+ function firstUnknown(vs: Verdict[]): Unknown | undefined {
24
+ return vs.find(isUnknown);
25
+ }
26
+
27
+ /**
28
+ * Three-valued: true, false, or unknown with the reason the fact could not be
29
+ * read (a CI system or a hosting vdx does not inspect). Unknown never turns
30
+ * into true: `any_of` is true only on a true branch, `all_of` false on any
31
+ * false branch, otherwise unknown.
32
+ */
33
+ export function evalVerdict(p: Predicate, ctx: Ctx): Verdict {
19
34
  if (p === null || p === undefined) return false;
20
35
  if (typeof p === 'boolean') return p;
21
36
 
@@ -23,21 +38,27 @@ export function evalPredicate(p: Predicate, ctx: Ctx): boolean {
23
38
  const obj = p as Record<string, any>;
24
39
 
25
40
  if ('all_of' in obj) {
26
- const list = obj.all_of as Predicate[];
27
- return list.every((q) => evalPredicate(q, ctx));
41
+ const vs = (obj.all_of as Predicate[]).map((q) => evalVerdict(q, ctx));
42
+ if (vs.some((v) => v === false)) return false;
43
+ return firstUnknown(vs) ?? true;
28
44
  }
29
45
  if ('any_of' in obj) {
30
- const list = obj.any_of as Predicate[];
31
- return list.some((q) => evalPredicate(q, ctx));
46
+ const vs = (obj.any_of as Predicate[]).map((q) => evalVerdict(q, ctx));
47
+ if (vs.some((v) => v === true)) return true;
48
+ return firstUnknown(vs) ?? false;
32
49
  }
33
50
  if ('at_least_n_of' in obj) {
34
51
  const { n, predicates } = obj.at_least_n_of as { n: number; predicates: Predicate[] };
35
- let matched = 0;
36
- for (const q of predicates) if (evalPredicate(q, ctx)) matched += 1;
37
- return matched >= n;
52
+ const vs = predicates.map((q) => evalVerdict(q, ctx));
53
+ const matched = vs.filter((v) => v === true).length;
54
+ if (matched >= n) return true;
55
+ const open = vs.filter(isUnknown).length;
56
+ if (matched + open < n) return false;
57
+ return firstUnknown(vs)!;
38
58
  }
39
59
  if ('not' in obj) {
40
- return !evalPredicate(obj.not, ctx);
60
+ const v = evalVerdict(obj.not, ctx);
61
+ return isUnknown(v) ? v : !v;
41
62
  }
42
63
  if ('fn' in obj) {
43
64
  const fn = REGISTRY[String(obj.fn)];
@@ -70,3 +91,32 @@ export function evalPredicate(p: Predicate, ctx: Ctx): boolean {
70
91
  process.stderr.write(`warning: malformed predicate ${JSON.stringify(p)}\n`);
71
92
  return false;
72
93
  }
94
+
95
+ /** Two-valued view: unknown counts as not met (applies_when, flags, profile rules). */
96
+ export function evalPredicate(p: Predicate, ctx: Ctx): boolean {
97
+ return evalVerdict(p, ctx) === true;
98
+ }
99
+
100
+ /**
101
+ * Predicate names a tree refers to, for load-time validation. Sugar keys and
102
+ * `fn:` both count; combinators are walked, not reported.
103
+ */
104
+ export function predicateNames(p: Predicate): string[] {
105
+ const out: string[] = [];
106
+ const walk = (q: Predicate): void => {
107
+ if (q === null || q === undefined || typeof q !== 'object' || Array.isArray(q)) return;
108
+ const obj = q as Record<string, any>;
109
+ if ('all_of' in obj) return (obj.all_of as Predicate[]).forEach(walk);
110
+ if ('any_of' in obj) return (obj.any_of as Predicate[]).forEach(walk);
111
+ if ('at_least_n_of' in obj) return (obj.at_least_n_of?.predicates as Predicate[] ?? []).forEach(walk);
112
+ if ('not' in obj) return walk(obj.not);
113
+ if ('fn' in obj) {
114
+ out.push(String(obj.fn));
115
+ return;
116
+ }
117
+ const keys = Object.keys(obj);
118
+ if (keys.length === 1) out.push(keys[0]!);
119
+ };
120
+ walk(p);
121
+ return out;
122
+ }
package/src/gha.ts ADDED
@@ -0,0 +1,211 @@
1
+ /**
2
+ * GitHub Actions workflows as the `ci` axis reads them: which ones run on a
3
+ * push to the main branch, which steps run tests, which failures are masked,
4
+ * and which jobs ship something. Other CI systems are read as "present" (L1)
5
+ * and answer "unknown" above it — vdx does not inspect them yet.
6
+ */
7
+ import * as fs from 'node:fs';
8
+ import * as path from 'node:path';
9
+ import YAML from 'js-yaml';
10
+ import { fileExists, readText, type Ctx } from './facts.ts';
11
+ import { projectTaskCalls } from './vocabulary.ts';
12
+
13
+ export interface GhaStep {
14
+ run?: string;
15
+ uses?: string;
16
+ continueOnError: boolean;
17
+ }
18
+
19
+ export interface GhaJob {
20
+ id: string;
21
+ name?: string;
22
+ needs: string[];
23
+ steps: GhaStep[];
24
+ continueOnError: boolean;
25
+ matrix: boolean;
26
+ }
27
+
28
+ export interface GhaWorkflow {
29
+ file: string;
30
+ pushesMain: boolean;
31
+ jobs: GhaJob[];
32
+ }
33
+
34
+ const MAIN_BRANCHES = ['main', 'master'];
35
+
36
+ /** GitHub's branch filter glob: `**` crosses `/`, `*` does not. */
37
+ function filterMatches(pattern: string, branch: string): boolean {
38
+ const re = pattern
39
+ .split('**')
40
+ .map((part) =>
41
+ part
42
+ .split('*')
43
+ .map((s) => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&'))
44
+ .join('[^/]*'),
45
+ )
46
+ .join('.*');
47
+ return new RegExp(`^${re}$`).test(branch);
48
+ }
49
+
50
+ function branchSelected(filters: unknown, branch: string): boolean {
51
+ const list = Array.isArray(filters) ? filters : [filters];
52
+ let selected = false;
53
+ for (const f of list) {
54
+ if (typeof f !== 'string') continue;
55
+ if (f.startsWith('!')) {
56
+ if (filterMatches(f.slice(1), branch)) selected = false;
57
+ } else if (filterMatches(f, branch)) {
58
+ selected = true;
59
+ }
60
+ }
61
+ return selected;
62
+ }
63
+
64
+ /** Does `on:` start the workflow on a push to main/master? */
65
+ export function pushesMain(on: unknown): boolean {
66
+ if (on === 'push') return true;
67
+ if (Array.isArray(on)) return on.includes('push');
68
+ if (!on || typeof on !== 'object') return false;
69
+ if (!('push' in on)) return false;
70
+ const push = (on as Record<string, any>).push;
71
+ if (push === null || push === undefined || typeof push !== 'object') return true;
72
+ const hasBranchFilter = push.branches !== undefined || push['branches-ignore'] !== undefined;
73
+ if (!hasBranchFilter && (push.tags !== undefined || push['tags-ignore'] !== undefined)) return false;
74
+ return MAIN_BRANCHES.some((b) => {
75
+ if (push.branches !== undefined) return branchSelected(push.branches, b);
76
+ if (push['branches-ignore'] !== undefined) return !branchSelected(push['branches-ignore'], b);
77
+ return true;
78
+ });
79
+ }
80
+
81
+ function truthy(v: unknown): boolean {
82
+ return v === true || v === 'true';
83
+ }
84
+
85
+ export function parseWorkflow(file: string, text: string): GhaWorkflow | null {
86
+ let data: any;
87
+ try {
88
+ data = YAML.load(text);
89
+ } catch {
90
+ return null;
91
+ }
92
+ if (!data || typeof data !== 'object') return null;
93
+ const jobs: GhaJob[] = [];
94
+ for (const [id, job] of Object.entries((data.jobs ?? {}) as Record<string, any>)) {
95
+ if (!job || typeof job !== 'object') continue;
96
+ const needs = job.needs === undefined ? [] : Array.isArray(job.needs) ? job.needs : [job.needs];
97
+ const steps: GhaStep[] = (Array.isArray(job.steps) ? job.steps : [])
98
+ .filter((s: unknown) => s && typeof s === 'object')
99
+ .map((s: any) => ({
100
+ ...(typeof s.run === 'string' ? { run: s.run } : {}),
101
+ ...(typeof s.uses === 'string' ? { uses: s.uses } : {}),
102
+ continueOnError: truthy(s['continue-on-error']),
103
+ }));
104
+ jobs.push({
105
+ id,
106
+ ...(typeof job.name === 'string' ? { name: job.name } : {}),
107
+ needs: needs.filter((n: unknown): n is string => typeof n === 'string'),
108
+ steps,
109
+ continueOnError: truthy(job['continue-on-error']),
110
+ matrix: Boolean(job.strategy?.matrix),
111
+ });
112
+ }
113
+ return { file, pushesMain: pushesMain(data.on), jobs };
114
+ }
115
+
116
+ export function readWorkflows(ctx: Ctx): GhaWorkflow[] {
117
+ const key = 'gha:workflows';
118
+ if (ctx.cache.has(key)) return ctx.cache.get(key) as GhaWorkflow[];
119
+ const dir = path.join(ctx.projectRoot, '.github', 'workflows');
120
+ const out: GhaWorkflow[] = [];
121
+ let files: string[] = [];
122
+ try {
123
+ files = fs.readdirSync(dir).filter((f) => /\.ya?ml$/.test(f)).sort();
124
+ } catch {
125
+ files = [];
126
+ }
127
+ for (const f of files) {
128
+ const rel = path.join('.github', 'workflows', f);
129
+ const text = readText(ctx, rel);
130
+ const wf = text === null ? null : parseWorkflow(rel, text);
131
+ if (wf) out.push(wf);
132
+ }
133
+ ctx.cache.set(key, out);
134
+ return out;
135
+ }
136
+
137
+ /** CI systems vdx sees but does not read beyond "a config exists". */
138
+ const OTHER_CI: Array<[string, string]> = [
139
+ ['.gitlab-ci.yml', 'GitLab CI'],
140
+ ['.circleci/config.yml', 'CircleCI'],
141
+ ];
142
+
143
+ /** The CI systems a repository configures: `gha` for GitHub Actions workflows, others by name. */
144
+ export function ciSystems(ctx: Ctx): string[] {
145
+ const out: string[] = [];
146
+ if (readWorkflows(ctx).length > 0) out.push('gha');
147
+ for (const [file, name] of OTHER_CI) if (fileExists(ctx, file)) out.push(name);
148
+ return out;
149
+ }
150
+
151
+ /** `|| true` and its relatives: the step reports success whatever the command did. */
152
+ const MASK_RE = /\|\|\s*(true|:|exit\s+0)\b/;
153
+
154
+ export function stepMasked(step: GhaStep, job: GhaJob): boolean {
155
+ if (job.continueOnError || step.continueOnError) return true;
156
+ return step.run !== undefined && MASK_RE.test(step.run);
157
+ }
158
+
159
+ /** Unmasked `run:` steps of jobs in workflows started by a push to main. */
160
+ export function mainPushSteps(ctx: Ctx): Array<{ wf: GhaWorkflow; job: GhaJob; step: GhaStep }> {
161
+ const out: Array<{ wf: GhaWorkflow; job: GhaJob; step: GhaStep }> = [];
162
+ for (const wf of readWorkflows(ctx)) {
163
+ if (!wf.pushesMain) continue;
164
+ for (const job of wf.jobs) {
165
+ for (const step of job.steps) {
166
+ if (step.run === undefined || stepMasked(step, job)) continue;
167
+ out.push({ wf, job, step });
168
+ }
169
+ }
170
+ }
171
+ return out;
172
+ }
173
+
174
+ /** Project tasks CI calls on a push to main, unmasked. */
175
+ export function ciTaskCalls(ctx: Ctx): Set<string> {
176
+ const out = new Set<string>();
177
+ for (const { step } of mainPushSteps(ctx)) for (const t of projectTaskCalls(step.run!, ctx)) out.add(t);
178
+ return out;
179
+ }
180
+
181
+ /** Jobs that check: an unmasked step runs tests (`pattern`) or a `test`/`check` task. */
182
+ export function checkingJobs(ctx: Ctx, pattern: RegExp): Set<GhaJob> {
183
+ const out = new Set<GhaJob>();
184
+ for (const { job, step } of mainPushSteps(ctx)) {
185
+ if (pattern.test(step.run!) || projectTaskCalls(step.run!, ctx).some((t) => /^(test|check)(:|$)/.test(t))) {
186
+ out.add(job);
187
+ }
188
+ }
189
+ return out;
190
+ }
191
+
192
+ const SHIP_RE = /deploy|release|publish/i;
193
+
194
+ /** Jobs that ship: deploy, release, publish — by id or name. */
195
+ export function shippingJobs(wf: GhaWorkflow): GhaJob[] {
196
+ return wf.jobs.filter((j) => SHIP_RE.test(j.id) || (j.name !== undefined && SHIP_RE.test(j.name)));
197
+ }
198
+
199
+ /** Every job `job` waits for, transitively, within its workflow. */
200
+ export function upstream(wf: GhaWorkflow, job: GhaJob): Set<GhaJob> {
201
+ const byId = new Map(wf.jobs.map((j) => [j.id, j] as const));
202
+ const seen = new Set<GhaJob>();
203
+ const queue = [...job.needs];
204
+ while (queue.length > 0) {
205
+ const j = byId.get(queue.shift()!);
206
+ if (!j || seen.has(j)) continue;
207
+ seen.add(j);
208
+ queue.push(...j.needs);
209
+ }
210
+ return seen;
211
+ }