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/index.cjs CHANGED
@@ -7,6 +7,8 @@ let cosmiconfig_extends = require("cosmiconfig-extends");
7
7
  let node_fs_promises = require("node:fs/promises");
8
8
  let yaml = require("yaml");
9
9
  let node_child_process = require("node:child_process");
10
+ let jsonc_parser = require("jsonc-parser");
11
+ let smol_toml = require("smol-toml");
10
12
  let _exadev_config = require("@exadev/config");
11
13
  let zod = require("zod");
12
14
  //#region src/files.ts
@@ -246,7 +248,7 @@ function aliasNamed(typeName) {
246
248
  /**
247
249
  * 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.
248
250
  */
249
- function problem(declaration, name, inferences, followed = /* @__PURE__ */ new Set()) {
251
+ function problem$1(declaration, name, inferences, followed = /* @__PURE__ */ new Set()) {
250
252
  if (ts_morph.Node.isInterfaceDeclaration(declaration)) return {
251
253
  reason: "hand-written-interface",
252
254
  message: `${name} is a hand-written interface; derive the command from its schema`
@@ -263,7 +265,7 @@ function problem(declaration, name, inferences, followed = /* @__PURE__ */ new S
263
265
  };
264
266
  }
265
267
  const alias = aliasNamed(written.getTypeName());
266
- if (alias !== void 0 && !followed.has(alias)) return problem(alias, name, inferences, /* @__PURE__ */ new Set([...followed, alias]));
268
+ if (alias !== void 0 && !followed.has(alias)) return problem$1(alias, name, inferences, /* @__PURE__ */ new Set([...followed, alias]));
267
269
  }
268
270
  return {
269
271
  reason: "hand-written-alias",
@@ -287,7 +289,7 @@ const commandTypes = async ({ cwd, options }) => {
287
289
  for (const file of files) for (const [name, declaration] of exportedTypes(project.getSourceFileOrThrow((0, node_path.resolve)(cwd, file)))) {
288
290
  if (excluded.has(name) || seen.has(declaration)) continue;
289
291
  seen.add(declaration);
290
- const found = problem(declaration, name, inferences);
292
+ const found = problem$1(declaration, name, inferences);
291
293
  if (found !== void 0) violations.push({
292
294
  code: `command-types/${found.reason}`,
293
295
  message: found.message,
@@ -658,11 +660,19 @@ function isolatedGroupRules(packages, isolatedGroups) {
658
660
  }));
659
661
  }
660
662
  /**
663
+ * The name of {@link cycleRule}, which a check reads its findings by.
664
+ */
665
+ const CYCLE_RULE_NAME = "import-cycle";
666
+ /**
667
+ * The name of {@link unresolvedNameImportRule}, which a check reads its findings by.
668
+ */
669
+ const UNRESOLVED_NAME_IMPORT_RULE_NAME = "unresolved-name-import";
670
+ /**
661
671
  * The rule that forbids any import cycle among the packages' files.
662
672
  */
663
673
  function cycleRule() {
664
674
  return {
665
- name: "import-cycle",
675
+ name: CYCLE_RULE_NAME,
666
676
  severity: "error",
667
677
  comment: "files may not import each other in a cycle",
668
678
  from: {},
@@ -677,7 +687,7 @@ function cycleRule() {
677
687
  function unresolvedNameImportRule(packages) {
678
688
  const names = packageNamesPattern(packages);
679
689
  return names === void 0 ? void 0 : {
680
- name: "unresolved-name-import",
690
+ name: UNRESOLVED_NAME_IMPORT_RULE_NAME,
681
691
  severity: "error",
682
692
  comment: "marks an import of a workspace package by a name that resolves to no file",
683
693
  from: { path: packagesPattern(packages) },
@@ -866,15 +876,22 @@ function assertTypeScriptAvailable(transpilers) {
866
876
  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`);
867
877
  }
868
878
  /**
869
- * 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.
870
- *
871
- * 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.
879
+ * 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.
880
+ */
881
+ function cruiseKey(cwd, options) {
882
+ return JSON.stringify({
883
+ exclude: options.exclude ?? DEFAULT_GRAPH_EXCLUDES,
884
+ doNotFollow: options.doNotFollow ?? DEFAULT_DO_NOT_FOLLOW,
885
+ tsConfig: options.tsConfig === void 0 ? null : (0, node_path.resolve)(cwd, options.tsConfig)
886
+ });
887
+ }
888
+ /**
889
+ * 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.
872
890
  *
873
- * 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.
891
+ * 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.
874
892
  */
875
893
  async function cruiseViolations(input) {
876
894
  const { cwd, root, packages, rules, options } = input;
877
- if (rules.length === 0) return [];
878
895
  const tsConfigFile = options.tsConfig === void 0 ? void 0 : (0, node_path.resolve)(cwd, options.tsConfig);
879
896
  const { cruise, getAvailableTranspilers } = await import("dependency-cruiser");
880
897
  assertTypeScriptAvailable(getAvailableTranspilers());
@@ -893,19 +910,77 @@ async function cruiseViolations(input) {
893
910
  return result.output.summary.violations;
894
911
  }
895
912
  /**
896
- * Run one import check: generate its rules from the layout, cruise the packages and report each finding.
913
+ * 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.
914
+ *
915
+ * 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.
916
+ */
917
+ async function runImportChecks(input) {
918
+ const { cwd, layout, entries } = input;
919
+ const root = workspaceRoot(cwd, layout);
920
+ const packages = await readWorkspacePackages(cwd, layout);
921
+ const scope = {
922
+ cwd,
923
+ root,
924
+ packages
925
+ };
926
+ const contributions = entries.map((entry) => ({
927
+ ...entry,
928
+ rules: entry.spec.rules(packages, layout)
929
+ }));
930
+ const groups = /* @__PURE__ */ new Map();
931
+ for (const contribution of contributions) {
932
+ const key = cruiseKey(cwd, contribution.options);
933
+ const group = groups.get(key);
934
+ if (group === void 0) groups.set(key, {
935
+ options: contribution.options,
936
+ members: [contribution]
937
+ });
938
+ else group.members.push(contribution);
939
+ }
940
+ const found = /* @__PURE__ */ new Map();
941
+ for (const { options, members } of groups.values()) {
942
+ const rules = members.flatMap((member) => member.rules);
943
+ if (rules.length === 0) continue;
944
+ const owner = /* @__PURE__ */ new Map();
945
+ for (const member of members) for (const rule of member.rules) {
946
+ const existing = owner.get(rule.name);
947
+ 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`);
948
+ owner.set(rule.name, member.spec.name);
949
+ }
950
+ for (const finding of await cruiseViolations({
951
+ cwd,
952
+ root,
953
+ packages,
954
+ rules,
955
+ options
956
+ })) {
957
+ const name = owner.get(finding.rule.name);
958
+ if (name === void 0) throw new ConformanceError(`dependency-cruiser reported the rule '${finding.rule.name}', which no check generated`);
959
+ const own = found.get(name);
960
+ if (own === void 0) found.set(name, [finding]);
961
+ else own.push(finding);
962
+ }
963
+ }
964
+ return new Map(contributions.map(({ spec }) => [spec.name, spec.report(found.get(spec.name) ?? [], scope)]));
965
+ }
966
+ /**
967
+ * Run one import check on its own.
897
968
  */
898
969
  async function runImportCheck(context, spec) {
899
- const root = workspaceRoot(context.cwd, context.layout);
900
- const packages = await readWorkspacePackages(context.cwd, context.layout);
901
- const rules = spec.rules(packages, context);
902
- return (await cruiseViolations({
970
+ return (await runImportChecks({
903
971
  cwd: context.cwd,
904
- root,
905
- packages,
906
- rules,
907
- options: context.options
908
- })).map((finding) => {
972
+ layout: context.layout,
973
+ entries: [{
974
+ spec,
975
+ options: context.options
976
+ }]
977
+ })).get(spec.name) ?? [];
978
+ }
979
+ /**
980
+ * 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.
981
+ */
982
+ function edgeReport(spec) {
983
+ return (findings, { cwd, root, packages }) => findings.map((finding) => {
909
984
  const from = packageOfPath(packages, finding.from);
910
985
  const to = packageOfPath(packages, finding.to);
911
986
  return {
@@ -916,7 +991,7 @@ async function runImportCheck(context, spec) {
916
991
  fromFile: finding.from,
917
992
  toFile: finding.to
918
993
  }),
919
- file: relativePosix(context.cwd, (0, node_path.join)(root, finding.from))
994
+ file: relativePosix(cwd, (0, node_path.join)(root, finding.from))
920
995
  };
921
996
  }).sort((a, b) => a.file.localeCompare(b.file) || a.message.localeCompare(b.message));
922
997
  }
@@ -936,17 +1011,24 @@ function describeTarget(edge) {
936
1011
  //#endregion
937
1012
  //#region src/checks/import-cross-slice.ts
938
1013
  /**
939
- * A package imports a package in a different slice.
1014
+ * How `import-cross-slice` joins a shared cruise.
940
1015
  */
941
- const importCrossSlice = async (context) => runImportCheck(context, {
1016
+ const importCrossSliceSpec = {
942
1017
  name: "import-cross-slice",
943
- reason: "other-slice",
944
1018
  rules: (packages) => {
945
1019
  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");
946
1020
  return crossSliceRules(packages);
947
1021
  },
948
- message: (edge) => `${describePackage(edge.from)} (slice '${String(edge.from.slice)}') imports ${describeTarget(edge)} (slice '${String(edge.to.slice)}')`
949
- });
1022
+ report: edgeReport({
1023
+ name: "import-cross-slice",
1024
+ reason: "other-slice",
1025
+ message: (edge) => `${describePackage(edge.from)} (slice '${String(edge.from.slice)}') imports ${describeTarget(edge)} (slice '${String(edge.to.slice)}')`
1026
+ })
1027
+ };
1028
+ /**
1029
+ * A package imports a package in a different slice.
1030
+ */
1031
+ const importCrossSlice = async (context) => runImportCheck(context, importCrossSliceSpec);
950
1032
  //#endregion
951
1033
  //#region src/checks/import-cycles.ts
952
1034
  /**
@@ -1015,84 +1097,106 @@ function nameCycles(imports) {
1015
1097
  return cycles;
1016
1098
  }
1017
1099
  /**
1100
+ * How `import-cycles` joins a shared cruise.
1101
+ */
1102
+ const importCyclesSpec = {
1103
+ name: "import-cycles",
1104
+ rules: (packages) => {
1105
+ const nameRule = unresolvedNameImportRule(packages);
1106
+ return nameRule === void 0 ? [cycleRule()] : [cycleRule(), nameRule];
1107
+ },
1108
+ report: (found, { cwd, root, packages }) => {
1109
+ const inFiles = fileCycles(found.filter((finding) => finding.rule.name === CYCLE_RULE_NAME));
1110
+ const inPackages = nameCycles(found.filter((finding) => finding.rule.name === UNRESOLVED_NAME_IMPORT_RULE_NAME).map((finding) => ({
1111
+ from: packageOfPath(packages, finding.from),
1112
+ to: packageOfPath(packages, finding.to),
1113
+ file: finding.from
1114
+ })));
1115
+ return [...[...inFiles.values()].map((ordered) => {
1116
+ const [first] = ordered;
1117
+ if (first === void 0) throw new ConformanceError("an import cycle with no files was reported");
1118
+ return {
1119
+ code: "import-cycles/cycle",
1120
+ message: `import cycle: ${[...ordered, first].join(" -> ")}`,
1121
+ file: relativePosix(cwd, (0, node_path.join)(root, first))
1122
+ };
1123
+ }), ...[...inPackages.values()].map((ring) => {
1124
+ const [first] = ring;
1125
+ if (first === void 0) throw new ConformanceError("an import cycle with no packages was reported");
1126
+ return {
1127
+ code: "import-cycles/cycle",
1128
+ 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(" -> ")}`,
1129
+ file: relativePosix(cwd, (0, node_path.join)(root, first.file))
1130
+ };
1131
+ })].sort((a, b) => a.file.localeCompare(b.file) || a.message.localeCompare(b.message));
1132
+ }
1133
+ };
1134
+ /**
1018
1135
  * 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.
1019
1136
  *
1020
1137
  * 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.
1021
1138
  */
1022
- const importCycles = async (context) => {
1023
- const root = workspaceRoot(context.cwd, context.layout);
1024
- const packages = await readWorkspacePackages(context.cwd, context.layout);
1025
- const nameRule = unresolvedNameImportRule(packages);
1026
- const found = await cruiseViolations({
1027
- cwd: context.cwd,
1028
- root,
1029
- packages,
1030
- rules: nameRule === void 0 ? [cycleRule()] : [cycleRule(), nameRule],
1031
- options: context.options
1032
- });
1033
- const inFiles = fileCycles(found.filter((finding) => finding.rule.name === cycleRule().name));
1034
- const inPackages = nameCycles(found.filter((finding) => finding.rule.name === nameRule?.name).map((finding) => ({
1035
- from: packageOfPath(packages, finding.from),
1036
- to: packageOfPath(packages, finding.to),
1037
- file: finding.from
1038
- })));
1039
- return [...[...inFiles.values()].map((ordered) => {
1040
- const [first] = ordered;
1041
- if (first === void 0) throw new ConformanceError("an import cycle with no files was reported");
1042
- return {
1043
- code: "import-cycles/cycle",
1044
- message: `import cycle: ${[...ordered, first].join(" -> ")}`,
1045
- file: relativePosix(context.cwd, (0, node_path.join)(root, first))
1046
- };
1047
- }), ...[...inPackages.values()].map((ring) => {
1048
- const [first] = ring;
1049
- if (first === void 0) throw new ConformanceError("an import cycle with no packages was reported");
1050
- return {
1051
- code: "import-cycles/cycle",
1052
- 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(" -> ")}`,
1053
- file: relativePosix(context.cwd, (0, node_path.join)(root, first.file))
1054
- };
1055
- })].sort((a, b) => a.file.localeCompare(b.file) || a.message.localeCompare(b.message));
1056
- };
1139
+ const importCycles = async (context) => runImportCheck(context, importCyclesSpec);
1057
1140
  //#endregion
1058
1141
  //#region src/checks/import-isolated-groups.ts
1059
1142
  /**
1060
- * A package imports a package in a group the layout isolates from its own.
1143
+ * How `import-isolated-groups` joins a shared cruise.
1061
1144
  */
1062
- const importIsolatedGroups = async (context) => runImportCheck(context, {
1145
+ const importIsolatedGroupsSpec = {
1063
1146
  name: "import-isolated-groups",
1064
- reason: "isolated-group",
1065
- rules: (packages) => {
1066
- if (context.layout.isolatedGroups === void 0) throw new ConformanceError("the 'import-isolated-groups' check is enabled but the layout has no 'isolatedGroups'");
1067
- return isolatedGroupRules(packages, context.layout.isolatedGroups);
1147
+ rules: (packages, layout) => {
1148
+ if (layout.isolatedGroups === void 0) throw new ConformanceError("the 'import-isolated-groups' check is enabled but the layout has no 'isolatedGroups'");
1149
+ return isolatedGroupRules(packages, layout.isolatedGroups);
1068
1150
  },
1069
- message: (edge) => `${describePackage(edge.from)} (group '${edge.from.group}') imports ${describeTarget(edge)} (group '${edge.to.group}'), which the layout isolates from it`
1070
- });
1151
+ report: edgeReport({
1152
+ name: "import-isolated-groups",
1153
+ reason: "isolated-group",
1154
+ message: (edge) => `${describePackage(edge.from)} (group '${edge.from.group}') imports ${describeTarget(edge)} (group '${edge.to.group}'), which the layout isolates from it`
1155
+ })
1156
+ };
1157
+ /**
1158
+ * A package imports a package in a group the layout isolates from its own.
1159
+ */
1160
+ const importIsolatedGroups = async (context) => runImportCheck(context, importIsolatedGroupsSpec);
1071
1161
  //#endregion
1072
1162
  //#region src/checks/import-rank-skip.ts
1073
1163
  /**
1074
- * A package imports a package more than `rankSkip.maxDistance` ranks below it that is not in `rankSkip.exemptRanks`.
1164
+ * How `import-rank-skip` joins a shared cruise.
1075
1165
  */
1076
- const importRankSkip = async (context) => runImportCheck(context, {
1166
+ const importRankSkipSpec = {
1077
1167
  name: "import-rank-skip",
1078
- reason: "skipped-rank",
1079
- rules: (packages) => {
1080
- if (context.layout.rankSkip === void 0) throw new ConformanceError("the 'import-rank-skip' check is enabled but the layout has no 'rankSkip'");
1081
- return rankSkipRules(packages, context.layout.rankSkip);
1168
+ rules: (packages, layout) => {
1169
+ if (layout.rankSkip === void 0) throw new ConformanceError("the 'import-rank-skip' check is enabled but the layout has no 'rankSkip'");
1170
+ return rankSkipRules(packages, layout.rankSkip);
1082
1171
  },
1083
- message: (edge) => `${describePackage(edge.from)} (rank ${String(edge.from.rank)}) imports ${describeTarget(edge)} (rank ${String(edge.to.rank)}), further below than the layout allows`
1084
- });
1172
+ report: edgeReport({
1173
+ name: "import-rank-skip",
1174
+ reason: "skipped-rank",
1175
+ message: (edge) => `${describePackage(edge.from)} (rank ${String(edge.from.rank)}) imports ${describeTarget(edge)} (rank ${String(edge.to.rank)}), further below than the layout allows`
1176
+ })
1177
+ };
1178
+ /**
1179
+ * A package imports a package more than `rankSkip.maxDistance` ranks below it that is not in `rankSkip.exemptRanks`.
1180
+ */
1181
+ const importRankSkip = async (context) => runImportCheck(context, importRankSkipSpec);
1085
1182
  //#endregion
1086
1183
  //#region src/checks/import-uphill.ts
1087
1184
  /**
1088
- * A package imports a package of a strictly higher rank: dependencies run downhill only.
1185
+ * How `import-uphill` joins a shared cruise.
1089
1186
  */
1090
- const importUphill = async (context) => runImportCheck(context, {
1187
+ const importUphillSpec = {
1091
1188
  name: "import-uphill",
1092
- reason: "higher-rank",
1093
1189
  rules: (packages) => uphillRules(packages),
1094
- message: (edge) => `${describePackage(edge.from)} (rank ${String(edge.from.rank)}) imports ${describeTarget(edge)} (rank ${String(edge.to.rank)}), a higher rank`
1095
- });
1190
+ report: edgeReport({
1191
+ name: "import-uphill",
1192
+ reason: "higher-rank",
1193
+ message: (edge) => `${describePackage(edge.from)} (rank ${String(edge.from.rank)}) imports ${describeTarget(edge)} (rank ${String(edge.to.rank)}), a higher rank`
1194
+ })
1195
+ };
1196
+ /**
1197
+ * A package imports a package of a strictly higher rank: dependencies run downhill only.
1198
+ */
1199
+ const importUphill = async (context) => runImportCheck(context, importUphillSpec);
1096
1200
  async function git(cwd, args) {
1097
1201
  return new Promise((resolve, reject) => {
1098
1202
  (0, node_child_process.execFile)("git", [...args], {
@@ -1132,6 +1236,17 @@ async function blobContent(cwd, blob) {
1132
1236
  blob
1133
1237
  ]);
1134
1238
  }
1239
+ /**
1240
+ * 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.
1241
+ */
1242
+ async function remoteUrl(cwd, name) {
1243
+ if (!(await git(cwd, ["remote"])).split("\n").includes(name)) return;
1244
+ return (await git(cwd, [
1245
+ "config",
1246
+ "--get",
1247
+ `remote.${name}.url`
1248
+ ])).trim();
1249
+ }
1135
1250
  //#endregion
1136
1251
  //#region src/checks/instruction-symlinks.ts
1137
1252
  /**
@@ -1204,185 +1319,1756 @@ const instructionSymlinks = async ({ cwd, options }) => {
1204
1319
  return violations;
1205
1320
  };
1206
1321
  //#endregion
1207
- //#region src/checks/single-storybook.ts
1208
- const STORYBOOK_DIRECTORY = ".storybook";
1322
+ //#region src/migrations/adapters.ts
1209
1323
  /**
1210
- * At most one Storybook exists, in the permitted directory (the workspace root unless the options say otherwise), and none in a package such as a UI package. A Storybook is a `.storybook` directory.
1324
+ * The generators that have an adapter.
1211
1325
  */
1212
- const singleStorybook = async ({ cwd, options }) => {
1213
- const location = node_path.posix.normalize(options.location ?? ".");
1214
- const permitted = node_path.posix.normalize(node_path.posix.join(location, STORYBOOK_DIRECTORY));
1215
- return (await findDirectories(cwd, [`**/${STORYBOOK_DIRECTORY}`, ...excludedFrom(options.exclude)])).filter((directory) => directory !== permitted).map((directory) => ({
1216
- code: "single-storybook/misplaced",
1217
- message: `${directory} is a Storybook outside ${location === "." ? "the workspace root" : location}; keep the one Storybook there`,
1218
- file: directory
1219
- }));
1326
+ const generatorNames = ["drizzle-kit"];
1327
+ /**
1328
+ * The deploy tools that have an adapter.
1329
+ */
1330
+ const deployToolNames = ["wrangler"];
1331
+ function recordOf(config, file) {
1332
+ if (!isRecord(config)) throw new ConformanceError(`${file}: the config must be an object for the migrations directory to be read`);
1333
+ return config;
1334
+ }
1335
+ const DRIZZLE_DEFAULT_OUT = "drizzle";
1336
+ /**
1337
+ * 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.
1338
+ */
1339
+ const drizzleKit = {
1340
+ configFiles: [
1341
+ "drizzle.config.ts",
1342
+ "drizzle.config.js",
1343
+ "drizzle.config.json"
1344
+ ],
1345
+ applyCommand: /(?:^|[\s;&|(])drizzle-kit\s+migrate(?![\w:-])/u,
1346
+ applyCommandName: "drizzle-kit migrate",
1347
+ outputDirectory: (config, file) => {
1348
+ const out = recordOf(config, file)["out"];
1349
+ if (out === void 0) return DRIZZLE_DEFAULT_OUT;
1350
+ if (typeof out !== "string") throw new ConformanceError(`${file}: 'out' must be a string`);
1351
+ 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`);
1352
+ return out;
1353
+ }
1220
1354
  };
1221
- //#endregion
1222
- //#region src/config.ts
1223
- const path = zod.z.string().min(1);
1224
- const paths = zod.z.array(path).min(1);
1225
- const names = zod.z.array(zod.z.string().min(1));
1226
- function compiles(source) {
1227
- try {
1228
- new RegExp(source);
1229
- return true;
1230
- } catch {
1231
- return false;
1355
+ const WRANGLER_DEFAULT_MIGRATIONS_DIR = "migrations";
1356
+ function databasesOf(scope, file) {
1357
+ const databases = scope["d1_databases"];
1358
+ if (databases === void 0) return [];
1359
+ if (!Array.isArray(databases) || !databases.every(isRecord)) throw new ConformanceError(`${file}: 'd1_databases' must be an array of objects`);
1360
+ return databases;
1361
+ }
1362
+ /**
1363
+ * 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.
1364
+ */
1365
+ const wrangler = {
1366
+ configFiles: [
1367
+ "wrangler.json",
1368
+ "wrangler.jsonc",
1369
+ "wrangler.toml"
1370
+ ],
1371
+ migrationsDirectory: (config, file, selector) => {
1372
+ const top = recordOf(config, file);
1373
+ let scope = top;
1374
+ if (selector.environment !== void 0) {
1375
+ const environments = top["env"];
1376
+ const environment = isRecord(environments) ? environments[selector.environment] : void 0;
1377
+ if (!isRecord(environment)) throw new ConformanceError(`${file} has no environment '${selector.environment}' (checks.migrations-directory.environment)`);
1378
+ scope = environment;
1379
+ }
1380
+ const where = selector.environment === void 0 ? file : `${file} (environment '${selector.environment}')`;
1381
+ const databases = databasesOf(scope, file);
1382
+ const chosen = selector.database === void 0 ? databases : databases.filter((database) => database["binding"] === selector.database);
1383
+ if (chosen.length > 1) throw new ConformanceError(`${where} declares several D1 databases; name the one the generator writes for with checks.migrations-directory.database`);
1384
+ const [only] = chosen;
1385
+ 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}'` };
1386
+ const directory = only["migrations_dir"];
1387
+ if (directory === void 0) return { directory: WRANGLER_DEFAULT_MIGRATIONS_DIR };
1388
+ if (typeof directory !== "string") throw new ConformanceError(`${where}: 'migrations_dir' must be a string`);
1389
+ return { directory };
1232
1390
  }
1233
- }
1391
+ };
1392
+ const generators = { "drizzle-kit": drizzleKit };
1393
+ const deployTools = { wrangler };
1394
+ //#endregion
1395
+ //#region src/migrations/config-data.ts
1234
1396
  /**
1235
- * Source text of a regular expression that dependency-cruiser compiles as written. It is checked here because dependency-cruiser reports one that does not compile as a slow pattern and quotes it.
1397
+ * The first of `candidates` (relative to `cwd`) that exists, or `undefined` when none does.
1236
1398
  */
1237
- const pattern = zod.z.string().min(1).refine(compiles, "not a valid regular expression");
1238
- const patterns = zod.z.array(pattern);
1239
- const importGraph = zod.z.strictObject({
1240
- exclude: zod.z.exactOptional(patterns),
1241
- doNotFollow: zod.z.exactOptional(patterns),
1242
- tsConfig: zod.z.exactOptional(path)
1243
- });
1399
+ function firstExisting$1(cwd, candidates) {
1400
+ return candidates.find((candidate) => (0, node_fs.existsSync)((0, node_path.resolve)(cwd, candidate)));
1401
+ }
1402
+ function assertValid(errors, file) {
1403
+ const [first] = errors;
1404
+ if (first !== void 0) throw new ConformanceError(`${file}: not valid JSON (${(0, jsonc_parser.printParseErrorCode)(first.error)} at offset ${String(first.offset)})`);
1405
+ }
1244
1406
  /**
1245
- * A check's setting: `false`, or its options. A union reports only "Invalid input" for a setting that is wrong, so the issues of the options branch, which is the one the person meant unless they wrote `false`, are put in the message.
1407
+ * 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.
1246
1408
  */
1247
- const enabled = (options) => zod.z.union([zod.z.literal(false), options], { error: (issue) => {
1248
- const [, optionsIssues] = issue.errors;
1249
- if (optionsIssues === void 0) return;
1250
- return `expected false or an options object: ${optionsIssues.map((found) => found.path.length === 0 ? found.message : `${found.path.map(String).join(".")}: ${found.message}`).join("; ")}`;
1251
- } });
1252
- const checks = zod.z.strictObject({
1253
- "aggregate-mappers": zod.z.exactOptional(enabled(zod.z.strictObject({
1254
- contracts: paths,
1255
- mapper: path,
1256
- adapters: zod.z.exactOptional(path),
1257
- exclude: zod.z.exactOptional(names),
1258
- tsConfig: zod.z.exactOptional(path)
1259
- }))),
1260
- "command-types": zod.z.exactOptional(enabled(zod.z.strictObject({
1261
- commands: paths,
1262
- exclude: zod.z.exactOptional(names),
1263
- inferences: zod.z.exactOptional(paths),
1264
- tsConfig: zod.z.exactOptional(path)
1265
- }))),
1266
- "import-uphill": zod.z.exactOptional(enabled(importGraph)),
1267
- "import-rank-skip": zod.z.exactOptional(enabled(importGraph)),
1268
- "import-cross-slice": zod.z.exactOptional(enabled(importGraph)),
1269
- "import-isolated-groups": zod.z.exactOptional(enabled(importGraph)),
1270
- "import-cycles": zod.z.exactOptional(enabled(importGraph)),
1271
- "instruction-symlinks": zod.z.exactOptional(enabled(zod.z.strictObject({
1272
- files: zod.z.exactOptional(paths),
1273
- directories: zod.z.exactOptional(paths),
1274
- target: zod.z.exactOptional(path)
1275
- }))),
1276
- "single-storybook": zod.z.exactOptional(enabled(zod.z.strictObject({
1277
- location: zod.z.exactOptional(path),
1278
- exclude: zod.z.exactOptional(paths)
1279
- }))),
1280
- "commit-types": zod.z.exactOptional(enabled(zod.z.strictObject({
1281
- commitlint: zod.z.exactOptional(path),
1282
- release: zod.z.exactOptional(path)
1283
- }))),
1284
- "dockerfile-package-manager": zod.z.exactOptional(enabled(zod.z.strictObject({
1285
- dockerfiles: zod.z.exactOptional(paths),
1286
- exclude: zod.z.exactOptional(paths),
1287
- packageJson: zod.z.exactOptional(path)
1288
- })))
1289
- });
1409
+ function parseJsonc(text, file) {
1410
+ const errors = [];
1411
+ const data = (0, jsonc_parser.parse)(text, errors, { allowTrailingComma: true });
1412
+ assertValid(errors, file);
1413
+ return data;
1414
+ }
1290
1415
  /**
1291
- * 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.
1416
+ * 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.
1292
1417
  */
1293
- const conformanceSchema = zod.z.strictObject({ checks });
1418
+ function parseJsoncTree(text, file) {
1419
+ const errors = [];
1420
+ const tree = (0, jsonc_parser.parseTree)(text, errors, { allowTrailingComma: true });
1421
+ assertValid(errors, file);
1422
+ if (tree === void 0) throw new ConformanceError(`${file}: holds no JSON value`);
1423
+ return tree;
1424
+ }
1294
1425
  /**
1295
- * The `conformance` section. Pass it to `withSections` beside `layoutSection` when authoring `exadev.config.ts`.
1426
+ * 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.
1296
1427
  */
1297
- const conformanceSection = (0, _exadev_config.defineSection)("conformance", conformanceSchema);
1428
+ async function readConfigData(cwd, file, configFiles) {
1429
+ const extension = (0, node_path.extname)(file).toLowerCase();
1430
+ if (extension === ".json" || extension === ".jsonc") return parseJsonc(await (0, node_fs_promises.readFile)((0, node_path.resolve)(cwd, file), "utf8"), file);
1431
+ if (extension === ".toml") try {
1432
+ return (0, smol_toml.parse)(await (0, node_fs_promises.readFile)((0, node_path.resolve)(cwd, file), "utf8"));
1433
+ } catch (error) {
1434
+ throw new ConformanceError(`${file}: not valid TOML (${error instanceof Error ? error.message : String(error)})`);
1435
+ }
1436
+ return evaluateConfigFile(cwd, file, configFiles);
1437
+ }
1298
1438
  //#endregion
1299
- //#region src/registry.ts
1439
+ //#region src/checks/migrations-directory.ts
1440
+ const DEFAULT_PACKAGE_JSONS = ["**/package.json"];
1300
1441
  /**
1301
- * The options of an enabled check, or `undefined` when the setting is absent or `false`.
1442
+ * The 1-based line and column of the character at `offset` in `text`, which is not the first character.
1302
1443
  */
1303
- function enabledOptions(setting) {
1304
- return setting === false ? void 0 : setting;
1305
- }
1306
- function plain(spec) {
1444
+ function positionOf(text, offset) {
1445
+ const lineStart = text.lastIndexOf("\n", offset - 1) + 1;
1307
1446
  return {
1308
- name: spec.name,
1309
- description: spec.description,
1310
- requiresLayout: false,
1311
- isEnabled: (checks) => spec.select(checks) !== void 0,
1312
- run: async ({ cwd, checks, configFiles }) => {
1313
- const options = spec.select(checks);
1314
- if (options === void 0) throw new ConformanceError(`the check '${spec.name}' is not enabled`);
1315
- return spec.check({
1316
- cwd,
1317
- options,
1318
- ...configFiles === void 0 ? {} : { configFiles }
1319
- });
1320
- }
1447
+ line: text.slice(0, lineStart).split("\n").length,
1448
+ column: offset - lineStart + 1
1321
1449
  };
1322
1450
  }
1323
- function withLayout(spec) {
1324
- return {
1325
- name: spec.name,
1326
- description: spec.description,
1327
- requiresLayout: true,
1328
- isEnabled: (checks) => spec.select(checks) !== void 0,
1329
- run: async ({ cwd, checks, layout }) => {
1330
- const options = spec.select(checks);
1331
- if (options === void 0) throw new ConformanceError(`the check '${spec.name}' is not enabled`);
1332
- if (layout === void 0) throw new ConformanceError(`the check '${spec.name}' reads the workspace layout, which was not supplied`);
1333
- return spec.check({
1334
- cwd,
1335
- options,
1336
- layout
1451
+ /**
1452
+ * 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.
1453
+ */
1454
+ function locate(cwd, explicit, candidates) {
1455
+ return explicit === void 0 ? firstExisting$1(cwd, candidates) : firstExisting$1(cwd, [explicit]);
1456
+ }
1457
+ function missingConfig(explicit, candidates) {
1458
+ return explicit === void 0 ? {
1459
+ code: "migrations-directory/missing-config",
1460
+ message: `none of ${candidates.join(", ")} exists`,
1461
+ file: candidates[0]
1462
+ } : {
1463
+ code: "migrations-directory/missing-config",
1464
+ message: `${explicit} does not exist`,
1465
+ file: explicit
1466
+ };
1467
+ }
1468
+ /**
1469
+ * Violations for every script of the `package.json` at `file` that matches `command`.
1470
+ */
1471
+ async function scriptViolations(cwd, file, command, describe) {
1472
+ const text = await (0, node_fs_promises.readFile)((0, node_path.resolve)(cwd, file), "utf8");
1473
+ const scripts = (0, jsonc_parser.findNodeAtLocation)(parseJsoncTree(text, file), ["scripts"]);
1474
+ const violations = [];
1475
+ for (const entry of scripts?.children ?? []) {
1476
+ const [key, value] = entry.children ?? [];
1477
+ if (key?.type === "string" && value?.type === "string" && typeof value.value === "string" && command.test(value.value)) violations.push({
1478
+ code: "migrations-directory/generator-apply-command",
1479
+ message: describe(value.value, String(key.value)),
1480
+ file,
1481
+ location: positionOf(text, value.offset)
1482
+ });
1483
+ }
1484
+ return violations;
1485
+ }
1486
+ /**
1487
+ * 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.
1488
+ *
1489
+ * 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.
1490
+ */
1491
+ const migrationsDirectory = async ({ cwd, options, configFiles }) => {
1492
+ const generator = generators[options.generator];
1493
+ const deployTool = deployTools[options.deployTool];
1494
+ const violations = [];
1495
+ const generatorFile = locate(cwd, options.generatorConfig, generator.configFiles);
1496
+ const deployFile = locate(cwd, options.deployConfig, deployTool.configFiles);
1497
+ if (generatorFile === void 0) violations.push(missingConfig(options.generatorConfig, generator.configFiles));
1498
+ if (deployFile === void 0) violations.push(missingConfig(options.deployConfig, deployTool.configFiles));
1499
+ if (generatorFile !== void 0 && deployFile !== void 0) {
1500
+ const written = generator.outputDirectory(await readConfigData(cwd, generatorFile, configFiles), generatorFile);
1501
+ const applied = deployTool.migrationsDirectory(await readConfigData(cwd, deployFile, configFiles), deployFile, {
1502
+ ...options.database === void 0 ? {} : { database: options.database },
1503
+ ...options.environment === void 0 ? {} : { environment: options.environment }
1504
+ });
1505
+ if (applied.none !== void 0) violations.push({
1506
+ code: "migrations-directory/no-database",
1507
+ message: applied.none,
1508
+ file: deployFile
1509
+ });
1510
+ else {
1511
+ const generated = (0, node_path.resolve)(cwd, (0, node_path.dirname)(generatorFile), written);
1512
+ const deployed = (0, node_path.resolve)(cwd, (0, node_path.dirname)(deployFile), applied.directory);
1513
+ if (generated !== deployed) violations.push({
1514
+ code: "migrations-directory/mismatch",
1515
+ message: `${generatorFile} writes migrations to ${relativePosix(cwd, generated)}, but ${deployFile} applies them from ${relativePosix(cwd, deployed)}`,
1516
+ file: generatorFile
1337
1517
  });
1338
1518
  }
1519
+ }
1520
+ 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}`));
1521
+ return violations;
1522
+ };
1523
+ //#endregion
1524
+ //#region src/settings/repository.ts
1525
+ const SLUG = /^([^/\s]+)\/([^/\s]+)$/u;
1526
+ const REMOTE = /github\.com[:/]([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/u;
1527
+ const REMOTE_NAME = "origin";
1528
+ /**
1529
+ * The repository the settings checks read: `repository` from the options, else the one the `origin` remote of `cwd` points at.
1530
+ */
1531
+ async function repositoryOf(cwd, options) {
1532
+ if (options.repository !== void 0) {
1533
+ const [, owner, name] = SLUG.exec(options.repository) ?? [];
1534
+ if (owner === void 0 || name === void 0) throw new ConformanceError(`the repository '${options.repository}' is not written owner/name`);
1535
+ return {
1536
+ owner,
1537
+ name
1538
+ };
1539
+ }
1540
+ const url = await remoteUrl(cwd, REMOTE_NAME);
1541
+ const [, owner, name] = REMOTE.exec(url ?? "") ?? [];
1542
+ if (owner === void 0 || name === void 0) throw new ConformanceError(`${cwd} has no '${REMOTE_NAME}' remote on github.com; set the repository option`);
1543
+ return {
1544
+ owner,
1545
+ name
1339
1546
  };
1340
1547
  }
1341
1548
  /**
1342
- * 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.
1549
+ * The branch whose rules are read: `branch` from the options, else the repository's default branch.
1343
1550
  */
1344
- const registry = {
1345
- "aggregate-mappers": plain({
1346
- name: "aggregate-mappers",
1347
- description: "Every exported aggregate type in a contract file has a mapper file where the template puts it",
1348
- select: (checks) => enabledOptions(checks["aggregate-mappers"]),
1349
- check: aggregateMappers
1350
- }),
1351
- "command-types": plain({
1352
- name: "command-types",
1353
- description: "Every exported command type is derived from a schema, not hand-written",
1354
- select: (checks) => enabledOptions(checks["command-types"]),
1355
- check: commandTypes
1356
- }),
1357
- "import-uphill": withLayout({
1358
- name: "import-uphill",
1359
- description: "No package imports a package of a higher rank",
1360
- select: (checks) => enabledOptions(checks["import-uphill"]),
1361
- check: importUphill
1362
- }),
1363
- "import-rank-skip": withLayout({
1364
- name: "import-rank-skip",
1551
+ async function branchOf(github, slug, options) {
1552
+ return options.branch ?? (await github.repository(slug)).defaultBranch;
1553
+ }
1554
+ /**
1555
+ * What a violation of a settings check names as its file.
1556
+ */
1557
+ function repositoryFile(slug) {
1558
+ return `${slug.owner}/${slug.name}`;
1559
+ }
1560
+ //#endregion
1561
+ //#region src/checks/settings-merge-methods.ts
1562
+ const METHODS = [
1563
+ "rebase",
1564
+ "squash",
1565
+ "merge"
1566
+ ];
1567
+ /**
1568
+ * 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.
1569
+ *
1570
+ * 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.
1571
+ */
1572
+ const settingsMergeMethods = async ({ cwd, options, github }) => {
1573
+ const slug = await repositoryOf(cwd, options);
1574
+ const file = repositoryFile(slug);
1575
+ const settings = await github.repository(slug);
1576
+ const rules = await github.branchRules(slug, await branchOf(github, slug, options));
1577
+ const wanted = options.allowed ?? "rebase";
1578
+ const repositoryAllows = {
1579
+ rebase: settings.allowRebaseMerge,
1580
+ squash: settings.allowSquashMerge,
1581
+ merge: settings.allowMergeCommit
1582
+ };
1583
+ const available = METHODS.filter((method) => repositoryAllows[method] && rules.pullRequests.every((rule) => rule.allowedMergeMethods === void 0 || rule.allowedMergeMethods.includes(method)));
1584
+ const violations = available.filter((method) => method !== wanted).map((method) => ({
1585
+ code: "settings-merge-methods/method-enabled",
1586
+ message: `${file} allows ${method} merges; only ${wanted} should be allowed`,
1587
+ file
1588
+ }));
1589
+ if (!available.includes(wanted)) violations.push({
1590
+ code: "settings-merge-methods/method-unavailable",
1591
+ message: `${file} does not allow ${wanted} merges`,
1592
+ file
1593
+ });
1594
+ return violations;
1595
+ };
1596
+ //#endregion
1597
+ //#region src/workflows/model.ts
1598
+ /**
1599
+ * The access `permissions` grants to `scope`, or `undefined` when the permissions are not declared and the repository default applies.
1600
+ */
1601
+ function accessOf(permissions, scope) {
1602
+ if (permissions.kind === "default") return;
1603
+ return permissions.kind === "all" ? permissions.access : permissions.scopes.get(scope) ?? "none";
1604
+ }
1605
+ /**
1606
+ * The text of a scalar value; `undefined` for anything that is not a string, number or boolean.
1607
+ */
1608
+ function scalarText(value) {
1609
+ return typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? String(value) : void 0;
1610
+ }
1611
+ /**
1612
+ * 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.
1613
+ */
1614
+ function locationAt(document, lineCounter, path) {
1615
+ const parent = path.length === 0 ? document.contents : document.getIn(path.slice(0, -1), true);
1616
+ const last = path.at(-1);
1617
+ let range;
1618
+ if ((0, yaml.isMap)(parent) && typeof last === "string") {
1619
+ const pair = parent.items.find((item) => (0, yaml.isScalar)(item.key) && item.key.value === last);
1620
+ range = (0, yaml.isScalar)(pair?.key) ? pair.key.range : void 0;
1621
+ } else {
1622
+ const target = (0, yaml.isSeq)(parent) && typeof last === "number" ? parent.items[last] : parent;
1623
+ range = (0, yaml.isNode)(target) ? target.range : void 0;
1624
+ }
1625
+ const position = lineCounter.linePos(range?.[0] ?? 0);
1626
+ return {
1627
+ line: position.line,
1628
+ column: position.col
1629
+ };
1630
+ }
1631
+ /**
1632
+ * Parse YAML text. It throws `ConformanceError` naming `file` when the text is not valid YAML.
1633
+ */
1634
+ function parseYaml(file, source) {
1635
+ const lineCounter = new yaml.LineCounter();
1636
+ const document = (0, yaml.parseDocument)(source, {
1637
+ lineCounter,
1638
+ prettyErrors: false
1639
+ });
1640
+ const [error] = document.errors;
1641
+ if (error !== void 0) throw new ConformanceError(`${file}: not valid YAML: ${error.message}`, { cause: error });
1642
+ return {
1643
+ value: document.toJS(),
1644
+ locate: (path) => locationAt(document, lineCounter, path)
1645
+ };
1646
+ }
1647
+ /**
1648
+ * A reader for one file, so the helpers can name the field they read when the file is not a workflow.
1649
+ */
1650
+ var Reader = class {
1651
+ file;
1652
+ parsed;
1653
+ constructor(file, parsed) {
1654
+ this.file = file;
1655
+ this.parsed = parsed;
1656
+ }
1657
+ fail(what) {
1658
+ throw new ConformanceError(`${this.file}: ${what}`);
1659
+ }
1660
+ location(path) {
1661
+ return this.parsed.locate(path);
1662
+ }
1663
+ record(value, what) {
1664
+ if (value === void 0 || value === null) return {};
1665
+ if (!isRecord(value)) return this.fail(`${what} must be a mapping`);
1666
+ return value;
1667
+ }
1668
+ text(value, what) {
1669
+ if (value === void 0 || value === null) return;
1670
+ return scalarText(value) ?? this.fail(`${what} must be a string, number or boolean`);
1671
+ }
1672
+ texts(value, what) {
1673
+ return Object.fromEntries(Object.entries(this.record(value, what)).map(([key, entry]) => [key, this.text(entry, `${what}.${key}`) ?? ""]));
1674
+ }
1675
+ list(value, what) {
1676
+ if (value === void 0 || value === null) return [];
1677
+ return (Array.isArray(value) ? value : [value]).map((item) => this.text(item, what) ?? this.fail(`${what} must be a string or a list of strings`));
1678
+ }
1679
+ };
1680
+ const PERMISSION_ACCESS = /* @__PURE__ */ new Set([
1681
+ "read",
1682
+ "write",
1683
+ "none"
1684
+ ]);
1685
+ function isPermissionAccess(value) {
1686
+ return PERMISSION_ACCESS.has(value);
1687
+ }
1688
+ function permissionsOf(reader, value, what) {
1689
+ if (value === void 0 || value === null) return { kind: "default" };
1690
+ if (value === "read-all") return {
1691
+ kind: "all",
1692
+ access: "read"
1693
+ };
1694
+ if (value === "write-all") return {
1695
+ kind: "all",
1696
+ access: "write"
1697
+ };
1698
+ const scopes = /* @__PURE__ */ new Map();
1699
+ for (const [scope, access] of Object.entries(reader.record(value, what))) {
1700
+ const text = reader.text(access, `${what}.${scope}`);
1701
+ if (text === void 0 || !isPermissionAccess(text)) return reader.fail(`${what}.${scope} must be read, write or none`);
1702
+ scopes.set(scope, text);
1703
+ }
1704
+ return {
1705
+ kind: "scopes",
1706
+ scopes
1707
+ };
1708
+ }
1709
+ function runsOnOf(reader, value, what) {
1710
+ const mapping = isRecord(value) ? value : void 0;
1711
+ const entries = reader.list(mapping === void 0 ? value : mapping["labels"], what);
1712
+ const group = mapping === void 0 ? void 0 : reader.text(mapping["group"], `${what}.group`);
1713
+ return {
1714
+ labels: entries.filter((entry) => !entry.includes("${{")),
1715
+ expressions: entries.filter((entry) => entry.includes("${{")),
1716
+ group
1717
+ };
1718
+ }
1719
+ function stepOf(reader, value, jobPath, index) {
1720
+ const path = [
1721
+ ...jobPath,
1722
+ "steps",
1723
+ index
1724
+ ];
1725
+ const what = path.join(".");
1726
+ const step = reader.record(value, what);
1727
+ return {
1728
+ index,
1729
+ id: reader.text(step["id"], `${what}.id`),
1730
+ name: reader.text(step["name"], `${what}.name`),
1731
+ uses: reader.text(step["uses"], `${what}.uses`),
1732
+ run: reader.text(step["run"], `${what}.run`),
1733
+ with: reader.texts(step["with"], `${what}.with`),
1734
+ env: reader.texts(step["env"], `${what}.env`),
1735
+ condition: reader.text(step["if"], `${what}.if`),
1736
+ location: reader.location(path)
1737
+ };
1738
+ }
1739
+ function jobOf(reader, id, value) {
1740
+ const path = ["jobs", id];
1741
+ const what = path.join(".");
1742
+ const job = reader.record(value, what);
1743
+ const steps = job["steps"];
1744
+ if (steps !== void 0 && !Array.isArray(steps)) return reader.fail(`${what}.steps must be a list`);
1745
+ return {
1746
+ id,
1747
+ name: reader.text(job["name"], `${what}.name`),
1748
+ needs: reader.list(job["needs"], `${what}.needs`),
1749
+ condition: reader.text(job["if"], `${what}.if`),
1750
+ permissions: permissionsOf(reader, job["permissions"], `${what}.permissions`),
1751
+ env: reader.texts(job["env"], `${what}.env`),
1752
+ runsOn: runsOnOf(reader, job["runs-on"], `${what}.runs-on`),
1753
+ timeoutMinutes: reader.text(job["timeout-minutes"], `${what}.timeout-minutes`),
1754
+ uses: reader.text(job["uses"], `${what}.uses`),
1755
+ with: reader.texts(job["with"], `${what}.with`),
1756
+ outputs: reader.texts(job["outputs"], `${what}.outputs`),
1757
+ steps: (steps ?? []).map((step, index) => stepOf(reader, step, path, index)),
1758
+ location: reader.location(path)
1759
+ };
1760
+ }
1761
+ function triggerLocationsOf(reader, triggers, value) {
1762
+ const events = [...triggers.keys()];
1763
+ return new Map(events.map((event, index) => [event, reader.location(Array.isArray(value) ? ["on", index] : isRecord(value) ? ["on", event] : ["on"])]));
1764
+ }
1765
+ function triggersOf(reader, value) {
1766
+ if (typeof value === "string") return /* @__PURE__ */ new Map([[value, void 0]]);
1767
+ if (Array.isArray(value)) return new Map(reader.list(value, "on").map((event) => [event, void 0]));
1768
+ return new Map(Object.entries(reader.record(value, "on")));
1769
+ }
1770
+ /**
1771
+ * 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.
1772
+ */
1773
+ function parseWorkflow(file, source) {
1774
+ const parsed = parseYaml(file, source);
1775
+ const reader = new Reader(file, parsed);
1776
+ const root = reader.record(parsed.value, "the workflow");
1777
+ const triggers = triggersOf(reader, root["on"]);
1778
+ const call = triggers.get("workflow_call");
1779
+ const outputs = reader.record(reader.record(call, "on.workflow_call")["outputs"], "on.workflow_call.outputs");
1780
+ return {
1781
+ file,
1782
+ name: reader.text(root["name"], "name"),
1783
+ triggers,
1784
+ triggerLocations: triggerLocationsOf(reader, triggers, root["on"]),
1785
+ permissions: permissionsOf(reader, root["permissions"], "permissions"),
1786
+ env: reader.texts(root["env"], "env"),
1787
+ jobs: Object.entries(reader.record(root["jobs"], "jobs")).map(([id, job]) => jobOf(reader, id, job)),
1788
+ 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`) ?? ""]))
1789
+ };
1790
+ }
1791
+ /**
1792
+ * 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.
1793
+ */
1794
+ function effectivePermissions(workflow, job) {
1795
+ return job.permissions.kind === "default" ? workflow.permissions : job.permissions;
1796
+ }
1797
+ /**
1798
+ * The environment a step runs with: the workflow's, the job's and the step's, the later winning.
1799
+ */
1800
+ function effectiveEnv(workflow, job, step) {
1801
+ return {
1802
+ ...workflow.env,
1803
+ ...job.env,
1804
+ ...step?.env
1805
+ };
1806
+ }
1807
+ /**
1808
+ * 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.
1809
+ */
1810
+ function transitiveNeeds(workflow, jobId) {
1811
+ const byId = new Map(workflow.jobs.map((job) => [job.id, job]));
1812
+ const reached = /* @__PURE__ */ new Set();
1813
+ const pending = [...byId.get(jobId)?.needs ?? []];
1814
+ for (let next = pending.pop(); next !== void 0; next = pending.pop()) if (!reached.has(next)) {
1815
+ reached.add(next);
1816
+ pending.push(...byId.get(next)?.needs ?? []);
1817
+ }
1818
+ reached.delete(jobId);
1819
+ return reached;
1820
+ }
1821
+ /**
1822
+ * Split a `uses` value into its parts.
1823
+ */
1824
+ function parseUses(uses) {
1825
+ if (uses.startsWith("./")) return {
1826
+ kind: "local",
1827
+ path: uses
1828
+ };
1829
+ if (uses.startsWith("docker://")) {
1830
+ const reference = uses.slice(9);
1831
+ const at = reference.indexOf("@");
1832
+ return at === -1 ? {
1833
+ kind: "docker",
1834
+ image: reference,
1835
+ digest: void 0
1836
+ } : {
1837
+ kind: "docker",
1838
+ image: reference.slice(0, at),
1839
+ digest: reference.slice(at + 1)
1840
+ };
1841
+ }
1842
+ const at = uses.lastIndexOf("@");
1843
+ const [owner = "", repository = "", ...path] = (at === -1 ? uses : uses.slice(0, at)).split("/");
1844
+ return {
1845
+ kind: "remote",
1846
+ owner,
1847
+ repository,
1848
+ path: path.join("/"),
1849
+ ref: at === -1 ? void 0 : uses.slice(at + 1)
1850
+ };
1851
+ }
1852
+ /**
1853
+ * Whether `value` contains an expression, which is only known when the workflow runs.
1854
+ */
1855
+ function hasExpression(value) {
1856
+ return value.includes("${{");
1857
+ }
1858
+ //#endregion
1859
+ //#region src/workflows/load.ts
1860
+ /**
1861
+ * Where GitHub looks for workflows: only this directory, and only the files directly in it.
1862
+ */
1863
+ const DEFAULT_WORKFLOWS = [".github/workflows/*.yml", ".github/workflows/*.yaml"];
1864
+ /**
1865
+ * Read and parse the selected workflow files, sorted by path.
1866
+ */
1867
+ async function loadWorkflows(cwd, selection) {
1868
+ const files = await findFiles(cwd, [...selection.workflows ?? DEFAULT_WORKFLOWS, ...excludedFrom(selection.exclude)]);
1869
+ return Promise.all(files.map(async (file) => parseWorkflow(file, await (0, node_fs_promises.readFile)((0, node_path.resolve)(cwd, file), "utf8"))));
1870
+ }
1871
+ //#endregion
1872
+ //#region src/workflows/shared.ts
1873
+ /**
1874
+ * A violation about a place in a workflow file.
1875
+ */
1876
+ function workflowViolation(workflow, code, message, location) {
1877
+ return {
1878
+ code,
1879
+ message,
1880
+ file: workflow.file,
1881
+ location
1882
+ };
1883
+ }
1884
+ /**
1885
+ * 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.
1886
+ */
1887
+ function commandLines(run) {
1888
+ const lines = [];
1889
+ let continued = "";
1890
+ for (const line of run.split("\n")) {
1891
+ const joined = `${continued}${line.trim()}`;
1892
+ if (joined.endsWith("\\")) continued = `${joined.slice(0, -1).trimEnd()} `;
1893
+ else {
1894
+ continued = "";
1895
+ if (joined !== "" && !joined.startsWith("#")) lines.push(joined);
1896
+ }
1897
+ }
1898
+ if (continued !== "") lines.push(continued.trim());
1899
+ return lines;
1900
+ }
1901
+ /**
1902
+ * Variable assignments that prefix a command (`HUSKY=0 pnpm exec ...`).
1903
+ */
1904
+ const LEADING_ASSIGNMENTS = /^(?:[A-Za-z_]\w*=\S*\s+)+/u;
1905
+ /**
1906
+ * 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.
1907
+ */
1908
+ function commandSegments(line) {
1909
+ const segments = [];
1910
+ let current = "";
1911
+ let quote;
1912
+ for (let index = 0; index < line.length; index += 1) {
1913
+ const rest = line.slice(index);
1914
+ const character = rest.charAt(0);
1915
+ if (quote !== void 0) {
1916
+ current += character;
1917
+ quote = character === quote ? void 0 : quote;
1918
+ } else if (rest.startsWith("${{")) {
1919
+ const end = rest.indexOf("}}");
1920
+ const expression = end === -1 ? rest : rest.slice(0, end + 2);
1921
+ current += expression;
1922
+ index += expression.length - 1;
1923
+ } else if (character === "\"" || character === "'") {
1924
+ current += character;
1925
+ quote = character;
1926
+ } else if (character === "&" && (current.endsWith(">") || current.endsWith("<") || rest.startsWith("&>"))) current += character;
1927
+ else if (character === "&" || character === "|" || character === ";") {
1928
+ segments.push(current);
1929
+ current = "";
1930
+ } else current += character;
1931
+ }
1932
+ segments.push(current);
1933
+ return segments.map((segment) => segment.trim().replace(LEADING_ASSIGNMENTS, "")).filter((segment) => segment !== "");
1934
+ }
1935
+ /**
1936
+ * The simple commands of every line of a `run` script.
1937
+ */
1938
+ function scriptCommands(run) {
1939
+ return commandLines(run ?? "").flatMap(commandSegments);
1940
+ }
1941
+ /**
1942
+ * Whether a step uses the action `owner/repository`, at any path or ref.
1943
+ */
1944
+ function stepUses(step, action) {
1945
+ const target = step.uses?.split("@")[0]?.toLowerCase();
1946
+ return target === action.toLowerCase() || target?.startsWith(`${action.toLowerCase()}/`) === true;
1947
+ }
1948
+ /**
1949
+ * The jobs among `ids` that exist in the workflow, in the workflow's order.
1950
+ */
1951
+ function jobsNamed(workflow, ids) {
1952
+ return workflow.jobs.filter((job) => ids.includes(job.id));
1953
+ }
1954
+ /**
1955
+ * Whether an `if` expression calls `always()`.
1956
+ */
1957
+ function callsAlways(condition) {
1958
+ return condition !== void 0 && /\balways\(\)/u.test(condition);
1959
+ }
1960
+ /**
1961
+ * The default ids of junction jobs, shared by the checks that look for one.
1962
+ */
1963
+ const DEFAULT_JUNCTION_JOBS = ["required-checks"];
1964
+ //#endregion
1965
+ //#region src/checks/settings-required-checks.ts
1966
+ /**
1967
+ * 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.
1968
+ *
1969
+ * 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`).
1970
+ *
1971
+ * 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.
1972
+ */
1973
+ const settingsRequiredChecks = async ({ cwd, options, github }) => {
1974
+ const slug = await repositoryOf(cwd, options);
1975
+ const file = repositoryFile(slug);
1976
+ const rules = await github.branchRules(slug, await branchOf(github, slug, options));
1977
+ const junctions = (await loadWorkflows(cwd, options)).flatMap((workflow) => jobsNamed(workflow, options.junctionJobs ?? DEFAULT_JUNCTION_JOBS).map((job) => ({
1978
+ workflow,
1979
+ name: job.name ?? job.id
1980
+ })));
1981
+ const violations = [];
1982
+ if (junctions.length === 0) violations.push({
1983
+ code: "settings-required-checks/no-junction-job",
1984
+ message: "no workflow has a junction job, so there is no check that reports for the whole workflow to require",
1985
+ file
1986
+ });
1987
+ if (rules.requiredStatusChecks.length === 0) {
1988
+ violations.push({
1989
+ code: "settings-required-checks/no-required-checks",
1990
+ message: `no rule requires status checks on the branch of ${file}`,
1991
+ file
1992
+ });
1993
+ return violations;
1994
+ }
1995
+ const required = new Set(rules.requiredStatusChecks.flatMap((rule) => rule.contexts));
1996
+ for (const { workflow, name } of junctions.filter((junction) => !required.has(junction.name))) violations.push({
1997
+ code: "settings-required-checks/required-check-missing",
1998
+ message: `${file} does not require the check '${name}' of ${workflow.file}`,
1999
+ file
2000
+ });
2001
+ if (!rules.requiredStatusChecks.some((rule) => rule.strict)) violations.push({
2002
+ code: "settings-required-checks/not-strict",
2003
+ message: `${file} does not require branches to be up to date before merging`,
2004
+ file
2005
+ });
2006
+ return violations;
2007
+ };
2008
+ //#endregion
2009
+ //#region src/checks/settings-review-thread-resolution.ts
2010
+ /**
2011
+ * 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`.
2012
+ *
2013
+ * Limits: it reads the rules of rulesets; classic branch protection is not read.
2014
+ */
2015
+ const settingsReviewThreadResolution = async ({ cwd, options, github }) => {
2016
+ const slug = await repositoryOf(cwd, options);
2017
+ const rules = await github.branchRules(slug, await branchOf(github, slug, options));
2018
+ const file = repositoryFile(slug);
2019
+ return rules.pullRequests.some((rule) => rule.requiredReviewThreadResolution) ? [] : [{
2020
+ code: "settings-review-thread-resolution/not-required",
2021
+ message: `no rule requires review conversations to be resolved before merging on the branch of ${file}`,
2022
+ file
2023
+ }];
2024
+ };
2025
+ //#endregion
2026
+ //#region src/checks/single-storybook.ts
2027
+ const STORYBOOK_DIRECTORY = ".storybook";
2028
+ /**
2029
+ * At most one Storybook exists, in the permitted directory (the workspace root unless the options say otherwise), and none in a package such as a UI package. A Storybook is a `.storybook` directory.
2030
+ */
2031
+ const singleStorybook = async ({ cwd, options }) => {
2032
+ const location = node_path.posix.normalize(options.location ?? ".");
2033
+ const permitted = node_path.posix.normalize(node_path.posix.join(location, STORYBOOK_DIRECTORY));
2034
+ return (await findDirectories(cwd, [`**/${STORYBOOK_DIRECTORY}`, ...excludedFrom(options.exclude)])).filter((directory) => directory !== permitted).map((directory) => ({
2035
+ code: "single-storybook/misplaced",
2036
+ message: `${directory} is a Storybook outside ${location === "." ? "the workspace root" : location}; keep the one Storybook there`,
2037
+ file: directory
2038
+ }));
2039
+ };
2040
+ //#endregion
2041
+ //#region src/checks/workflow-action-pinning.ts
2042
+ const FULL_COMMIT_SHA = /^[0-9a-f]{40}$/u;
2043
+ const DOCKER_DIGEST = /^sha256:[0-9a-f]{64}$/u;
2044
+ /**
2045
+ * Whether `repository` (`owner/repository`) matches an `allow` entry, which is `owner/repository` or `owner/*`.
2046
+ */
2047
+ function allowed(owner, repository, allow) {
2048
+ return allow.some((entry) => entry.toLowerCase() === `${owner}/${repository}`.toLowerCase() || entry.toLowerCase() === `${owner}/*`.toLowerCase());
2049
+ }
2050
+ /**
2051
+ * Why a reference breaks the policy, with the code of the violation, or `undefined` when it follows it.
2052
+ */
2053
+ function problem(reference, options) {
2054
+ if (reference.kind === "local") return;
2055
+ if (reference.kind === "docker") return reference.digest !== void 0 && DOCKER_DIGEST.test(reference.digest) ? void 0 : {
2056
+ code: "docker-not-pinned",
2057
+ reason: "a Docker image must be pinned by digest (@sha256:...), because a tag can be moved"
2058
+ };
2059
+ const { owner, repository, ref } = reference;
2060
+ if (allowed(owner, repository, options.allow ?? [])) return;
2061
+ if (ref === void 0 || ref === "") return {
2062
+ code: "missing-ref",
2063
+ reason: "it names no ref"
2064
+ };
2065
+ return ((options.organisations ?? []).some((organisation) => organisation.toLowerCase() === owner.toLowerCase()) ? options.sameOrganisation ?? "ref" : options.thirdParty ?? "sha") === "sha" && !FULL_COMMIT_SHA.test(ref) ? {
2066
+ code: "not-pinned",
2067
+ reason: `ref '${ref}' is not a full commit SHA, and a tag or branch can be moved to different code`
2068
+ } : void 0;
2069
+ }
2070
+ /**
2071
+ * 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.
2072
+ *
2073
+ * 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.
2074
+ */
2075
+ const workflowActionPinning = async ({ cwd, options }) => {
2076
+ const violations = [];
2077
+ for (const workflow of await loadWorkflows(cwd, options)) {
2078
+ const uses = [...workflow.jobs.flatMap((job) => job.uses === void 0 ? [] : [{
2079
+ value: job.uses,
2080
+ location: job.location
2081
+ }]), ...workflow.jobs.flatMap((job) => job.steps.flatMap((step) => step.uses === void 0 ? [] : [{
2082
+ value: step.uses,
2083
+ location: step.location
2084
+ }]))].sort((left, right) => left.location.line - right.location.line || left.location.column - right.location.column);
2085
+ for (const { value, location } of uses) {
2086
+ const found = problem(parseUses(value), options);
2087
+ if (found !== void 0) violations.push(workflowViolation(workflow, `workflow-action-pinning/${found.code}`, `${value}: ${found.reason}`, location));
2088
+ }
2089
+ }
2090
+ return violations;
2091
+ };
2092
+ //#endregion
2093
+ //#region src/checks/workflow-credentials.ts
2094
+ /**
2095
+ * The actions that sign an attestation, which needs an OIDC identity and the right to store the attestation.
2096
+ */
2097
+ const ATTESTATION_ACTIONS = [
2098
+ "actions/attest",
2099
+ "actions/attest-build-provenance",
2100
+ "actions/attest-sbom"
2101
+ ];
2102
+ /**
2103
+ * The scopes an attestation step needs written.
2104
+ */
2105
+ const ATTESTATION_SCOPES = ["id-token", "attestations"];
2106
+ /**
2107
+ * 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.
2108
+ */
2109
+ const PACKAGE_MANAGER_OPTIONS = String.raw`(?:\s+(?:(?:--filter|-F|--workspace|-w|--dir|-C)(?:\s+|=)\S+|--?[\w-]+))*`;
2110
+ /**
2111
+ * Commands that publish a package to a registry.
2112
+ */
2113
+ 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");
2114
+ /**
2115
+ * The variables a token for the registry is passed in.
2116
+ */
2117
+ const TOKEN_VARIABLES = ["NODE_AUTH_TOKEN", "NPM_TOKEN"];
2118
+ const PROVENANCE_FLAG = /--provenance(?:=true)?(?:\s|$)/u;
2119
+ /**
2120
+ * Whether the `package.json` in the working directory asks for provenance with `publishConfig.provenance: true`, which applies to every publish of that package.
2121
+ */
2122
+ async function requestsProvenance(cwd) {
2123
+ const file = (0, node_path.resolve)(cwd, "package.json");
2124
+ if (!(0, node_fs.existsSync)(file)) return false;
2125
+ const manifest = JSON.parse(await (0, node_fs_promises.readFile)(file, "utf8"));
2126
+ const config = isRecord(manifest) ? manifest["publishConfig"] : void 0;
2127
+ return isRecord(config) && config["provenance"] === true;
2128
+ }
2129
+ function attestationViolations(workflow, job) {
2130
+ const permissions = effectivePermissions(workflow, job);
2131
+ return job.steps.filter((step) => ATTESTATION_ACTIONS.some((action) => stepUses(step, action))).flatMap((step) => {
2132
+ const missing = ATTESTATION_SCOPES.filter((scope) => accessOf(permissions, scope) !== "write").map((scope) => `${scope}: write`).join(" and ");
2133
+ const problem = permissions.kind === "default" ? `declares no permissions; declare ${missing}` : `does not grant ${missing}`;
2134
+ return missing === "" ? [] : [workflowViolation(workflow, "workflow-credentials/attestation-permissions", `job '${job.id}' creates an attestation but ${problem}`, step.location)];
2135
+ });
2136
+ }
2137
+ /**
2138
+ * 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.
2139
+ */
2140
+ function passesToken(workflow, job) {
2141
+ return [void 0, ...job.steps].some((step) => TOKEN_VARIABLES.some((variable) => (effectiveEnv(workflow, job, step)[variable] ?? "") !== ""));
2142
+ }
2143
+ function tokenlessPublishViolations(workflow, job, provenanceInManifest) {
2144
+ if (accessOf(effectivePermissions(workflow, job), "id-token") !== "write" || passesToken(workflow, job)) return [];
2145
+ return job.steps.flatMap((step) => {
2146
+ const publishes = scriptCommands(step.run).filter((line) => PUBLISH_COMMAND.test(line));
2147
+ if (publishes.length === 0) return [];
2148
+ const env = effectiveEnv(workflow, job, step);
2149
+ const blanked = TOKEN_VARIABLES.every((variable) => env[variable] === "");
2150
+ const provenance = provenanceInManifest || publishes.some((line) => PROVENANCE_FLAG.test(line)) || env["NPM_CONFIG_PROVENANCE"] === "true";
2151
+ 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)];
2152
+ });
2153
+ }
2154
+ /**
2155
+ * Credentials are granted to the steps that need them, and a publish with no token cannot quietly use one.
2156
+ *
2157
+ * `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.
2158
+ *
2159
+ * `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.
2160
+ *
2161
+ * 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.
2162
+ */
2163
+ const workflowCredentials = async ({ cwd, options }) => {
2164
+ const provenanceInManifest = await requestsProvenance(cwd);
2165
+ return (await loadWorkflows(cwd, options)).flatMap((workflow) => workflow.jobs.flatMap((job) => [...attestationViolations(workflow, job), ...tokenlessPublishViolations(workflow, job, provenanceInManifest)]));
2166
+ };
2167
+ //#endregion
2168
+ //#region src/checks/workflow-job-ordering.ts
2169
+ const DEFAULT_RELEASE_JOBS = ["release"];
2170
+ const DEFAULT_DEPLOY_JOBS = ["deploy"];
2171
+ const DEFAULT_DOCS_DEPLOY_JOBS = ["docs-deploy"];
2172
+ const DEFAULT_BRANCH = "main";
2173
+ /**
2174
+ * The expression that always names the repository's default branch.
2175
+ */
2176
+ const DEFAULT_BRANCH_EXPRESSION$1 = /^\$\{\{\s*github\.event\.repository\.default_branch\s*\}\}$/u;
2177
+ /**
2178
+ * The text a junction job decides on: its `if`, and the conditions and scripts of its steps.
2179
+ */
2180
+ function decisionText(job) {
2181
+ return [job.condition, ...job.steps.flatMap((step) => [
2182
+ step.condition,
2183
+ step.run,
2184
+ ...Object.values(step.env),
2185
+ ...Object.values(step.with)
2186
+ ])].filter((text) => text !== void 0).join("\n");
2187
+ }
2188
+ /**
2189
+ * 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`.
2190
+ */
2191
+ const RESULT_SOURCE = /needs\.(?:\*|[\w-]+)\.result|toJSON\(\s*needs\s*\)/u;
2192
+ const FAILURE_WORD = /['"]failure['"]/u;
2193
+ const CANCELLED_WORD = /['"]cancelled['"]/u;
2194
+ const SKIPPED_OR_NOT_SUCCESS = /needs\.(?:\*|[\w-]+)\.result[^\n]*(?:['"]skipped['"]|!==?\s*['"]success['"])|!\s*contains\(\s*needs\.\*\.result\s*,\s*['"]success['"]/u;
2195
+ /**
2196
+ * An `if` that compares the ref or the event name.
2197
+ */
2198
+ const COMPARES_REF_OR_EVENT = /github\.(?:ref|ref_name|event_name)\s*==/u;
2199
+ /**
2200
+ * An expression written inside the `${{ }}` that marks it as one.
2201
+ */
2202
+ const EXPRESSION_WRAPPER = /^\$\{\{\s*([\s\S]*?)\s*\}\}$/u;
2203
+ /**
2204
+ * The operands of `operator` at the top level of an expression: not inside parentheses or quotes.
2205
+ */
2206
+ function operands(expression, operator) {
2207
+ const parts = [];
2208
+ let depth = 0;
2209
+ let quote;
2210
+ let start = 0;
2211
+ for (let index = 0; index < expression.length; index += 1) {
2212
+ const character = expression.charAt(index);
2213
+ if (quote !== void 0) quote = character === quote ? void 0 : quote;
2214
+ else if (character === "'" || character === "\"") quote = character;
2215
+ else if (character === "(") depth += 1;
2216
+ else if (character === ")") depth -= 1;
2217
+ else if (depth === 0 && expression.startsWith(operator, index)) {
2218
+ parts.push(expression.slice(start, index));
2219
+ index += operator.length - 1;
2220
+ start = index + 1;
2221
+ }
2222
+ }
2223
+ parts.push(expression.slice(start));
2224
+ return parts.map((part) => part.trim());
2225
+ }
2226
+ /**
2227
+ * Whether the first parenthesis of an expression is closed by its last character, so the parentheses enclose all of it, unlike `(a) && (b)`.
2228
+ */
2229
+ function enclosedByOneGroup(expression) {
2230
+ let depth = 0;
2231
+ for (let index = 0; index < expression.length; index += 1) {
2232
+ depth += expression.charAt(index) === "(" ? 1 : expression.charAt(index) === ")" ? -1 : 0;
2233
+ if (depth === 0) return index === expression.length - 1;
2234
+ }
2235
+ return false;
2236
+ }
2237
+ /**
2238
+ * The expression without the `${{ }}` that wraps it, or the parentheses that enclose all of it.
2239
+ */
2240
+ function unwrapped(expression) {
2241
+ const trimmed = expression.trim();
2242
+ const inner = EXPRESSION_WRAPPER.exec(trimmed)?.[1] ?? trimmed;
2243
+ return enclosedByOneGroup(inner) ? unwrapped(inner.slice(1, -1)) : inner;
2244
+ }
2245
+ /**
2246
+ * 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`.
2247
+ */
2248
+ function keepsOutOfPullRequests(expression) {
2249
+ if (expression === void 0) return false;
2250
+ const text = unwrapped(expression);
2251
+ const alternatives = operands(text, "||");
2252
+ if (alternatives.length > 1) return alternatives.every(keepsOutOfPullRequests);
2253
+ const conjuncts = operands(text, "&&");
2254
+ if (conjuncts.length > 1) return conjuncts.some(keepsOutOfPullRequests);
2255
+ return COMPARES_REF_OR_EVENT.test(text) && !text.includes("pull_request") && !text.includes("merge_group");
2256
+ }
2257
+ const OUTCOME_ACTION = "re-actors/alls-green";
2258
+ function releaseAndDocsOrdering(workflow, options) {
2259
+ const releases = jobsNamed(workflow, options.releaseJobs ?? DEFAULT_RELEASE_JOBS);
2260
+ const deploys = jobsNamed(workflow, options.deployJobs ?? DEFAULT_DEPLOY_JOBS);
2261
+ const docs = jobsNamed(workflow, options.docsDeployJobs ?? DEFAULT_DOCS_DEPLOY_JOBS);
2262
+ const violations = [];
2263
+ for (const release of releases) {
2264
+ const waitsFor = transitiveNeeds(workflow, release.id);
2265
+ 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));
2266
+ }
2267
+ for (const doc of docs) {
2268
+ const waitsFor = transitiveNeeds(workflow, doc.id);
2269
+ 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));
2270
+ const branch = options.defaultBranch ?? DEFAULT_BRANCH;
2271
+ for (const checkout of doc.steps.filter((step) => stepUses(step, "actions/checkout"))) {
2272
+ const ref = checkout.with["ref"]?.trim();
2273
+ 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));
2274
+ }
2275
+ }
2276
+ return violations;
2277
+ }
2278
+ /**
2279
+ * 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.
2280
+ */
2281
+ function exemptFromJunction(workflow, junctionExempt) {
2282
+ const direct = /* @__PURE__ */ new Set([...junctionExempt, ...workflow.jobs.filter((job) => keepsOutOfPullRequests(job.condition)).map((job) => job.id)]);
2283
+ return new Set(workflow.jobs.filter((job) => direct.has(job.id) || [...transitiveNeeds(workflow, job.id)].some((needed) => direct.has(needed))).map((job) => job.id));
2284
+ }
2285
+ function junctionOrdering(workflow, options) {
2286
+ const violations = [];
2287
+ const exempt = exemptFromJunction(workflow, options.junctionExempt ?? []);
2288
+ for (const junction of jobsNamed(workflow, options.junctionJobs ?? DEFAULT_JUNCTION_JOBS)) {
2289
+ 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));
2290
+ 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));
2291
+ 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));
2292
+ const text = decisionText(junction);
2293
+ const reportsOutcome = junction.steps.some((step) => stepUses(step, OUTCOME_ACTION));
2294
+ 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));
2295
+ 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));
2296
+ }
2297
+ return violations;
2298
+ }
2299
+ /**
2300
+ * 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`.
2301
+ *
2302
+ * 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.
2303
+ */
2304
+ const workflowJobOrdering = async ({ cwd, options }) => (await loadWorkflows(cwd, options)).flatMap((workflow) => [...releaseAndDocsOrdering(workflow, options), ...junctionOrdering(workflow, options)]);
2305
+ //#endregion
2306
+ //#region src/checks/workflow-merge-group.ts
2307
+ /**
2308
+ * 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.
2309
+ *
2310
+ * 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.
2311
+ */
2312
+ const workflowMergeGroup = async ({ cwd, options }) => (await loadWorkflows(cwd, options)).flatMap((workflow) => {
2313
+ const location = workflow.triggerLocations.get("pull_request");
2314
+ 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)];
2315
+ });
2316
+ //#endregion
2317
+ //#region src/checks/workflow-repository-dispatch.ts
2318
+ const DEFAULT_BRANCHES = ["main", "master"];
2319
+ const DEFAULT_BRANCH_EXPRESSION = /\$\{\{\s*github\.event\.repository\.default_branch\s*\}\}/u;
2320
+ const DEFAULT_TOKEN = /\b(?:secrets\.GITHUB_TOKEN|github\.token)\b/u;
2321
+ /**
2322
+ * 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`.
2323
+ */
2324
+ const PUSH = /^git(?:\s+(?:-[Cc]\s+\S+|--[\w-]+=\S+))*\s+push\b/u;
2325
+ const BRANCH_CREATION = /^git\s+(?:checkout\s+-[bB]|switch\s+-[cC])\b/u;
2326
+ const AUTO_MERGE = /^gh\s+pr\s+merge\b.*\s--auto\b/u;
2327
+ const PULL_REQUEST_CREATION = /^gh\s+pr\s+create\b|^gh\s+api\b.*\brepos\/[^\s]+\/pulls\b/u;
2328
+ 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];
2329
+ const AUTO_COMMIT_ACTION = "stefanzweifel/git-auto-commit-action";
2330
+ const PULL_REQUEST_ACTION = "peter-evans/create-pull-request";
2331
+ const DISPATCH_ACTION = "peter-evans/repository-dispatch";
2332
+ const CHECKOUT_ACTION = "actions/checkout";
2333
+ function isDefaultBranch(branch, branches) {
2334
+ const name = branch.replace(/^refs\/heads\//u, "");
2335
+ return branches.includes(name) || DEFAULT_BRANCH_EXPRESSION.test(name);
2336
+ }
2337
+ /**
2338
+ * The `git push` options that take their value as the next word, which would otherwise be read as the remote.
2339
+ */
2340
+ const PUSH_OPTIONS_WITH_VALUE = /* @__PURE__ */ new Set([
2341
+ "-o",
2342
+ "--push-option",
2343
+ "--repo",
2344
+ "--receive-pack",
2345
+ "--exec"
2346
+ ]);
2347
+ /**
2348
+ * 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.
2349
+ */
2350
+ const URL_WITH_CREDENTIALS = /[a-z][a-z0-9+.-]*:\/\/(?:[^\s/@{]|\$\{\{[^}]*\}\})*@/iu;
2351
+ /**
2352
+ * The words of a `git push` command line after `push`, without the quotes that wrap a word.
2353
+ */
2354
+ function pushWords(line) {
2355
+ return line.replace(PUSH, "").split(/\s+/u).map((word) => word.replace(/^(["'])(.*)\1$/u, "$2")).filter((word) => word !== "");
2356
+ }
2357
+ /**
2358
+ * The refspecs of a `git push` command line, after the remote, the options and the values of the options that take one.
2359
+ */
2360
+ function pushRefspecs(line) {
2361
+ const positional = [];
2362
+ const words = pushWords(line)[Symbol.iterator]();
2363
+ for (const word of words) if (PUSH_OPTIONS_WITH_VALUE.has(word)) words.next();
2364
+ else if (!word.startsWith("-")) positional.push(word);
2365
+ return positional.slice(1);
2366
+ }
2367
+ /**
2368
+ * 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`.
2369
+ */
2370
+ function pushesToDefault(line, onOtherBranch, branches) {
2371
+ const refspecs = pushRefspecs(line);
2372
+ if (refspecs.length === 0) return !onOtherBranch;
2373
+ return refspecs.some((refspec) => {
2374
+ const destination = refspec.replace(/^\+/u, "");
2375
+ const named = destination.includes(":") ? destination.slice(destination.lastIndexOf(":") + 1) : destination;
2376
+ return named === "HEAD" ? !onOtherBranch : isDefaultBranch(named, branches);
2377
+ });
2378
+ }
2379
+ /**
2380
+ * 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.
2381
+ */
2382
+ function pushesWithGitConfig(line) {
2383
+ return !URL_WITH_CREDENTIALS.test(line);
2384
+ }
2385
+ function usesDefaultToken(workflow, job, step) {
2386
+ return DEFAULT_TOKEN.test([
2387
+ step.run,
2388
+ ...Object.values(effectiveEnv(workflow, job, step)),
2389
+ ...Object.values(step.with)
2390
+ ].filter((text) => text !== void 0).join("\n"));
2391
+ }
2392
+ /**
2393
+ * Whether the job's checkout leaves the default token in the git config, so a `git push` authenticates as it.
2394
+ */
2395
+ function checkoutKeepsDefaultToken(job) {
2396
+ 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");
2397
+ }
2398
+ function checkoutRefOtherThanDefault(job, branches) {
2399
+ return job.steps.some((step) => stepUses(step, CHECKOUT_ACTION) && step.with["ref"] !== void 0 && !isDefaultBranch(step.with["ref"], branches));
2400
+ }
2401
+ function stepViolations(context, step, onOtherBranch) {
2402
+ const { workflow, job, branches } = context;
2403
+ const lines = scriptCommands(step.run);
2404
+ const found = [];
2405
+ const report = (reason, message) => {
2406
+ found.push(workflowViolation(workflow, `workflow-repository-dispatch/${reason}`, message, step.location));
2407
+ };
2408
+ const pushes = lines.filter((line) => PUSH.test(line));
2409
+ const autoCommits = stepUses(step, AUTO_COMMIT_ACTION);
2410
+ const autoCommitBranch = step.with["branch"];
2411
+ 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`);
2412
+ 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)`);
2413
+ const pushesAsDefaultToken = (pushes.some(pushesWithGitConfig) || autoCommits) && checkoutKeepsDefaultToken(job);
2414
+ 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"]));
2415
+ 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`);
2416
+ 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);
2417
+ 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}`);
2418
+ return found;
2419
+ }
2420
+ function jobViolations(context) {
2421
+ let onOtherBranch = checkoutRefOtherThanDefault(context.job, context.branches);
2422
+ const found = [];
2423
+ for (const step of context.job.steps) {
2424
+ found.push(...stepViolations(context, step, onOtherBranch));
2425
+ onOtherBranch ||= scriptCommands(step.run).some((line) => BRANCH_CREATION.test(line));
2426
+ }
2427
+ return found;
2428
+ }
2429
+ /**
2430
+ * 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.
2431
+ *
2432
+ * `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.
2433
+ *
2434
+ * 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.
2435
+ */
2436
+ const workflowRepositoryDispatch = async ({ cwd, options }) => (await loadWorkflows(cwd, options)).filter((workflow) => workflow.triggers.has("repository_dispatch")).flatMap((workflow) => workflow.jobs.flatMap((job) => jobViolations({
2437
+ workflow,
2438
+ job,
2439
+ branches: options.defaultBranches ?? DEFAULT_BRANCHES,
2440
+ assumeRequiredChecks: options.assumeRequiredChecks === true
2441
+ })));
2442
+ //#endregion
2443
+ //#region src/checks/workflow-runner-resolution.ts
2444
+ /**
2445
+ * GitHub's own images: `ubuntu`, `windows` and `macos`, as `-latest`, a version, or a version with an architecture or size suffix, and `ubuntu-slim`.
2446
+ */
2447
+ const DEFAULT_HOSTED_LABELS = ["^(?:ubuntu|windows|macos)-(?:latest|slim|[0-9][0-9.]*)(?:-(?:arm|intel|xlarge|large))*$"];
2448
+ /**
2449
+ * `fromJson(needs.<job>.outputs.<name>)` in a `runs-on`.
2450
+ */
2451
+ const RESOLVED_RUNNER = /fromJSON\(\s*needs\.([\w-]+)\.outputs\.([\w-]+)\s*\)/giu;
2452
+ /**
2453
+ * `${{ jobs.<job>.outputs.<name> }}` as the value of a reusable workflow's output.
2454
+ */
2455
+ const JOB_OUTPUT_REFERENCE = /^\$\{\{\s*jobs\.([\w-]+)\.outputs\.([\w-]+)\s*\}\}$/u;
2456
+ /**
2457
+ * A fallback in an output expression: `||` followed by a string literal, whose body may hold `''` for a quote.
2458
+ */
2459
+ const LITERAL_FALLBACK = /\|\|\s*'((?:[^']|'')*)'/gu;
2460
+ /**
2461
+ * The resolver behind each `fromJson(needs.<job>.outputs.<name>)` in a `runs-on` of the workflow; none for one this check cannot read.
2462
+ */
2463
+ function resolversOf(workflow, workflows) {
2464
+ return workflow.jobs.flatMap((job) => job.runsOn.expressions.flatMap((expression) => [...expression.matchAll(RESOLVED_RUNNER)].flatMap((match) => {
2465
+ const caller = workflow.jobs.find((candidate) => candidate.id === match[1]);
2466
+ const output = match[2];
2467
+ const resolver = caller === void 0 || output === void 0 ? void 0 : implementation(workflow, workflows, caller, output);
2468
+ return resolver === void 0 ? [] : [resolver];
2469
+ })));
2470
+ }
2471
+ /**
2472
+ * 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.
2473
+ */
2474
+ function implementation(workflow, workflows, caller, output) {
2475
+ if (caller.uses === void 0) return {
2476
+ workflow,
2477
+ job: caller,
2478
+ output
2479
+ };
2480
+ const reference = parseUses(caller.uses);
2481
+ if (reference.kind !== "local") return;
2482
+ const called = workflows.find((candidate) => `./${candidate.file}` === reference.path);
2483
+ if (called === void 0) throw new ConformanceError(`${workflow.file}: job '${caller.id}' calls ${caller.uses}, which is not a workflow file`);
2484
+ const forwarded = JOB_OUTPUT_REFERENCE.exec(called.callOutputs[output] ?? "");
2485
+ const job = called.jobs.find((candidate) => candidate.id === forwarded?.[1]);
2486
+ const jobOutput = forwarded?.[2];
2487
+ return job === void 0 || jobOutput === void 0 ? void 0 : {
2488
+ workflow: called,
2489
+ job,
2490
+ output: jobOutput
2491
+ };
2492
+ }
2493
+ /**
2494
+ * 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.
2495
+ */
2496
+ function fallbackLabels(expression) {
2497
+ const literal = [...expression.matchAll(LITERAL_FALLBACK)].at(-1)?.[1]?.replaceAll("''", "'");
2498
+ if (literal === void 0) return;
2499
+ let parsed;
2500
+ try {
2501
+ parsed = JSON.parse(literal);
2502
+ } catch {
2503
+ return [];
2504
+ }
2505
+ const items = Array.isArray(parsed) ? parsed : [parsed];
2506
+ const labels = items.filter((item) => typeof item === "string");
2507
+ return labels.length === items.length ? labels : [];
2508
+ }
2509
+ function resolverViolations(resolver, hosted) {
2510
+ const violations = [];
2511
+ 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));
2512
+ const fallback = fallbackLabels(resolver.job.outputs[resolver.output] ?? "");
2513
+ 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));
2514
+ 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));
2515
+ return violations;
2516
+ }
2517
+ function customLabels(job, hosted) {
2518
+ return /* @__PURE__ */ new Set([...job.runsOn.labels.filter((label) => !hosted.some((pattern) => pattern.test(label))), ...job.runsOn.group === void 0 ? [] : [`group:${job.runsOn.group}`]]);
2519
+ }
2520
+ function repeatedLabels(workflow, hosted) {
2521
+ const labelsByJob = workflow.jobs.map((job) => ({
2522
+ job,
2523
+ labels: customLabels(job, hosted)
2524
+ }));
2525
+ const users = (label) => labelsByJob.filter((entry) => entry.labels.has(label)).map((entry) => entry.job.id);
2526
+ return labelsByJob.flatMap(({ job, labels }) => {
2527
+ const repeated = [...labels].filter((label) => users(label).length > 1);
2528
+ 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)];
2529
+ });
2530
+ }
2531
+ /**
2532
+ * 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.
2533
+ *
2534
+ * 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.
2535
+ */
2536
+ const workflowRunnerResolution = async ({ cwd, options }) => {
2537
+ const hosted = (options.hostedLabels ?? DEFAULT_HOSTED_LABELS).map((source) => new RegExp(source, "u"));
2538
+ const selected = await loadWorkflows(cwd, options);
2539
+ const available = await loadWorkflows(cwd, { workflows: DEFAULT_WORKFLOWS });
2540
+ const resolvers = /* @__PURE__ */ new Map();
2541
+ for (const workflow of selected) for (const resolver of resolversOf(workflow, available)) resolvers.set(`${resolver.workflow.file}#${resolver.job.id}#${resolver.output}`, resolver);
2542
+ return [...selected.flatMap((workflow) => repeatedLabels(workflow, hosted)), ...[...resolvers.values()].flatMap((resolver) => resolverViolations(resolver, hosted))];
2543
+ };
2544
+ //#endregion
2545
+ //#region src/checks/workflow-skippable-jobs.ts
2546
+ const DEFAULT_PATH_FILTER_ACTIONS = [
2547
+ "dorny/paths-filter",
2548
+ "tj-actions/changed-files",
2549
+ "step-security/changed-files"
2550
+ ];
2551
+ /**
2552
+ * The events whose `paths` and `paths-ignore` decide whether the whole workflow runs for a pull request.
2553
+ */
2554
+ const PULL_REQUEST_EVENTS = ["pull_request", "pull_request_target"];
2555
+ /**
2556
+ * The jobs a job `if` reads outputs from: `needs.<job>.outputs.<name>`.
2557
+ */
2558
+ const NEEDS_OUTPUT = /\bneeds\.([\w-]+)\.outputs\./gu;
2559
+ function hasPathFilter(workflow, event) {
2560
+ const configuration = workflow.triggers.get(event);
2561
+ return isRecord(configuration) && ("paths" in configuration || "paths-ignore" in configuration);
2562
+ }
2563
+ /**
2564
+ * The jobs a job's `if` reads path filter outputs from.
2565
+ */
2566
+ function filterJobsRead(workflow, job, actions) {
2567
+ const read = [...(job.condition ?? "").matchAll(NEEDS_OUTPUT)].map((match) => match[1]);
2568
+ return workflow.jobs.filter((candidate) => read.includes(candidate.id) && candidate.steps.some((step) => actions.some((action) => stepUses(step, action)))).map((candidate) => candidate.id);
2569
+ }
2570
+ /**
2571
+ * 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.
2572
+ *
2573
+ * 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`.
2574
+ */
2575
+ const workflowSkippableJobs = async ({ cwd, options }) => {
2576
+ const violations = [];
2577
+ for (const workflow of await loadWorkflows(cwd, options)) {
2578
+ const junctions = jobsNamed(workflow, options.junctionJobs ?? DEFAULT_JUNCTION_JOBS);
2579
+ if (junctions.length > 0) {
2580
+ 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));
2581
+ }
2582
+ for (const job of workflow.jobs) {
2583
+ const filters = filterJobsRead(workflow, job, options.pathFilterActions ?? DEFAULT_PATH_FILTER_ACTIONS);
2584
+ const reported = junctions.some((junction) => junction.needs.includes(job.id));
2585
+ 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));
2586
+ }
2587
+ }
2588
+ return violations;
2589
+ };
2590
+ //#endregion
2591
+ //#region src/checks/workflow-update-bot-cooldown.ts
2592
+ const DEPENDABOT_FILES = [".github/dependabot.yml", ".github/dependabot.yaml"];
2593
+ const RENOVATE_FILES = [
2594
+ "renovate.json",
2595
+ ".renovaterc.json",
2596
+ ".renovaterc",
2597
+ ".github/renovate.json"
2598
+ ];
2599
+ /**
2600
+ * The cooldown settings that delay a release by a number of days.
2601
+ */
2602
+ const COOLDOWN_DAYS = [
2603
+ "default-days",
2604
+ "semver-major-days",
2605
+ "semver-minor-days",
2606
+ "semver-patch-days"
2607
+ ];
2608
+ const RENOVATE_SETTING = "minimumReleaseAge";
2609
+ function firstExisting(cwd, explicit, defaults) {
2610
+ if (explicit !== void 0) {
2611
+ if (!(0, node_fs.existsSync)((0, node_path.resolve)(cwd, explicit))) throw new ConformanceError(`${explicit} does not exist`);
2612
+ return explicit;
2613
+ }
2614
+ return defaults.find((file) => (0, node_fs.existsSync)((0, node_path.resolve)(cwd, file)));
2615
+ }
2616
+ function hasCooldown(update) {
2617
+ if (!isRecord(update) || !isRecord(update["cooldown"])) return false;
2618
+ const cooldown = update["cooldown"];
2619
+ return COOLDOWN_DAYS.some((setting) => typeof cooldown[setting] === "number" && cooldown[setting] > 0);
2620
+ }
2621
+ async function dependabotViolations(cwd, file) {
2622
+ const parsed = parseYaml(file, await (0, node_fs_promises.readFile)((0, node_path.resolve)(cwd, file), "utf8"));
2623
+ return (isRecord(parsed.value) && Array.isArray(parsed.value["updates"]) ? parsed.value["updates"] : []).flatMap((update, index) => hasCooldown(update) ? [] : [{
2624
+ code: "workflow-update-bot-cooldown/dependabot-no-cooldown",
2625
+ 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`,
2626
+ file,
2627
+ location: parsed.locate(["updates", index])
2628
+ }]);
2629
+ }
2630
+ /**
2631
+ * 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.
2632
+ */
2633
+ function delaysRelease(age) {
2634
+ const amount = typeof age === "string" ? /^\s*(\d+(?:\.\d+)?)\s*[a-z]/iu.exec(age)?.[1] : void 0;
2635
+ return amount !== void 0 && Number(amount) > 0;
2636
+ }
2637
+ function setsMinimumReleaseAge(value) {
2638
+ if (!isRecord(value)) return false;
2639
+ const rules = Array.isArray(value["packageRules"]) ? value["packageRules"] : [];
2640
+ const presets = Array.isArray(value["extends"]) ? value["extends"] : [];
2641
+ return delaysRelease(value[RENOVATE_SETTING]) || rules.some((rule) => isRecord(rule) && delaysRelease(rule[RENOVATE_SETTING])) || presets.some((preset) => typeof preset === "string" && preset.includes(RENOVATE_SETTING));
2642
+ }
2643
+ async function renovateViolations(cwd, file) {
2644
+ const errors = [];
2645
+ const value = (0, jsonc_parser.parse)(await (0, node_fs_promises.readFile)((0, node_path.resolve)(cwd, file), "utf8"), errors, { allowTrailingComma: true });
2646
+ const [error] = errors;
2647
+ if (error !== void 0) throw new ConformanceError(`${file}: not valid JSON: ${(0, jsonc_parser.printParseErrorCode)(error.error)} at offset ${String(error.offset)}`);
2648
+ return setsMinimumReleaseAge(value) ? [] : [{
2649
+ code: "workflow-update-bot-cooldown/renovate-no-minimum-release-age",
2650
+ message: `${file} sets no ${RENOVATE_SETTING} above zero, so Renovate proposes a release the day it is published`,
2651
+ file
2652
+ }];
2653
+ }
2654
+ /**
2655
+ * 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.
2656
+ *
2657
+ * 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.
2658
+ */
2659
+ const workflowUpdateBotCooldown = async ({ cwd, options }) => {
2660
+ const dependabot = firstExisting(cwd, options.dependabot, DEPENDABOT_FILES);
2661
+ const renovate = firstExisting(cwd, options.renovate, RENOVATE_FILES);
2662
+ return [...dependabot === void 0 ? [] : await dependabotViolations(cwd, dependabot), ...renovate === void 0 ? [] : await renovateViolations(cwd, renovate)];
2663
+ };
2664
+ //#endregion
2665
+ //#region src/checks/workflow-version-single-source.ts
2666
+ const TOOL_VERSIONS = ".tool-versions";
2667
+ const NVMRC = ".nvmrc";
2668
+ const SETUP_ACTIONS = [
2669
+ {
2670
+ action: "actions/setup-node",
2671
+ versionInput: "node-version",
2672
+ fileInput: "node-version-file",
2673
+ tools: ["nodejs", "node"]
2674
+ },
2675
+ {
2676
+ action: "actions/setup-python",
2677
+ versionInput: "python-version",
2678
+ fileInput: "python-version-file",
2679
+ tools: ["python"]
2680
+ },
2681
+ {
2682
+ action: "actions/setup-go",
2683
+ versionInput: "go-version",
2684
+ fileInput: "go-version-file",
2685
+ tools: ["golang", "go"]
2686
+ },
2687
+ {
2688
+ action: "actions/setup-java",
2689
+ versionInput: "java-version",
2690
+ fileInput: "java-version-file",
2691
+ tools: ["java"]
2692
+ },
2693
+ {
2694
+ action: "actions/setup-dotnet",
2695
+ versionInput: "dotnet-version",
2696
+ fileInput: "global-json-file",
2697
+ tools: ["dotnet", "dotnet-core"]
2698
+ },
2699
+ {
2700
+ action: "ruby/setup-ruby",
2701
+ versionInput: "ruby-version",
2702
+ fileInput: void 0,
2703
+ tools: ["ruby"]
2704
+ },
2705
+ {
2706
+ action: "oven-sh/setup-bun",
2707
+ versionInput: "bun-version",
2708
+ fileInput: "bun-version-file",
2709
+ tools: ["bun"]
2710
+ },
2711
+ {
2712
+ action: "denoland/setup-deno",
2713
+ versionInput: "deno-version",
2714
+ fileInput: "deno-version-file",
2715
+ tools: ["deno"]
2716
+ },
2717
+ {
2718
+ action: "pnpm/action-setup",
2719
+ versionInput: "version",
2720
+ fileInput: void 0,
2721
+ tools: ["pnpm"]
2722
+ },
2723
+ {
2724
+ action: "hashicorp/setup-terraform",
2725
+ versionInput: "terraform_version",
2726
+ fileInput: void 0,
2727
+ tools: ["terraform"]
2728
+ }
2729
+ ];
2730
+ /**
2731
+ * The tool names a `.tool-versions` file lists. Each line is a tool name followed by versions; a comment starts with `#`.
2732
+ */
2733
+ function listedTools(content) {
2734
+ 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])));
2735
+ }
2736
+ /**
2737
+ * 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.
2738
+ *
2739
+ * 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.
2740
+ */
2741
+ const workflowVersionSingleSource = async ({ cwd, options }) => {
2742
+ const toolVersions = (0, node_fs.existsSync)((0, node_path.resolve)(cwd, TOOL_VERSIONS)) ? listedTools(await (0, node_fs_promises.readFile)((0, node_path.resolve)(cwd, TOOL_VERSIONS), "utf8")) : /* @__PURE__ */ new Set();
2743
+ const hasNvmrc = (0, node_fs.existsSync)((0, node_path.resolve)(cwd, NVMRC));
2744
+ const violations = [];
2745
+ 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))) {
2746
+ const version = step.with[setup.versionInput];
2747
+ const source = setup.action === "actions/setup-node" && hasNvmrc ? NVMRC : setup.tools.find((tool) => toolVersions.has(tool)) === void 0 ? void 0 : TOOL_VERSIONS;
2748
+ 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));
2749
+ }
2750
+ return violations;
2751
+ };
2752
+ //#endregion
2753
+ //#region src/config.ts
2754
+ const path = zod.z.string().min(1);
2755
+ const paths = zod.z.array(path).min(1);
2756
+ const names = zod.z.array(zod.z.string().min(1));
2757
+ function compiles(source) {
2758
+ try {
2759
+ new RegExp(source);
2760
+ return true;
2761
+ } catch {
2762
+ return false;
2763
+ }
2764
+ }
2765
+ /**
2766
+ * Source text of a regular expression that dependency-cruiser compiles as written. It is checked here because dependency-cruiser reports one that does not compile as a slow pattern and quotes it.
2767
+ */
2768
+ const pattern = zod.z.string().min(1).refine(compiles, "not a valid regular expression");
2769
+ const patterns = zod.z.array(pattern);
2770
+ const selection = {
2771
+ workflows: zod.z.exactOptional(paths),
2772
+ exclude: zod.z.exactOptional(paths)
2773
+ };
2774
+ const pinningLevel = zod.z.enum(["sha", "ref"]);
2775
+ const settings = {
2776
+ repository: zod.z.exactOptional(zod.z.string().regex(/^[^/\s]+\/[^/\s]+$/u, "expected owner/name")),
2777
+ branch: zod.z.exactOptional(zod.z.string().min(1))
2778
+ };
2779
+ const importGraph = zod.z.strictObject({
2780
+ exclude: zod.z.exactOptional(patterns),
2781
+ doNotFollow: zod.z.exactOptional(patterns),
2782
+ tsConfig: zod.z.exactOptional(path)
2783
+ });
2784
+ /**
2785
+ * A check's setting: `false`, or its options. A union reports only "Invalid input" for a setting that is wrong, so the issues of the options branch, which is the one the person meant unless they wrote `false`, are put in the message.
2786
+ */
2787
+ const enabled = (options) => zod.z.union([zod.z.literal(false), options], { error: (issue) => {
2788
+ const [, optionsIssues] = issue.errors;
2789
+ if (optionsIssues === void 0) return;
2790
+ return `expected false or an options object: ${optionsIssues.map((found) => found.path.length === 0 ? found.message : `${found.path.map(String).join(".")}: ${found.message}`).join("; ")}`;
2791
+ } });
2792
+ const checks = zod.z.strictObject({
2793
+ "aggregate-mappers": zod.z.exactOptional(enabled(zod.z.strictObject({
2794
+ contracts: paths,
2795
+ mapper: path,
2796
+ adapters: zod.z.exactOptional(path),
2797
+ exclude: zod.z.exactOptional(names),
2798
+ tsConfig: zod.z.exactOptional(path)
2799
+ }))),
2800
+ "command-types": zod.z.exactOptional(enabled(zod.z.strictObject({
2801
+ commands: paths,
2802
+ exclude: zod.z.exactOptional(names),
2803
+ inferences: zod.z.exactOptional(paths),
2804
+ tsConfig: zod.z.exactOptional(path)
2805
+ }))),
2806
+ "import-uphill": zod.z.exactOptional(enabled(importGraph)),
2807
+ "import-rank-skip": zod.z.exactOptional(enabled(importGraph)),
2808
+ "import-cross-slice": zod.z.exactOptional(enabled(importGraph)),
2809
+ "import-isolated-groups": zod.z.exactOptional(enabled(importGraph)),
2810
+ "import-cycles": zod.z.exactOptional(enabled(importGraph)),
2811
+ "instruction-symlinks": zod.z.exactOptional(enabled(zod.z.strictObject({
2812
+ files: zod.z.exactOptional(paths),
2813
+ directories: zod.z.exactOptional(paths),
2814
+ target: zod.z.exactOptional(path)
2815
+ }))),
2816
+ "single-storybook": zod.z.exactOptional(enabled(zod.z.strictObject({
2817
+ location: zod.z.exactOptional(path),
2818
+ exclude: zod.z.exactOptional(paths)
2819
+ }))),
2820
+ "commit-types": zod.z.exactOptional(enabled(zod.z.strictObject({
2821
+ commitlint: zod.z.exactOptional(path),
2822
+ release: zod.z.exactOptional(path)
2823
+ }))),
2824
+ "dockerfile-package-manager": zod.z.exactOptional(enabled(zod.z.strictObject({
2825
+ dockerfiles: zod.z.exactOptional(paths),
2826
+ exclude: zod.z.exactOptional(paths),
2827
+ packageJson: zod.z.exactOptional(path)
2828
+ }))),
2829
+ "migrations-directory": zod.z.exactOptional(enabled(zod.z.strictObject({
2830
+ generator: zod.z.enum(generatorNames),
2831
+ deployTool: zod.z.enum(deployToolNames),
2832
+ generatorConfig: zod.z.exactOptional(path),
2833
+ deployConfig: zod.z.exactOptional(path),
2834
+ database: zod.z.exactOptional(zod.z.string().min(1)),
2835
+ environment: zod.z.exactOptional(zod.z.string().min(1)),
2836
+ packageJsons: zod.z.exactOptional(paths)
2837
+ }))),
2838
+ "workflow-job-ordering": zod.z.exactOptional(enabled(zod.z.strictObject({
2839
+ ...selection,
2840
+ releaseJobs: zod.z.exactOptional(names),
2841
+ deployJobs: zod.z.exactOptional(names),
2842
+ docsDeployJobs: zod.z.exactOptional(names),
2843
+ junctionJobs: zod.z.exactOptional(names),
2844
+ junctionExempt: zod.z.exactOptional(names),
2845
+ defaultBranch: zod.z.exactOptional(zod.z.string().min(1))
2846
+ }))),
2847
+ "workflow-skippable-jobs": zod.z.exactOptional(enabled(zod.z.strictObject({
2848
+ ...selection,
2849
+ junctionJobs: zod.z.exactOptional(names),
2850
+ pathFilterActions: zod.z.exactOptional(names)
2851
+ }))),
2852
+ "workflow-runner-resolution": zod.z.exactOptional(enabled(zod.z.strictObject({
2853
+ ...selection,
2854
+ hostedLabels: zod.z.exactOptional(patterns)
2855
+ }))),
2856
+ "workflow-version-single-source": zod.z.exactOptional(enabled(zod.z.strictObject(selection))),
2857
+ "workflow-credentials": zod.z.exactOptional(enabled(zod.z.strictObject(selection))),
2858
+ "workflow-repository-dispatch": zod.z.exactOptional(enabled(zod.z.strictObject({
2859
+ ...selection,
2860
+ defaultBranches: zod.z.exactOptional(names),
2861
+ assumeRequiredChecks: zod.z.exactOptional(zod.z.boolean())
2862
+ }))),
2863
+ "workflow-update-bot-cooldown": zod.z.exactOptional(enabled(zod.z.strictObject({
2864
+ dependabot: zod.z.exactOptional(path),
2865
+ renovate: zod.z.exactOptional(path)
2866
+ }))),
2867
+ "workflow-merge-group": zod.z.exactOptional(enabled(zod.z.strictObject(selection))),
2868
+ "workflow-action-pinning": zod.z.exactOptional(enabled(zod.z.strictObject({
2869
+ ...selection,
2870
+ thirdParty: zod.z.exactOptional(pinningLevel),
2871
+ sameOrganisation: zod.z.exactOptional(pinningLevel),
2872
+ organisations: zod.z.exactOptional(names),
2873
+ allow: zod.z.exactOptional(names)
2874
+ }))),
2875
+ "settings-merge-methods": zod.z.exactOptional(enabled(zod.z.strictObject({
2876
+ ...settings,
2877
+ allowed: zod.z.exactOptional(zod.z.enum([
2878
+ "rebase",
2879
+ "squash",
2880
+ "merge"
2881
+ ]))
2882
+ }))),
2883
+ "settings-required-checks": zod.z.exactOptional(enabled(zod.z.strictObject({
2884
+ ...settings,
2885
+ ...selection,
2886
+ junctionJobs: zod.z.exactOptional(names)
2887
+ }))),
2888
+ "settings-review-thread-resolution": zod.z.exactOptional(enabled(zod.z.strictObject(settings)))
2889
+ });
2890
+ /**
2891
+ * 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.
2892
+ */
2893
+ const conformanceSchema = zod.z.strictObject({ checks });
2894
+ /**
2895
+ * The `conformance` section. Pass it to `withSections` beside `layoutSection` when authoring `exadev.config.ts`.
2896
+ */
2897
+ const conformanceSection = (0, _exadev_config.defineSection)("conformance", conformanceSchema);
2898
+ //#endregion
2899
+ //#region src/github.ts
2900
+ const repositorySchema = zod.z.object({
2901
+ default_branch: zod.z.string(),
2902
+ allow_rebase_merge: zod.z.optional(zod.z.boolean()),
2903
+ allow_squash_merge: zod.z.optional(zod.z.boolean()),
2904
+ allow_merge_commit: zod.z.optional(zod.z.boolean())
2905
+ });
2906
+ const rulesSchema = zod.z.array(zod.z.looseObject({
2907
+ type: zod.z.string(),
2908
+ parameters: zod.z.optional(zod.z.looseObject({
2909
+ required_status_checks: zod.z.optional(zod.z.array(zod.z.object({ context: zod.z.string() }))),
2910
+ strict_required_status_checks_policy: zod.z.optional(zod.z.boolean()),
2911
+ required_review_thread_resolution: zod.z.optional(zod.z.boolean()),
2912
+ allowed_merge_methods: zod.z.optional(zod.z.array(zod.z.string()))
2913
+ }))
2914
+ }));
2915
+ const API_ROOT = "https://api.github.com";
2916
+ const API_VERSION = "2022-11-28";
2917
+ /**
2918
+ * How many rules the API returns in one page, its maximum.
2919
+ */
2920
+ const PAGE_SIZE = 100;
2921
+ /**
2922
+ * A {@link GitHubClient} over the REST API.
2923
+ */
2924
+ function createGitHubClient(options) {
2925
+ const request = options.fetch ?? fetch;
2926
+ const root = options.apiUrl ?? API_ROOT;
2927
+ async function get(path) {
2928
+ const response = await request(`${root}${path}`, { headers: {
2929
+ Accept: "application/vnd.github+json",
2930
+ Authorization: `Bearer ${options.token}`,
2931
+ "X-GitHub-Api-Version": API_VERSION
2932
+ } });
2933
+ if (!response.ok) throw new ConformanceError(`GET ${path} failed: ${String(response.status)} ${response.statusText}`);
2934
+ return response.json();
2935
+ }
2936
+ return {
2937
+ repository: async (slug) => {
2938
+ const parsed = repositorySchema.parse(await get(`/repos/${slug.owner}/${slug.name}`));
2939
+ const { allow_rebase_merge: allowRebaseMerge, allow_squash_merge: allowSquashMerge, allow_merge_commit: allowMergeCommit } = parsed;
2940
+ 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`);
2941
+ return {
2942
+ defaultBranch: parsed.default_branch,
2943
+ allowRebaseMerge,
2944
+ allowSquashMerge,
2945
+ allowMergeCommit
2946
+ };
2947
+ },
2948
+ branchRules: async (slug, branch) => {
2949
+ const rules = [];
2950
+ for (let page = 1, more = true; more; page += 1) {
2951
+ const found = rulesSchema.parse(await get(`/repos/${slug.owner}/${slug.name}/rules/branches/${encodeURIComponent(branch)}?per_page=${String(PAGE_SIZE)}&page=${String(page)}`));
2952
+ rules.push(...found);
2953
+ more = found.length === PAGE_SIZE;
2954
+ }
2955
+ return {
2956
+ requiredStatusChecks: rules.filter((rule) => rule.type === "required_status_checks").map((rule) => ({
2957
+ contexts: (rule.parameters?.required_status_checks ?? []).map((check) => check.context),
2958
+ strict: rule.parameters?.strict_required_status_checks_policy === true
2959
+ })),
2960
+ pullRequests: rules.filter((rule) => rule.type === "pull_request").map((rule) => ({
2961
+ requiredReviewThreadResolution: rule.parameters?.required_review_thread_resolution === true,
2962
+ allowedMergeMethods: rule.parameters?.allowed_merge_methods
2963
+ }))
2964
+ };
2965
+ }
2966
+ };
2967
+ }
2968
+ //#endregion
2969
+ //#region src/registry.ts
2970
+ /**
2971
+ * The options of an enabled check, or `undefined` when the setting is absent or `false`.
2972
+ */
2973
+ function enabledOptions(setting) {
2974
+ return setting === false ? void 0 : setting;
2975
+ }
2976
+ function plain(spec) {
2977
+ return {
2978
+ name: spec.name,
2979
+ description: spec.description,
2980
+ requiresLayout: false,
2981
+ requiresGitHub: false,
2982
+ isEnabled: (checks) => spec.select(checks) !== void 0,
2983
+ run: async ({ cwd, checks, configFiles }) => {
2984
+ const options = spec.select(checks);
2985
+ if (options === void 0) throw new ConformanceError(`the check '${spec.name}' is not enabled`);
2986
+ return spec.check({
2987
+ cwd,
2988
+ options,
2989
+ ...configFiles === void 0 ? {} : { configFiles }
2990
+ });
2991
+ }
2992
+ };
2993
+ }
2994
+ function online(spec) {
2995
+ return {
2996
+ name: spec.name,
2997
+ description: spec.description,
2998
+ requiresLayout: false,
2999
+ requiresGitHub: true,
3000
+ isEnabled: (checks) => spec.select(checks) !== void 0,
3001
+ run: async ({ cwd, checks, configFiles, github }) => {
3002
+ const options = spec.select(checks);
3003
+ if (options === void 0) throw new ConformanceError(`the check '${spec.name}' is not enabled`);
3004
+ if (github === void 0) throw new ConformanceError(`the check '${spec.name}' reads the repository's settings and needs a GitHub client`);
3005
+ return spec.check({
3006
+ cwd,
3007
+ options,
3008
+ github,
3009
+ ...configFiles === void 0 ? {} : { configFiles }
3010
+ });
3011
+ }
3012
+ };
3013
+ }
3014
+ function importCheck(spec) {
3015
+ return {
3016
+ name: spec.name,
3017
+ description: spec.description,
3018
+ requiresLayout: true,
3019
+ requiresGitHub: false,
3020
+ isEnabled: (checks) => spec.select(checks) !== void 0,
3021
+ importGraph: {
3022
+ spec: spec.importSpec,
3023
+ optionsOf: spec.select
3024
+ }
3025
+ };
3026
+ }
3027
+ /**
3028
+ * 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.
3029
+ */
3030
+ const registry = {
3031
+ "aggregate-mappers": plain({
3032
+ name: "aggregate-mappers",
3033
+ description: "Every exported aggregate type in a contract file has a mapper file where the template puts it",
3034
+ select: (checks) => enabledOptions(checks["aggregate-mappers"]),
3035
+ check: aggregateMappers
3036
+ }),
3037
+ "command-types": plain({
3038
+ name: "command-types",
3039
+ description: "Every exported command type is derived from a schema, not hand-written",
3040
+ select: (checks) => enabledOptions(checks["command-types"]),
3041
+ check: commandTypes
3042
+ }),
3043
+ "import-uphill": importCheck({
3044
+ name: "import-uphill",
3045
+ description: "No package imports a package of a higher rank",
3046
+ select: (checks) => enabledOptions(checks["import-uphill"]),
3047
+ importSpec: importUphillSpec
3048
+ }),
3049
+ "import-rank-skip": importCheck({
3050
+ name: "import-rank-skip",
1365
3051
  description: "No package imports further below itself than the layout allows",
1366
3052
  select: (checks) => enabledOptions(checks["import-rank-skip"]),
1367
- check: importRankSkip
3053
+ importSpec: importRankSkipSpec
1368
3054
  }),
1369
- "import-cross-slice": withLayout({
3055
+ "import-cross-slice": importCheck({
1370
3056
  name: "import-cross-slice",
1371
3057
  description: "No package imports a package in a different slice",
1372
3058
  select: (checks) => enabledOptions(checks["import-cross-slice"]),
1373
- check: importCrossSlice
3059
+ importSpec: importCrossSliceSpec
1374
3060
  }),
1375
- "import-isolated-groups": withLayout({
3061
+ "import-isolated-groups": importCheck({
1376
3062
  name: "import-isolated-groups",
1377
3063
  description: "No package imports a package in a group the layout isolates from its own",
1378
3064
  select: (checks) => enabledOptions(checks["import-isolated-groups"]),
1379
- check: importIsolatedGroups
3065
+ importSpec: importIsolatedGroupsSpec
1380
3066
  }),
1381
- "import-cycles": withLayout({
3067
+ "import-cycles": importCheck({
1382
3068
  name: "import-cycles",
1383
3069
  description: "No files of the workspace packages import each other in a cycle",
1384
3070
  select: (checks) => enabledOptions(checks["import-cycles"]),
1385
- check: importCycles
3071
+ importSpec: importCyclesSpec
1386
3072
  }),
1387
3073
  "instruction-symlinks": plain({
1388
3074
  name: "instruction-symlinks",
@@ -1407,6 +3093,84 @@ const registry = {
1407
3093
  description: "A package manager version pinned in a Dockerfile is the version packageManager names",
1408
3094
  select: (checks) => enabledOptions(checks["dockerfile-package-manager"]),
1409
3095
  check: dockerfilePackageManager
3096
+ }),
3097
+ "migrations-directory": plain({
3098
+ name: "migrations-directory",
3099
+ 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",
3100
+ select: (checks) => enabledOptions(checks["migrations-directory"]),
3101
+ check: migrationsDirectory
3102
+ }),
3103
+ "workflow-job-ordering": plain({
3104
+ name: "workflow-job-ordering",
3105
+ description: "Release, deploy and junction jobs are ordered so a failure stops what follows",
3106
+ select: (checks) => enabledOptions(checks["workflow-job-ordering"]),
3107
+ check: workflowJobOrdering
3108
+ }),
3109
+ "workflow-skippable-jobs": plain({
3110
+ name: "workflow-skippable-jobs",
3111
+ description: "A path filter does not leave a required check pending or skip a job no junction job reports for",
3112
+ select: (checks) => enabledOptions(checks["workflow-skippable-jobs"]),
3113
+ check: workflowSkippableJobs
3114
+ }),
3115
+ "workflow-runner-resolution": plain({
3116
+ name: "workflow-runner-resolution",
3117
+ description: "A self-hosted or custom runner label is resolved once by a resolver job, not repeated in several jobs",
3118
+ select: (checks) => enabledOptions(checks["workflow-runner-resolution"]),
3119
+ check: workflowRunnerResolution
3120
+ }),
3121
+ "workflow-version-single-source": plain({
3122
+ name: "workflow-version-single-source",
3123
+ description: "Setup steps read a runtime version from .nvmrc or .tool-versions instead of holding a literal",
3124
+ select: (checks) => enabledOptions(checks["workflow-version-single-source"]),
3125
+ check: workflowVersionSingleSource
3126
+ }),
3127
+ "workflow-credentials": plain({
3128
+ name: "workflow-credentials",
3129
+ description: "Attestation steps have the permissions they need, and a tokenless publish cannot quietly use a token",
3130
+ select: (checks) => enabledOptions(checks["workflow-credentials"]),
3131
+ check: workflowCredentials
3132
+ }),
3133
+ "workflow-repository-dispatch": plain({
3134
+ name: "workflow-repository-dispatch",
3135
+ description: "A repository_dispatch handler does not push to the default branch, auto-merge unguarded, or rely on the default token",
3136
+ select: (checks) => enabledOptions(checks["workflow-repository-dispatch"]),
3137
+ check: workflowRepositoryDispatch
3138
+ }),
3139
+ "workflow-update-bot-cooldown": plain({
3140
+ name: "workflow-update-bot-cooldown",
3141
+ description: "Dependabot and Renovate wait before proposing a new release",
3142
+ select: (checks) => enabledOptions(checks["workflow-update-bot-cooldown"]),
3143
+ check: workflowUpdateBotCooldown
3144
+ }),
3145
+ "workflow-merge-group": plain({
3146
+ name: "workflow-merge-group",
3147
+ description: "A workflow that runs for pull requests also runs for the merge queue",
3148
+ select: (checks) => enabledOptions(checks["workflow-merge-group"]),
3149
+ check: workflowMergeGroup
3150
+ }),
3151
+ "workflow-action-pinning": plain({
3152
+ name: "workflow-action-pinning",
3153
+ description: "Actions and reusable workflows are pinned as the configured policy requires",
3154
+ select: (checks) => enabledOptions(checks["workflow-action-pinning"]),
3155
+ check: workflowActionPinning
3156
+ }),
3157
+ "settings-merge-methods": online({
3158
+ name: "settings-merge-methods",
3159
+ description: "The repository allows only one merge method",
3160
+ select: (checks) => enabledOptions(checks["settings-merge-methods"]),
3161
+ check: settingsMergeMethods
3162
+ }),
3163
+ "settings-required-checks": online({
3164
+ name: "settings-required-checks",
3165
+ description: "The default branch requires the junction job check, up to date with the base",
3166
+ select: (checks) => enabledOptions(checks["settings-required-checks"]),
3167
+ check: settingsRequiredChecks
3168
+ }),
3169
+ "settings-review-thread-resolution": online({
3170
+ name: "settings-review-thread-resolution",
3171
+ description: "A pull request cannot merge while a review conversation is unresolved",
3172
+ select: (checks) => enabledOptions(checks["settings-review-thread-resolution"]),
3173
+ check: settingsReviewThreadResolution
1410
3174
  })
1411
3175
  };
1412
3176
  /**
@@ -1448,35 +3212,74 @@ async function layoutOf(options, needed) {
1448
3212
  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`);
1449
3213
  return loaded;
1450
3214
  }
1451
- function selected(config, requested) {
3215
+ function selected(config, requested, online) {
1452
3216
  const enabled = checkNames.filter((name) => registry[name].isEnabled(config.checks));
1453
3217
  if (requested === void 0) {
1454
3218
  if (enabled.length === 0) throw new ConformanceError("no check is enabled; add one to the 'checks' of the 'conformance' section");
1455
- return enabled;
3219
+ const runnable = enabled.filter((name) => online || !registry[name].requiresGitHub);
3220
+ 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)`);
3221
+ return runnable;
1456
3222
  }
1457
3223
  const disabled = requested.filter((name) => !enabled.includes(name));
1458
3224
  if (disabled.length > 0) throw new ConformanceError(`${disabled.join(", ")} ${disabled.length === 1 ? "is" : "are"} not enabled in the 'conformance' section`);
3225
+ const offline = requested.filter((name) => !online && registry[name].requiresGitHub);
3226
+ 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)`);
1459
3227
  return checkNames.filter((name) => requested.includes(name));
1460
3228
  }
1461
3229
  /**
3230
+ * 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.
3231
+ */
3232
+ async function runImportGraphChecks(cwd, layout, names, checks) {
3233
+ const entries = names.flatMap((name) => {
3234
+ const check = registry[name];
3235
+ if (!("importGraph" in check)) return [];
3236
+ const options = check.importGraph.optionsOf(checks);
3237
+ return options === void 0 ? [] : [{
3238
+ spec: check.importGraph.spec,
3239
+ options
3240
+ }];
3241
+ });
3242
+ if (entries.length === 0) return /* @__PURE__ */ new Map();
3243
+ if (layout === void 0) throw new ConformanceError(`${entries.map((entry) => entry.spec.name).join(", ")} read the workspace layout, which was not supplied`);
3244
+ return runImportChecks({
3245
+ cwd,
3246
+ layout,
3247
+ entries
3248
+ });
3249
+ }
3250
+ /**
3251
+ * 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.
3252
+ */
3253
+ function readImported(imported, name) {
3254
+ const found = imported.get(name);
3255
+ if (found === void 0) throw new ConformanceError(`the import check '${name}' produced no result`);
3256
+ return found;
3257
+ }
3258
+ /**
1462
3259
  * 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`.
1463
3260
  *
1464
3261
  * 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.
1465
3262
  */
1466
3263
  async function runChecks(options) {
1467
3264
  const config = await conformanceOf(options);
1468
- const names = selected(config, options.checks);
3265
+ const names = selected(config, options.checks, options.github !== void 0);
1469
3266
  const layout = await layoutOf(options, names.filter((name) => registry[name].requiresLayout));
3267
+ const imported = await runImportGraphChecks(options.cwd, layout, names, config.checks);
1470
3268
  const results = [];
1471
- for (const name of names) results.push({
1472
- check: name,
1473
- violations: await registry[name].run({
3269
+ for (const name of names) {
3270
+ const check = registry[name];
3271
+ const violations = "run" in check ? await check.run({
1474
3272
  cwd: options.cwd,
1475
3273
  checks: config.checks,
1476
3274
  layout,
1477
- configFiles: options.configFiles
1478
- })
1479
- });
3275
+ configFiles: options.configFiles,
3276
+ github: options.github
3277
+ }) : readImported(imported, name);
3278
+ results.push({
3279
+ check: name,
3280
+ violations
3281
+ });
3282
+ }
1480
3283
  const violations = results.flatMap((result) => result.violations);
1481
3284
  return {
1482
3285
  results,
@@ -1580,9 +3383,11 @@ function proveExhaustive(input) {
1580
3383
  //#endregion
1581
3384
  exports.ConformanceError = ConformanceError;
1582
3385
  exports.DEFAULT_INFERENCES = DEFAULT_INFERENCES;
3386
+ exports.DEFAULT_WORKFLOWS = DEFAULT_WORKFLOWS;
1583
3387
  exports.EXIT_CODES = EXIT_CODES;
1584
3388
  exports.ExhaustivenessError = ExhaustivenessError;
1585
3389
  exports.PRESET_COMMIT_TYPES = PRESET_COMMIT_TYPES;
3390
+ exports.accessOf = accessOf;
1586
3391
  exports.aggregateMappers = aggregateMappers;
1587
3392
  exports.checkNames = checkNames;
1588
3393
  exports.classifyPackages = classifyPackages;
@@ -1590,9 +3395,14 @@ exports.commandTypes = commandTypes;
1590
3395
  exports.commitTypes = commitTypes;
1591
3396
  exports.conformanceSchema = conformanceSchema;
1592
3397
  exports.conformanceSection = conformanceSection;
3398
+ exports.createGitHubClient = createGitHubClient;
1593
3399
  exports.crossSliceRules = crossSliceRules;
1594
3400
  exports.cycleRule = cycleRule;
3401
+ exports.deployTools = deployTools;
1595
3402
  exports.dockerfilePackageManager = dockerfilePackageManager;
3403
+ exports.effectiveEnv = effectiveEnv;
3404
+ exports.effectivePermissions = effectivePermissions;
3405
+ exports.generators = generators;
1596
3406
  exports.handlerCases = handlerCases;
1597
3407
  exports.importCrossSlice = importCrossSlice;
1598
3408
  exports.importCycles = importCycles;
@@ -1602,12 +3412,29 @@ exports.importUphill = importUphill;
1602
3412
  exports.instructionSymlinks = instructionSymlinks;
1603
3413
  exports.isCheckName = isCheckName;
1604
3414
  exports.isolatedGroupRules = isolatedGroupRules;
3415
+ exports.loadWorkflows = loadWorkflows;
3416
+ exports.migrationsDirectory = migrationsDirectory;
1605
3417
  exports.packagesPattern = packagesPattern;
3418
+ exports.parseUses = parseUses;
3419
+ exports.parseWorkflow = parseWorkflow;
1606
3420
  exports.proveExhaustive = proveExhaustive;
1607
3421
  exports.rankSkipRules = rankSkipRules;
1608
3422
  exports.registry = registry;
1609
3423
  exports.removeCase = removeCase;
1610
3424
  exports.runChecks = runChecks;
3425
+ exports.settingsMergeMethods = settingsMergeMethods;
3426
+ exports.settingsRequiredChecks = settingsRequiredChecks;
3427
+ exports.settingsReviewThreadResolution = settingsReviewThreadResolution;
1611
3428
  exports.singleStorybook = singleStorybook;
3429
+ exports.transitiveNeeds = transitiveNeeds;
1612
3430
  exports.typeErrors = typeErrors;
1613
3431
  exports.uphillRules = uphillRules;
3432
+ exports.workflowActionPinning = workflowActionPinning;
3433
+ exports.workflowCredentials = workflowCredentials;
3434
+ exports.workflowJobOrdering = workflowJobOrdering;
3435
+ exports.workflowMergeGroup = workflowMergeGroup;
3436
+ exports.workflowRepositoryDispatch = workflowRepositoryDispatch;
3437
+ exports.workflowRunnerResolution = workflowRunnerResolution;
3438
+ exports.workflowSkippableJobs = workflowSkippableJobs;
3439
+ exports.workflowUpdateBotCooldown = workflowUpdateBotCooldown;
3440
+ exports.workflowVersionSingleSource = workflowVersionSingleSource;