@zgeoff/chex 0.1.2 → 0.1.5

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/chex",
3
- "version": "0.1.2",
3
+ "version": "0.1.5",
4
4
  "description": "Run each shared docs and repo check as one subcommand, with the lefthook pre-push baseline that calls them",
5
5
  "keywords": [
6
6
  "checks",
@@ -35,14 +35,14 @@
35
35
  "typecheck": "tsrs"
36
36
  },
37
37
  "dependencies": {
38
- "@zgeoff/std": "0.4.0",
38
+ "@zgeoff/std": "0.4.1",
39
39
  "oxc-parser": "0.156.0",
40
40
  "ts-pattern": "5.9.2"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/bun": "1.4.4",
44
- "@zgeoff/rig": "0.1.1",
45
- "@zgeoff/std": "0.4.0",
44
+ "@zgeoff/rig": "0.1.3",
45
+ "@zgeoff/std": "0.4.1",
46
46
  "@zgeoff/testing": "0.2.0",
47
47
  "tiny-invariant": "1.3.3",
48
48
  "tsrs": "0.18.0-ts7.1.0-dev.20260929",
@@ -0,0 +1,244 @@
1
+ import { collectExpressionReferences } from './collect-expression-references.ts';
2
+ import { findMember } from './find-member.ts';
3
+ import { getTopLevelLine } from './get-top-level-line.ts';
4
+ import { pullRequestTriggers } from './pull-request-triggers.ts';
5
+ import type { Job, WorkflowContext, WorkflowFinding } from './workflow-context.ts';
6
+
7
+ export function collectConcurrencyFindings(context: WorkflowContext): WorkflowFinding[] {
8
+ const deployJobs = context.jobs.filter((job) => isDeployJob(job));
9
+ const workflowConcurrency = findMember(context.workflow, 'concurrency');
10
+
11
+ return [
12
+ ...deployJobs.flatMap((job) => collectDeployFindings(context, job, workflowConcurrency)),
13
+ ...(isChecksWorkflow(context)
14
+ ? collectChecksFindings(context, deployJobs, workflowConcurrency)
15
+ : []),
16
+ ];
17
+ }
18
+
19
+ const deployJobPattern = /release|deploy|migrate/iu;
20
+
21
+ function isDeployJob(job: Job): boolean {
22
+ const name = findMember(job.definition, 'name');
23
+
24
+ return deployJobPattern.test(job.id) || (typeof name === 'string' && deployJobPattern.test(name));
25
+ }
26
+
27
+ function collectDeployFindings(
28
+ context: WorkflowContext,
29
+ job: Job,
30
+ workflowConcurrency: unknown,
31
+ ): WorkflowFinding[] {
32
+ const concurrency = findMember(job.definition, 'concurrency');
33
+ const isQueued = isQueueGroup(concurrency ?? workflowConcurrency);
34
+
35
+ return [
36
+ ...(isQueued
37
+ ? []
38
+ : [
39
+ {
40
+ path: context.path,
41
+ line: job.line,
42
+ message: `job ${job.id} releases or deploys, so it or its workflow sets a concurrency group with cancel-in-progress: false`,
43
+ },
44
+ ]),
45
+ ...(isCancelling(workflowConcurrency)
46
+ ? [
47
+ {
48
+ path: context.path,
49
+ line: getTopLevelLine(context.lines, 'concurrency'),
50
+ message: `the workflow-level cancel-in-progress: true can cancel job ${job.id}, which releases or deploys; set each concurrency group per job`,
51
+ },
52
+ ]
53
+ : []),
54
+ ];
55
+ }
56
+
57
+ function isQueueGroup(concurrency: unknown): boolean {
58
+ return (
59
+ typeof findMember(concurrency, 'group') === 'string' &&
60
+ findMember(concurrency, 'cancel-in-progress') === false
61
+ );
62
+ }
63
+
64
+ function isCancelling(concurrency: unknown): boolean {
65
+ return findMember(concurrency, 'cancel-in-progress') === true;
66
+ }
67
+
68
+ function isChecksWorkflow(context: WorkflowContext): boolean {
69
+ return context.triggers.has('pull_request') || context.triggers.has('push');
70
+ }
71
+
72
+ function collectChecksFindings(
73
+ context: WorkflowContext,
74
+ deployJobs: readonly Job[],
75
+ workflowConcurrency: unknown,
76
+ ): WorkflowFinding[] {
77
+ const otherJobs = context.jobs.filter((job) => !deployJobs.includes(job));
78
+
79
+ const isEveryJobCovered =
80
+ otherJobs.length > 0 &&
81
+ otherJobs.every((job) => isChecksGroup(context, findMember(job.definition, 'concurrency')));
82
+
83
+ const isCovered = isChecksGroup(context, workflowConcurrency) || isEveryJobCovered;
84
+
85
+ const workflowFindings =
86
+ deployJobs.length > 0 || isCovered
87
+ ? []
88
+ : [
89
+ {
90
+ path: context.path,
91
+ line: getTopLevelLine(context.lines, 'on'),
92
+ message:
93
+ 'a checks workflow, which runs on pull_request or push, sets concurrency with cancel-in-progress: true; a gate that runs only on a push to main may instead group by github.sha and never cancel',
94
+ },
95
+ ];
96
+
97
+ const jobFindings =
98
+ deployJobs.length === 0
99
+ ? []
100
+ : otherJobs
101
+ .filter((job) => !isChecksGroup(context, findMember(job.definition, 'concurrency')))
102
+ .map((job) => ({
103
+ path: context.path,
104
+ line: job.line,
105
+ message: `job ${job.id} runs in a checks workflow beside a release or deploy job, so it sets a job-level concurrency group with cancel-in-progress: true`,
106
+ }));
107
+
108
+ // A workflow-level group that never cancels queues the release or deploy jobs, and the job-level
109
+ // groups decide which checks cancel, so a per-run value there only keeps two runs apart.
110
+ const isDeployQueue = deployJobs.length > 0 && isQueueGroup(workflowConcurrency);
111
+
112
+ return [
113
+ ...workflowFindings,
114
+ ...jobFindings,
115
+ ...(isDeployQueue
116
+ ? []
117
+ : collectPerRunGroupFindings(
118
+ context,
119
+ workflowConcurrency,
120
+ getTopLevelLine(context.lines, 'concurrency'),
121
+ )),
122
+ ...otherJobs.flatMap((job) =>
123
+ collectPerRunGroupFindings(context, findMember(job.definition, 'concurrency'), job.line),
124
+ ),
125
+ ];
126
+ }
127
+
128
+ function isChecksGroup(context: WorkflowContext, concurrency: unknown): boolean {
129
+ return (
130
+ isCancelling(concurrency) ||
131
+ isGateGroup(context, concurrency) ||
132
+ isDispatchGroup(context, concurrency)
133
+ );
134
+ }
135
+
136
+ function isGateGroup(context: WorkflowContext, concurrency: unknown): boolean {
137
+ const group = findMember(concurrency, 'group');
138
+ const cancel = findMember(concurrency, 'cancel-in-progress');
139
+
140
+ return (
141
+ isMainGate(context) &&
142
+ typeof group === 'string' &&
143
+ collectExpressionReferences(group).includes('github.sha') &&
144
+ (cancel === undefined || cancel === false)
145
+ );
146
+ }
147
+
148
+ // A gate on main runs once for each commit, so a newer commit must not cancel the older run.
149
+ function isMainGate(context: WorkflowContext): boolean {
150
+ const push = findMember(findMember(context.workflow, 'on'), 'push');
151
+ const branches = [findMember(push, 'branches')].flat();
152
+
153
+ return (
154
+ !pullRequestTriggers.some((name) => context.triggers.has(name)) &&
155
+ branches.length > 0 &&
156
+ branches.every((branch) => branch === 'main')
157
+ );
158
+ }
159
+
160
+ // A dispatched run whose group is a run-unique value shares the group with no other run, so no run
161
+ // cancels it, and every other run still cancels.
162
+ function isDispatchGroup(context: WorkflowContext, concurrency: unknown): boolean {
163
+ const group = findMember(concurrency, 'group');
164
+
165
+ return (
166
+ typeof group === 'string' &&
167
+ findNonDispatchGroup(group) !== undefined &&
168
+ isCancellingOutsideDispatch(context, findMember(concurrency, 'cancel-in-progress'))
169
+ );
170
+ }
171
+
172
+ // GitHub reads `a && b || c` as `(a && b) || c`, so a dispatched run takes its group from b and
173
+ // every other run from c. A `||` inside b ends b early, so b no longer reads as one value.
174
+ const dispatchGroupPattern =
175
+ /^(?<prefix>[^$]*)\$\{\{\s*github\.event_name\s*==\s*'workflow_dispatch'\s*&&(?<dispatch>.+?)\|\|(?<other>.+)\}\}(?<suffix>[^$]*)$/su;
176
+
177
+ // Two dispatches at one commit share github.sha, and github.run_attempt starts at 1 in each run.
178
+ const dispatchRunValues = new Set(['github.run_id', 'github.run_number']);
179
+
180
+ // Returns the group a run other than a dispatched one reads, when a dispatched run's group is a
181
+ // lone run-unique value.
182
+ function findNonDispatchGroup(group: string): string | undefined {
183
+ const split = dispatchGroupPattern.exec(group)?.groups;
184
+
185
+ return split !== undefined && dispatchRunValues.has((split['dispatch'] ?? '').trim())
186
+ ? `${split['prefix'] ?? ''}\${{ ${split['other'] ?? ''} }}${split['suffix'] ?? ''}`
187
+ : undefined;
188
+ }
189
+
190
+ function isCancellingOutsideDispatch(context: WorkflowContext, cancel: unknown): boolean {
191
+ const events = [...context.triggers].filter((event) => event !== 'workflow_dispatch');
192
+
193
+ return (
194
+ cancel === true ||
195
+ (typeof cancel === 'string' && events.every((event) => isEventExpressionTrue(cancel, event)))
196
+ );
197
+ }
198
+
199
+ const wholeExpressionPattern = /^\s*\$\{\{(?<body>.*)\}\}\s*$/su;
200
+ const eventTestPattern = /^\s*github\.event_name\s*(?<operator>==|!=)\s*'(?<event>[^']*)'\s*$/u;
201
+
202
+ // Reads only github.event_name tests joined by ||; any other expression counts as false.
203
+ function isEventExpressionTrue(text: string, event: string): boolean {
204
+ const body = wholeExpressionPattern.exec(text)?.groups?.['body'];
205
+ const terms = body === undefined ? [] : body.split('||');
206
+
207
+ const tests = terms.flatMap((term) => {
208
+ const test = eventTestPattern.exec(term)?.groups;
209
+
210
+ return test === undefined ? [] : [test];
211
+ });
212
+
213
+ return (
214
+ tests.length > 0 &&
215
+ tests.length === terms.length &&
216
+ tests.some((test) => (test['operator'] === '==') === (test['event'] === event))
217
+ );
218
+ }
219
+
220
+ // A value that differs between two runs on the same branch gives each run its own group, which no
221
+ // later run cancels.
222
+ const perRunValues = ['github.sha', 'github.run_id', 'github.run_number', 'github.run_attempt'];
223
+
224
+ function collectPerRunGroupFindings(
225
+ context: WorkflowContext,
226
+ concurrency: unknown,
227
+ line: number,
228
+ ): WorkflowFinding[] {
229
+ const group = findMember(concurrency, 'group');
230
+ const checked = typeof group === 'string' ? (findNonDispatchGroup(group) ?? group) : '';
231
+ const references = collectExpressionReferences(checked);
232
+ const allowed = isGateGroup(context, concurrency) ? 'github.sha' : undefined;
233
+ const perRun = perRunValues.find((value) => value !== allowed && references.includes(value));
234
+
235
+ return perRun === undefined
236
+ ? []
237
+ : [
238
+ {
239
+ path: context.path,
240
+ line,
241
+ message: `the concurrency group holds ${perRun}, so no newer run cancels this one`,
242
+ },
243
+ ];
244
+ }
@@ -0,0 +1,5 @@
1
+ export function findMember(value: unknown, key: string): unknown {
2
+ return typeof value === 'object' && value !== null && Object.hasOwn(value, key)
3
+ ? Reflect.get(value, key)
4
+ : undefined;
5
+ }
@@ -0,0 +1,5 @@
1
+ export function getTopLevelLine(lines: readonly string[], key: string): number {
2
+ const keyPattern = new RegExp(`^['"]?${RegExp.escape(key)}['"]?:`, 'u');
3
+
4
+ return lines.findIndex((line) => keyPattern.test(line)) + 1 || 1;
5
+ }
@@ -0,0 +1 @@
1
+ export const pullRequestTriggers = ['pull_request', 'pull_request_target'];
@@ -1,12 +1,18 @@
1
1
  import { parseArgs } from 'node:util';
