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