@zgeoff/chex 0.0.0 → 0.0.2

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.
Files changed (53) hide show
  1. package/README.md +96 -3
  2. package/lefthook.yml +123 -0
  3. package/package.json +31 -3
  4. package/src/build-init-package.ts +57 -0
  5. package/src/build-init-plan.ts +96 -0
  6. package/src/build-lefthook-extends.ts +117 -0
  7. package/src/check-names.ts +18 -0
  8. package/src/cli.ts +49 -0
  9. package/src/collect-command-names.ts +297 -0
  10. package/src/collect-expression-references.ts +17 -0
  11. package/src/dependency-gates/collect-stale-age-gate-excludes.ts +83 -0
  12. package/src/dependency-gates/collect-unneeded-audit-ignores.ts +41 -0
  13. package/src/dependency-gates/collect-unneeded-overrides.ts +70 -0
  14. package/src/dependency-gates/first-party-scopes.ts +1 -0
  15. package/src/dependency-gates/is-record.ts +4 -0
  16. package/src/dependency-gates/parse-audit-advisories.ts +33 -0
  17. package/src/dependency-gates/parse-audit-ignore-ids.ts +15 -0
  18. package/src/dependency-gates/parse-fixable-packages.ts +29 -0
  19. package/src/dependency-gates/parse-lockfile-dependents.ts +77 -0
  20. package/src/dependency-gates/parse-root-manifest.ts +68 -0
  21. package/src/dependency-gates/read-dependency-gate-findings.ts +91 -0
  22. package/src/dependency-gates/run-dependency-gates-check.ts +54 -0
  23. package/src/dependency-gates/types.ts +19 -0
  24. package/src/docs/check-length.sh +117 -0
  25. package/src/docs/check-links.sh +245 -0
  26. package/src/docs/check-prose.sh +657 -0
  27. package/src/docs/check-readmes.sh +102 -0
  28. package/src/docs/check-skill.sh +413 -0
  29. package/src/docs/check-vision.sh +34 -0
  30. package/src/find-install-cache-path.ts +16 -0
  31. package/src/format-commit-lines.ts +3 -0
  32. package/src/make-check-runner.ts +40 -0
  33. package/src/parse-added-lines.ts +112 -0
  34. package/src/parse-chex-args.ts +76 -0
  35. package/src/parse-lane-limits.ts +59 -0
  36. package/src/parse-pushed-refs.ts +29 -0
  37. package/src/parse-shell-words.ts +520 -0
  38. package/src/pick-merge-blobs-command.ts +152 -0
  39. package/src/read-branch-base.ts +31 -0
  40. package/src/read-branch-files.ts +59 -0
  41. package/src/read-pushed-ranges.ts +47 -0
  42. package/src/read-pushed-refs-input.ts +18 -0
  43. package/src/read-range-commits.ts +30 -0
  44. package/src/run-chex.ts +62 -0
  45. package/src/run-commits-check.ts +178 -0
  46. package/src/run-init.ts +129 -0
  47. package/src/run-local-paths-check.ts +185 -0
  48. package/src/run-program.ts +38 -0
  49. package/src/run-test-timeouts-check.ts +498 -0
  50. package/src/run-uncommitted-check.ts +82 -0
  51. package/src/run-workflows-check.ts +610 -0
  52. package/src/shell-escape-parsers.ts +197 -0
  53. package/src/write-text-file.ts +7 -0
