workspace-conformance 1.0.0 → 1.2.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/dist/cli.js CHANGED
@@ -1,15 +1,98 @@
1
1
  #!/usr/bin/env node
2
- import { dirname, join, posix, relative, resolve, sep } from "node:path";
2
+ import { dirname, extname, join, posix, relative, resolve, sep } from "node:path";
3
3
  import { parseArgs } from "node:util";
4
+ import { z } from "zod";
4
5
  import { existsSync } from "node:fs";
5
6
  import { escapePath, glob, isDynamicPattern } from "tinyglobby";
6
7
  import { Node, Project } from "ts-morph";
7
8
  import { createJitiLoader, validateStandard } from "cosmiconfig-extends";
8
9
  import { readFile, realpath } from "node:fs/promises";
9
- import { parse } from "yaml";
10
10
  import { execFile } from "node:child_process";
11
+ import { LineCounter, isMap, isNode, isScalar, isSeq, parse, parseDocument } from "yaml";
12
+ import { findNodeAtLocation, parse as parse$1, parseTree, printParseErrorCode } from "jsonc-parser";
13
+ import { parse as parse$2 } from "smol-toml";
11
14
  import { defineSection, layoutSection, loadSection } from "@exadev/config";
12
- import { z } from "zod";
15
+ //#region src/errors.ts
16
+ /**
17
+ * A problem with how the tool was used or configured rather than a finding about the workspace: an unknown or disabled check, a missing or invalid section, a layout that cannot be resolved, a repository that is not a git checkout. The command line reports it and exits 2.
18
+ */
19
+ var ConformanceError = class extends Error {
20
+ constructor(message, options) {
21
+ super(message, options);
22
+ this.name = "ConformanceError";
23
+ }
24
+ };
25
+ //#endregion
26
+ //#region src/github.ts
27
+ const repositorySchema = z.object({
28
+ default_branch: z.string(),
29
+ allow_rebase_merge: z.optional(z.boolean()),
30
+ allow_squash_merge: z.optional(z.boolean()),
31
+ allow_merge_commit: z.optional(z.boolean())
32
+ });
33
+ const rulesSchema = z.array(z.looseObject({
34
+ type: z.string(),
35
+ parameters: z.optional(z.looseObject({
36
+ required_status_checks: z.optional(z.array(z.object({ context: z.string() }))),
37
+ strict_required_status_checks_policy: z.optional(z.boolean()),
38
+ required_review_thread_resolution: z.optional(z.boolean()),
39
+ allowed_merge_methods: z.optional(z.array(z.string()))
40
+ }))
41
+ }));
42
+ const API_ROOT = "https://api.github.com";
43
+ const API_VERSION = "2022-11-28";
44
+ /**
45
+ * How many rules the API returns in one page, its maximum.
46
+ */
47
+ const PAGE_SIZE = 100;
48
+ /**
49
+ * A {@link GitHubClient} over the REST API.
50
+ */
51
+ function createGitHubClient(options) {
52
+ const request = options.fetch ?? fetch;
53
+ const root = options.apiUrl ?? API_ROOT;
54
+ async function get(path) {
55
+ const response = await request(`${root}${path}`, { headers: {
56
+ Accept: "application/vnd.github+json",
57
+ Authorization: `Bearer ${options.token}`,
58
+ "X-GitHub-Api-Version": API_VERSION
59
+ } });
60
+ if (!response.ok) throw new ConformanceError(`GET ${path} failed: ${String(response.status)} ${response.statusText}`);
61
+ return response.json();
62
+ }
63
+ return {
64
+ repository: async (slug) => {
65
+ const parsed = repositorySchema.parse(await get(`/repos/${slug.owner}/${slug.name}`));
66
+ const { allow_rebase_merge: allowRebaseMerge, allow_squash_merge: allowSquashMerge, allow_merge_commit: allowMergeCommit } = parsed;
67
+ if (allowRebaseMerge === void 0 || allowSquashMerge === void 0 || allowMergeCommit === void 0) throw new ConformanceError(`GET /repos/${slug.owner}/${slug.name} did not report the merge-method settings, which GitHub shows only to a token with write access to the repository`);
68
+ return {
69
+ defaultBranch: parsed.default_branch,
70
+ allowRebaseMerge,
71
+ allowSquashMerge,
72
+ allowMergeCommit
73
+ };
74
+ },
75
+ branchRules: async (slug, branch) => {
76
+ const rules = [];
77
+ for (let page = 1, more = true; more; page += 1) {
78
+ const found = rulesSchema.parse(await get(`/repos/${slug.owner}/${slug.name}/rules/branches/${encodeURIComponent(branch)}?per_page=${String(PAGE_SIZE)}&page=${String(page)}`));
79
+ rules.push(...found);
80
+ more = found.length === PAGE_SIZE;
81
+ }
82
+ return {
83
+ requiredStatusChecks: rules.filter((rule) => rule.type === "required_status_checks").map((rule) => ({
84
+ contexts: (rule.parameters?.required_status_checks ?? []).map((check) => check.context),
85
+ strict: rule.parameters?.strict_required_status_checks_policy === true
86
+ })),
87
+ pullRequests: rules.filter((rule) => rule.type === "pull_request").map((rule) => ({
88
+ requiredReviewThreadResolution: rule.parameters?.required_review_thread_resolution === true,
89
+ allowedMergeMethods: rule.parameters?.allowed_merge_methods
90
+ }))
91
+ };
92
+ }
93
+ };
94
+ }
95
+ //#endregion
13
96
  //#region src/files.ts
14
97
  /**
15
98
  * Directories no check descends into: installed dependencies are not the workspace's own files.
@@ -75,17 +158,6 @@ function relativePosix(cwd, absolute) {
75
158
  return toPosix(relative(cwd, absolute));
76
159
  }
77
160
  //#endregion
78
- //#region src/errors.ts
79
- /**
80
- * A problem with how the tool was used or configured rather than a finding about the workspace: an unknown or disabled check, a missing or invalid section, a layout that cannot be resolved, a repository that is not a git checkout. The command line reports it and exits 2.
81
- */
82
- var ConformanceError = class extends Error {
83
- constructor(message, options) {
84
- super(message, options);
85
- this.name = "ConformanceError";
86
- }
87
- };
88
- //#endregion
89
161
  //#region src/template.ts
90
162
  const PLACEHOLDER = /\{([a-z]+)\}/gu;
91
163
  /**
@@ -247,7 +319,7 @@ function aliasNamed(typeName) {
247
319
  /**
248
320
  * Why a command type is not derived from a schema, or `undefined` when it is. `followed` holds the aliases already followed from the exported type, so a chain of aliases is followed to its end and a loop of them is not followed twice.
249
321
  */