2
2
  import { collectCommandNames } from './collect-command-names.ts';
3
- import { collectExpressionReferences } from './collect-expression-references.ts';
3
+ import { collectConcurrencyFindings } from './collect-concurrency-findings.ts';
4
4
  import { findInstallCachePath } from './find-install-cache-path.ts';
5
+ import { findMember } from './find-member.ts';
6
+ import { getTopLevelLine } from './get-top-level-line.ts';
5
7
  import { parseShellWords } from './parse-shell-words.ts';
8
+ import { pullRequestTriggers } from './pull-request-triggers.ts';
6
9
  import type { ChecksProfile } from './read-checks-profile.ts';
7
10
  import { readChecksProfile } from './read-checks-profile.ts';
8
11
  import type { RunProgram } from './run-program.ts';
9
12
  import { runProgram } from './run-program.ts';
13
+ import type { Job, WorkflowContext, WorkflowFinding } from './workflow-context.ts';
14
+
15
+ export type { WorkflowFinding } from './workflow-context.ts';
10
16
 
11
17
  export interface WorkflowsCheckOptions {
12
18
  readonly cwd: string;
@@ -21,14 +27,6 @@ export interface WorkflowsCheckOptions {
21
27
  readonly runProgram: RunProgram;
22
28
  }
23
29
 
24
- export interface WorkflowFinding {
25
- // Relative to the repo root.
26
- readonly path: string;
27
-
28
- readonly line: number;
29
- readonly message: string;
30
- }
31
-
32
30
  // GitHub runs no workflow from a subfolder of .github/workflows.
33
31
  const workflowPathPattern = /^(?:\.github\/workflows|sync\/workflow-callers)\/[^/]+\.ya?ml$/u;
34
32
 
@@ -57,21 +55,6 @@ function runGit(options: Readonly<WorkflowsCheckOptions>, args: readonly string[
57
55
  return result.stdout;
58
56
  }
59
57
 
60
- interface Job {
61
- readonly id: string;
62
- readonly definition: unknown;
63
- readonly line: number;
64
- }
65
-
66
- interface WorkflowContext {
67
- readonly path: string;
68
- readonly profile: ChecksProfile | undefined;
69
- readonly workflow: unknown;
70
- readonly lines: readonly string[];
71
- readonly jobs: readonly Job[];
72
- readonly triggers: ReadonlySet<string>;
73
- }
74
-
75
58
  function collectWorkflowFindings(
76
59
  path: string,
77
60
  text: string,
@@ -131,12 +114,6 @@ function getJobLine(lines: readonly string[], jobsLine: number, id: string): num
131
114
  return offset === -1 ? Math.max(jobsLine + 1, 1) : jobsLine + offset + 2;
132
115
  }
133
116
 
134
- function findMember(value: unknown, key: string): unknown {
135
- return typeof value === 'object' && value !== null && Object.hasOwn(value, key)
136
- ? Reflect.get(value, key)
137
- : undefined;
138
- }
139
-
140
117
  function collectTriggers(on: unknown): Set<string> {
141
118
  if (typeof on === 'string') {
142
119
  return new Set([on]);
@@ -231,8 +208,6 @@ function collectTimeoutFindings(context: WorkflowContext, job: Job): WorkflowFin
231
208
  : [];
232
209
  }
233
210
 
234
- const pullRequestTriggers = ['pull_request', 'pull_request_target'];
235
-
236
211
  // ci-design states the rule for a private repo only, so a public run leaves it alone.
237
212
  function collectHeavyFindings(context: WorkflowContext, job: Job): WorkflowFinding[] {
238
213
  const trigger = pullRequestTriggers.find((name) => context.triggers.has(name));
@@ -346,176 +321,6 @@ function collectCheckoutFindings(context: WorkflowContext, job: Job): WorkflowFi
346
321
  });
347
322
  }
348
323
 
349
- function collectConcurrencyFindings(context: WorkflowContext): WorkflowFinding[] {
350
- const deployJobs = context.jobs.filter((job) => isDeployJob(job));
351
- const workflowConcurrency = findMember(context.workflow, 'concurrency');
352
-
353
- return [
354
- ...deployJobs.flatMap((job) => collectDeployFindings(context, job, workflowConcurrency)),
355
- ...(isChecksWorkflow(context)
356
- ? collectChecksFindings(context, deployJobs, workflowConcurrency)
357
- : []),
358
- ];
359
- }
360
-
361
- const deployJobPattern = /release|deploy|migrate/iu;
362
-
363
- function isDeployJob(job: Job): boolean {
364
- const name = findMember(job.definition, 'name');
365
-
366
- return deployJobPattern.test(job.id) || (typeof name === 'string' && deployJobPattern.test(name));
367
- }
368
-
369
- function collectDeployFindings(
370
- context: WorkflowContext,
371
- job: Job,
372
- workflowConcurrency: unknown,
373
- ): WorkflowFinding[] {
374
- const concurrency = findMember(job.definition, 'concurrency');
375
-
376
- const hasOwnGroup =
377
- typeof findMember(concurrency, 'group') === 'string' &&
378
- findMember(concurrency, 'cancel-in-progress') === false;
379
-
380
- return [
381
- ...(hasOwnGroup
382
- ? []
383
- : [
384
- {
385
- path: context.path,
386
- line: job.line,
387
- message: `job ${job.id} releases or deploys, so it sets a job-level concurrency group with cancel-in-progress: false`,
388
- },
389
- ]),
390
- ...(isCancelling(workflowConcurrency)
391
- ? [
392
- {
393
- path: context.path,
394
- line: getTopLevelLine(context.lines, 'concurrency'),
395
- message: `the workflow-level cancel-in-progress: true can cancel job ${job.id}, which releases or deploys; set each concurrency group per job`,
396
- },
397
- ]
398
- : []),
399
- ];
400
- }
401
-
402
- function isCancelling(concurrency: unknown): boolean {
403
- return findMember(concurrency, 'cancel-in-progress') === true;
404
- }
405
-
406
- function getTopLevelLine(lines: readonly string[], key: string): number {
407
- const keyPattern = new RegExp(`^['"]?${RegExp.escape(key)}['"]?:`, 'u');
408
-
409
- return lines.findIndex((line) => keyPattern.test(line)) + 1 || 1;
410
- }
411
-
412
- function isChecksWorkflow(context: WorkflowContext): boolean {
413
- return context.triggers.has('pull_request') || context.triggers.has('push');
414
- }
415
-
416
- function collectChecksFindings(
417
- context: WorkflowContext,
418
- deployJobs: readonly Job[],
419
- workflowConcurrency: unknown,
420
- ): WorkflowFinding[] {
421
- const otherJobs = context.jobs.filter((job) => !deployJobs.includes(job));
422
-
423
- const isEveryJobCovered =
424
- otherJobs.length > 0 &&
425
- otherJobs.every((job) => isChecksGroup(context, findMember(job.definition, 'concurrency')));
426
-
427
- const isCovered = isChecksGroup(context, workflowConcurrency) || isEveryJobCovered;
428
-
429
- const workflowFindings =
430
- deployJobs.length > 0 || isCovered
431
- ? []
432
- : [
433
- {
434
- path: context.path,
435
- line: getTopLevelLine(context.lines, 'on'),
436
- message:
437
- 'a checks workflow, which runs on pull_request or push, sets concurrency with cancel-in-progress: true; a gate that runs only on a push to main may instead group by github.sha and never cancel',
438
- },
439
- ];
440
-
441
- const jobFindings =
442
- deployJobs.length === 0
443
- ? []
444
- : otherJobs
445
- .filter((job) => !isChecksGroup(context, findMember(job.definition, 'concurrency')))
446
- .map((job) => ({
447
- path: context.path,
448
- line: job.line,
449
- message: `job ${job.id} runs in a checks workflow beside a release or deploy job, so it sets a job-level concurrency group with cancel-in-progress: true`,
450
- }));
451
-
452
- return [
453
- ...workflowFindings,
454
- ...jobFindings,
455
- ...collectPerRunGroupFindings(
456
- context,
457
- workflowConcurrency,
458
- getTopLevelLine(context.lines, 'concurrency'),
459
- ),
460
- ...otherJobs.flatMap((job) =>
461
- collectPerRunGroupFindings(context, findMember(job.definition, 'concurrency'), job.line),
462
- ),
463
- ];
464
- }
465
-
466
- function isChecksGroup(context: WorkflowContext, concurrency: unknown): boolean {
467
- return isCancelling(concurrency) || isGateGroup(context, concurrency);
468
- }
469
-
470
- function isGateGroup(context: WorkflowContext, concurrency: unknown): boolean {
471
- const group = findMember(concurrency, 'group');
472
- const cancel = findMember(concurrency, 'cancel-in-progress');
473
-
474
- return (
475
- isMainGate(context) &&
476
- typeof group === 'string' &&
477
- collectExpressionReferences(group).includes('github.sha') &&
478
- (cancel === undefined || cancel === false)
479
- );
480
- }
481
-
482
- // A gate on main runs once for each commit, so a newer commit must not cancel the older run.
483
- function isMainGate(context: WorkflowContext): boolean {
484
- const push = findMember(findMember(context.workflow, 'on'), 'push');
485
- const branches = [findMember(push, 'branches')].flat();
486
-
487
- return (
488
- !pullRequestTriggers.some((name) => context.triggers.has(name)) &&
489
- branches.length > 0 &&
490
- branches.every((branch) => branch === 'main')
491
- );
492
- }
493
-
494
- // A value that differs between two runs on the same branch gives each run its own group, which no
495
- // later run cancels.
496
- const perRunValues = ['github.sha', 'github.run_id', 'github.run_number', 'github.run_attempt'];
497
-
498
- function collectPerRunGroupFindings(
499
- context: WorkflowContext,
500
- concurrency: unknown,
501
- line: number,
502
- ): WorkflowFinding[] {
503
- const group = findMember(concurrency, 'group');
504
- const references = typeof group === 'string' ? collectExpressionReferences(group) : [];
505
- const allowed = isGateGroup(context, concurrency) ? 'github.sha' : undefined;
506
- const perRun = perRunValues.find((value) => value !== allowed && references.includes(value));
507
-
508
- return perRun === undefined
509
- ? []
510
- : [
511
- {
512
- path: context.path,
513
- line,
514
- message: `the concurrency group holds ${perRun}, so no newer run cancels this one`,
515
- },
516
- ];
517
- }
518
-
519
324
  function collectWorkflowRunFindings(context: WorkflowContext): WorkflowFinding[] {
520
325
  if (!context.triggers.has('workflow_run')) {
521
326
  return [];
@@ -0,0 +1,24 @@
1
+ import type { ChecksProfile } from './read-checks-profile.ts';
2
+
3
+ export interface WorkflowContext {
4
+ readonly path: string;
5
+ readonly profile: ChecksProfile | undefined;
6
+ readonly workflow: unknown;
7
+ readonly lines: readonly string[];
8
+ readonly jobs: readonly Job[];
9
+ readonly triggers: ReadonlySet<string>;
10
+ }
11
+
12
+ export interface Job {
13
+ readonly id: string;
14
+ readonly definition: unknown;
15
+ readonly line: number;
16
+ }
17
+
18
+ export interface WorkflowFinding {
19
+ // Relative to the repo root.
20
+ readonly path: string;
21
+
22
+ readonly line: number;
23
+ readonly message: string;
24
+ }