@@ -0,0 +1,610 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { collectCommandNames } from './collect-command-names.ts';
3
+ import { collectExpressionReferences } from './collect-expression-references.ts';
4
+ import { findInstallCachePath } from './find-install-cache-path.ts';
5
+ import { parseShellWords } from './parse-shell-words.ts';
6
+ import type { RunProgram } from './run-program.ts';
7
+ import { runProgram } from './run-program.ts';
8
+
9
+ export type ChecksProfile = 'public' | 'private';
10
+
11
+ export interface WorkflowsCheckOptions {
12
+ readonly cwd: string;
13
+ readonly env: Readonly<Record<string, string | undefined>>;
14
+
15
+ // The workflow files to read, relative to the repo root; empty reads every one HEAD holds.
16
+ readonly paths: readonly string[];
17
+
18
+ // The bun-checks profile of the run; undefined when the check cannot see it, such as in a hook.
19
+ readonly profile: ChecksProfile | undefined;
20
+
21
+ readonly runProgram: RunProgram;
22
+ }
23
+
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
+ // GitHub runs no workflow from a subfolder of .github/workflows.
33
+ const workflowPathPattern = /^(?:\.github\/workflows|sync\/workflow-callers)\/[^/]+\.ya?ml$/u;
34
+
35
+ // The check reads the files as HEAD holds them, so a push checks what it sends.
36
+ export function runWorkflowsCheck(options: Readonly<WorkflowsCheckOptions>): WorkflowFinding[] {
37
+ const tracked = runGit(options, ['ls-tree', '-r', '-z', '--name-only', 'HEAD'])
38
+ .split('\0')
39
+ .filter((path) => workflowPathPattern.test(path));
40
+
41
+ const asked = new Set(options.paths);
42
+
43
+ const paths = options.paths.length === 0 ? tracked : tracked.filter((path) => asked.has(path));
44
+
45
+ return paths.flatMap((path) =>
46
+ collectWorkflowFindings(path, runGit(options, ['show', `HEAD:${path}`]), options.profile),
47
+ );
48
+ }
49
+
50
+ function runGit(options: Readonly<WorkflowsCheckOptions>, args: readonly string[]): string {
51
+ const result = options.runProgram(['git', ...args], { cwd: options.cwd, env: options.env });
52
+
53
+ if (result.exitCode !== 0) {
54
+ throw new Error(`git ${args.join(' ')} failed: ${result.stderr.trim()}`);
55
+ }
56
+
57
+ return result.stdout;
58
+ }
59
+
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
+ function collectWorkflowFindings(
76
+ path: string,
77
+ text: string,
78
+ profile: ChecksProfile | undefined,
79
+ ): WorkflowFinding[] {
80
+ const workflow = parseWorkflow(text);
81
+
82
+ if (workflow instanceof Error) {
83
+ return [{ path, line: 1, message: `cannot parse the workflow: ${workflow.message}` }];
84
+ }
85
+
86
+ const lines = text.split('\n');
87
+
88
+ const context: WorkflowContext = {
89
+ path,
90
+ profile,
91
+ workflow,
92
+ lines,
93
+ jobs: collectJobs(findMember(workflow, 'jobs'), lines),
94
+ triggers: collectTriggers(findMember(workflow, 'on')),
95
+ };
96
+
97
+ return [
98
+ ...context.jobs.flatMap((job) => jobRules.flatMap((rule) => rule(context, job))),
99
+ ...collectConcurrencyFindings(context),
100
+ ...collectWorkflowRunFindings(context),
101
+ ];
102
+ }
103
+
104
+ function parseWorkflow(text: string): unknown {
105
+ try {
106
+ return Bun.YAML.parse(text);
107
+ } catch (error) {
108
+ return error instanceof Error ? error : new Error(String(error));
109
+ }
110
+ }
111
+
112
+ function collectJobs(jobs: unknown, lines: readonly string[]): Job[] {
113
+ if (typeof jobs !== 'object' || jobs === null) {
114
+ return [];
115
+ }
116
+
117
+ const jobsLine = lines.findIndex((line) => line.startsWith('jobs:'));
118
+
119
+ return Object.entries(jobs).map(([id, definition]: readonly [string, unknown]) => ({
120
+ id,
121
+ definition,
122
+ line: getJobLine(lines, jobsLine, id),
123
+ }));
124
+ }
125
+
126
+ function getJobLine(lines: readonly string[], jobsLine: number, id: string): number {
127
+ const keyPattern = new RegExp(`^\\s+${RegExp.escape(id)}:(?:\\s|$)`, 'u');
128
+
129
+ const offset = lines.slice(jobsLine + 1).findIndex((line) => keyPattern.test(line));
130
+
131
+ return offset === -1 ? Math.max(jobsLine + 1, 1) : jobsLine + offset + 2;
132
+ }
133
+
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
+ function collectTriggers(on: unknown): Set<string> {
141
+ if (typeof on === 'string') {
142
+ return new Set([on]);
143
+ }
144
+
145
+ if (isList(on)) {
146
+ return new Set(on.filter((trigger): trigger is string => typeof trigger === 'string'));
147
+ }
148
+
149
+ return typeof on === 'object' && on !== null ? new Set(Object.keys(on)) : new Set();
150
+ }
151
+
152
+ // Array.isArray narrows to any[], which turns the type check off for each value.
153
+ function isList(value: unknown): value is readonly unknown[] {
154
+ return Array.isArray(value);
155
+ }
156
+
157
+ const jobRules: readonly ((context: WorkflowContext, job: Job) => WorkflowFinding[])[] = [
158
+ collectRunFindings,
159
+ collectTimeoutFindings,
160
+ collectHeavyFindings,
161
+ collectCacheFindings,
162
+ collectCheckoutFindings,
163
+ ];
164
+
165
+ function collectRunFindings(context: WorkflowContext, job: Job): WorkflowFinding[] {
166
+ return collectSteps(job).flatMap((step, index) => {
167
+ const run = findMember(step, 'run');
168
+ const fault = typeof run === 'string' ? findRunFault(run) : undefined;
169
+
170
+ return fault === undefined
171
+ ? []
172
+ : [
173
+ {
174
+ path: context.path,
175
+ line: job.line,
176
+ message: `job ${job.id}, ${formatStepLabel(step, index)}: the run ${fault}; a run holds one command on one line, and anything more goes in a script`,
177
+ },
178
+ ];
179
+ });
180
+ }
181
+
182
+ function collectSteps(job: Job): readonly unknown[] {
183
+ const steps = findMember(job.definition, 'steps');
184
+
185
+ return isList(steps) ? steps : [];
186
+ }
187
+
188
+ const expressionOpening = `\${{`;
189
+
190
+ const shellKeywords = new Set(['if', 'for', 'while', 'case']);
191
+
192
+ function findRunFault(run: string): string | undefined {
193
+ const command = run.trim();
194
+
195
+ if (command.includes('\n')) {
196
+ return 'spans more than one line';
197
+ }
198
+
199
+ if (command.includes(expressionOpening)) {
200
+ return `holds a ${expressionOpening} }} expression, which belongs in env`;
201
+ }
202
+
203
+ const words = parseShellWords(command);
204
+ const operator = words.find((word) => word.isOperator);
205
+
206
+ if (operator !== undefined) {
207
+ return `chains commands with \`${operator.text}\``;
208
+ }
209
+
210
+ const [first] = words;
211
+
212
+ return first !== undefined && !first.isQuoted && shellKeywords.has(first.text)
213
+ ? `starts with the shell keyword \`${first.text}\``
214
+ : undefined;
215
+ }
216
+
217
+ function formatStepLabel(step: unknown, index: number): string {
218
+ const name = findMember(step, 'name');
219
+
220
+ return typeof name === 'string' ? `step "${name}"` : `step ${index + 1}`;
221
+ }
222
+
223
+ function collectTimeoutFindings(context: WorkflowContext, job: Job): WorkflowFinding[] {
224
+ // GitHub refuses timeout-minutes on a job that calls a reusable workflow.
225
+ if (findMember(job.definition, 'uses') !== undefined) {
226
+ return [];
227
+ }
228
+
229
+ return findMember(job.definition, 'timeout-minutes') === undefined
230
+ ? [{ path: context.path, line: job.line, message: `job ${job.id} sets no timeout-minutes` }]
231
+ : [];
232
+ }
233
+
234
+ const pullRequestTriggers = ['pull_request', 'pull_request_target'];
235
+
236
+ // ci-design states the rule for a private repo only, so a public run leaves it alone.
237
+ function collectHeavyFindings(context: WorkflowContext, job: Job): WorkflowFinding[] {
238
+ const trigger = pullRequestTriggers.find((name) => context.triggers.has(name));
239
+ const reason = findHeavyReason(job);
240
+
241
+ return context.profile === 'public' || trigger === undefined || reason === undefined
242
+ ? []
243
+ : [
244
+ {
245
+ path: context.path,
246
+ line: job.line,
247
+ message: `job ${job.id} is a heavy check, because it ${reason}, in a workflow that runs on ${trigger}; a private repo runs a heavy check nightly or on dispatch, never on each pull request`,
248
+ },
249
+ ];
250
+ }
251
+
252
+ // A KVM job runs on a GitHub-hosted Ubuntu runner, so only its setup step marks it. The fleet has
253
+ // no shared KVM step, and each repo's own command is listed here.
254
+ const kvmSetupCommands = new Set(['scripts/ci/check-kvm.sh', 'scripts/open-kvm.sh', 'ci:open-kvm']);
255
+
256
+ function findHeavyReason(job: Job): string | undefined {
257
+ const runner = findHeavyRunner(findMember(job.definition, 'runs-on'));
258
+
259
+ if (runner !== undefined) {
260
+ return `runs on ${runner}`;
261
+ }
262
+
263
+ const command = collectSteps(job)
264
+ .map((step) => findMember(step, 'run'))
265
+ .filter((run): run is string => typeof run === 'string')
266
+ .flatMap((run) => collectCommandNames(parseShellWords(run)) ?? [])
267
+ .find((name) => kvmSetupCommands.has(name));
268
+
269
+ return command === undefined ? undefined : `enables KVM through ${command}`;
270
+ }
271
+
272
+ // Any runner but a GitHub-hosted Ubuntu one counts as heavy: macOS, Windows, self-hosted, or a
273
+ // runner group. A runner chosen by an expression is left alone, because GitHub resolves it only
274
+ // when the job runs.
275
+ function findHeavyRunner(runsOn: unknown): string | undefined {
276
+ if (findMember(runsOn, 'group') !== undefined) {
277
+ return 'a runner group';
278
+ }
279
+
280
+ const labels = [runsOn, findMember(runsOn, 'labels')]
281
+ .flat()
282
+ .filter((label): label is string => typeof label === 'string');
283
+
284
+ if (labels.some((label) => label.includes(expressionOpening))) {
285
+ return undefined;
286
+ }
287
+
288
+ return labels.find((label) => !label.startsWith('ubuntu-'));
289
+ }
290
+
291
+ const setupBunPattern = /^oven-sh\/setup-bun(?:@|$)/u;
292
+ const cachePattern = /^actions\/cache(?:\/(?:restore|save))?(?:@|$)/u;
293
+
294
+ function collectCacheFindings(context: WorkflowContext, job: Job): WorkflowFinding[] {
295
+ const steps = collectSteps(job);
296
+ const isBunJob = steps.some((step) => isStepUsing(step, setupBunPattern));
297
+
298
+ return steps.flatMap((step, index) => {
299
+ const action = findMember(step, 'uses');
300
+
301
+ const cached = isStepUsing(step, cachePattern)
302
+ ? findInstallCachePath(findMember(findMember(step, 'with'), 'path'))
303
+ : undefined;
304
+
305
+ return isBunJob && typeof action === 'string' && cached !== undefined
306
+ ? [
307
+ {
308
+ path: context.path,
309
+ line: job.line,
310
+ message: `job ${job.id}, ${formatStepLabel(step, index)}: a Bun job caches ${cached} with ${action}; bun install --frozen-lockfile runs faster than the cache restore`,
311
+ },
312
+ ]
313
+ : [];
314
+ });
315
+ }
316
+
317
+ function isStepUsing(step: unknown, pattern: RegExp): boolean {
318
+ const action = findMember(step, 'uses');
319
+
320
+ return typeof action === 'string' && pattern.test(action);
321
+ }
322
+
323
+ const checkoutPattern = /^actions\/checkout(?:@|$)/u;
324
+
325
+ // A blobless clone fetches every commit and tree, and git fetches a file's contents when a command
326
+ // first reads them, which suits a job that reads history but few old files. A job that reads many
327
+ // old files, one fetch each, clones them all and states so with an empty filter.
328
+ function collectCheckoutFindings(context: WorkflowContext, job: Job): WorkflowFinding[] {
329
+ return collectSteps(job).flatMap((step, index) => {
330
+ const action = findMember(step, 'uses');
331
+ const inputs = findMember(step, 'with');
332
+ const depth = findMember(inputs, 'fetch-depth');
333
+
334
+ const isFullHistory =
335
+ typeof action === 'string' && checkoutPattern.test(action) && (depth === 0 || depth === '0');
336
+
337
+ return isFullHistory && typeof findMember(inputs, 'filter') !== 'string'
338
+ ? [
339
+ {
340
+ path: context.path,
341
+ line: job.line,
342
+ message: `job ${job.id}, ${formatStepLabel(step, index)}: a checkout with fetch-depth: 0 sets filter: blob:none to clone the history without the file contents, or filter: '' when the job reads many old files`,
343
+ },
344
+ ]
345
+ : [];
346
+ });
347
+ }
348
+
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
+ function collectWorkflowRunFindings(context: WorkflowContext): WorkflowFinding[] {
520
+ if (!context.triggers.has('workflow_run')) {
521
+ return [];
522
+ }
523
+
524
+ const trigger = findMember(findMember(context.workflow, 'on'), 'workflow_run');
525
+ const branches = findMember(trigger, 'branches');
526
+
527
+ return isList(branches) && branches.length > 0
528
+ ? []
529
+ : [
530
+ {
531
+ path: context.path,
532
+ line: getTopLevelLine(context.lines, 'on'),
533
+ message:
534
+ 'the workflow_run trigger has no branches filter, so a run of the named workflow on any branch starts it',
535
+ },
536
+ ];
537
+ }
538
+
539
+ // Returns the exit code: 0 when clean or with a warning, since the lint only warns, and 2 when the
540
+ // check cannot run.
541
+ export function runWorkflowsCommand(args: readonly string[]): number {
542
+ const paths = parseCLIPaths(args);
543
+
544
+ if (paths instanceof Error) {
545
+ process.stderr.write(`workflows: ${paths.message}\nusage: chex workflows [path...]\n`);
546
+
547
+ return 2;
548
+ }
549
+
550
+ try {
551
+ const findings = runWorkflowsCheck({
552
+ cwd: process.cwd(),
553
+ env: process.env,
554
+ paths,
555
+ profile: parseChecksProfile(process.env['CHECKS_PROFILE']),
556
+ runProgram,
557
+ });
558
+
559
+ const isActions = process.env['GITHUB_ACTIONS'] === 'true';
560
+
561
+ for (const finding of findings) {
562
+ process.stdout.write(`${isActions ? formatAnnotation(finding) : formatLine(finding)}\n`);
563
+ }
564
+
565
+ if (findings.length > 0) {
566
+ process.stdout.write(
567
+ `workflows: ${findings.length} ${findings.length === 1 ? 'warning' : 'warnings'}; the workflow lint warns and does not fail yet\n`,
568
+ );
569
+ }
570
+
571
+ return 0;
572
+ } catch (error) {
573
+ process.stderr.write(`workflows: ${error instanceof Error ? error.message : String(error)}\n`);
574
+
575
+ return 2;
576
+ }
577
+ }
578
+
579
+ function parseCLIPaths(args: readonly string[]): string[] | Error {
580
+ try {
581
+ return parseArgs({ args: [...args], options: {}, allowPositionals: true, strict: true })
582
+ .positionals;
583
+ } catch (error) {
584
+ return error instanceof Error ? error : new Error(String(error));
585
+ }
586
+ }
587
+
588
+ function parseChecksProfile(value: string | undefined): ChecksProfile | undefined {
589
+ return value === 'public' || value === 'private' ? value : undefined;
590
+ }
591
+
592
+ // GitHub reads a workflow command's properties up to `,` and `::`, and its message up to a
593
+ // newline, so each escapes its own characters.
594
+ function formatAnnotation(finding: WorkflowFinding): string {
595
+ const file = escapeAnnotationData(finding.path).replaceAll(':', '%3A').replaceAll(',', '%2C');
596
+
597
+ return `::warning file=${file},line=${finding.line}::${escapeAnnotationData(finding.message)}`;
598
+ }
599
+
600
+ function escapeAnnotationData(text: string): string {
601
+ return text.replaceAll('%', '%25').replaceAll('\r', '%0D').replaceAll('\n', '%0A');
602
+ }
603
+
604
+ function formatLine(finding: WorkflowFinding): string {
605
+ return `${finding.path}:${finding.line}: ${finding.message}`;
606
+ }
607
+
608
+ if (import.meta.main) {
609
+ process.exitCode = runWorkflowsCommand(process.argv.slice(2));
610
+ }