250
- function problem(declaration, name, inferences, followed = /* @__PURE__ */ new Set()) {
322
+ function problem$1(declaration, name, inferences, followed = /* @__PURE__ */ new Set()) {
251
323
  if (Node.isInterfaceDeclaration(declaration)) return {
252
324
  reason: "hand-written-interface",
253
325
  message: `${name} is a hand-written interface; derive the command from its schema`
@@ -264,7 +336,7 @@ function problem(declaration, name, inferences, followed = /* @__PURE__ */ new S
264
336
  };
265
337
  }
266
338
  const alias = aliasNamed(written.getTypeName());
267
- if (alias !== void 0 && !followed.has(alias)) return problem(alias, name, inferences, /* @__PURE__ */ new Set([...followed, alias]));
339
+ if (alias !== void 0 && !followed.has(alias)) return problem$1(alias, name, inferences, /* @__PURE__ */ new Set([...followed, alias]));
268
340
  }
269
341
  return {
270
342
  reason: "hand-written-alias",
@@ -288,7 +360,7 @@ const commandTypes = async ({ cwd, options }) => {
288
360
  for (const file of files) for (const [name, declaration] of exportedTypes(project.getSourceFileOrThrow(resolve(cwd, file)))) {
289
361
  if (excluded.has(name) || seen.has(declaration)) continue;
290
362
  seen.add(declaration);
291
- const found = problem(declaration, name, inferences);
363
+ const found = problem$1(declaration, name, inferences);
292
364
  if (found !== void 0) violations.push({
293
365
  code: `command-types/${found.reason}`,
294
366
  message: found.message,
@@ -486,58 +558,1322 @@ function variablesSetBy(line) {
486
558
  const [name, ...value] = body.trim().split(/\s+/u);
487
559
  if (name !== void 0 && name !== "") variables.set(name, instruction.toUpperCase() === "ENV" && value.length > 0 ? unquoted(value.join(" ")) : void 0);
488
560
  }
489
- return variables;
490
- }
561
+ return variables;
562
+ }
563
+ /**
564
+ * The version a pin names once the variables it refers to are replaced by the values in effect, or `undefined` when a variable has no value there (an `ARG` without a default, or a variable this file does not set) or its value refers to another variable, which makes the version unknowable from the file. A `+sha` suffix is dropped.
565
+ */
566
+ function versionOf(token, variables) {
567
+ const unknown = [];
568
+ const replaced = token.replace(VARIABLE, (_reference, variable) => {
569
+ const value = variables.get(variable);
570
+ if (value === void 0 || value.includes("$")) {
571
+ unknown.push(variable);
572
+ return "";
573
+ }
574
+ return value;
575
+ });
576
+ return unknown.length === 0 ? replaced.split("+")[0] : void 0;
577
+ }
578
+ /**
579
+ * Every pin of the package manager that `packageManager` names, in each Dockerfile, is the version `packageManager` names, with any `+sha` integrity suffix ignored on both sides. Pins of other package managers are not the workspace's package manager and are left alone.
580
+ *
581
+ * It reads `<manager>@<version>` as written on a line that is not a comment. A version that refers to a variable is compared with the value the file gives that variable by an `ARG` or `ENV` before the pin (the latest such value, whichever build stage it was in); one whose variable has no value in the file is not compared, since the build supplies it.
582
+ */
583
+ const dockerfilePackageManager = async ({ cwd, options }) => {
584
+ const packageJson = options.packageJson ?? DEFAULT_PACKAGE_JSON;
585
+ const manifest = JSON.parse(await readFile(resolve(cwd, packageJson), "utf8"));
586
+ const declared = isRecord(manifest) && typeof manifest["packageManager"] === "string" ? PACKAGE_MANAGER.exec(manifest["packageManager"]) : null;
587
+ const name = declared?.[1];
588
+ const version = declared?.[2];
589
+ if (name === void 0 || version === void 0) return [{
590
+ code: "dockerfile-package-manager/no-package-manager",
591
+ message: `${packageJson} has no valid 'packageManager' field to compare Dockerfile pins with`,
592
+ file: packageJson
593
+ }];
594
+ const violations = [];
595
+ for (const file of await findFiles(cwd, [...options.dockerfiles ?? DEFAULT_DOCKERFILES, ...excludedFrom(options.exclude)])) {
596
+ const variables = /* @__PURE__ */ new Map();
597
+ (await readFile(resolve(cwd, file), "utf8")).split("\n").forEach((text, index) => {
598
+ if (text.trimStart().startsWith("#")) return;
599
+ for (const [variable, value] of variablesSetBy(text)) variables.set(variable, value);
600
+ for (const match of text.matchAll(PIN)) {
601
+ const pinned = versionOf(String(match[2]), variables);
602
+ if (match[1] === name && pinned !== void 0 && pinned !== version) violations.push({
603
+ code: "dockerfile-package-manager/version-mismatch",
604
+ message: `${file} pins ${name}@${pinned} but packageManager in ${packageJson} is ${name}@${version}`,
605
+ file,
606
+ location: {
607
+ line: index + 1,
608
+ column: match.index + 1
609
+ }
610
+ });
611
+ }
612
+ });
613
+ }
614
+ return violations;
615
+ };
616
+ async function git(cwd, args) {
617
+ return new Promise((resolve, reject) => {
618
+ execFile("git", [...args], {
619
+ cwd,
620
+ encoding: "utf8"
621
+ }, (error, stdout) => {
622
+ if (error === null) resolve(stdout);
623
+ else reject(new ConformanceError(`git ${args.join(" ")} failed in ${cwd}; this check reads the git index, so it needs a git working tree`, { cause: error }));
624
+ });
625
+ });
626
+ }
627
+ /**
628
+ * The index entry for `path` (relative to `cwd`), or `undefined` when git does not track it.
629
+ */
630
+ async function indexEntry(cwd, path) {
631
+ const listing = await git(cwd, [
632
+ "ls-files",
633
+ "--stage",
634
+ "--",
635
+ path
636
+ ]);
637
+ const match = /^(\d+) ([0-9a-f]+) \d\t/u.exec(listing);
638
+ const mode = match?.[1];
639
+ const blob = match?.[2];
640
+ return mode === void 0 || blob === void 0 ? void 0 : {
641
+ mode,
642
+ blob
643
+ };
644
+ }
645
+ /**
646
+ * The content of a blob. For a symbolic link it is the link's target.
647
+ */
648
+ async function blobContent(cwd, blob) {
649
+ return git(cwd, [
650
+ "cat-file",
651
+ "blob",
652
+ blob
653
+ ]);
654
+ }
655
+ /**
656
+ * The URL of the remote `name` as configured, before any `url.<base>.insteadOf` rewriting (which `git remote get-url` applies and which can turn a GitHub address into a host alias), or `undefined` when the working tree has no such remote.
657
+ */
658
+ async function remoteUrl(cwd, name) {
659
+ if (!(await git(cwd, ["remote"])).split("\n").includes(name)) return;
660
+ return (await git(cwd, [
661
+ "config",
662
+ "--get",
663
+ `remote.${name}.url`
664
+ ])).trim();
665
+ }
666
+ //#endregion
667
+ //#region src/settings/repository.ts
668
+ const SLUG = /^([^/\s]+)\/([^/\s]+)$/u;
669
+ const REMOTE = /github\.com[:/]([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/u;
670
+ const REMOTE_NAME = "origin";
671
+ /**
672
+ * The repository the settings checks read: `repository` from the options, else the one the `origin` remote of `cwd` points at.
673
+ */
674
+ async function repositoryOf(cwd, options) {
675
+ if (options.repository !== void 0) {
676
+ const [, owner, name] = SLUG.exec(options.repository) ?? [];
677
+ if (owner === void 0 || name === void 0) throw new ConformanceError(`the repository '${options.repository}' is not written owner/name`);
678
+ return {
679
+ owner,
680
+ name
681
+ };
682
+ }
683
+ const url = await remoteUrl(cwd, REMOTE_NAME);
684
+ const [, owner, name] = REMOTE.exec(url ?? "") ?? [];
685
+ if (owner === void 0 || name === void 0) throw new ConformanceError(`${cwd} has no '${REMOTE_NAME}' remote on github.com; set the repository option`);
686
+ return {
687
+ owner,
688
+ name
689
+ };
690
+ }
691
+ /**
692
+ * The branch whose rules are read: `branch` from the options, else the repository's default branch.
693
+ */
694
+ async function branchOf(github, slug, options) {
695
+ return options.branch ?? (await github.repository(slug)).defaultBranch;
696
+ }
697
+ /**
698
+ * What a violation of a settings check names as its file.
699
+ */
700
+ function repositoryFile(slug) {
701
+ return `${slug.owner}/${slug.name}`;
702
+ }
703
+ //#endregion
704
+ //#region src/checks/settings-merge-methods.ts
705
+ const METHODS = [
706
+ "rebase",
707
+ "squash",
708
+ "merge"
709
+ ];
710
+ /**
711
+ * Only one merge method is available for a pull request into the branch, `rebase` unless the options say otherwise. A method is available when the repository allows it and no `pull_request` rule that applies to the branch leaves it out of its allowed methods.
712
+ *
713
+ * Limits: it reads the repository settings and the rules of rulesets that apply to the branch; classic branch protection and a merge queue's own merge method are not read, so a repository whose queue owns the strategy is judged by what it still allows for a direct merge.
714
+ */
715
+ const settingsMergeMethods = async ({ cwd, options, github }) => {
716
+ const slug = await repositoryOf(cwd, options);
717
+ const file = repositoryFile(slug);
718
+ const settings = await github.repository(slug);
719
+ const rules = await github.branchRules(slug, await branchOf(github, slug, options));
720
+ const wanted = options.allowed ?? "rebase";
721
+ const repositoryAllows = {
722
+ rebase: settings.allowRebaseMerge,
723
+ squash: settings.allowSquashMerge,
724
+ merge: settings.allowMergeCommit
725
+ };
726
+ const available = METHODS.filter((method) => repositoryAllows[method] && rules.pullRequests.every((rule) => rule.allowedMergeMethods === void 0 || rule.allowedMergeMethods.includes(method)));
727
+ const violations = available.filter((method) => method !== wanted).map((method) => ({
728
+ code: "settings-merge-methods/method-enabled",
729
+ message: `${file} allows ${method} merges; only ${wanted} should be allowed`,
730
+ file
731
+ }));
732
+ if (!available.includes(wanted)) violations.push({
733
+ code: "settings-merge-methods/method-unavailable",
734
+ message: `${file} does not allow ${wanted} merges`,
735
+ file
736
+ });
737
+ return violations;
738
+ };
739
+ //#endregion
740
+ //#region src/workflows/model.ts
741
+ /**
742
+ * The access `permissions` grants to `scope`, or `undefined` when the permissions are not declared and the repository default applies.
743
+ */
744
+ function accessOf(permissions, scope) {
745
+ if (permissions.kind === "default") return;
746
+ return permissions.kind === "all" ? permissions.access : permissions.scopes.get(scope) ?? "none";
747
+ }
748
+ /**
749
+ * The text of a scalar value; `undefined` for anything that is not a string, number or boolean.
750
+ */
751
+ function scalarText(value) {
752
+ return typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? String(value) : void 0;
753
+ }
754
+ /**
755
+ * Where a node of the document is: the key for a mapping entry, the item for a sequence entry, else the node itself. Positions are counted from 1.
756
+ */
757
+ function locationAt(document, lineCounter, path) {
758
+ const parent = path.length === 0 ? document.contents : document.getIn(path.slice(0, -1), true);
759
+ const last = path.at(-1);
760
+ let range;
761
+ if (isMap(parent) && typeof last === "string") {
762
+ const pair = parent.items.find((item) => isScalar(item.key) && item.key.value === last);
763
+ range = isScalar(pair?.key) ? pair.key.range : void 0;
764
+ } else {
765
+ const target = isSeq(parent) && typeof last === "number" ? parent.items[last] : parent;
766
+ range = isNode(target) ? target.range : void 0;
767
+ }
768
+ const position = lineCounter.linePos(range?.[0] ?? 0);
769
+ return {
770
+ line: position.line,
771
+ column: position.col
772
+ };
773
+ }
774
+ /**
775
+ * Parse YAML text. It throws `ConformanceError` naming `file` when the text is not valid YAML.
776
+ */
777
+ function parseYaml(file, source) {
778
+ const lineCounter = new LineCounter();
779
+ const document = parseDocument(source, {
780
+ lineCounter,
781
+ prettyErrors: false
782
+ });
783
+ const [error] = document.errors;
784
+ if (error !== void 0) throw new ConformanceError(`${file}: not valid YAML: ${error.message}`, { cause: error });
785
+ return {
786
+ value: document.toJS(),
787
+ locate: (path) => locationAt(document, lineCounter, path)
788
+ };
789
+ }
790
+ /**
791
+ * A reader for one file, so the helpers can name the field they read when the file is not a workflow.
792
+ */
793
+ var Reader = class {
794
+ file;
795
+ parsed;
796
+ constructor(file, parsed) {
797
+ this.file = file;
798
+ this.parsed = parsed;
799
+ }
800
+ fail(what) {
801
+ throw new ConformanceError(`${this.file}: ${what}`);
802
+ }
803
+ location(path) {
804
+ return this.parsed.locate(path);
805
+ }
806
+ record(value, what) {
807
+ if (value === void 0 || value === null) return {};
808
+ if (!isRecord(value)) return this.fail(`${what} must be a mapping`);
809
+ return value;
810
+ }
811
+ text(value, what) {
812
+ if (value === void 0 || value === null) return;
813
+ return scalarText(value) ?? this.fail(`${what} must be a string, number or boolean`);
814
+ }
815
+ texts(value, what) {
816
+ return Object.fromEntries(Object.entries(this.record(value, what)).map(([key, entry]) => [key, this.text(entry, `${what}.${key}`) ?? ""]));
817
+ }
818
+ list(value, what) {
819
+ if (value === void 0 || value === null) return [];
820
+ return (Array.isArray(value) ? value : [value]).map((item) => this.text(item, what) ?? this.fail(`${what} must be a string or a list of strings`));
821
+ }
822
+ };
823
+ const PERMISSION_ACCESS = /* @__PURE__ */ new Set([
824
+ "read",
825
+ "write",
826
+ "none"
827
+ ]);
828
+ function isPermissionAccess(value) {
829
+ return PERMISSION_ACCESS.has(value);
830
+ }
831
+ function permissionsOf(reader, value, what) {
832
+ if (value === void 0 || value === null) return { kind: "default" };
833
+ if (value === "read-all") return {
834
+ kind: "all",
835
+ access: "read"
836
+ };
837
+ if (value === "write-all") return {
838
+ kind: "all",
839
+ access: "write"
840
+ };
841
+ const scopes = /* @__PURE__ */ new Map();
842
+ for (const [scope, access] of Object.entries(reader.record(value, what))) {
843
+ const text = reader.text(access, `${what}.${scope}`);
844
+ if (text === void 0 || !isPermissionAccess(text)) return reader.fail(`${what}.${scope} must be read, write or none`);
845
+ scopes.set(scope, text);
846
+ }
847
+ return {
848
+ kind: "scopes",
849
+ scopes
850
+ };
851
+ }
852
+ function runsOnOf(reader, value, what) {
853
+ const mapping = isRecord(value) ? value : void 0;
854
+ const entries = reader.list(mapping === void 0 ? value : mapping["labels"], what);
855
+ const group = mapping === void 0 ? void 0 : reader.text(mapping["group"], `${what}.group`);
856
+ return {
857
+ labels: entries.filter((entry) => !entry.includes("${{")),
858
+ expressions: entries.filter((entry) => entry.includes("${{")),
859
+ group
860
+ };
861
+ }
862
+ function stepOf(reader, value, jobPath, index) {
863
+ const path = [
864
+ ...jobPath,
865
+ "steps",
866
+ index
867
+ ];
868
+ const what = path.join(".");
869
+ const step = reader.record(value, what);
870
+ return {
871
+ index,
872
+ id: reader.text(step["id"], `${what}.id`),
873
+ name: reader.text(step["name"], `${what}.name`),
874
+ uses: reader.text(step["uses"], `${what}.uses`),
875
+ run: reader.text(step["run"], `${what}.run`),
876
+ with: reader.texts(step["with"], `${what}.with`),
877
+ env: reader.texts(step["env"], `${what}.env`),
878
+ condition: reader.text(step["if"], `${what}.if`),
879
+ location: reader.location(path)
880
+ };
881
+ }
882
+ function jobOf(reader, id, value) {
883
+ const path = ["jobs", id];
884
+ const what = path.join(".");
885
+ const job = reader.record(value, what);
886
+ const steps = job["steps"];
887
+ if (steps !== void 0 && !Array.isArray(steps)) return reader.fail(`${what}.steps must be a list`);
888
+ return {
889
+ id,
890
+ name: reader.text(job["name"], `${what}.name`),
891
+ needs: reader.list(job["needs"], `${what}.needs`),
892
+ condition: reader.text(job["if"], `${what}.if`),
893
+ permissions: permissionsOf(reader, job["permissions"], `${what}.permissions`),
894
+ env: reader.texts(job["env"], `${what}.env`),
895
+ runsOn: runsOnOf(reader, job["runs-on"], `${what}.runs-on`),
896
+ timeoutMinutes: reader.text(job["timeout-minutes"], `${what}.timeout-minutes`),
897
+ uses: reader.text(job["uses"], `${what}.uses`),
898
+ with: reader.texts(job["with"], `${what}.with`),
899
+ outputs: reader.texts(job["outputs"], `${what}.outputs`),
900
+ steps: (steps ?? []).map((step, index) => stepOf(reader, step, path, index)),
901
+ location: reader.location(path)
902
+ };
903
+ }
904
+ function triggerLocationsOf(reader, triggers, value) {
905
+ const events = [...triggers.keys()];
906
+ return new Map(events.map((event, index) => [event, reader.location(Array.isArray(value) ? ["on", index] : isRecord(value) ? ["on", event] : ["on"])]));
907
+ }
908
+ function triggersOf(reader, value) {
909
+ if (typeof value === "string") return /* @__PURE__ */ new Map([[value, void 0]]);
910
+ if (Array.isArray(value)) return new Map(reader.list(value, "on").map((event) => [event, void 0]));
911
+ return new Map(Object.entries(reader.record(value, "on")));
912
+ }
913
+ /**
914
+ * Parse the text of a workflow file into the model the checks read. It throws `ConformanceError` when the file is not YAML or is not shaped like a workflow, since a check cannot judge what it cannot read.
915
+ */
916
+ function parseWorkflow(file, source) {
917
+ const parsed = parseYaml(file, source);
918
+ const reader = new Reader(file, parsed);
919
+ const root = reader.record(parsed.value, "the workflow");
920
+ const triggers = triggersOf(reader, root["on"]);
921
+ const call = triggers.get("workflow_call");
922
+ const outputs = reader.record(reader.record(call, "on.workflow_call")["outputs"], "on.workflow_call.outputs");
923
+ return {
924
+ file,
925
+ name: reader.text(root["name"], "name"),
926
+ triggers,
927
+ triggerLocations: triggerLocationsOf(reader, triggers, root["on"]),
928
+ permissions: permissionsOf(reader, root["permissions"], "permissions"),
929
+ env: reader.texts(root["env"], "env"),
930
+ jobs: Object.entries(reader.record(root["jobs"], "jobs")).map(([id, job]) => jobOf(reader, id, job)),
931
+ callOutputs: Object.fromEntries(Object.entries(outputs).map(([name, output]) => [name, reader.text(reader.record(output, `on.workflow_call.outputs.${name}`)["value"], `on.workflow_call.outputs.${name}.value`) ?? ""]))
932
+ };
933
+ }
934
+ /**
935
+ * The permissions that apply to a job: its own when it declares any, else the workflow's. A job's declaration replaces the workflow's whole, it is not merged with it.
936
+ */
937
+ function effectivePermissions(workflow, job) {
938
+ return job.permissions.kind === "default" ? workflow.permissions : job.permissions;
939
+ }
940
+ /**
941
+ * The environment a step runs with: the workflow's, the job's and the step's, the later winning.
942
+ */
943
+ function effectiveEnv(workflow, job, step) {
944
+ return {
945
+ ...workflow.env,
946
+ ...job.env,
947
+ ...step?.env
948
+ };
949
+ }
950
+ /**
951
+ * Every job `jobId` waits for, directly or through other jobs, not including itself. A `needs` that names no job of the workflow is followed no further.
952
+ */
953
+ function transitiveNeeds(workflow, jobId) {
954
+ const byId = new Map(workflow.jobs.map((job) => [job.id, job]));
955
+ const reached = /* @__PURE__ */ new Set();
956
+ const pending = [...byId.get(jobId)?.needs ?? []];
957
+ for (let next = pending.pop(); next !== void 0; next = pending.pop()) if (!reached.has(next)) {
958
+ reached.add(next);
959
+ pending.push(...byId.get(next)?.needs ?? []);
960
+ }
961
+ reached.delete(jobId);
962
+ return reached;
963
+ }
964
+ /**
965
+ * Split a `uses` value into its parts.
966
+ */
967
+ function parseUses(uses) {
968
+ if (uses.startsWith("./")) return {
969
+ kind: "local",
970
+ path: uses
971
+ };
972
+ if (uses.startsWith("docker://")) {
973
+ const reference = uses.slice(9);
974
+ const at = reference.indexOf("@");
975
+ return at === -1 ? {
976
+ kind: "docker",
977
+ image: reference,
978
+ digest: void 0
979
+ } : {
980
+ kind: "docker",
981
+ image: reference.slice(0, at),
982
+ digest: reference.slice(at + 1)
983
+ };
984
+ }
985
+ const at = uses.lastIndexOf("@");
986
+ const [owner = "", repository = "", ...path] = (at === -1 ? uses : uses.slice(0, at)).split("/");
987
+ return {
988
+ kind: "remote",
989
+ owner,
990
+ repository,
991
+ path: path.join("/"),
992
+ ref: at === -1 ? void 0 : uses.slice(at + 1)
993
+ };
994
+ }
995
+ /**
996
+ * Whether `value` contains an expression, which is only known when the workflow runs.
997
+ */
998
+ function hasExpression(value) {
999
+ return value.includes("${{");
1000
+ }
1001
+ //#endregion
1002
+ //#region src/workflows/load.ts
1003
+ /**
1004
+ * Where GitHub looks for workflows: only this directory, and only the files directly in it.
1005
+ */
1006
+ const DEFAULT_WORKFLOWS = [".github/workflows/*.yml", ".github/workflows/*.yaml"];
1007
+ /**
1008
+ * Read and parse the selected workflow files, sorted by path.
1009
+ */
1010
+ async function loadWorkflows(cwd, selection) {
1011
+ const files = await findFiles(cwd, [...selection.workflows ?? DEFAULT_WORKFLOWS, ...excludedFrom(selection.exclude)]);
1012
+ return Promise.all(files.map(async (file) => parseWorkflow(file, await readFile(resolve(cwd, file), "utf8"))));
1013
+ }
1014
+ //#endregion
1015
+ //#region src/workflows/shared.ts
1016
+ /**
1017
+ * A violation about a place in a workflow file.
1018
+ */
1019
+ function workflowViolation(workflow, code, message, location) {
1020
+ return {
1021
+ code,
1022
+ message,
1023
+ file: workflow.file,
1024
+ location
1025
+ };
1026
+ }
1027
+ /**
1028
+ * The commands of a `run` script as logical lines: a line ended by a backslash is joined to the next one, comment lines and blank lines are dropped.
1029
+ */
1030
+ function commandLines(run) {
1031
+ const lines = [];
1032
+ let continued = "";
1033
+ for (const line of run.split("\n")) {
1034
+ const joined = `${continued}${line.trim()}`;
1035
+ if (joined.endsWith("\\")) continued = `${joined.slice(0, -1).trimEnd()} `;
1036
+ else {
1037
+ continued = "";
1038
+ if (joined !== "" && !joined.startsWith("#")) lines.push(joined);
1039
+ }
1040
+ }
1041
+ if (continued !== "") lines.push(continued.trim());
1042
+ return lines;
1043
+ }
1044
+ /**
1045
+ * Variable assignments that prefix a command (`HUSKY=0 pnpm exec ...`).
1046
+ */
1047
+ const LEADING_ASSIGNMENTS = /^(?:[A-Za-z_]\w*=\S*\s+)+/u;
1048
+ /**
1049
+ * The simple commands of a logical line, without the variable assignments that prefix them: it is split at `&&`, `||`, `;`, `|` and `&` that are outside quotes and outside `${{ }}` expressions, and that are not part of a redirection (`2>&1`, `&>file`), so text inside a quoted string (an `echo` of a command) is not mistaken for a command.
1050
+ */
1051
+ function commandSegments(line) {
1052
+ const segments = [];
1053
+ let current = "";
1054
+ let quote;
1055
+ for (let index = 0; index < line.length; index += 1) {
1056
+ const rest = line.slice(index);
1057
+ const character = rest.charAt(0);
1058
+ if (quote !== void 0) {
1059
+ current += character;
1060
+ quote = character === quote ? void 0 : quote;
1061
+ } else if (rest.startsWith("${{")) {
1062
+ const end = rest.indexOf("}}");
1063
+ const expression = end === -1 ? rest : rest.slice(0, end + 2);
1064
+ current += expression;
1065
+ index += expression.length - 1;
1066
+ } else if (character === "\"" || character === "'") {
1067
+ current += character;
1068
+ quote = character;
1069
+ } else if (character === "&" && (current.endsWith(">") || current.endsWith("<") || rest.startsWith("&>"))) current += character;
1070
+ else if (character === "&" || character === "|" || character === ";") {
1071
+ segments.push(current);
1072
+ current = "";
1073
+ } else current += character;
1074
+ }
1075
+ segments.push(current);
1076
+ return segments.map((segment) => segment.trim().replace(LEADING_ASSIGNMENTS, "")).filter((segment) => segment !== "");
1077
+ }
1078
+ /**
1079
+ * The simple commands of every line of a `run` script.
1080
+ */
1081
+ function scriptCommands(run) {
1082
+ return commandLines(run ?? "").flatMap(commandSegments);
1083
+ }
1084
+ /**
1085
+ * Whether a step uses the action `owner/repository`, at any path or ref.
1086
+ */
1087
+ function stepUses(step, action) {
1088
+ const target = step.uses?.split("@")[0]?.toLowerCase();
1089
+ return target === action.toLowerCase() || target?.startsWith(`${action.toLowerCase()}/`) === true;
1090
+ }
1091
+ /**
1092
+ * The jobs among `ids` that exist in the workflow, in the workflow's order.
1093
+ */
1094
+ function jobsNamed(workflow, ids) {
1095
+ return workflow.jobs.filter((job) => ids.includes(job.id));
1096
+ }
1097
+ /**
1098
+ * Whether an `if` expression calls `always()`.
1099
+ */
1100
+ function callsAlways(condition) {
1101
+ return condition !== void 0 && /\balways\(\)/u.test(condition);
1102
+ }
1103
+ /**
1104
+ * The default ids of junction jobs, shared by the checks that look for one.
1105
+ */
1106
+ const DEFAULT_JUNCTION_JOBS = ["required-checks"];
1107
+ //#endregion
1108
+ //#region src/checks/settings-required-checks.ts
1109
+ /**
1110
+ * The branch requires the check of every junction job of the workflows, and requires branches to be up to date before merging (strict mode). The check name of a job is its `name`, else its id.
1111
+ *
1112
+ * The junction job is the one that reports for the whole workflow, so requiring it requires everything it needs, including jobs a path filter may skip. Codes: `no-required-checks` (no `required_status_checks` rule applies), `required-check-missing` (a junction job's check is not among the required ones), `not-strict` (no rule sets the strict policy) and `no-junction-job` (no workflow has a job with an id from `junctionJobs`).
1113
+ *
1114
+ * Limits: it reads the rules of rulesets that apply to the branch; classic branch protection is not read, so a repository that uses it is reported as having no required checks. A junction job in a reusable workflow, or a name that is an expression, is not resolved.
1115
+ */
1116
+ const settingsRequiredChecks = async ({ cwd, options, github }) => {
1117
+ const slug = await repositoryOf(cwd, options);
1118
+ const file = repositoryFile(slug);
1119
+ const rules = await github.branchRules(slug, await branchOf(github, slug, options));
1120
+ const junctions = (await loadWorkflows(cwd, options)).flatMap((workflow) => jobsNamed(workflow, options.junctionJobs ?? DEFAULT_JUNCTION_JOBS).map((job) => ({
1121
+ workflow,
1122
+ name: job.name ?? job.id
1123
+ })));
1124
+ const violations = [];
1125
+ if (junctions.length === 0) violations.push({
1126
+ code: "settings-required-checks/no-junction-job",
1127
+ message: "no workflow has a junction job, so there is no check that reports for the whole workflow to require",
1128
+ file
1129
+ });
1130
+ if (rules.requiredStatusChecks.length === 0) {
1131
+ violations.push({
1132
+ code: "settings-required-checks/no-required-checks",
1133
+ message: `no rule requires status checks on the branch of ${file}`,
1134
+ file
1135
+ });
1136
+ return violations;
1137
+ }
1138
+ const required = new Set(rules.requiredStatusChecks.flatMap((rule) => rule.contexts));
1139
+ for (const { workflow, name } of junctions.filter((junction) => !required.has(junction.name))) violations.push({
1140
+ code: "settings-required-checks/required-check-missing",
1141
+ message: `${file} does not require the check '${name}' of ${workflow.file}`,
1142
+ file
1143
+ });
1144
+ if (!rules.requiredStatusChecks.some((rule) => rule.strict)) violations.push({
1145
+ code: "settings-required-checks/not-strict",
1146
+ message: `${file} does not require branches to be up to date before merging`,
1147
+ file
1148
+ });
1149
+ return violations;
1150
+ };
1151
+ //#endregion
1152
+ //#region src/checks/settings-review-thread-resolution.ts
1153
+ /**
1154
+ * A pull request cannot merge while a review conversation is unresolved: a `pull_request` rule that applies to the branch sets `required_review_thread_resolution`.
1155
+ *
1156
+ * Limits: it reads the rules of rulesets; classic branch protection is not read.
1157
+ */
1158
+ const settingsReviewThreadResolution = async ({ cwd, options, github }) => {
1159
+ const slug = await repositoryOf(cwd, options);
1160
+ const rules = await github.branchRules(slug, await branchOf(github, slug, options));
1161
+ const file = repositoryFile(slug);
1162
+ return rules.pullRequests.some((rule) => rule.requiredReviewThreadResolution) ? [] : [{
1163
+ code: "settings-review-thread-resolution/not-required",
1164
+ message: `no rule requires review conversations to be resolved before merging on the branch of ${file}`,
1165
+ file
1166
+ }];
1167
+ };
1168
+ //#endregion
1169
+ //#region src/checks/workflow-action-pinning.ts
1170
+ const FULL_COMMIT_SHA = /^[0-9a-f]{40}$/u;
1171
+ const DOCKER_DIGEST = /^sha256:[0-9a-f]{64}$/u;
1172
+ /**
1173
+ * Whether `repository` (`owner/repository`) matches an `allow` entry, which is `owner/repository` or `owner/*`.
1174
+ */
1175
+ function allowed(owner, repository, allow) {
1176
+ return allow.some((entry) => entry.toLowerCase() === `${owner}/${repository}`.toLowerCase() || entry.toLowerCase() === `${owner}/*`.toLowerCase());
1177
+ }
1178
+ /**
1179
+ * Why a reference breaks the policy, with the code of the violation, or `undefined` when it follows it.
1180
+ */
1181
+ function problem(reference, options) {
1182
+ if (reference.kind === "local") return;
1183
+ if (reference.kind === "docker") return reference.digest !== void 0 && DOCKER_DIGEST.test(reference.digest) ? void 0 : {
1184
+ code: "docker-not-pinned",
1185
+ reason: "a Docker image must be pinned by digest (@sha256:...), because a tag can be moved"
1186
+ };
1187
+ const { owner, repository, ref } = reference;
1188
+ if (allowed(owner, repository, options.allow ?? [])) return;
1189
+ if (ref === void 0 || ref === "") return {
1190
+ code: "missing-ref",
1191
+ reason: "it names no ref"
1192
+ };
1193
+ return ((options.organisations ?? []).some((organisation) => organisation.toLowerCase() === owner.toLowerCase()) ? options.sameOrganisation ?? "ref" : options.thirdParty ?? "sha") === "sha" && !FULL_COMMIT_SHA.test(ref) ? {
1194
+ code: "not-pinned",
1195
+ reason: `ref '${ref}' is not a full commit SHA, and a tag or branch can be moved to different code`
1196
+ } : void 0;
1197
+ }
1198
+ /**
1199
+ * Every `uses` of a step or job follows the pinning policy. By default an action or reusable workflow from outside the organisations in the options must be pinned by full commit SHA (a Docker image by digest), and one owned by an organisation listed in `organisations` may use any ref; `thirdParty` and `sameOrganisation` set either to `sha` or `ref`, and `allow` exempts repositories. Local actions and workflows (`./...`) are not judged.
1200
+ *
1201
+ * The check encodes whichever policy the options state and is never silent about it: with nothing configured every remote reference must be a SHA. Limits: it checks the form of the ref, not that the SHA belongs to the repository or to a release; a SHA written with a trailing version comment is read as the SHA; composite actions are not read.
1202
+ */
1203
+ const workflowActionPinning = async ({ cwd, options }) => {
1204
+ const violations = [];
1205
+ for (const workflow of await loadWorkflows(cwd, options)) {
1206
+ const uses = [...workflow.jobs.flatMap((job) => job.uses === void 0 ? [] : [{
1207
+ value: job.uses,
1208
+ location: job.location
1209
+ }]), ...workflow.jobs.flatMap((job) => job.steps.flatMap((step) => step.uses === void 0 ? [] : [{
1210
+ value: step.uses,
1211
+ location: step.location
1212
+ }]))].sort((left, right) => left.location.line - right.location.line || left.location.column - right.location.column);
1213
+ for (const { value, location } of uses) {
1214
+ const found = problem(parseUses(value), options);
1215
+ if (found !== void 0) violations.push(workflowViolation(workflow, `workflow-action-pinning/${found.code}`, `${value}: ${found.reason}`, location));
1216
+ }
1217
+ }
1218
+ return violations;
1219
+ };
1220
+ //#endregion
1221
+ //#region src/checks/workflow-credentials.ts
1222
+ /**
1223
+ * The actions that sign an attestation, which needs an OIDC identity and the right to store the attestation.
1224
+ */
1225
+ const ATTESTATION_ACTIONS = [
1226
+ "actions/attest",
1227
+ "actions/attest-build-provenance",
1228
+ "actions/attest-sbom"
1229
+ ];
1230
+ /**
1231
+ * The scopes an attestation step needs written.
1232
+ */
1233
+ const ATTESTATION_SCOPES = ["id-token", "attestations"];
1234
+ /**
1235
+ * The options a package manager takes before its subcommand: flags such as `-r`, and the options that select a workspace package or directory, with their value after a space or an equals sign.
1236
+ */
1237
+ const PACKAGE_MANAGER_OPTIONS = String.raw`(?:\s+(?:(?:--filter|-F|--workspace|-w|--dir|-C)(?:\s+|=)\S+|--?[\w-]+))*`;
1238
+ /**
1239
+ * Commands that publish a package to a registry.
1240
+ */
1241
+ const PUBLISH_COMMAND = new RegExp(String.raw`^(?:(?:npm|pnpm|yarn)${PACKAGE_MANAGER_OPTIONS}(?:\s+npm)?\s+publish|(?:(?:pnpm|yarn)(?:\s+exec)?\s+|npx\s+)?(?:semantic-release|changeset\s+publish|lerna\s+publish))(?:\s|$)`, "u");
1242
+ /**
1243
+ * The variables a token for the registry is passed in.
1244
+ */
1245
+ const TOKEN_VARIABLES = ["NODE_AUTH_TOKEN", "NPM_TOKEN"];
1246
+ const PROVENANCE_FLAG = /--provenance(?:=true)?(?:\s|$)/u;
1247
+ /**
1248
+ * Whether the `package.json` in the working directory asks for provenance with `publishConfig.provenance: true`, which applies to every publish of that package.
1249
+ */
1250
+ async function requestsProvenance(cwd) {
1251
+ const file = resolve(cwd, "package.json");
1252
+ if (!existsSync(file)) return false;
1253
+ const manifest = JSON.parse(await readFile(file, "utf8"));
1254
+ const config = isRecord(manifest) ? manifest["publishConfig"] : void 0;
1255
+ return isRecord(config) && config["provenance"] === true;
1256
+ }
1257
+ function attestationViolations(workflow, job) {
1258
+ const permissions = effectivePermissions(workflow, job);
1259
+ return job.steps.filter((step) => ATTESTATION_ACTIONS.some((action) => stepUses(step, action))).flatMap((step) => {
1260
+ const missing = ATTESTATION_SCOPES.filter((scope) => accessOf(permissions, scope) !== "write").map((scope) => `${scope}: write`).join(" and ");
1261
+ const problem = permissions.kind === "default" ? `declares no permissions; declare ${missing}` : `does not grant ${missing}`;
1262
+ return missing === "" ? [] : [workflowViolation(workflow, "workflow-credentials/attestation-permissions", `job '${job.id}' creates an attestation but ${problem}`, step.location)];
1263
+ });
1264
+ }
1265
+ /**
1266
+ * Whether the job passes a registry token to a step, which makes its publish not tokenless: a token variable with a value other than the empty string, anywhere in the environment.
1267
+ */
1268
+ function passesToken(workflow, job) {
1269
+ return [void 0, ...job.steps].some((step) => TOKEN_VARIABLES.some((variable) => (effectiveEnv(workflow, job, step)[variable] ?? "") !== ""));
1270
+ }
1271
+ function tokenlessPublishViolations(workflow, job, provenanceInManifest) {
1272
+ if (accessOf(effectivePermissions(workflow, job), "id-token") !== "write" || passesToken(workflow, job)) return [];
1273
+ return job.steps.flatMap((step) => {
1274
+ const publishes = scriptCommands(step.run).filter((line) => PUBLISH_COMMAND.test(line));
1275
+ if (publishes.length === 0) return [];
1276
+ const env = effectiveEnv(workflow, job, step);
1277
+ const blanked = TOKEN_VARIABLES.every((variable) => env[variable] === "");
1278
+ const provenance = provenanceInManifest || publishes.some((line) => PROVENANCE_FLAG.test(line)) || env["NPM_CONFIG_PROVENANCE"] === "true";
1279
+ return blanked || provenance ? [] : [workflowViolation(workflow, "workflow-credentials/tokenless-publish-unprotected", `job '${job.id}' publishes with an OIDC identity and no token, but does not set ${TOKEN_VARIABLES.join(" and ")} to the empty string, so a token inherited from the environment is used when the OIDC exchange fails; set them to the empty string to prevent that, or request provenance so that such a publish at least carries a traceable attestation`, step.location)];
1280
+ });
1281
+ }
1282
+ /**
1283
+ * Credentials are granted to the steps that need them, and a publish with no token cannot quietly use one.
1284
+ *
1285
+ * `workflow-credentials/attestation-permissions`: a step of `actions/attest`, `actions/attest-build-provenance` or `actions/attest-sbom` needs `id-token: write` and `attestations: write` in the job's effective permissions. Permissions a job declares replace the workflow's; permissions declared nowhere are the repository's default, which the file cannot show and which never includes `id-token`, so they are reported.
1286
+ *
1287
+ * `workflow-credentials/tokenless-publish-unprotected`: a job with `id-token: write` that publishes (`npm`, `pnpm` or `yarn npm publish`, with options such as `-r` or `--filter <package>` before `publish`, `semantic-release`, `changeset publish`, `lerna publish`, bare or run through `pnpm`, `pnpm exec`, `yarn` or `npx`) and passes no token must blank `NODE_AUTH_TOKEN` and `NPM_TOKEN` (set them to `''` at step, job or workflow level) or request provenance (`--provenance`, `NPM_CONFIG_PROVENANCE: true`, or `publishConfig.provenance: true` in the `package.json` of the working directory). The npm CLI replaces any configured token with the one from the OIDC exchange, but when the exchange fails it falls back to whatever token the environment holds, silently. Only blanking the variables prevents that fallback. Provenance does not: npm signs it with the CI's own OIDC identity (through Sigstore), separately from the registry token, so a fallback publish still goes ahead with the inherited token and carries an attestation of where it was built. Requesting provenance is accepted because it makes such a publish traceable, not because it makes it safe.
1288
+ *
1289
+ * Limits: `registry-url` is not reported, because it does not stop the exchange (see the README); a job that calls a reusable workflow is not read; a publish inside a script file or composite action is not seen; provenance set in a `package.json` other than the one in the working directory is not read; a job is tokenless only when no token variable is non-empty anywhere in the job, so a publish that uses a secret is left alone.
1290
+ */
1291
+ const workflowCredentials = async ({ cwd, options }) => {
1292
+ const provenanceInManifest = await requestsProvenance(cwd);
1293
+ return (await loadWorkflows(cwd, options)).flatMap((workflow) => workflow.jobs.flatMap((job) => [...attestationViolations(workflow, job), ...tokenlessPublishViolations(workflow, job, provenanceInManifest)]));
1294
+ };
1295
+ //#endregion
1296
+ //#region src/checks/workflow-job-ordering.ts
1297
+ const DEFAULT_RELEASE_JOBS = ["release"];
1298
+ const DEFAULT_DEPLOY_JOBS = ["deploy"];
1299
+ const DEFAULT_DOCS_DEPLOY_JOBS = ["docs-deploy"];
1300
+ const DEFAULT_BRANCH = "main";
1301
+ /**
1302
+ * The expression that always names the repository's default branch.
1303
+ */
1304
+ const DEFAULT_BRANCH_EXPRESSION$1 = /^\$\{\{\s*github\.event\.repository\.default_branch\s*\}\}$/u;
1305
+ /**
1306
+ * The text a junction job decides on: its `if`, and the conditions and scripts of its steps.
1307
+ */
1308
+ function decisionText(job) {
1309
+ return [job.condition, ...job.steps.flatMap((step) => [
1310
+ step.condition,
1311
+ step.run,
1312
+ ...Object.values(step.env),
1313
+ ...Object.values(step.with)
1314
+ ])].filter((text) => text !== void 0).join("\n");
1315
+ }
1316
+ /**
1317
+ * Where a script gets the results of the jobs it needs from: `needs.*.result`, one job's `needs.<job>.result` or the whole of `needs`.
1318
+ */
1319
+ const RESULT_SOURCE = /needs\.(?:\*|[\w-]+)\.result|toJSON\(\s*needs\s*\)/u;
1320
+ const FAILURE_WORD = /['"]failure['"]/u;
1321
+ const CANCELLED_WORD = /['"]cancelled['"]/u;
1322
+ const SKIPPED_OR_NOT_SUCCESS = /needs\.(?:\*|[\w-]+)\.result[^\n]*(?:['"]skipped['"]|!==?\s*['"]success['"])|!\s*contains\(\s*needs\.\*\.result\s*,\s*['"]success['"]/u;
1323
+ /**
1324
+ * An `if` that compares the ref or the event name.
1325
+ */
1326
+ const COMPARES_REF_OR_EVENT = /github\.(?:ref|ref_name|event_name)\s*==/u;
1327
+ /**
1328
+ * An expression written inside the `${{ }}` that marks it as one.
1329
+ */
1330
+ const EXPRESSION_WRAPPER = /^\$\{\{\s*([\s\S]*?)\s*\}\}$/u;
1331
+ /**
1332
+ * The operands of `operator` at the top level of an expression: not inside parentheses or quotes.
1333
+ */
1334
+ function operands(expression, operator) {
1335
+ const parts = [];
1336
+ let depth = 0;
1337
+ let quote;
1338
+ let start = 0;
1339
+ for (let index = 0; index < expression.length; index += 1) {
1340
+ const character = expression.charAt(index);
1341
+ if (quote !== void 0) quote = character === quote ? void 0 : quote;
1342
+ else if (character === "'" || character === "\"") quote = character;
1343
+ else if (character === "(") depth += 1;
1344
+ else if (character === ")") depth -= 1;
1345
+ else if (depth === 0 && expression.startsWith(operator, index)) {
1346
+ parts.push(expression.slice(start, index));
1347
+ index += operator.length - 1;
1348
+ start = index + 1;
1349
+ }
1350
+ }
1351
+ parts.push(expression.slice(start));
1352
+ return parts.map((part) => part.trim());
1353
+ }
1354
+ /**
1355
+ * Whether the first parenthesis of an expression is closed by its last character, so the parentheses enclose all of it, unlike `(a) && (b)`.
1356
+ */
1357
+ function enclosedByOneGroup(expression) {
1358
+ let depth = 0;
1359
+ for (let index = 0; index < expression.length; index += 1) {
1360
+ depth += expression.charAt(index) === "(" ? 1 : expression.charAt(index) === ")" ? -1 : 0;
1361
+ if (depth === 0) return index === expression.length - 1;
1362
+ }
1363
+ return false;
1364
+ }
1365
+ /**
1366
+ * The expression without the `${{ }}` that wraps it, or the parentheses that enclose all of it.
1367
+ */
1368
+ function unwrapped(expression) {
1369
+ const trimmed = expression.trim();
1370
+ const inner = EXPRESSION_WRAPPER.exec(trimmed)?.[1] ?? trimmed;
1371
+ return enclosedByOneGroup(inner) ? unwrapped(inner.slice(1, -1)) : inner;
1372
+ }
1373
+ /**
1374
+ * Whether an expression can only hold in runs other than those of a pull request and of a merge group, which are the runs the required check decides on: an operand of an `&&` that does so is enough, every operand of an `||` must, and a comparison of the ref or the event name does so when it names neither `pull_request` nor `merge_group`.
1375
+ */
1376
+ function keepsOutOfPullRequests(expression) {
1377
+ if (expression === void 0) return false;
1378
+ const text = unwrapped(expression);
1379
+ const alternatives = operands(text, "||");
1380
+ if (alternatives.length > 1) return alternatives.every(keepsOutOfPullRequests);
1381
+ const conjuncts = operands(text, "&&");
1382
+ if (conjuncts.length > 1) return conjuncts.some(keepsOutOfPullRequests);
1383
+ return COMPARES_REF_OR_EVENT.test(text) && !text.includes("pull_request") && !text.includes("merge_group");
1384
+ }
1385
+ const OUTCOME_ACTION = "re-actors/alls-green";
1386
+ function releaseAndDocsOrdering(workflow, options) {
1387
+ const releases = jobsNamed(workflow, options.releaseJobs ?? DEFAULT_RELEASE_JOBS);
1388
+ const deploys = jobsNamed(workflow, options.deployJobs ?? DEFAULT_DEPLOY_JOBS);
1389
+ const docs = jobsNamed(workflow, options.docsDeployJobs ?? DEFAULT_DOCS_DEPLOY_JOBS);
1390
+ const violations = [];
1391
+ for (const release of releases) {
1392
+ const waitsFor = transitiveNeeds(workflow, release.id);
1393
+ for (const deploy of deploys.filter((candidate) => candidate.id !== release.id && !waitsFor.has(candidate.id))) violations.push(workflowViolation(workflow, "workflow-job-ordering/release-before-deploy", `release job '${release.id}' does not need deploy job '${deploy.id}', so a release can be published after a failed deploy`, release.location));
1394
+ }
1395
+ for (const doc of docs) {
1396
+ const waitsFor = transitiveNeeds(workflow, doc.id);
1397
+ for (const release of releases.filter((candidate) => candidate.id !== doc.id && !waitsFor.has(candidate.id))) violations.push(workflowViolation(workflow, "workflow-job-ordering/docs-deploy-before-release", `documentation job '${doc.id}' does not need release job '${release.id}', so it can publish documentation for a release that did not happen`, doc.location));
1398
+ const branch = options.defaultBranch ?? DEFAULT_BRANCH;
1399
+ for (const checkout of doc.steps.filter((step) => stepUses(step, "actions/checkout"))) {
1400
+ const ref = checkout.with["ref"]?.trim();
1401
+ if (!(ref !== void 0 && (DEFAULT_BRANCH_EXPRESSION$1.test(ref) || ref === branch || ref === `refs/heads/${branch}`))) violations.push(workflowViolation(workflow, "workflow-job-ordering/docs-deploy-stale-checkout", `documentation job '${doc.id}' checks out ${ref === void 0 ? "the triggering commit" : `'${ref}'`}, which predates the release commit; check out the default branch afresh with ref: \${{ github.event.repository.default_branch }}`, checkout.location));
1402
+ }
1403
+ }
1404
+ return violations;
1405
+ }
1406
+ /**
1407
+ * The jobs a pull request's required check need not wait for: those the options exempt, those whose `if` keeps them to events other than pull requests and merge groups, and those that need such a job and so are skipped with it.
1408
+ */
1409
+ function exemptFromJunction(workflow, junctionExempt) {
1410
+ const direct = /* @__PURE__ */ new Set([...junctionExempt, ...workflow.jobs.filter((job) => keepsOutOfPullRequests(job.condition)).map((job) => job.id)]);
1411
+ return new Set(workflow.jobs.filter((job) => direct.has(job.id) || [...transitiveNeeds(workflow, job.id)].some((needed) => direct.has(needed))).map((job) => job.id));
1412
+ }
1413
+ function junctionOrdering(workflow, options) {
1414
+ const violations = [];
1415
+ const exempt = exemptFromJunction(workflow, options.junctionExempt ?? []);
1416
+ for (const junction of jobsNamed(workflow, options.junctionJobs ?? DEFAULT_JUNCTION_JOBS)) {
1417
+ if (!callsAlways(junction.condition)) violations.push(workflowViolation(workflow, "workflow-job-ordering/junction-not-always", `junction job '${junction.id}' has no if: always(), so it is skipped, and its required check never reports, when a job it needs fails`, junction.location));
1418
+ const missing = workflow.jobs.filter((job) => job.id !== junction.id && !exempt.has(job.id) && !junction.needs.includes(job.id) && !transitiveNeeds(workflow, job.id).has(junction.id));
1419
+ for (const job of missing) violations.push(workflowViolation(workflow, "workflow-job-ordering/junction-missing-need", `junction job '${junction.id}' does not list job '${job.id}' in needs, so its result is not part of the required check`, junction.location));
1420
+ const text = decisionText(junction);
1421
+ const reportsOutcome = junction.steps.some((step) => stepUses(step, OUTCOME_ACTION));
1422
+ if (SKIPPED_OR_NOT_SUCCESS.test(text)) violations.push(workflowViolation(workflow, "workflow-job-ordering/junction-fails-on-skipped", `junction job '${junction.id}' fails when a needed job is skipped, but a skipped job is how a path filter or an if keeps a job out; fail only on 'failure' and 'cancelled'`, junction.location));
1423
+ if (!reportsOutcome && !(RESULT_SOURCE.test(text) && FAILURE_WORD.test(text) && CANCELLED_WORD.test(text))) violations.push(workflowViolation(workflow, "workflow-job-ordering/junction-no-failure-condition", `junction job '${junction.id}' does not test the results of its needs for both 'failure' and 'cancelled', so it can pass when a job it needs did not succeed`, junction.location));
1424
+ }
1425
+ return violations;
1426
+ }
1427
+ /**
1428
+ * The jobs that release, deploy and report a required check are ordered so that a failure stops what follows: a release job needs the deploy job, a documentation deploy job needs the release job and checks out the default branch afresh, and one junction job with `if: always()` needs every other job and fails only on `failure` or `cancelled`.
1429
+ *
1430
+ * Jobs are recognised by id (see the options), and a role with no job in a workflow is not judged there. Ordering is judged through the transitive `needs` graph; the junction job must list every job directly, because `needs.*.result` only covers direct needs, except jobs that never run for a pull request or a merge group (an `if` on `github.ref` or `github.event_name` that mentions neither `pull_request` nor `merge_group`, where an `&&` needs one such operand and an `||` needs every operand to be), jobs that need such a job or the junction job itself, and the ids in `junctionExempt`. What the junction job decides on is read as text: it must read the results of its needs (`needs.*.result`, `needs.<job>.result` or `toJSON(needs)`) and name both `'failure'` and `'cancelled'`, or use the `re-actors/alls-green` action; a result read in a way the text does not show is reported as incomplete.
1431
+ */
1432
+ const workflowJobOrdering = async ({ cwd, options }) => (await loadWorkflows(cwd, options)).flatMap((workflow) => [...releaseAndDocsOrdering(workflow, options), ...junctionOrdering(workflow, options)]);
1433
+ //#endregion
1434
+ //#region src/checks/workflow-merge-group.ts
1435
+ /**
1436
+ * A workflow that checks pull requests also runs for the merge queue: GitHub starts no run of it for a queued pull request unless the workflow has a `merge_group` trigger, and a required check that never reports blocks the queue.
1437
+ *
1438
+ * A workflow is taken to produce required checks when it is triggered by `pull_request`; `pull_request_target` is left out, since it runs trusted code against untrusted input and is not a place for required checks. The file cannot show which checks are required, so a `pull_request` workflow whose checks are not required is reported too: narrow the check with `workflows` and `exclude`. A `merge_group` trigger on a workflow with no `pull_request` trigger is not judged. Limits: a `merge_group` trigger takes only `branches` filters (`paths` is a filter of `push`, `pull_request` and `pull_request_target`), so a workflow path-filtered for pull requests still runs in the queue once it has the trigger; the `branches` filter of a `merge_group` trigger is not read.
1439
+ */
1440
+ const workflowMergeGroup = async ({ cwd, options }) => (await loadWorkflows(cwd, options)).flatMap((workflow) => {
1441
+ const location = workflow.triggerLocations.get("pull_request");
1442
+ return location === void 0 || workflow.triggers.has("merge_group") ? [] : [workflowViolation(workflow, "workflow-merge-group/missing-trigger", "the workflow runs for pull requests but has no merge_group trigger, so its checks do not report for a pull request in the merge queue", location)];
1443
+ });
1444
+ //#endregion
1445
+ //#region src/checks/workflow-repository-dispatch.ts
1446
+ const DEFAULT_BRANCHES = ["main", "master"];
1447
+ const DEFAULT_BRANCH_EXPRESSION = /\$\{\{\s*github\.event\.repository\.default_branch\s*\}\}/u;
1448
+ const DEFAULT_TOKEN = /\b(?:secrets\.GITHUB_TOKEN|github\.token)\b/u;
1449
+ /**
1450
+ * The start of a `git push` command: `git`, the global options that take a value (`-C <directory>`, `-c <name=value>`) or are written `--name=value`, then `push`.
1451
+ */
1452
+ const PUSH = /^git(?:\s+(?:-[Cc]\s+\S+|--[\w-]+=\S+))*\s+push\b/u;
1453
+ const BRANCH_CREATION = /^git\s+(?:checkout\s+-[bB]|switch\s+-[cC])\b/u;
1454
+ const AUTO_MERGE = /^gh\s+pr\s+merge\b.*\s--auto\b/u;
1455
+ const PULL_REQUEST_CREATION = /^gh\s+pr\s+create\b|^gh\s+api\b.*\brepos\/[^\s]+\/pulls\b/u;
1456
+ const DISPATCH_TARGETS = [/^(?:gh\s+api|curl)\b.*\brepos\/([^\s/]+\/[^\s/]+)\/dispatches\b/u, /^gh\s+workflow\s+run\b.*\s(?:-R|--repo)[\s=]+(\S+)/u];
1457
+ const AUTO_COMMIT_ACTION = "stefanzweifel/git-auto-commit-action";
1458
+ const PULL_REQUEST_ACTION = "peter-evans/create-pull-request";
1459
+ const DISPATCH_ACTION = "peter-evans/repository-dispatch";
1460
+ const CHECKOUT_ACTION = "actions/checkout";
1461
+ function isDefaultBranch(branch, branches) {
1462
+ const name = branch.replace(/^refs\/heads\//u, "");
1463
+ return branches.includes(name) || DEFAULT_BRANCH_EXPRESSION.test(name);
1464
+ }
1465
+ /**
1466
+ * The `git push` options that take their value as the next word, which would otherwise be read as the remote.
1467
+ */
1468
+ const PUSH_OPTIONS_WITH_VALUE = /* @__PURE__ */ new Set([
1469
+ "-o",
1470
+ "--push-option",
1471
+ "--repo",
1472
+ "--receive-pack",
1473
+ "--exec"
1474
+ ]);
1475
+ /**
1476
+ * A URL with credentials in it (an expression such as `${{ secrets.TOKEN }}` may be part of them), which authenticates a push itself instead of using what the checkout left in git config.
1477
+ */
1478
+ const URL_WITH_CREDENTIALS = /[a-z][a-z0-9+.-]*:\/\/(?:[^\s/@{]|\$\{\{[^}]*\}\})*@/iu;
1479
+ /**
1480
+ * The words of a `git push` command line after `push`, without the quotes that wrap a word.
1481
+ */
1482
+ function pushWords(line) {
1483
+ return line.replace(PUSH, "").split(/\s+/u).map((word) => word.replace(/^(["'])(.*)\1$/u, "$2")).filter((word) => word !== "");
1484
+ }
1485
+ /**
1486
+ * The refspecs of a `git push` command line, after the remote, the options and the values of the options that take one.
1487
+ */
1488
+ function pushRefspecs(line) {
1489
+ const positional = [];
1490
+ const words = pushWords(line)[Symbol.iterator]();
1491
+ for (const word of words) if (PUSH_OPTIONS_WITH_VALUE.has(word)) words.next();
1492
+ else if (!word.startsWith("-")) positional.push(word);
1493
+ return positional.slice(1);
1494
+ }
1495
+ /**
1496
+ * Whether a `git push` command line pushes to the default branch. With a refspec it is the destination (a leading `+` only forces the update, and a bare `HEAD` is the checked-out branch); without one it is the branch the handler has checked out, which is the default branch unless the job created another or checked one out by `ref`.
1497
+ */
1498
+ function pushesToDefault(line, onOtherBranch, branches) {
1499
+ const refspecs = pushRefspecs(line);
1500
+ if (refspecs.length === 0) return !onOtherBranch;
1501
+ return refspecs.some((refspec) => {
1502
+ const destination = refspec.replace(/^\+/u, "");
1503
+ const named = destination.includes(":") ? destination.slice(destination.lastIndexOf(":") + 1) : destination;
1504
+ return named === "HEAD" ? !onOtherBranch : isDefaultBranch(named, branches);
1505
+ });
1506
+ }
1507
+ /**
1508
+ * Whether a `git push` command line authenticates with what the checkout left in git config, which it does unless the command line names a URL carrying its own credentials.
1509
+ */
1510
+ function pushesWithGitConfig(line) {
1511
+ return !URL_WITH_CREDENTIALS.test(line);
1512
+ }
1513
+ function usesDefaultToken(workflow, job, step) {
1514
+ return DEFAULT_TOKEN.test([
1515
+ step.run,
1516
+ ...Object.values(effectiveEnv(workflow, job, step)),
1517
+ ...Object.values(step.with)
1518
+ ].filter((text) => text !== void 0).join("\n"));
1519
+ }
1520
+ /**
1521
+ * Whether the job's checkout leaves the default token in the git config, so a `git push` authenticates as it.
1522
+ */
1523
+ function checkoutKeepsDefaultToken(job) {
1524
+ return job.steps.some((step) => stepUses(step, CHECKOUT_ACTION) && (step.with["token"] === void 0 || DEFAULT_TOKEN.test(step.with["token"])) && step.with["persist-credentials"] !== "false");
1525
+ }
1526
+ function checkoutRefOtherThanDefault(job, branches) {
1527
+ return job.steps.some((step) => stepUses(step, CHECKOUT_ACTION) && step.with["ref"] !== void 0 && !isDefaultBranch(step.with["ref"], branches));
1528
+ }
1529
+ function stepViolations(context, step, onOtherBranch) {
1530
+ const { workflow, job, branches } = context;
1531
+ const lines = scriptCommands(step.run);
1532
+ const found = [];
1533
+ const report = (reason, message) => {
1534
+ found.push(workflowViolation(workflow, `workflow-repository-dispatch/${reason}`, message, step.location));
1535
+ };
1536
+ const pushes = lines.filter((line) => PUSH.test(line));
1537
+ const autoCommits = stepUses(step, AUTO_COMMIT_ACTION);
1538
+ const autoCommitBranch = step.with["branch"];
1539
+ if (autoCommits && !onOtherBranch && (autoCommitBranch === void 0 || isDefaultBranch(autoCommitBranch, branches)) || pushes.some((line) => pushesToDefault(line, onOtherBranch, branches))) report("pushes-default-branch", `job '${job.id}' pushes to the default branch from a repository_dispatch handler, which anyone who can dispatch can trigger, bypassing review; push a branch and open a pull request`);
1540
+ if (!context.assumeRequiredChecks && lines.some((line) => AUTO_MERGE.test(line))) report("auto-merge-without-required-checks", `job '${job.id}' merges with --auto, which merges at once when the base branch has no required checks; require status checks on the default branch (or set assumeRequiredChecks once it does)`);
1541
+ const pushesAsDefaultToken = (pushes.some(pushesWithGitConfig) || autoCommits) && checkoutKeepsDefaultToken(job);
1542
+ const createsPullRequestAsDefaultToken = lines.some((line) => PULL_REQUEST_CREATION.test(line)) && usesDefaultToken(workflow, job, step) || stepUses(step, PULL_REQUEST_ACTION) && (step.with["token"] === void 0 || DEFAULT_TOKEN.test(step.with["token"]));
1543
+ if (pushesAsDefaultToken || createsPullRequestAsDefaultToken) report("default-token-triggers-nothing", `job '${job.id}' pushes or opens a pull request as GITHUB_TOKEN, and events GitHub Actions creates with that token start no workflows, so nothing validates the result; use a token from a GitHub App or a personal access token`);
1544
+ const targets = [...lines.flatMap((line) => DISPATCH_TARGETS.map((pattern) => pattern.exec(line)?.[1])), ...stepUses(step, DISPATCH_ACTION) ? [step.with["repository"]] : []].filter((target) => target !== void 0);
1545
+ for (const target of targets.filter((candidate) => !candidate.includes("github.repository"))) if (usesDefaultToken(workflow, job, step)) report("dispatch-other-repository-with-default-token", `job '${job.id}' dispatches to ${target} with GITHUB_TOKEN, which is limited to this repository; use a token that has access to ${target}`);
1546
+ return found;
1547
+ }
1548
+ function jobViolations(context) {
1549
+ let onOtherBranch = checkoutRefOtherThanDefault(context.job, context.branches);
1550
+ const found = [];
1551
+ for (const step of context.job.steps) {
1552
+ found.push(...stepViolations(context, step, onOtherBranch));
1553
+ onOtherBranch ||= scriptCommands(step.run).some((line) => BRANCH_CREATION.test(line));
1554
+ }
1555
+ return found;
1556
+ }
1557
+ /**
1558
+ * A workflow that handles `repository_dispatch` can be started by anyone with write access through the API, so what it does with the repository is held to a higher bar.
1559
+ *
1560
+ * `workflow-repository-dispatch/pushes-default-branch`: a `git push` to the default branch, or one with no refspec while the default branch is checked out, or `git-auto-commit-action` on it. `auto-merge-without-required-checks`: `gh pr merge --auto`, which merges at once when the base branch requires no checks (the file cannot show the rule; `assumeRequiredChecks` states it, and `settings-required-checks` verifies it). `default-token-triggers-nothing`: a push (the checkout leaves `GITHUB_TOKEN` in git config unless it is given a `token`, and a push to a URL carrying its own credentials does not use it) or a pull request opened as `GITHUB_TOKEN`; events created with it start no workflows, apart from `workflow_dispatch` and `repository_dispatch`, so the pushed commit gets no checks. `dispatch-other-repository-with-default-token`: a dispatch to a repository other than `github.repository` with `GITHUB_TOKEN`, whose access is limited to its own repository. Dispatching in the same repository with `GITHUB_TOKEN` is allowed by GitHub and is not reported.
1561
+ *
1562
+ * Limits: commands are read as text, so a script file or composite action is not seen; a push is judged by its command line, so a branch created by a helper is not seen, and a quoted word that contains a space is not read as one word; a dispatch target written literally as this repository's own name is reported, since the file does not know the name; a job that calls a reusable workflow is not read.
1563
+ */
1564
+ const workflowRepositoryDispatch = async ({ cwd, options }) => (await loadWorkflows(cwd, options)).filter((workflow) => workflow.triggers.has("repository_dispatch")).flatMap((workflow) => workflow.jobs.flatMap((job) => jobViolations({
1565
+ workflow,
1566
+ job,
1567
+ branches: options.defaultBranches ?? DEFAULT_BRANCHES,
1568
+ assumeRequiredChecks: options.assumeRequiredChecks === true
1569
+ })));
1570
+ //#endregion
1571
+ //#region src/checks/workflow-runner-resolution.ts
1572
+ /**
1573
+ * GitHub's own images: `ubuntu`, `windows` and `macos`, as `-latest`, a version, or a version with an architecture or size suffix, and `ubuntu-slim`.
1574
+ */
1575
+ const DEFAULT_HOSTED_LABELS = ["^(?:ubuntu|windows|macos)-(?:latest|slim|[0-9][0-9.]*)(?:-(?:arm|intel|xlarge|large))*$"];
1576
+ /**
1577
+ * `fromJson(needs.<job>.outputs.<name>)` in a `runs-on`.
1578
+ */
1579
+ const RESOLVED_RUNNER = /fromJSON\(\s*needs\.([\w-]+)\.outputs\.([\w-]+)\s*\)/giu;
1580
+ /**
1581
+ * `${{ jobs.<job>.outputs.<name> }}` as the value of a reusable workflow's output.
1582
+ */
1583
+ const JOB_OUTPUT_REFERENCE = /^\$\{\{\s*jobs\.([\w-]+)\.outputs\.([\w-]+)\s*\}\}$/u;
1584
+ /**
1585
+ * A fallback in an output expression: `||` followed by a string literal, whose body may hold `''` for a quote.
1586
+ */
1587
+ const LITERAL_FALLBACK = /\|\|\s*'((?:[^']|'')*)'/gu;
1588
+ /**
1589
+ * The resolver behind each `fromJson(needs.<job>.outputs.<name>)` in a `runs-on` of the workflow; none for one this check cannot read.
1590
+ */
1591
+ function resolversOf(workflow, workflows) {
1592
+ return workflow.jobs.flatMap((job) => job.runsOn.expressions.flatMap((expression) => [...expression.matchAll(RESOLVED_RUNNER)].flatMap((match) => {
1593
+ const caller = workflow.jobs.find((candidate) => candidate.id === match[1]);
1594
+ const output = match[2];
1595
+ const resolver = caller === void 0 || output === void 0 ? void 0 : implementation(workflow, workflows, caller, output);
1596
+ return resolver === void 0 ? [] : [resolver];
1597
+ })));
1598
+ }
1599
+ /**
1600
+ * The job that produces `output` for `caller`: the job itself, or for a call of a local reusable workflow the job in that workflow the output is forwarded from. `undefined` when the reusable workflow is not a file of this repository, which this check cannot read.
1601
+ */
1602
+ function implementation(workflow, workflows, caller, output) {
1603
+ if (caller.uses === void 0) return {
1604
+ workflow,
1605
+ job: caller,
1606
+ output
1607
+ };
1608
+ const reference = parseUses(caller.uses);
1609
+ if (reference.kind !== "local") return;
1610
+ const called = workflows.find((candidate) => `./${candidate.file}` === reference.path);
1611
+ if (called === void 0) throw new ConformanceError(`${workflow.file}: job '${caller.id}' calls ${caller.uses}, which is not a workflow file`);
1612
+ const forwarded = JOB_OUTPUT_REFERENCE.exec(called.callOutputs[output] ?? "");
1613
+ const job = called.jobs.find((candidate) => candidate.id === forwarded?.[1]);
1614
+ const jobOutput = forwarded?.[2];
1615
+ return job === void 0 || jobOutput === void 0 ? void 0 : {
1616
+ workflow: called,
1617
+ job,
1618
+ output: jobOutput
1619
+ };
1620
+ }
1621
+ /**
1622
+ * The runner labels the last literal fallback of an output expression names, read as the JSON the `fromJson` that consumes it expects: a label or a list of labels. `undefined` when there is no literal fallback, and an empty list when the literal is not labels.
1623
+ */
1624
+ function fallbackLabels(expression) {
1625
+ const literal = [...expression.matchAll(LITERAL_FALLBACK)].at(-1)?.[1]?.replaceAll("''", "'");
1626
+ if (literal === void 0) return;
1627
+ let parsed;
1628
+ try {
1629
+ parsed = JSON.parse(literal);
1630
+ } catch {
1631
+ return [];
1632
+ }
1633
+ const items = Array.isArray(parsed) ? parsed : [parsed];
1634
+ const labels = items.filter((item) => typeof item === "string");
1635
+ return labels.length === items.length ? labels : [];
1636
+ }
1637
+ function resolverViolations(resolver, hosted) {
1638
+ const violations = [];
1639
+ if (resolver.job.timeoutMinutes === void 0) violations.push(workflowViolation(resolver.workflow, "workflow-runner-resolution/resolver-no-timeout", `resolver job '${resolver.job.id}' has no timeout-minutes, so a runner that never starts holds every job waiting for it until the default six hours`, resolver.job.location));
1640
+ const fallback = fallbackLabels(resolver.job.outputs[resolver.output] ?? "");
1641
+ if (fallback === void 0) violations.push(workflowViolation(resolver.workflow, "workflow-runner-resolution/resolver-no-fallback", `output '${resolver.output}' of resolver job '${resolver.job.id}' has no literal fallback (\`|| '["<hosted label>"]'\`), so the jobs that read it have no runner when resolution produces nothing`, resolver.job.location));
1642
+ else if (fallback.length === 0 || !fallback.every((label) => hosted.some((pattern) => pattern.test(label)))) violations.push(workflowViolation(resolver.workflow, "workflow-runner-resolution/resolver-fallback-not-hosted", `the literal fallback of output '${resolver.output}' of resolver job '${resolver.job.id}' does not name only hosted runner labels, so it offers no runner when the self-hosted fleet is down`, resolver.job.location));
1643
+ return violations;
1644
+ }
1645
+ function customLabels(job, hosted) {
1646
+ return /* @__PURE__ */ new Set([...job.runsOn.labels.filter((label) => !hosted.some((pattern) => pattern.test(label))), ...job.runsOn.group === void 0 ? [] : [`group:${job.runsOn.group}`]]);
1647
+ }
1648
+ function repeatedLabels(workflow, hosted) {
1649
+ const labelsByJob = workflow.jobs.map((job) => ({
1650
+ job,
1651
+ labels: customLabels(job, hosted)
1652
+ }));
1653
+ const users = (label) => labelsByJob.filter((entry) => entry.labels.has(label)).map((entry) => entry.job.id);
1654
+ return labelsByJob.flatMap(({ job, labels }) => {
1655
+ const repeated = [...labels].filter((label) => users(label).length > 1);
1656
+ return repeated.length === 0 ? [] : [workflowViolation(workflow, "workflow-runner-resolution/repeated-label", `job '${job.id}' names the runner label${repeated.length === 1 ? "" : "s"} ${repeated.map((label) => `'${label}'`).join(", ")} literally, like ${[...new Set(repeated.flatMap(users))].filter((id) => id !== job.id).map((id) => `'${id}'`).join(", ")}; resolve it once in a resolver job and read it with fromJson`, job.location)];
1657
+ });
1658
+ }
1659
+ /**
1660
+ * A self-hosted or custom runner label named literally in more than one job of a workflow is resolved once instead: a resolver job decides the runner and the others read it with `fromJson(needs.<resolver>.outputs.<name>)`. The resolver must have a `timeout-minutes` and its output a literal fallback that names only hosted runner labels, so jobs still get a runner when resolution fails or the self-hosted fleet is down.
1661
+ *
1662
+ * A label is custom when it matches none of `hostedLabels`; the default patterns recognise GitHub's standard images, so a larger runner is named by a custom label and counts. Labels are compared within a workflow file, not across files. A resolver that is a reusable workflow is followed when it is a file of this repository (the output is traced through `on.workflow_call.outputs` to the job that sets it); a resolver in another repository cannot be read and is not judged. An output is taken to have a fallback when its expression has `||` followed by a string literal, and the last such literal is read as JSON (a label or a list of labels) and matched against `hostedLabels`; a fallback that is not valid JSON, or names any other label, is reported.
1663
+ */
1664
+ const workflowRunnerResolution = async ({ cwd, options }) => {
1665
+ const hosted = (options.hostedLabels ?? DEFAULT_HOSTED_LABELS).map((source) => new RegExp(source, "u"));
1666
+ const selected = await loadWorkflows(cwd, options);
1667
+ const available = await loadWorkflows(cwd, { workflows: DEFAULT_WORKFLOWS });
1668
+ const resolvers = /* @__PURE__ */ new Map();
1669
+ for (const workflow of selected) for (const resolver of resolversOf(workflow, available)) resolvers.set(`${resolver.workflow.file}#${resolver.job.id}#${resolver.output}`, resolver);
1670
+ return [...selected.flatMap((workflow) => repeatedLabels(workflow, hosted)), ...[...resolvers.values()].flatMap((resolver) => resolverViolations(resolver, hosted))];
1671
+ };
1672
+ //#endregion
1673
+ //#region src/checks/workflow-skippable-jobs.ts
1674
+ const DEFAULT_PATH_FILTER_ACTIONS = [
1675
+ "dorny/paths-filter",
1676
+ "tj-actions/changed-files",
1677
+ "step-security/changed-files"
1678
+ ];
1679
+ /**
1680
+ * The events whose `paths` and `paths-ignore` decide whether the whole workflow runs for a pull request.
1681
+ */
1682
+ const PULL_REQUEST_EVENTS = ["pull_request", "pull_request_target"];
1683
+ /**
1684
+ * The jobs a job `if` reads outputs from: `needs.<job>.outputs.<name>`.
1685
+ */
1686
+ const NEEDS_OUTPUT = /\bneeds\.([\w-]+)\.outputs\./gu;
1687
+ function hasPathFilter(workflow, event) {
1688
+ const configuration = workflow.triggers.get(event);
1689
+ return isRecord(configuration) && ("paths" in configuration || "paths-ignore" in configuration);
1690
+ }
1691
+ /**
1692
+ * The jobs a job's `if` reads path filter outputs from.
1693
+ */
1694
+ function filterJobsRead(workflow, job, actions) {
1695
+ const read = [...(job.condition ?? "").matchAll(NEEDS_OUTPUT)].map((match) => match[1]);
1696
+ return workflow.jobs.filter((candidate) => read.includes(candidate.id) && candidate.steps.some((step) => actions.some((action) => stepUses(step, action)))).map((candidate) => candidate.id);
1697
+ }
1698
+ /**
1699
+ * Keeping work off a pull request must not leave a required check without a result. A `paths` or `paths-ignore` filter on the trigger of a workflow with a junction job skips the whole workflow, so the required check stays pending forever; a job whose own `if` reads a path filter's outputs is skipped, which is only safe when a junction job with `if: always()` needs it and reports for it. Filter the steps of the job instead and the job still runs and reports.
1700
+ *
1701
+ * Limits: a workflow is taken to produce a required check when it has a junction job, so a trigger path filter in a workflow without one is not reported; a path filter is recognised by the actions in the options, so a script that computes changed paths itself is not seen, and a job gated on the output of such a script is not reported; only the filters of the `pull_request` and `pull_request_target` triggers are read, so a `paths` filter on `push` is not reported; only `needs.<job>.outputs` in the job's own `if` is read. Whether the junction job really reports is judged by `workflow-job-ordering`.
1702
+ */
1703
+ const workflowSkippableJobs = async ({ cwd, options }) => {
1704
+ const violations = [];
1705
+ for (const workflow of await loadWorkflows(cwd, options)) {
1706
+ const junctions = jobsNamed(workflow, options.junctionJobs ?? DEFAULT_JUNCTION_JOBS);
1707
+ if (junctions.length > 0) {
1708
+ for (const [event, location] of workflow.triggerLocations) if (PULL_REQUEST_EVENTS.includes(event) && hasPathFilter(workflow, event)) violations.push(workflowViolation(workflow, "workflow-skippable-jobs/trigger-path-filter", `${event} has a paths filter, so the workflow does not run, and its required check stays pending, when no matching file changed; filter the steps of the jobs instead`, location));
1709
+ }
1710
+ for (const job of workflow.jobs) {
1711
+ const filters = filterJobsRead(workflow, job, options.pathFilterActions ?? DEFAULT_PATH_FILTER_ACTIONS);
1712
+ const reported = junctions.some((junction) => junction.needs.includes(job.id));
1713
+ if (filters.length > 0 && !reported) violations.push(workflowViolation(workflow, "workflow-skippable-jobs/job-gated-by-path-filter", `job '${job.id}' is skipped by an if on the path filter in '${filters.join("', '")}' and no junction job reports for it; put the condition on its steps, or add the job to the needs of a junction job with if: always()`, job.location));
1714
+ }
1715
+ }
1716
+ return violations;
1717
+ };
1718
+ //#endregion
1719
+ //#region src/checks/workflow-update-bot-cooldown.ts
1720
+ const DEPENDABOT_FILES = [".github/dependabot.yml", ".github/dependabot.yaml"];
1721
+ const RENOVATE_FILES = [
1722
+ "renovate.json",
1723
+ ".renovaterc.json",
1724
+ ".renovaterc",
1725
+ ".github/renovate.json"
1726
+ ];
1727
+ /**
1728
+ * The cooldown settings that delay a release by a number of days.
1729
+ */
1730
+ const COOLDOWN_DAYS = [
1731
+ "default-days",
1732
+ "semver-major-days",
1733
+ "semver-minor-days",
1734
+ "semver-patch-days"
1735
+ ];
1736
+ const RENOVATE_SETTING = "minimumReleaseAge";
1737
+ function firstExisting$1(cwd, explicit, defaults) {
1738
+ if (explicit !== void 0) {
1739
+ if (!existsSync(resolve(cwd, explicit))) throw new ConformanceError(`${explicit} does not exist`);
1740
+ return explicit;
1741
+ }
1742
+ return defaults.find((file) => existsSync(resolve(cwd, file)));
1743
+ }
1744
+ function hasCooldown(update) {
1745
+ if (!isRecord(update) || !isRecord(update["cooldown"])) return false;
1746
+ const cooldown = update["cooldown"];
1747
+ return COOLDOWN_DAYS.some((setting) => typeof cooldown[setting] === "number" && cooldown[setting] > 0);
1748
+ }
1749
+ async function dependabotViolations(cwd, file) {
1750
+ const parsed = parseYaml(file, await readFile(resolve(cwd, file), "utf8"));
1751
+ return (isRecord(parsed.value) && Array.isArray(parsed.value["updates"]) ? parsed.value["updates"] : []).flatMap((update, index) => hasCooldown(update) ? [] : [{
1752
+ code: "workflow-update-bot-cooldown/dependabot-no-cooldown",
1753
+ message: `the update entry for ${isRecord(update) && typeof update["package-ecosystem"] === "string" ? update["package-ecosystem"] : `entry ${String(index)}`} has no cooldown with a number of days above zero, so Dependabot proposes a release the day it is published`,
1754
+ file,
1755
+ location: parsed.locate(["updates", index])
1756
+ }]);
1757
+ }
1758
+ /**
1759
+ * Whether a `minimumReleaseAge` value waits for a positive time: a duration string such as `3 days`, whose number is above zero. `0 days` and `null` (which clears the setting) wait for nothing.
1760
+ */
1761
+ function delaysRelease(age) {
1762
+ const amount = typeof age === "string" ? /^\s*(\d+(?:\.\d+)?)\s*[a-z]/iu.exec(age)?.[1] : void 0;
1763
+ return amount !== void 0 && Number(amount) > 0;
1764
+ }
1765
+ function setsMinimumReleaseAge(value) {
1766
+ if (!isRecord(value)) return false;
1767
+ const rules = Array.isArray(value["packageRules"]) ? value["packageRules"] : [];
1768
+ const presets = Array.isArray(value["extends"]) ? value["extends"] : [];
1769
+ return delaysRelease(value[RENOVATE_SETTING]) || rules.some((rule) => isRecord(rule) && delaysRelease(rule[RENOVATE_SETTING])) || presets.some((preset) => typeof preset === "string" && preset.includes(RENOVATE_SETTING));
1770
+ }
1771
+ async function renovateViolations(cwd, file) {
1772
+ const errors = [];
1773
+ const value = parse$1(await readFile(resolve(cwd, file), "utf8"), errors, { allowTrailingComma: true });
1774
+ const [error] = errors;
1775
+ if (error !== void 0) throw new ConformanceError(`${file}: not valid JSON: ${printParseErrorCode(error.error)} at offset ${String(error.offset)}`);
1776
+ return setsMinimumReleaseAge(value) ? [] : [{
1777
+ code: "workflow-update-bot-cooldown/renovate-no-minimum-release-age",
1778
+ message: `${file} sets no ${RENOVATE_SETTING} above zero, so Renovate proposes a release the day it is published`,
1779
+ file
1780
+ }];
1781
+ }
1782
+ /**
1783
+ * The update bots wait before proposing a release, so a compromised release is withdrawn before it is installed. Dependabot: every `updates` entry needs a `cooldown` with `default-days` or one of the `semver-*-days` above zero. Renovate: `minimumReleaseAge` must be set to a duration above zero at the top level or in a `packageRules` entry.
1784
+ *
1785
+ * Limits: the bots' own rules differ, since Dependabot's cooldown does not apply to security updates, and a Renovate preset is not resolved (an `extends` entry counts only when its name contains `minimumReleaseAge`), so a setting that comes from a preset under another name is reported. Renovate configs in JSON5 syntax beyond comments and trailing commas, and a `renovate` key in `package.json`, are not read. A repository with neither bot has nothing to report.
1786
+ */
1787
+ const workflowUpdateBotCooldown = async ({ cwd, options }) => {
1788
+ const dependabot = firstExisting$1(cwd, options.dependabot, DEPENDABOT_FILES);
1789
+ const renovate = firstExisting$1(cwd, options.renovate, RENOVATE_FILES);
1790
+ return [...dependabot === void 0 ? [] : await dependabotViolations(cwd, dependabot), ...renovate === void 0 ? [] : await renovateViolations(cwd, renovate)];
1791
+ };
1792
+ //#endregion
1793
+ //#region src/checks/workflow-version-single-source.ts
1794
+ const TOOL_VERSIONS = ".tool-versions";
1795
+ const NVMRC = ".nvmrc";
1796
+ const SETUP_ACTIONS = [
1797
+ {
1798
+ action: "actions/setup-node",
1799
+ versionInput: "node-version",
1800
+ fileInput: "node-version-file",
1801
+ tools: ["nodejs", "node"]
1802
+ },
1803
+ {
1804
+ action: "actions/setup-python",
1805
+ versionInput: "python-version",
1806
+ fileInput: "python-version-file",
1807
+ tools: ["python"]
1808
+ },
1809
+ {
1810
+ action: "actions/setup-go",
1811
+ versionInput: "go-version",
1812
+ fileInput: "go-version-file",
1813
+ tools: ["golang", "go"]
1814
+ },
1815
+ {
1816
+ action: "actions/setup-java",
1817
+ versionInput: "java-version",
1818
+ fileInput: "java-version-file",
1819
+ tools: ["java"]
1820
+ },
1821
+ {
1822
+ action: "actions/setup-dotnet",
1823
+ versionInput: "dotnet-version",
1824
+ fileInput: "global-json-file",
1825
+ tools: ["dotnet", "dotnet-core"]
1826
+ },
1827
+ {
1828
+ action: "ruby/setup-ruby",
1829
+ versionInput: "ruby-version",
1830
+ fileInput: void 0,
1831
+ tools: ["ruby"]
1832
+ },
1833
+ {
1834
+ action: "oven-sh/setup-bun",
1835
+ versionInput: "bun-version",
1836
+ fileInput: "bun-version-file",
1837
+ tools: ["bun"]
1838
+ },
1839
+ {
1840
+ action: "denoland/setup-deno",
1841
+ versionInput: "deno-version",
1842
+ fileInput: "deno-version-file",
1843
+ tools: ["deno"]
1844
+ },
1845
+ {
1846
+ action: "pnpm/action-setup",
1847
+ versionInput: "version",
1848
+ fileInput: void 0,
1849
+ tools: ["pnpm"]
1850
+ },
1851
+ {
1852
+ action: "hashicorp/setup-terraform",
1853
+ versionInput: "terraform_version",
1854
+ fileInput: void 0,
1855
+ tools: ["terraform"]
1856
+ }
1857
+ ];
491
1858
  /**
492
- * The version a pin names once the variables it refers to are replaced by the values in effect, or `undefined` when a variable has no value there (an `ARG` without a default, or a variable this file does not set) or its value refers to another variable, which makes the version unknowable from the file. A `+sha` suffix is dropped.
1859
+ * The tool names a `.tool-versions` file lists. Each line is a tool name followed by versions; a comment starts with `#`.
493
1860
  */
494
- function versionOf(token, variables) {
495
- const unknown = [];
496
- const replaced = token.replace(VARIABLE, (_reference, variable) => {
497
- const value = variables.get(variable);
498
- if (value === void 0 || value.includes("$")) {
499
- unknown.push(variable);
500
- return "";
501
- }
502
- return value;
503
- });
504
- return unknown.length === 0 ? replaced.split("+")[0] : void 0;
1861
+ function listedTools(content) {
1862
+ return new Set(content.split("\n").map((line) => line.replace(/#.*$/u, "").trim().split(/\s+/u)).filter((fields) => fields.length >= 2).map((fields) => String(fields[0])));
505
1863
  }
506
1864
  /**
507
- * Every pin of the package manager that `packageManager` names, in each Dockerfile, is the version `packageManager` names, with any `+sha` integrity suffix ignored on both sides. Pins of other package managers are not the workspace's package manager and are left alone.
1865
+ * A setup step keeps a runtime version in the workflow when a version file already says it: `.nvmrc` for Node, `.tool-versions` for any tool it lists. The step reads the file instead (`node-version-file`, or `mise` or `asdf` actions), so there is one place to change.
508
1866
  *
509
- * It reads `<manager>@<version>` as written on a line that is not a comment. A version that refers to a variable is compared with the value the file gives that variable by an `ARG` or `ENV` before the pin (the latest such value, whichever build stage it was in); one whose variable has no value in the file is not compared, since the build supplies it.
1867
+ * A version is a literal when it contains no expression, so `${{ matrix.node }}` is not reported: a matrix tests versions on purpose. A range or alias such as `lts/*` is a literal. Only the setup actions listed in the README are recognised, only the files in the working directory are looked for, and composite actions are not read.
510
1868
  */
511
- const dockerfilePackageManager = async ({ cwd, options }) => {
512
- const packageJson = options.packageJson ?? DEFAULT_PACKAGE_JSON;
513
- const manifest = JSON.parse(await readFile(resolve(cwd, packageJson), "utf8"));
514
- const declared = isRecord(manifest) && typeof manifest["packageManager"] === "string" ? PACKAGE_MANAGER.exec(manifest["packageManager"]) : null;
515
- const name = declared?.[1];
516
- const version = declared?.[2];
517
- if (name === void 0 || version === void 0) return [{
518
- code: "dockerfile-package-manager/no-package-manager",
519
- message: `${packageJson} has no valid 'packageManager' field to compare Dockerfile pins with`,
520
- file: packageJson
521
- }];
1869
+ const workflowVersionSingleSource = async ({ cwd, options }) => {
1870
+ const toolVersions = existsSync(resolve(cwd, TOOL_VERSIONS)) ? listedTools(await readFile(resolve(cwd, TOOL_VERSIONS), "utf8")) : /* @__PURE__ */ new Set();
1871
+ const hasNvmrc = existsSync(resolve(cwd, NVMRC));
522
1872
  const violations = [];
523
- for (const file of await findFiles(cwd, [...options.dockerfiles ?? DEFAULT_DOCKERFILES, ...excludedFrom(options.exclude)])) {
524
- const variables = /* @__PURE__ */ new Map();
525
- (await readFile(resolve(cwd, file), "utf8")).split("\n").forEach((text, index) => {
526
- if (text.trimStart().startsWith("#")) return;
527
- for (const [variable, value] of variablesSetBy(text)) variables.set(variable, value);
528
- for (const match of text.matchAll(PIN)) {
529
- const pinned = versionOf(String(match[2]), variables);
530
- if (match[1] === name && pinned !== void 0 && pinned !== version) violations.push({
531
- code: "dockerfile-package-manager/version-mismatch",
532
- message: `${file} pins ${name}@${pinned} but packageManager in ${packageJson} is ${name}@${version}`,
533
- file,
534
- location: {
535
- line: index + 1,
536
- column: match.index + 1
537
- }
538
- });
539
- }
540
- });
1873
+ for (const workflow of await loadWorkflows(cwd, options)) for (const job of workflow.jobs) for (const step of job.steps) for (const setup of SETUP_ACTIONS.filter((candidate) => stepUses(step, candidate.action))) {
1874
+ const version = step.with[setup.versionInput];
1875
+ const source = setup.action === "actions/setup-node" && hasNvmrc ? NVMRC : setup.tools.find((tool) => toolVersions.has(tool)) === void 0 ? void 0 : TOOL_VERSIONS;
1876
+ if (version !== void 0 && !hasExpression(version) && source !== void 0) violations.push(workflowViolation(workflow, "workflow-version-single-source/literal-version", `${setup.action} sets ${setup.versionInput}: ${version}, but ${source} already holds the version; read it from there${setup.fileInput === void 0 ? "" : ` with ${setup.fileInput}`}`, step.location));
541
1877
  }
542
1878
  return violations;
543
1879
  };
@@ -659,11 +1995,19 @@ function isolatedGroupRules(packages, isolatedGroups) {
659
1995
  }));
660
1996
  }
661
1997
  /**
1998
+ * The name of {@link cycleRule}, which a check reads its findings by.
1999
+ */
2000
+ const CYCLE_RULE_NAME = "import-cycle";
2001
+ /**
2002
+ * The name of {@link unresolvedNameImportRule}, which a check reads its findings by.
2003
+ */
2004
+ const UNRESOLVED_NAME_IMPORT_RULE_NAME = "unresolved-name-import";
2005
+ /**
662
2006
  * The rule that forbids any import cycle among the packages' files.
663
2007
  */
664
2008
  function cycleRule() {
665
2009
  return {
666
- name: "import-cycle",
2010
+ name: CYCLE_RULE_NAME,
667
2011
  severity: "error",
668
2012
  comment: "files may not import each other in a cycle",
669
2013
  from: {},
@@ -678,7 +2022,7 @@ function cycleRule() {
678
2022
  function unresolvedNameImportRule(packages) {
679
2023
  const names = packageNamesPattern(packages);
680
2024
  return names === void 0 ? void 0 : {
681
- name: "unresolved-name-import",
2025
+ name: UNRESOLVED_NAME_IMPORT_RULE_NAME,
682
2026
  severity: "error",
683
2027
  comment: "marks an import of a workspace package by a name that resolves to no file",
684
2028
  from: { path: packagesPattern(packages) },
@@ -867,15 +2211,22 @@ function assertTypeScriptAvailable(transpilers) {
867
2211
  if (typescript?.available !== true) throw new ConformanceError(`dependency-cruiser cannot load TypeScript, so the import checks would find no TypeScript file and report nothing; install ${typescript === void 0 ? "a version it supports" : `a version in ${typescript.version}`} as the 'typescript' of this workspace`);
868
2212
  }
869
2213
  /**
870
- * The violations of `rules` among the files of `packages`. The graph is limited to the packages' own files, so nothing outside them is a dependant or a dependency.
871
- *
872
- * The base directory is the real path of the root: dependency-cruiser resolves symbolic links, so the path of a file behind one would otherwise not be relative to it.
2214
+ * The graph-wide options that decide what a cruise sees, with their defaults filled in. Checks whose settings are equal share a cruise; the key is the serialised form.
2215
+ */
2216
+ function cruiseKey(cwd, options) {
2217
+ return JSON.stringify({
2218
+ exclude: options.exclude ?? DEFAULT_GRAPH_EXCLUDES,
2219
+ doNotFollow: options.doNotFollow ?? DEFAULT_DO_NOT_FOLLOW,
2220
+ tsConfig: options.tsConfig === void 0 ? null : resolve(cwd, options.tsConfig)
2221
+ });
2222
+ }
2223
+ /**
2224
+ * One cruise of the packages' files with `rules`, whose findings are read from the cruise summary: `cruise()` reports success in its exit code whatever the summary holds, so the code is not consulted.
873
2225
  *
874
- * The result is read from the cruise summary: `cruise()` reports success in its exit code whatever the summary holds, so the code is not consulted.
2226
+ * The graph is limited to the packages' own files, so nothing outside them is a dependant or a dependency. The base directory is the real path of the root: dependency-cruiser resolves symbolic links, so the path of a file behind one would otherwise not be relative to it.
875
2227
  */
876
2228
  async function cruiseViolations(input) {
877
2229
  const { cwd, root, packages, rules, options } = input;
878
- if (rules.length === 0) return [];
879
2230
  const tsConfigFile = options.tsConfig === void 0 ? void 0 : resolve(cwd, options.tsConfig);
880
2231
  const { cruise, getAvailableTranspilers } = await import("dependency-cruiser");
881
2232
  assertTypeScriptAvailable(getAvailableTranspilers());
@@ -894,19 +2245,64 @@ async function cruiseViolations(input) {
894
2245
  return result.output.summary.violations;
895
2246
  }
896
2247
  /**
897
- * Run one import check: generate its rules from the layout, cruise the packages and report each finding.
2248
+ * Run import checks over one read of the workspace's packages, and cruise once for each distinct combination of `exclude`, `doNotFollow` and `tsConfig` among them rather than once per check. Every check adds its rules to its group's cruise and gets back the findings of its own rules, which are the same as a cruise of that check alone would give: each rule is matched against each import independently.
2249
+ *
2250
+ * The result has an entry for every check, in the order of `entries`. A check whose layout yields no rules reports nothing and adds nothing to a cruise; a group none of whose checks has rules is not cruised.
898
2251
  */
899
- async function runImportCheck(context, spec) {
900
- const root = workspaceRoot(context.cwd, context.layout);
901
- const packages = await readWorkspacePackages(context.cwd, context.layout);
902
- const rules = spec.rules(packages, context);
903
- return (await cruiseViolations({
904
- cwd: context.cwd,
2252
+ async function runImportChecks(input) {
2253
+ const { cwd, layout, entries } = input;
2254
+ const root = workspaceRoot(cwd, layout);
2255
+ const packages = await readWorkspacePackages(cwd, layout);
2256
+ const scope = {
2257
+ cwd,
905
2258
  root,
906
- packages,
907
- rules,
908
- options: context.options
909
- })).map((finding) => {
2259
+ packages
2260
+ };
2261
+ const contributions = entries.map((entry) => ({
2262
+ ...entry,
2263
+ rules: entry.spec.rules(packages, layout)
2264
+ }));
2265
+ const groups = /* @__PURE__ */ new Map();
2266
+ for (const contribution of contributions) {
2267
+ const key = cruiseKey(cwd, contribution.options);
2268
+ const group = groups.get(key);
2269
+ if (group === void 0) groups.set(key, {
2270
+ options: contribution.options,
2271
+ members: [contribution]
2272
+ });
2273
+ else group.members.push(contribution);
2274
+ }
2275
+ const found = /* @__PURE__ */ new Map();
2276
+ for (const { options, members } of groups.values()) {
2277
+ const rules = members.flatMap((member) => member.rules);
2278
+ if (rules.length === 0) continue;
2279
+ const owner = /* @__PURE__ */ new Map();
2280
+ for (const member of members) for (const rule of member.rules) {
2281
+ const existing = owner.get(rule.name);
2282
+ if (existing !== void 0 && existing !== member.spec.name) throw new ConformanceError(`the checks ${existing} and ${member.spec.name} both generate a rule named '${rule.name}', so their findings cannot be told apart`);
2283
+ owner.set(rule.name, member.spec.name);
2284
+ }
2285
+ for (const finding of await cruiseViolations({
2286
+ cwd,
2287
+ root,
2288
+ packages,
2289
+ rules,
2290
+ options
2291
+ })) {
2292
+ const name = owner.get(finding.rule.name);
2293
+ if (name === void 0) throw new ConformanceError(`dependency-cruiser reported the rule '${finding.rule.name}', which no check generated`);
2294
+ const own = found.get(name);
2295
+ if (own === void 0) found.set(name, [finding]);
2296
+ else own.push(finding);
2297
+ }
2298
+ }
2299
+ return new Map(contributions.map(({ spec }) => [spec.name, spec.report(found.get(spec.name) ?? [], scope)]));
2300
+ }
2301
+ /**
2302
+ * The report of a check whose every rule forbids one package importing another: one violation per finding, at the importing file, ordered by file and message.
2303
+ */
2304
+ function edgeReport(spec) {
2305
+ return (findings, { cwd, root, packages }) => findings.map((finding) => {
910
2306
  const from = packageOfPath(packages, finding.from);
911
2307
  const to = packageOfPath(packages, finding.to);
912
2308
  return {
@@ -917,7 +2313,7 @@ async function runImportCheck(context, spec) {
917
2313
  fromFile: finding.from,
918
2314
  toFile: finding.to
919
2315
  }),
920
- file: relativePosix(context.cwd, join(root, finding.from))
2316
+ file: relativePosix(cwd, join(root, finding.from))
921
2317
  };
922
2318
  }).sort((a, b) => a.file.localeCompare(b.file) || a.message.localeCompare(b.message));
923
2319
  }
@@ -937,17 +2333,20 @@ function describeTarget(edge) {
937
2333
  //#endregion
938
2334
  //#region src/checks/import-cross-slice.ts
939
2335
  /**
940
- * A package imports a package in a different slice.
2336
+ * How `import-cross-slice` joins a shared cruise.
941
2337
  */
942
- const importCrossSlice = async (context) => runImportCheck(context, {
2338
+ const importCrossSliceSpec = {
943
2339
  name: "import-cross-slice",
944
- reason: "other-slice",
945
2340
  rules: (packages) => {
946
2341
  if (!packages.some((member) => member.slice !== void 0)) throw new ConformanceError("the 'import-cross-slice' check is enabled but no package in the layout has a slice");
947
2342
  return crossSliceRules(packages);
948
2343
  },
949
- message: (edge) => `${describePackage(edge.from)} (slice '${String(edge.from.slice)}') imports ${describeTarget(edge)} (slice '${String(edge.to.slice)}')`
950
- });
2344
+ report: edgeReport({
2345
+ name: "import-cross-slice",
2346
+ reason: "other-slice",
2347
+ message: (edge) => `${describePackage(edge.from)} (slice '${String(edge.from.slice)}') imports ${describeTarget(edge)} (slice '${String(edge.to.slice)}')`
2348
+ })
2349
+ };
951
2350
  //#endregion
952
2351
  //#region src/checks/import-cycles.ts
953
2352
  /**
@@ -1016,123 +2415,88 @@ function nameCycles(imports) {
1016
2415
  return cycles;
1017
2416
  }
1018
2417
  /**
1019
- * Files of the workspace's packages import each other in a cycle, and so do packages that import each other by a name that resolves to no file. Each cycle is reported once, at its first file (or, for packages, the file of the first import), starting from its smallest path.
1020
- *
1021
- * Cycles among packages are found only when every import in the ring is by a name that resolves to no file. A ring that mixes such imports with imports that do resolve to files has no complete path through either graph and is not seen.
1022
- */
1023
- const importCycles = async (context) => {
1024
- const root = workspaceRoot(context.cwd, context.layout);
1025
- const packages = await readWorkspacePackages(context.cwd, context.layout);
1026
- const nameRule = unresolvedNameImportRule(packages);
1027
- const found = await cruiseViolations({
1028
- cwd: context.cwd,
1029
- root,
1030
- packages,
1031
- rules: nameRule === void 0 ? [cycleRule()] : [cycleRule(), nameRule],
1032
- options: context.options
1033
- });
1034
- const inFiles = fileCycles(found.filter((finding) => finding.rule.name === cycleRule().name));
1035
- const inPackages = nameCycles(found.filter((finding) => finding.rule.name === nameRule?.name).map((finding) => ({
1036
- from: packageOfPath(packages, finding.from),
1037
- to: packageOfPath(packages, finding.to),
1038
- file: finding.from
1039
- })));
1040
- return [...[...inFiles.values()].map((ordered) => {
1041
- const [first] = ordered;
1042
- if (first === void 0) throw new ConformanceError("an import cycle with no files was reported");
1043
- return {
1044
- code: "import-cycles/cycle",
1045
- message: `import cycle: ${[...ordered, first].join(" -> ")}`,
1046
- file: relativePosix(context.cwd, join(root, first))
1047
- };
1048
- }), ...[...inPackages.values()].map((ring) => {
1049
- const [first] = ring;
1050
- if (first === void 0) throw new ConformanceError("an import cycle with no packages was reported");
1051
- return {
1052
- code: "import-cycles/cycle",
1053
- message: `import cycle between packages that import each other by a name that resolves to no file: ${[...ring.map((edge) => describePackage(edge.from)), describePackage(first.from)].join(" -> ")}`,
1054
- file: relativePosix(context.cwd, join(root, first.file))
1055
- };
1056
- })].sort((a, b) => a.file.localeCompare(b.file) || a.message.localeCompare(b.message));
2418
+ * How `import-cycles` joins a shared cruise.
2419
+ */
2420
+ const importCyclesSpec = {
2421
+ name: "import-cycles",
2422
+ rules: (packages) => {
2423
+ const nameRule = unresolvedNameImportRule(packages);
2424
+ return nameRule === void 0 ? [cycleRule()] : [cycleRule(), nameRule];
2425
+ },
2426
+ report: (found, { cwd, root, packages }) => {
2427
+ const inFiles = fileCycles(found.filter((finding) => finding.rule.name === CYCLE_RULE_NAME));
2428
+ const inPackages = nameCycles(found.filter((finding) => finding.rule.name === UNRESOLVED_NAME_IMPORT_RULE_NAME).map((finding) => ({
2429
+ from: packageOfPath(packages, finding.from),
2430
+ to: packageOfPath(packages, finding.to),
2431
+ file: finding.from
2432
+ })));
2433
+ return [...[...inFiles.values()].map((ordered) => {
2434
+ const [first] = ordered;
2435
+ if (first === void 0) throw new ConformanceError("an import cycle with no files was reported");
2436
+ return {
2437
+ code: "import-cycles/cycle",
2438
+ message: `import cycle: ${[...ordered, first].join(" -> ")}`,
2439
+ file: relativePosix(cwd, join(root, first))
2440
+ };
2441
+ }), ...[...inPackages.values()].map((ring) => {
2442
+ const [first] = ring;
2443
+ if (first === void 0) throw new ConformanceError("an import cycle with no packages was reported");
2444
+ return {
2445
+ code: "import-cycles/cycle",
2446
+ message: `import cycle between packages that import each other by a name that resolves to no file: ${[...ring.map((edge) => describePackage(edge.from)), describePackage(first.from)].join(" -> ")}`,
2447
+ file: relativePosix(cwd, join(root, first.file))
2448
+ };
2449
+ })].sort((a, b) => a.file.localeCompare(b.file) || a.message.localeCompare(b.message));
2450
+ }
1057
2451
  };
1058
2452
  //#endregion
1059
2453
  //#region src/checks/import-isolated-groups.ts
1060
2454
  /**
1061
- * A package imports a package in a group the layout isolates from its own.
2455
+ * How `import-isolated-groups` joins a shared cruise.
1062
2456
  */
1063
- const importIsolatedGroups = async (context) => runImportCheck(context, {
2457
+ const importIsolatedGroupsSpec = {
1064
2458
  name: "import-isolated-groups",
1065
- reason: "isolated-group",
1066
- rules: (packages) => {
1067
- if (context.layout.isolatedGroups === void 0) throw new ConformanceError("the 'import-isolated-groups' check is enabled but the layout has no 'isolatedGroups'");
1068
- return isolatedGroupRules(packages, context.layout.isolatedGroups);
2459
+ rules: (packages, layout) => {
2460
+ if (layout.isolatedGroups === void 0) throw new ConformanceError("the 'import-isolated-groups' check is enabled but the layout has no 'isolatedGroups'");
2461
+ return isolatedGroupRules(packages, layout.isolatedGroups);
1069
2462
  },
1070
- message: (edge) => `${describePackage(edge.from)} (group '${edge.from.group}') imports ${describeTarget(edge)} (group '${edge.to.group}'), which the layout isolates from it`
1071
- });
2463
+ report: edgeReport({
2464
+ name: "import-isolated-groups",
2465
+ reason: "isolated-group",
2466
+ message: (edge) => `${describePackage(edge.from)} (group '${edge.from.group}') imports ${describeTarget(edge)} (group '${edge.to.group}'), which the layout isolates from it`
2467
+ })
2468
+ };
1072
2469
  //#endregion
1073
2470
  //#region src/checks/import-rank-skip.ts
1074
2471
  /**
1075
- * A package imports a package more than `rankSkip.maxDistance` ranks below it that is not in `rankSkip.exemptRanks`.
2472
+ * How `import-rank-skip` joins a shared cruise.
1076
2473
  */
1077
- const importRankSkip = async (context) => runImportCheck(context, {
2474
+ const importRankSkipSpec = {
1078
2475
  name: "import-rank-skip",
1079
- reason: "skipped-rank",
1080
- rules: (packages) => {
1081
- if (context.layout.rankSkip === void 0) throw new ConformanceError("the 'import-rank-skip' check is enabled but the layout has no 'rankSkip'");
1082
- return rankSkipRules(packages, context.layout.rankSkip);
2476
+ rules: (packages, layout) => {
2477
+ if (layout.rankSkip === void 0) throw new ConformanceError("the 'import-rank-skip' check is enabled but the layout has no 'rankSkip'");
2478
+ return rankSkipRules(packages, layout.rankSkip);
1083
2479
  },
1084
- message: (edge) => `${describePackage(edge.from)} (rank ${String(edge.from.rank)}) imports ${describeTarget(edge)} (rank ${String(edge.to.rank)}), further below than the layout allows`
1085
- });
2480
+ report: edgeReport({
2481
+ name: "import-rank-skip",
2482
+ reason: "skipped-rank",
2483
+ message: (edge) => `${describePackage(edge.from)} (rank ${String(edge.from.rank)}) imports ${describeTarget(edge)} (rank ${String(edge.to.rank)}), further below than the layout allows`
2484
+ })
2485
+ };
1086
2486
  //#endregion
1087
2487
  //#region src/checks/import-uphill.ts
1088
2488
  /**
1089
- * A package imports a package of a strictly higher rank: dependencies run downhill only.
2489
+ * How `import-uphill` joins a shared cruise.
1090
2490
  */
1091
- const importUphill = async (context) => runImportCheck(context, {
2491
+ const importUphillSpec = {
1092
2492
  name: "import-uphill",
1093
- reason: "higher-rank",
1094
2493
  rules: (packages) => uphillRules(packages),
1095
- message: (edge) => `${describePackage(edge.from)} (rank ${String(edge.from.rank)}) imports ${describeTarget(edge)} (rank ${String(edge.to.rank)}), a higher rank`
1096
- });
1097
- async function git(cwd, args) {
1098
- return new Promise((resolve, reject) => {
1099
- execFile("git", [...args], {
1100
- cwd,
1101
- encoding: "utf8"
1102
- }, (error, stdout) => {
1103
- if (error === null) resolve(stdout);
1104
- else reject(new ConformanceError(`git ${args.join(" ")} failed in ${cwd}; this check reads the git index, so it needs a git working tree`, { cause: error }));
1105
- });
1106
- });
1107
- }
1108
- /**
1109
- * The index entry for `path` (relative to `cwd`), or `undefined` when git does not track it.
1110
- */
1111
- async function indexEntry(cwd, path) {
1112
- const listing = await git(cwd, [
1113
- "ls-files",
1114
- "--stage",
1115
- "--",
1116
- path
1117
- ]);
1118
- const match = /^(\d+) ([0-9a-f]+) \d\t/u.exec(listing);
1119
- const mode = match?.[1];
1120
- const blob = match?.[2];
1121
- return mode === void 0 || blob === void 0 ? void 0 : {
1122
- mode,
1123
- blob
1124
- };
1125
- }
1126
- /**
1127
- * The content of a blob. For a symbolic link it is the link's target.
1128
- */
1129
- async function blobContent(cwd, blob) {
1130
- return git(cwd, [
1131
- "cat-file",
1132
- "blob",
1133
- blob
1134
- ]);
1135
- }
2494
+ report: edgeReport({
2495
+ name: "import-uphill",
2496
+ reason: "higher-rank",
2497
+ message: (edge) => `${describePackage(edge.from)} (rank ${String(edge.from.rank)}) imports ${describeTarget(edge)} (rank ${String(edge.to.rank)}), a higher rank`
2498
+ })
2499
+ };
1136
2500
  //#endregion
1137
2501
  //#region src/checks/instruction-symlinks.ts
1138
2502
  /**
@@ -1205,6 +2569,208 @@ const instructionSymlinks = async ({ cwd, options }) => {
1205
2569
  return violations;
1206
2570
  };
1207
2571
  //#endregion
2572
+ //#region src/migrations/adapters.ts
2573
+ /**
2574
+ * The generators that have an adapter.
2575
+ */
2576
+ const generatorNames = ["drizzle-kit"];
2577
+ /**
2578
+ * The deploy tools that have an adapter.
2579
+ */
2580
+ const deployToolNames = ["wrangler"];
2581
+ function recordOf(config, file) {
2582
+ if (!isRecord(config)) throw new ConformanceError(`${file}: the config must be an object for the migrations directory to be read`);
2583
+ return config;
2584
+ }
2585
+ const DRIZZLE_DEFAULT_OUT = "drizzle";
2586
+ /**
2587
+ * drizzle-kit: `out` of `drizzle.config.{ts,js,json}`, `drizzle` when unset. An empty `out` is a configuration error, not the default. Migrations are applied with `drizzle-kit migrate`; `push` writes no migration files and is not an apply command here.
2588
+ */
2589
+ const drizzleKit = {
2590
+ configFiles: [
2591
+ "drizzle.config.ts",
2592
+ "drizzle.config.js",
2593
+ "drizzle.config.json"
2594
+ ],
2595
+ applyCommand: /(?:^|[\s;&|(])drizzle-kit\s+migrate(?![\w:-])/u,
2596
+ applyCommandName: "drizzle-kit migrate",
2597
+ outputDirectory: (config, file) => {
2598
+ const out = recordOf(config, file)["out"];
2599
+ if (out === void 0) return DRIZZLE_DEFAULT_OUT;
2600
+ if (typeof out !== "string") throw new ConformanceError(`${file}: 'out' must be a string`);
2601
+ if (out === "") throw new ConformanceError(`${file}: 'out' must not be empty: drizzle-kit reads an empty 'out' as unset in some commands, so the directory it names is ambiguous`);
2602
+ return out;
2603
+ }
2604
+ };
2605
+ const WRANGLER_DEFAULT_MIGRATIONS_DIR = "migrations";
2606
+ function databasesOf(scope, file) {
2607
+ const databases = scope["d1_databases"];
2608
+ if (databases === void 0) return [];
2609
+ if (!Array.isArray(databases) || !databases.every(isRecord)) throw new ConformanceError(`${file}: 'd1_databases' must be an array of objects`);
2610
+ return databases;
2611
+ }
2612
+ /**
2613
+ * wrangler: `migrations_dir` of a `d1_databases` entry of `wrangler.json`, `wrangler.jsonc` or `wrangler.toml`, `migrations` when unset, relative to the config file. D1 databases are not inherited by an environment, so a named environment is read from its own `env.<name>` table.
2614
+ */
2615
+ const wrangler = {
2616
+ configFiles: [
2617
+ "wrangler.json",
2618
+ "wrangler.jsonc",
2619
+ "wrangler.toml"
2620
+ ],
2621
+ migrationsDirectory: (config, file, selector) => {
2622
+ const top = recordOf(config, file);
2623
+ let scope = top;
2624
+ if (selector.environment !== void 0) {
2625
+ const environments = top["env"];
2626
+ const environment = isRecord(environments) ? environments[selector.environment] : void 0;
2627
+ if (!isRecord(environment)) throw new ConformanceError(`${file} has no environment '${selector.environment}' (checks.migrations-directory.environment)`);
2628
+ scope = environment;
2629
+ }
2630
+ const where = selector.environment === void 0 ? file : `${file} (environment '${selector.environment}')`;
2631
+ const databases = databasesOf(scope, file);
2632
+ const chosen = selector.database === void 0 ? databases : databases.filter((database) => database["binding"] === selector.database);
2633
+ if (chosen.length > 1) throw new ConformanceError(`${where} declares several D1 databases; name the one the generator writes for with checks.migrations-directory.database`);
2634
+ const [only] = chosen;
2635
+ if (only === void 0) return { none: selector.database === void 0 ? `${where} declares no D1 database` : `${where} declares no D1 database with the binding '${selector.database}'` };
2636
+ const directory = only["migrations_dir"];
2637
+ if (directory === void 0) return { directory: WRANGLER_DEFAULT_MIGRATIONS_DIR };
2638
+ if (typeof directory !== "string") throw new ConformanceError(`${where}: 'migrations_dir' must be a string`);
2639
+ return { directory };
2640
+ }
2641
+ };
2642
+ const generators = { "drizzle-kit": drizzleKit };
2643
+ const deployTools = { wrangler };
2644
+ //#endregion
2645
+ //#region src/migrations/config-data.ts
2646
+ /**
2647
+ * The first of `candidates` (relative to `cwd`) that exists, or `undefined` when none does.
2648
+ */
2649
+ function firstExisting(cwd, candidates) {
2650
+ return candidates.find((candidate) => existsSync(resolve(cwd, candidate)));
2651
+ }
2652
+ function assertValid(errors, file) {
2653
+ const [first] = errors;
2654
+ if (first !== void 0) throw new ConformanceError(`${file}: not valid JSON (${printParseErrorCode(first.error)} at offset ${String(first.offset)})`);
2655
+ }
2656
+ /**
2657
+ * The data in the text of a JSON file with comments and trailing commas allowed (`.jsonc`, and the `.json` files of tools that read them that way). Throws `ConformanceError` naming `file` when it is not valid.
2658
+ */
2659
+ function parseJsonc(text, file) {
2660
+ const errors = [];
2661
+ const data = parse$1(text, errors, { allowTrailingComma: true });
2662
+ assertValid(errors, file);
2663
+ return data;
2664
+ }
2665
+ /**
2666
+ * The syntax tree of the same text, with the offsets of its nodes. Throws `ConformanceError` naming `file` when it is not valid or holds no value.
2667
+ */
2668
+ function parseJsoncTree(text, file) {
2669
+ const errors = [];
2670
+ const tree = parseTree(text, errors, { allowTrailingComma: true });
2671
+ assertValid(errors, file);
2672
+ if (tree === void 0) throw new ConformanceError(`${file}: holds no JSON value`);
2673
+ return tree;
2674
+ }
2675
+ /**
2676
+ * The data of the config file at `file` (relative to `cwd`), which must exist: JSON and JSONC are parsed, TOML is parsed, and anything else is evaluated as a module and its default export taken, as {@link evaluateConfigFile} does, so a TypeScript config runs as its own tool would run it.
2677
+ */
2678
+ async function readConfigData(cwd, file, configFiles) {
2679
+ const extension = extname(file).toLowerCase();
2680
+ if (extension === ".json" || extension === ".jsonc") return parseJsonc(await readFile(resolve(cwd, file), "utf8"), file);
2681
+ if (extension === ".toml") try {
2682
+ return parse$2(await readFile(resolve(cwd, file), "utf8"));
2683
+ } catch (error) {
2684
+ throw new ConformanceError(`${file}: not valid TOML (${error instanceof Error ? error.message : String(error)})`);
2685
+ }
2686
+ return evaluateConfigFile(cwd, file, configFiles);
2687
+ }
2688
+ //#endregion
2689
+ //#region src/checks/migrations-directory.ts
2690
+ const DEFAULT_PACKAGE_JSONS = ["**/package.json"];
2691
+ /**
2692
+ * The 1-based line and column of the character at `offset` in `text`, which is not the first character.
2693
+ */
2694
+ function positionOf(text, offset) {
2695
+ const lineStart = text.lastIndexOf("\n", offset - 1) + 1;
2696
+ return {
2697
+ line: text.slice(0, lineStart).split("\n").length,
2698
+ column: offset - lineStart + 1
2699
+ };
2700
+ }
2701
+ /**
2702
+ * The config file a check reads: the one the option names, else the first of the adapter's file names that exists. `undefined` when there is none.
2703
+ */
2704
+ function locate(cwd, explicit, candidates) {
2705
+ return explicit === void 0 ? firstExisting(cwd, candidates) : firstExisting(cwd, [explicit]);
2706
+ }
2707
+ function missingConfig(explicit, candidates) {
2708
+ return explicit === void 0 ? {
2709
+ code: "migrations-directory/missing-config",
2710
+ message: `none of ${candidates.join(", ")} exists`,
2711
+ file: candidates[0]
2712
+ } : {
2713
+ code: "migrations-directory/missing-config",
2714
+ message: `${explicit} does not exist`,
2715
+ file: explicit
2716
+ };
2717
+ }
2718
+ /**
2719
+ * Violations for every script of the `package.json` at `file` that matches `command`.
2720
+ */
2721
+ async function scriptViolations(cwd, file, command, describe) {
2722
+ const text = await readFile(resolve(cwd, file), "utf8");
2723
+ const scripts = findNodeAtLocation(parseJsoncTree(text, file), ["scripts"]);
2724
+ const violations = [];
2725
+ for (const entry of scripts?.children ?? []) {
2726
+ const [key, value] = entry.children ?? [];
2727
+ if (key?.type === "string" && value?.type === "string" && typeof value.value === "string" && command.test(value.value)) violations.push({
2728
+ code: "migrations-directory/generator-apply-command",
2729
+ message: describe(value.value, String(key.value)),
2730
+ file,
2731
+ location: positionOf(text, value.offset)
2732
+ });
2733
+ }
2734
+ return violations;
2735
+ }
2736
+ /**
2737
+ * The directory a schema generator writes migrations to is the one the deploy tool applies them from, and no script applies them with the generator instead of the deploy tool. The generator and the deploy tool are named in the options and each has an adapter that knows its config file names and where in them the directory is; both directories are resolved against the directory of the file that sets them.
2738
+ *
2739
+ * The config files are read as data: JSON, JSONC and TOML are parsed, and a module (a generator's TypeScript config) is evaluated, so it must be trusted.
2740
+ */
2741
+ const migrationsDirectory = async ({ cwd, options, configFiles }) => {
2742
+ const generator = generators[options.generator];
2743
+ const deployTool = deployTools[options.deployTool];
2744
+ const violations = [];
2745
+ const generatorFile = locate(cwd, options.generatorConfig, generator.configFiles);
2746
+ const deployFile = locate(cwd, options.deployConfig, deployTool.configFiles);
2747
+ if (generatorFile === void 0) violations.push(missingConfig(options.generatorConfig, generator.configFiles));
2748
+ if (deployFile === void 0) violations.push(missingConfig(options.deployConfig, deployTool.configFiles));
2749
+ if (generatorFile !== void 0 && deployFile !== void 0) {
2750
+ const written = generator.outputDirectory(await readConfigData(cwd, generatorFile, configFiles), generatorFile);
2751
+ const applied = deployTool.migrationsDirectory(await readConfigData(cwd, deployFile, configFiles), deployFile, {
2752
+ ...options.database === void 0 ? {} : { database: options.database },
2753
+ ...options.environment === void 0 ? {} : { environment: options.environment }
2754
+ });
2755
+ if (applied.none !== void 0) violations.push({
2756
+ code: "migrations-directory/no-database",
2757
+ message: applied.none,
2758
+ file: deployFile
2759
+ });
2760
+ else {
2761
+ const generated = resolve(cwd, dirname(generatorFile), written);
2762
+ const deployed = resolve(cwd, dirname(deployFile), applied.directory);
2763
+ if (generated !== deployed) violations.push({
2764
+ code: "migrations-directory/mismatch",
2765
+ message: `${generatorFile} writes migrations to ${relativePosix(cwd, generated)}, but ${deployFile} applies them from ${relativePosix(cwd, deployed)}`,
2766
+ file: generatorFile
2767
+ });
2768
+ }
2769
+ }
2770
+ for (const file of await findFiles(cwd, options.packageJsons ?? DEFAULT_PACKAGE_JSONS)) violations.push(...await scriptViolations(cwd, file, generator.applyCommand, (script, name) => `the script '${name}' runs '${generator.applyCommandName}' (${script}) where migrations are applied by ${options.deployTool}`));
2771
+ return violations;
2772
+ };
2773
+ //#endregion
1208
2774
  //#region src/checks/single-storybook.ts
1209
2775
  const STORYBOOK_DIRECTORY = ".storybook";
1210
2776
  /**
@@ -1232,6 +2798,7 @@ function plain(spec) {
1232
2798
  name: spec.name,
1233
2799
  description: spec.description,
1234
2800
  requiresLayout: false,
2801
+ requiresGitHub: false,
1235
2802
  isEnabled: (checks) => spec.select(checks) !== void 0,
1236
2803
  run: async ({ cwd, checks, configFiles }) => {
1237
2804
  const options = spec.select(checks);
@@ -1244,24 +2811,39 @@ function plain(spec) {
1244
2811
  }
1245
2812
  };
1246
2813
  }
1247
- function withLayout(spec) {
2814
+ function online(spec) {
1248
2815
  return {
1249
2816
  name: spec.name,
1250
2817
  description: spec.description,
1251
- requiresLayout: true,
2818
+ requiresLayout: false,
2819
+ requiresGitHub: true,
1252
2820
  isEnabled: (checks) => spec.select(checks) !== void 0,
1253
- run: async ({ cwd, checks, layout }) => {
2821
+ run: async ({ cwd, checks, configFiles, github }) => {
1254
2822
  const options = spec.select(checks);
1255
2823
  if (options === void 0) throw new ConformanceError(`the check '${spec.name}' is not enabled`);
1256
- if (layout === void 0) throw new ConformanceError(`the check '${spec.name}' reads the workspace layout, which was not supplied`);
2824
+ if (github === void 0) throw new ConformanceError(`the check '${spec.name}' reads the repository's settings and needs a GitHub client`);
1257
2825
  return spec.check({
1258
2826
  cwd,
1259
2827
  options,
1260
- layout
2828
+ github,
2829
+ ...configFiles === void 0 ? {} : { configFiles }
1261
2830
  });
1262
2831
  }
1263
2832
  };
1264
2833
  }
2834
+ function importCheck(spec) {
2835
+ return {
2836
+ name: spec.name,
2837
+ description: spec.description,
2838
+ requiresLayout: true,
2839
+ requiresGitHub: false,
2840
+ isEnabled: (checks) => spec.select(checks) !== void 0,
2841
+ importGraph: {
2842
+ spec: spec.importSpec,
2843
+ optionsOf: spec.select
2844
+ }
2845
+ };
2846
+ }
1265
2847
  /**
1266
2848
  * Every check, by name, in the order they run. The key is the name used in the `checks` map, on the command line and in violation codes.
1267
2849
  */
@@ -1278,35 +2860,35 @@ const registry = {
1278
2860
  select: (checks) => enabledOptions(checks["command-types"]),
1279
2861
  check: commandTypes
1280
2862
  }),
1281
- "import-uphill": withLayout({
2863
+ "import-uphill": importCheck({
1282
2864
  name: "import-uphill",
1283
2865
  description: "No package imports a package of a higher rank",
1284
2866
  select: (checks) => enabledOptions(checks["import-uphill"]),
1285
- check: importUphill
2867
+ importSpec: importUphillSpec
1286
2868
  }),
1287
- "import-rank-skip": withLayout({
2869
+ "import-rank-skip": importCheck({
1288
2870
  name: "import-rank-skip",
1289
2871
  description: "No package imports further below itself than the layout allows",
1290
2872
  select: (checks) => enabledOptions(checks["import-rank-skip"]),
1291
- check: importRankSkip
2873
+ importSpec: importRankSkipSpec
1292
2874
  }),
1293
- "import-cross-slice": withLayout({
2875
+ "import-cross-slice": importCheck({
1294
2876
  name: "import-cross-slice",
1295
2877
  description: "No package imports a package in a different slice",
1296
2878
  select: (checks) => enabledOptions(checks["import-cross-slice"]),
1297
- check: importCrossSlice
2879
+ importSpec: importCrossSliceSpec
1298
2880
  }),
1299
- "import-isolated-groups": withLayout({
2881
+ "import-isolated-groups": importCheck({
1300
2882
  name: "import-isolated-groups",
1301
2883
  description: "No package imports a package in a group the layout isolates from its own",
1302
2884
  select: (checks) => enabledOptions(checks["import-isolated-groups"]),
1303
- check: importIsolatedGroups
2885
+ importSpec: importIsolatedGroupsSpec
1304
2886
  }),
1305
- "import-cycles": withLayout({
2887
+ "import-cycles": importCheck({
1306
2888
  name: "import-cycles",
1307
2889
  description: "No files of the workspace packages import each other in a cycle",
1308
2890
  select: (checks) => enabledOptions(checks["import-cycles"]),
1309
- check: importCycles
2891
+ importSpec: importCyclesSpec
1310
2892
  }),
1311
2893
  "instruction-symlinks": plain({
1312
2894
  name: "instruction-symlinks",
@@ -1331,6 +2913,84 @@ const registry = {
1331
2913
  description: "A package manager version pinned in a Dockerfile is the version packageManager names",
1332
2914
  select: (checks) => enabledOptions(checks["dockerfile-package-manager"]),
1333
2915
  check: dockerfilePackageManager
2916
+ }),
2917
+ "migrations-directory": plain({
2918
+ name: "migrations-directory",
2919
+ description: "The directory a schema generator writes migrations to is the one the deploy tool applies them from, and no script applies them with the generator",
2920
+ select: (checks) => enabledOptions(checks["migrations-directory"]),
2921
+ check: migrationsDirectory
2922
+ }),
2923
+ "workflow-job-ordering": plain({
2924
+ name: "workflow-job-ordering",
2925
+ description: "Release, deploy and junction jobs are ordered so a failure stops what follows",
2926
+ select: (checks) => enabledOptions(checks["workflow-job-ordering"]),
2927
+ check: workflowJobOrdering
2928
+ }),
2929
+ "workflow-skippable-jobs": plain({
2930
+ name: "workflow-skippable-jobs",
2931
+ description: "A path filter does not leave a required check pending or skip a job no junction job reports for",
2932
+ select: (checks) => enabledOptions(checks["workflow-skippable-jobs"]),
2933
+ check: workflowSkippableJobs
2934
+ }),
2935
+ "workflow-runner-resolution": plain({
2936
+ name: "workflow-runner-resolution",
2937
+ description: "A self-hosted or custom runner label is resolved once by a resolver job, not repeated in several jobs",
2938
+ select: (checks) => enabledOptions(checks["workflow-runner-resolution"]),
2939
+ check: workflowRunnerResolution
2940
+ }),
2941
+ "workflow-version-single-source": plain({
2942
+ name: "workflow-version-single-source",
2943
+ description: "Setup steps read a runtime version from .nvmrc or .tool-versions instead of holding a literal",
2944
+ select: (checks) => enabledOptions(checks["workflow-version-single-source"]),
2945
+ check: workflowVersionSingleSource
2946
+ }),
2947
+ "workflow-credentials": plain({
2948
+ name: "workflow-credentials",
2949
+ description: "Attestation steps have the permissions they need, and a tokenless publish cannot quietly use a token",
2950
+ select: (checks) => enabledOptions(checks["workflow-credentials"]),
2951
+ check: workflowCredentials
2952
+ }),
2953
+ "workflow-repository-dispatch": plain({
2954
+ name: "workflow-repository-dispatch",
2955
+ description: "A repository_dispatch handler does not push to the default branch, auto-merge unguarded, or rely on the default token",
2956
+ select: (checks) => enabledOptions(checks["workflow-repository-dispatch"]),
2957
+ check: workflowRepositoryDispatch
2958
+ }),
2959
+ "workflow-update-bot-cooldown": plain({
2960
+ name: "workflow-update-bot-cooldown",
2961
+ description: "Dependabot and Renovate wait before proposing a new release",
2962
+ select: (checks) => enabledOptions(checks["workflow-update-bot-cooldown"]),
2963
+ check: workflowUpdateBotCooldown
2964
+ }),
2965
+ "workflow-merge-group": plain({
2966
+ name: "workflow-merge-group",
2967
+ description: "A workflow that runs for pull requests also runs for the merge queue",
2968
+ select: (checks) => enabledOptions(checks["workflow-merge-group"]),
2969
+ check: workflowMergeGroup
2970
+ }),
2971
+ "workflow-action-pinning": plain({
2972
+ name: "workflow-action-pinning",
2973
+ description: "Actions and reusable workflows are pinned as the configured policy requires",
2974
+ select: (checks) => enabledOptions(checks["workflow-action-pinning"]),
2975
+ check: workflowActionPinning
2976
+ }),
2977
+ "settings-merge-methods": online({
2978
+ name: "settings-merge-methods",
2979
+ description: "The repository allows only one merge method",
2980
+ select: (checks) => enabledOptions(checks["settings-merge-methods"]),
2981
+ check: settingsMergeMethods
2982
+ }),
2983
+ "settings-required-checks": online({
2984
+ name: "settings-required-checks",
2985
+ description: "The default branch requires the junction job check, up to date with the base",
2986
+ select: (checks) => enabledOptions(checks["settings-required-checks"]),
2987
+ check: settingsRequiredChecks
2988
+ }),
2989
+ "settings-review-thread-resolution": online({
2990
+ name: "settings-review-thread-resolution",
2991
+ description: "A pull request cannot merge while a review conversation is unresolved",
2992
+ select: (checks) => enabledOptions(checks["settings-review-thread-resolution"]),
2993
+ check: settingsReviewThreadResolution
1334
2994
  })
1335
2995
  };
1336
2996
  /**
@@ -1361,6 +3021,15 @@ function compiles(source) {
1361
3021
  */
1362
3022
  const pattern = z.string().min(1).refine(compiles, "not a valid regular expression");
1363
3023
  const patterns = z.array(pattern);
3024
+ const selection = {
3025
+ workflows: z.exactOptional(paths),
3026
+ exclude: z.exactOptional(paths)
3027
+ };
3028
+ const pinningLevel = z.enum(["sha", "ref"]);
3029
+ const settings = {
3030
+ repository: z.exactOptional(z.string().regex(/^[^/\s]+\/[^/\s]+$/u, "expected owner/name")),
3031
+ branch: z.exactOptional(z.string().min(1))
3032
+ };
1364
3033
  const importGraph = z.strictObject({
1365
3034
  exclude: z.exactOptional(patterns),
1366
3035
  doNotFollow: z.exactOptional(patterns),
@@ -1410,7 +3079,67 @@ const checks = z.strictObject({
1410
3079
  dockerfiles: z.exactOptional(paths),
1411
3080
  exclude: z.exactOptional(paths),
1412
3081
  packageJson: z.exactOptional(path)
1413
- })))
3082
+ }))),
3083
+ "migrations-directory": z.exactOptional(enabled(z.strictObject({
3084
+ generator: z.enum(generatorNames),
3085
+ deployTool: z.enum(deployToolNames),
3086
+ generatorConfig: z.exactOptional(path),
3087
+ deployConfig: z.exactOptional(path),
3088
+ database: z.exactOptional(z.string().min(1)),
3089
+ environment: z.exactOptional(z.string().min(1)),
3090
+ packageJsons: z.exactOptional(paths)
3091
+ }))),
3092
+ "workflow-job-ordering": z.exactOptional(enabled(z.strictObject({
3093
+ ...selection,
3094
+ releaseJobs: z.exactOptional(names),
3095
+ deployJobs: z.exactOptional(names),
3096
+ docsDeployJobs: z.exactOptional(names),
3097
+ junctionJobs: z.exactOptional(names),
3098
+ junctionExempt: z.exactOptional(names),
3099
+ defaultBranch: z.exactOptional(z.string().min(1))
3100
+ }))),
3101
+ "workflow-skippable-jobs": z.exactOptional(enabled(z.strictObject({
3102
+ ...selection,
3103
+ junctionJobs: z.exactOptional(names),
3104
+ pathFilterActions: z.exactOptional(names)
3105
+ }))),
3106
+ "workflow-runner-resolution": z.exactOptional(enabled(z.strictObject({
3107
+ ...selection,
3108
+ hostedLabels: z.exactOptional(patterns)
3109
+ }))),
3110
+ "workflow-version-single-source": z.exactOptional(enabled(z.strictObject(selection))),
3111
+ "workflow-credentials": z.exactOptional(enabled(z.strictObject(selection))),
3112
+ "workflow-repository-dispatch": z.exactOptional(enabled(z.strictObject({
3113
+ ...selection,
3114
+ defaultBranches: z.exactOptional(names),
3115
+ assumeRequiredChecks: z.exactOptional(z.boolean())
3116
+ }))),
3117
+ "workflow-update-bot-cooldown": z.exactOptional(enabled(z.strictObject({
3118
+ dependabot: z.exactOptional(path),
3119
+ renovate: z.exactOptional(path)
3120
+ }))),
3121
+ "workflow-merge-group": z.exactOptional(enabled(z.strictObject(selection))),
3122
+ "workflow-action-pinning": z.exactOptional(enabled(z.strictObject({
3123
+ ...selection,
3124
+ thirdParty: z.exactOptional(pinningLevel),
3125
+ sameOrganisation: z.exactOptional(pinningLevel),
3126
+ organisations: z.exactOptional(names),
3127
+ allow: z.exactOptional(names)
3128
+ }))),
3129
+ "settings-merge-methods": z.exactOptional(enabled(z.strictObject({
3130
+ ...settings,
3131
+ allowed: z.exactOptional(z.enum([
3132
+ "rebase",
3133
+ "squash",
3134
+ "merge"
3135
+ ]))
3136
+ }))),
3137
+ "settings-required-checks": z.exactOptional(enabled(z.strictObject({
3138
+ ...settings,
3139
+ ...selection,
3140
+ junctionJobs: z.exactOptional(names)
3141
+ }))),
3142
+ "settings-review-thread-resolution": z.exactOptional(enabled(z.strictObject(settings)))
1414
3143
  });
1415
3144
  /**
1416
3145
  * The Standard Schema for {@link ConformanceConfig}. It rejects unknown keys at every level, so a misspelt check name or option fails when the section loads.
@@ -1449,35 +3178,74 @@ async function layoutOf(options, needed) {
1449
3178
  if (loaded === void 0) throw new ConformanceError(`${needed.join(", ")} read the workspace layout, and there is no 'layout' section in ${options.cwd}; define it in exadev.config.ts or in exadev.layout.config.ts`);
1450
3179
  return loaded;
1451
3180
  }
1452
- function selected(config, requested) {
3181
+ function selected(config, requested, online) {
1453
3182
  const enabled = checkNames.filter((name) => registry[name].isEnabled(config.checks));
1454
3183
  if (requested === void 0) {
1455
3184
  if (enabled.length === 0) throw new ConformanceError("no check is enabled; add one to the 'checks' of the 'conformance' section");
1456
- return enabled;
3185
+ const runnable = enabled.filter((name) => online || !registry[name].requiresGitHub);
3186
+ if (runnable.length === 0) throw new ConformanceError(`only checks that read the repository's settings are enabled (${enabled.join(", ")}); they run only with a GitHub client (--settings on the command line)`);
3187
+ return runnable;
1457
3188
  }
1458
3189
  const disabled = requested.filter((name) => !enabled.includes(name));
1459
3190
  if (disabled.length > 0) throw new ConformanceError(`${disabled.join(", ")} ${disabled.length === 1 ? "is" : "are"} not enabled in the 'conformance' section`);
3191
+ const offline = requested.filter((name) => !online && registry[name].requiresGitHub);
3192
+ if (offline.length > 0) throw new ConformanceError(`${offline.join(", ")} read${offline.length === 1 ? "s" : ""} the repository's settings and need${offline.length === 1 ? "s" : ""} a GitHub client (--settings on the command line)`);
1460
3193
  return checkNames.filter((name) => requested.includes(name));
1461
3194
  }
1462
3195
  /**
3196
+ * Run the import checks among `names` together, so checks with the same graph options share one cruise. The result has an entry for each of them and none for the other checks.
3197
+ */
3198
+ async function runImportGraphChecks(cwd, layout, names, checks) {
3199
+ const entries = names.flatMap((name) => {
3200
+ const check = registry[name];
3201
+ if (!("importGraph" in check)) return [];
3202
+ const options = check.importGraph.optionsOf(checks);
3203
+ return options === void 0 ? [] : [{
3204
+ spec: check.importGraph.spec,
3205
+ options
3206
+ }];
3207
+ });
3208
+ if (entries.length === 0) return /* @__PURE__ */ new Map();
3209
+ if (layout === void 0) throw new ConformanceError(`${entries.map((entry) => entry.spec.name).join(", ")} read the workspace layout, which was not supplied`);
3210
+ return runImportChecks({
3211
+ cwd,
3212
+ layout,
3213
+ entries
3214
+ });
3215
+ }
3216
+ /**
3217
+ * The findings of an import check that `runImportGraphChecks` ran. Every import check among the selected names has an entry, so a missing one is a defect.
3218
+ */
3219
+ function readImported(imported, name) {
3220
+ const found = imported.get(name);
3221
+ if (found === void 0) throw new ConformanceError(`the import check '${name}' produced no result`);
3222
+ return found;
3223
+ }
3224
+ /**
1463
3225
  * Run the enabled checks, or the enabled ones among `checks`, and return what they found. Sections not supplied in the options are loaded from `cwd` with `@exadev/config`.
1464
3226
  *
1465
3227
  * Violations do not throw. It throws `ConformanceError` when the checks cannot run (nothing enabled, a requested check disabled, a section missing) and `ConfigValidationError` when a section fails its schema, whether it was loaded from `cwd` or passed in.
1466
3228
  */
1467
3229
  async function runChecks(options) {
1468
3230
  const config = await conformanceOf(options);
1469
- const names = selected(config, options.checks);
3231
+ const names = selected(config, options.checks, options.github !== void 0);
1470
3232
  const layout = await layoutOf(options, names.filter((name) => registry[name].requiresLayout));
3233
+ const imported = await runImportGraphChecks(options.cwd, layout, names, config.checks);
1471
3234
  const results = [];
1472
- for (const name of names) results.push({
1473
- check: name,
1474
- violations: await registry[name].run({
3235
+ for (const name of names) {
3236
+ const check = registry[name];
3237
+ const violations = "run" in check ? await check.run({
1475
3238
  cwd: options.cwd,
1476
3239
  checks: config.checks,
1477
3240
  layout,
1478
- configFiles: options.configFiles
1479
- })
1480
- });
3241
+ configFiles: options.configFiles,
3242
+ github: options.github
3243
+ }) : readImported(imported, name);
3244
+ results.push({
3245
+ check: name,
3246
+ violations
3247
+ });
3248
+ }
1481
3249
  const violations = results.flatMap((result) => result.violations);
1482
3250
  return {
1483
3251
  results,
@@ -1487,13 +3255,14 @@ async function runChecks(options) {
1487
3255
  }
1488
3256
  //#endregion
1489
3257
  //#region src/cli-command.ts
1490
- const USAGE = `Usage: workspace-conformance check [--cwd <directory>] [--check <name>]... [--list]
3258
+ const USAGE = `Usage: workspace-conformance check [--cwd <directory>] [--check <name>]... [--list] [--settings]
1491
3259
 
1492
3260
  Runs the conformance checks enabled in the 'conformance' section of exadev.config.ts (or exadev.conformance.config.ts).
1493
3261
 
1494
3262
  --cwd <directory> Directory that holds the config files. Defaults to the current directory.
1495
3263
  --check <name> Run only this check, which must be enabled. Repeatable.
1496
3264
  --list Print the names of all checks and stop.
3265
+ --settings Also run the enabled settings-* checks, which read the repository's settings through the GitHub API with the token in GITHUB_TOKEN or GH_TOKEN. Offline otherwise.
1497
3266
  --help, -h Show this message.
1498
3267
 
1499
3268
  Exit status: ${String(EXIT_CODES.clean)} when nothing is found, ${String(EXIT_CODES.violations)} when a check finds a violation, ${String(EXIT_CODES.failed)} when the checks could not run.
@@ -1510,6 +3279,14 @@ function requestedChecks(names) {
1510
3279
  return name;
1511
3280
  });
1512
3281
  }
3282
+ /**
3283
+ * The client the settings checks read through, authenticated by the first non-empty token of `GITHUB_TOKEN` and `GH_TOKEN`.
3284
+ */
3285
+ function clientFromEnvironment() {
3286
+ const token = [process.env["GITHUB_TOKEN"], process.env["GH_TOKEN"]].find((value) => value !== void 0 && value !== "");
3287
+ if (token === void 0) throw new ConformanceError("--settings needs a token in GITHUB_TOKEN or GH_TOKEN");
3288
+ return createGitHubClient({ token });
3289
+ }
1513
3290
  async function runCheck(args, output) {
1514
3291
  const { values } = parseArgs({
1515
3292
  args: [...args],
@@ -1520,6 +3297,7 @@ async function runCheck(args, output) {
1520
3297
  multiple: true
1521
3298
  },
1522
3299
  list: { type: "boolean" },
3300
+ settings: { type: "boolean" },
1523
3301
  help: {
1524
3302
  type: "boolean",
1525
3303
  short: "h"
@@ -1538,7 +3316,8 @@ async function runCheck(args, output) {
1538
3316
  }
1539
3317
  const result = await runChecks({
1540
3318
  cwd: resolve(values.cwd ?? process.cwd()),
1541
- ...requested === void 0 ? {} : { checks: requested }
3319
+ ...requested === void 0 ? {} : { checks: requested },
3320
+ ...values.settings === true ? { github: clientFromEnvironment() } : {}
1542
3321
  });
1543
3322
  for (const violation of result.violations) output.stderr(formatViolation(violation));
1544
3323
  if (result.violations.length === 0) output.stdout(`no violations from ${result.results.map((entry) => entry.check).join(", ")}\n`);