workspace-conformance 1.0.0 → 1.1.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 +25 -3
- package/dist/cli.js +423 -109
- package/dist/index.cjs +456 -104
- package/dist/index.d.cts +245 -83
- package/dist/index.d.ts +245 -83
- package/dist/index.js +455 -106
- package/package.json +4 -1
package/dist/index.cjs
CHANGED
|
@@ -7,6 +7,8 @@ let cosmiconfig_extends = require("cosmiconfig-extends");
|
|
|
7
7
|
let node_fs_promises = require("node:fs/promises");
|
|
8
8
|
let yaml = require("yaml");
|
|
9
9
|
let node_child_process = require("node:child_process");
|
|
10
|
+
let jsonc_parser = require("jsonc-parser");
|
|
11
|
+
let smol_toml = require("smol-toml");
|
|
10
12
|
let _exadev_config = require("@exadev/config");
|
|
11
13
|
let zod = require("zod");
|
|
12
14
|
//#region src/files.ts
|
|
@@ -658,11 +660,19 @@ function isolatedGroupRules(packages, isolatedGroups) {
|
|
|
658
660
|
}));
|
|
659
661
|
}
|
|
660
662
|
/**
|
|
663
|
+
* The name of {@link cycleRule}, which a check reads its findings by.
|
|
664
|
+
*/
|
|
665
|
+
const CYCLE_RULE_NAME = "import-cycle";
|
|
666
|
+
/**
|
|
667
|
+
* The name of {@link unresolvedNameImportRule}, which a check reads its findings by.
|
|
668
|
+
*/
|
|
669
|
+
const UNRESOLVED_NAME_IMPORT_RULE_NAME = "unresolved-name-import";
|
|
670
|
+
/**
|
|
661
671
|
* The rule that forbids any import cycle among the packages' files.
|
|
662
672
|
*/
|
|
663
673
|
function cycleRule() {
|
|
664
674
|
return {
|
|
665
|
-
name:
|
|
675
|
+
name: CYCLE_RULE_NAME,
|
|
666
676
|
severity: "error",
|
|
667
677
|
comment: "files may not import each other in a cycle",
|
|
668
678
|
from: {},
|
|
@@ -677,7 +687,7 @@ function cycleRule() {
|
|
|
677
687
|
function unresolvedNameImportRule(packages) {
|
|
678
688
|
const names = packageNamesPattern(packages);
|
|
679
689
|
return names === void 0 ? void 0 : {
|
|
680
|
-
name:
|
|
690
|
+
name: UNRESOLVED_NAME_IMPORT_RULE_NAME,
|
|
681
691
|
severity: "error",
|
|
682
692
|
comment: "marks an import of a workspace package by a name that resolves to no file",
|
|
683
693
|
from: { path: packagesPattern(packages) },
|
|
@@ -866,15 +876,22 @@ function assertTypeScriptAvailable(transpilers) {
|
|
|
866
876
|
if (typescript?.available !== true) throw new ConformanceError(`dependency-cruiser cannot load TypeScript, so the import checks would find no TypeScript file and report nothing; install ${typescript === void 0 ? "a version it supports" : `a version in ${typescript.version}`} as the 'typescript' of this workspace`);
|
|
867
877
|
}
|
|
868
878
|
/**
|
|
869
|
-
* The
|
|
870
|
-
|
|
871
|
-
|
|
879
|
+
* The graph-wide options that decide what a cruise sees, with their defaults filled in. Checks whose settings are equal share a cruise; the key is the serialised form.
|
|
880
|
+
*/
|
|
881
|
+
function cruiseKey(cwd, options) {
|
|
882
|
+
return JSON.stringify({
|
|
883
|
+
exclude: options.exclude ?? DEFAULT_GRAPH_EXCLUDES,
|
|
884
|
+
doNotFollow: options.doNotFollow ?? DEFAULT_DO_NOT_FOLLOW,
|
|
885
|
+
tsConfig: options.tsConfig === void 0 ? null : (0, node_path.resolve)(cwd, options.tsConfig)
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
/**
|
|
889
|
+
* One cruise of the packages' files with `rules`, whose findings are read from the cruise summary: `cruise()` reports success in its exit code whatever the summary holds, so the code is not consulted.
|
|
872
890
|
*
|
|
873
|
-
* The
|
|
891
|
+
* The graph is limited to the packages' own files, so nothing outside them is a dependant or a dependency. The base directory is the real path of the root: dependency-cruiser resolves symbolic links, so the path of a file behind one would otherwise not be relative to it.
|
|
874
892
|
*/
|
|
875
893
|
async function cruiseViolations(input) {
|
|
876
894
|
const { cwd, root, packages, rules, options } = input;
|
|
877
|
-
if (rules.length === 0) return [];
|
|
878
895
|
const tsConfigFile = options.tsConfig === void 0 ? void 0 : (0, node_path.resolve)(cwd, options.tsConfig);
|
|
879
896
|
const { cruise, getAvailableTranspilers } = await import("dependency-cruiser");
|
|
880
897
|
assertTypeScriptAvailable(getAvailableTranspilers());
|
|
@@ -893,19 +910,77 @@ async function cruiseViolations(input) {
|
|
|
893
910
|
return result.output.summary.violations;
|
|
894
911
|
}
|
|
895
912
|
/**
|
|
896
|
-
* Run one
|
|
913
|
+
* Run import checks over one read of the workspace's packages, and cruise once for each distinct combination of `exclude`, `doNotFollow` and `tsConfig` among them rather than once per check. Every check adds its rules to its group's cruise and gets back the findings of its own rules, which are the same as a cruise of that check alone would give: each rule is matched against each import independently.
|
|
914
|
+
*
|
|
915
|
+
* The result has an entry for every check, in the order of `entries`. A check whose layout yields no rules reports nothing and adds nothing to a cruise; a group none of whose checks has rules is not cruised.
|
|
916
|
+
*/
|
|
917
|
+
async function runImportChecks(input) {
|
|
918
|
+
const { cwd, layout, entries } = input;
|
|
919
|
+
const root = workspaceRoot(cwd, layout);
|
|
920
|
+
const packages = await readWorkspacePackages(cwd, layout);
|
|
921
|
+
const scope = {
|
|
922
|
+
cwd,
|
|
923
|
+
root,
|
|
924
|
+
packages
|
|
925
|
+
};
|
|
926
|
+
const contributions = entries.map((entry) => ({
|
|
927
|
+
...entry,
|
|
928
|
+
rules: entry.spec.rules(packages, layout)
|
|
929
|
+
}));
|
|
930
|
+
const groups = /* @__PURE__ */ new Map();
|
|
931
|
+
for (const contribution of contributions) {
|
|
932
|
+
const key = cruiseKey(cwd, contribution.options);
|
|
933
|
+
const group = groups.get(key);
|
|
934
|
+
if (group === void 0) groups.set(key, {
|
|
935
|
+
options: contribution.options,
|
|
936
|
+
members: [contribution]
|
|
937
|
+
});
|
|
938
|
+
else group.members.push(contribution);
|
|
939
|
+
}
|
|
940
|
+
const found = /* @__PURE__ */ new Map();
|
|
941
|
+
for (const { options, members } of groups.values()) {
|
|
942
|
+
const rules = members.flatMap((member) => member.rules);
|
|
943
|
+
if (rules.length === 0) continue;
|
|
944
|
+
const owner = /* @__PURE__ */ new Map();
|
|
945
|
+
for (const member of members) for (const rule of member.rules) {
|
|
946
|
+
const existing = owner.get(rule.name);
|
|
947
|
+
if (existing !== void 0 && existing !== member.spec.name) throw new ConformanceError(`the checks ${existing} and ${member.spec.name} both generate a rule named '${rule.name}', so their findings cannot be told apart`);
|
|
948
|
+
owner.set(rule.name, member.spec.name);
|
|
949
|
+
}
|
|
950
|
+
for (const finding of await cruiseViolations({
|
|
951
|
+
cwd,
|
|
952
|
+
root,
|
|
953
|
+
packages,
|
|
954
|
+
rules,
|
|
955
|
+
options
|
|
956
|
+
})) {
|
|
957
|
+
const name = owner.get(finding.rule.name);
|
|
958
|
+
if (name === void 0) throw new ConformanceError(`dependency-cruiser reported the rule '${finding.rule.name}', which no check generated`);
|
|
959
|
+
const own = found.get(name);
|
|
960
|
+
if (own === void 0) found.set(name, [finding]);
|
|
961
|
+
else own.push(finding);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
return new Map(contributions.map(({ spec }) => [spec.name, spec.report(found.get(spec.name) ?? [], scope)]));
|
|
965
|
+
}
|
|
966
|
+
/**
|
|
967
|
+
* Run one import check on its own.
|
|
897
968
|
*/
|
|
898
969
|
async function runImportCheck(context, spec) {
|
|
899
|
-
|
|
900
|
-
const packages = await readWorkspacePackages(context.cwd, context.layout);
|
|
901
|
-
const rules = spec.rules(packages, context);
|
|
902
|
-
return (await cruiseViolations({
|
|
970
|
+
return (await runImportChecks({
|
|
903
971
|
cwd: context.cwd,
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
972
|
+
layout: context.layout,
|
|
973
|
+
entries: [{
|
|
974
|
+
spec,
|
|
975
|
+
options: context.options
|
|
976
|
+
}]
|
|
977
|
+
})).get(spec.name) ?? [];
|
|
978
|
+
}
|
|
979
|
+
/**
|
|
980
|
+
* The report of a check whose every rule forbids one package importing another: one violation per finding, at the importing file, ordered by file and message.
|
|
981
|
+
*/
|
|
982
|
+
function edgeReport(spec) {
|
|
983
|
+
return (findings, { cwd, root, packages }) => findings.map((finding) => {
|
|
909
984
|
const from = packageOfPath(packages, finding.from);
|
|
910
985
|
const to = packageOfPath(packages, finding.to);
|
|
911
986
|
return {
|
|
@@ -916,7 +991,7 @@ async function runImportCheck(context, spec) {
|
|
|
916
991
|
fromFile: finding.from,
|
|
917
992
|
toFile: finding.to
|
|
918
993
|
}),
|
|
919
|
-
file: relativePosix(
|
|
994
|
+
file: relativePosix(cwd, (0, node_path.join)(root, finding.from))
|
|
920
995
|
};
|
|
921
996
|
}).sort((a, b) => a.file.localeCompare(b.file) || a.message.localeCompare(b.message));
|
|
922
997
|
}
|
|
@@ -936,17 +1011,24 @@ function describeTarget(edge) {
|
|
|
936
1011
|
//#endregion
|
|
937
1012
|
//#region src/checks/import-cross-slice.ts
|
|
938
1013
|
/**
|
|
939
|
-
*
|
|
1014
|
+
* How `import-cross-slice` joins a shared cruise.
|
|
940
1015
|
*/
|
|
941
|
-
const
|
|
1016
|
+
const importCrossSliceSpec = {
|
|
942
1017
|
name: "import-cross-slice",
|
|
943
|
-
reason: "other-slice",
|
|
944
1018
|
rules: (packages) => {
|
|
945
1019
|
if (!packages.some((member) => member.slice !== void 0)) throw new ConformanceError("the 'import-cross-slice' check is enabled but no package in the layout has a slice");
|
|
946
1020
|
return crossSliceRules(packages);
|
|
947
1021
|
},
|
|
948
|
-
|
|
949
|
-
|
|
1022
|
+
report: edgeReport({
|
|
1023
|
+
name: "import-cross-slice",
|
|
1024
|
+
reason: "other-slice",
|
|
1025
|
+
message: (edge) => `${describePackage(edge.from)} (slice '${String(edge.from.slice)}') imports ${describeTarget(edge)} (slice '${String(edge.to.slice)}')`
|
|
1026
|
+
})
|
|
1027
|
+
};
|
|
1028
|
+
/**
|
|
1029
|
+
* A package imports a package in a different slice.
|
|
1030
|
+
*/
|
|
1031
|
+
const importCrossSlice = async (context) => runImportCheck(context, importCrossSliceSpec);
|
|
950
1032
|
//#endregion
|
|
951
1033
|
//#region src/checks/import-cycles.ts
|
|
952
1034
|
/**
|
|
@@ -1015,84 +1097,106 @@ function nameCycles(imports) {
|
|
|
1015
1097
|
return cycles;
|
|
1016
1098
|
}
|
|
1017
1099
|
/**
|
|
1100
|
+
* How `import-cycles` joins a shared cruise.
|
|
1101
|
+
*/
|
|
1102
|
+
const importCyclesSpec = {
|
|
1103
|
+
name: "import-cycles",
|
|
1104
|
+
rules: (packages) => {
|
|
1105
|
+
const nameRule = unresolvedNameImportRule(packages);
|
|
1106
|
+
return nameRule === void 0 ? [cycleRule()] : [cycleRule(), nameRule];
|
|
1107
|
+
},
|
|
1108
|
+
report: (found, { cwd, root, packages }) => {
|
|
1109
|
+
const inFiles = fileCycles(found.filter((finding) => finding.rule.name === CYCLE_RULE_NAME));
|
|
1110
|
+
const inPackages = nameCycles(found.filter((finding) => finding.rule.name === UNRESOLVED_NAME_IMPORT_RULE_NAME).map((finding) => ({
|
|
1111
|
+
from: packageOfPath(packages, finding.from),
|
|
1112
|
+
to: packageOfPath(packages, finding.to),
|
|
1113
|
+
file: finding.from
|
|
1114
|
+
})));
|
|
1115
|
+
return [...[...inFiles.values()].map((ordered) => {
|
|
1116
|
+
const [first] = ordered;
|
|
1117
|
+
if (first === void 0) throw new ConformanceError("an import cycle with no files was reported");
|
|
1118
|
+
return {
|
|
1119
|
+
code: "import-cycles/cycle",
|
|
1120
|
+
message: `import cycle: ${[...ordered, first].join(" -> ")}`,
|
|
1121
|
+
file: relativePosix(cwd, (0, node_path.join)(root, first))
|
|
1122
|
+
};
|
|
1123
|
+
}), ...[...inPackages.values()].map((ring) => {
|
|
1124
|
+
const [first] = ring;
|
|
1125
|
+
if (first === void 0) throw new ConformanceError("an import cycle with no packages was reported");
|
|
1126
|
+
return {
|
|
1127
|
+
code: "import-cycles/cycle",
|
|
1128
|
+
message: `import cycle between packages that import each other by a name that resolves to no file: ${[...ring.map((edge) => describePackage(edge.from)), describePackage(first.from)].join(" -> ")}`,
|
|
1129
|
+
file: relativePosix(cwd, (0, node_path.join)(root, first.file))
|
|
1130
|
+
};
|
|
1131
|
+
})].sort((a, b) => a.file.localeCompare(b.file) || a.message.localeCompare(b.message));
|
|
1132
|
+
}
|
|
1133
|
+
};
|
|
1134
|
+
/**
|
|
1018
1135
|
* Files of the workspace's packages import each other in a cycle, and so do packages that import each other by a name that resolves to no file. Each cycle is reported once, at its first file (or, for packages, the file of the first import), starting from its smallest path.
|
|
1019
1136
|
*
|
|
1020
1137
|
* Cycles among packages are found only when every import in the ring is by a name that resolves to no file. A ring that mixes such imports with imports that do resolve to files has no complete path through either graph and is not seen.
|
|
1021
1138
|
*/
|
|
1022
|
-
const importCycles = async (context) =>
|
|
1023
|
-
const root = workspaceRoot(context.cwd, context.layout);
|
|
1024
|
-
const packages = await readWorkspacePackages(context.cwd, context.layout);
|
|
1025
|
-
const nameRule = unresolvedNameImportRule(packages);
|
|
1026
|
-
const found = await cruiseViolations({
|
|
1027
|
-
cwd: context.cwd,
|
|
1028
|
-
root,
|
|
1029
|
-
packages,
|
|
1030
|
-
rules: nameRule === void 0 ? [cycleRule()] : [cycleRule(), nameRule],
|
|
1031
|
-
options: context.options
|
|
1032
|
-
});
|
|
1033
|
-
const inFiles = fileCycles(found.filter((finding) => finding.rule.name === cycleRule().name));
|
|
1034
|
-
const inPackages = nameCycles(found.filter((finding) => finding.rule.name === nameRule?.name).map((finding) => ({
|
|
1035
|
-
from: packageOfPath(packages, finding.from),
|
|
1036
|
-
to: packageOfPath(packages, finding.to),
|
|
1037
|
-
file: finding.from
|
|
1038
|
-
})));
|
|
1039
|
-
return [...[...inFiles.values()].map((ordered) => {
|
|
1040
|
-
const [first] = ordered;
|
|
1041
|
-
if (first === void 0) throw new ConformanceError("an import cycle with no files was reported");
|
|
1042
|
-
return {
|
|
1043
|
-
code: "import-cycles/cycle",
|
|
1044
|
-
message: `import cycle: ${[...ordered, first].join(" -> ")}`,
|
|
1045
|
-
file: relativePosix(context.cwd, (0, node_path.join)(root, first))
|
|
1046
|
-
};
|
|
1047
|
-
}), ...[...inPackages.values()].map((ring) => {
|
|
1048
|
-
const [first] = ring;
|
|
1049
|
-
if (first === void 0) throw new ConformanceError("an import cycle with no packages was reported");
|
|
1050
|
-
return {
|
|
1051
|
-
code: "import-cycles/cycle",
|
|
1052
|
-
message: `import cycle between packages that import each other by a name that resolves to no file: ${[...ring.map((edge) => describePackage(edge.from)), describePackage(first.from)].join(" -> ")}`,
|
|
1053
|
-
file: relativePosix(context.cwd, (0, node_path.join)(root, first.file))
|
|
1054
|
-
};
|
|
1055
|
-
})].sort((a, b) => a.file.localeCompare(b.file) || a.message.localeCompare(b.message));
|
|
1056
|
-
};
|
|
1139
|
+
const importCycles = async (context) => runImportCheck(context, importCyclesSpec);
|
|
1057
1140
|
//#endregion
|
|
1058
1141
|
//#region src/checks/import-isolated-groups.ts
|
|
1059
1142
|
/**
|
|
1060
|
-
*
|
|
1143
|
+
* How `import-isolated-groups` joins a shared cruise.
|
|
1061
1144
|
*/
|
|
1062
|
-
const
|
|
1145
|
+
const importIsolatedGroupsSpec = {
|
|
1063
1146
|
name: "import-isolated-groups",
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
return isolatedGroupRules(packages, context.layout.isolatedGroups);
|
|
1147
|
+
rules: (packages, layout) => {
|
|
1148
|
+
if (layout.isolatedGroups === void 0) throw new ConformanceError("the 'import-isolated-groups' check is enabled but the layout has no 'isolatedGroups'");
|
|
1149
|
+
return isolatedGroupRules(packages, layout.isolatedGroups);
|
|
1068
1150
|
},
|
|
1069
|
-
|
|
1070
|
-
|
|
1151
|
+
report: edgeReport({
|
|
1152
|
+
name: "import-isolated-groups",
|
|
1153
|
+
reason: "isolated-group",
|
|
1154
|
+
message: (edge) => `${describePackage(edge.from)} (group '${edge.from.group}') imports ${describeTarget(edge)} (group '${edge.to.group}'), which the layout isolates from it`
|
|
1155
|
+
})
|
|
1156
|
+
};
|
|
1157
|
+
/**
|
|
1158
|
+
* A package imports a package in a group the layout isolates from its own.
|
|
1159
|
+
*/
|
|
1160
|
+
const importIsolatedGroups = async (context) => runImportCheck(context, importIsolatedGroupsSpec);
|
|
1071
1161
|
//#endregion
|
|
1072
1162
|
//#region src/checks/import-rank-skip.ts
|
|
1073
1163
|
/**
|
|
1074
|
-
*
|
|
1164
|
+
* How `import-rank-skip` joins a shared cruise.
|
|
1075
1165
|
*/
|
|
1076
|
-
const
|
|
1166
|
+
const importRankSkipSpec = {
|
|
1077
1167
|
name: "import-rank-skip",
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
return rankSkipRules(packages, context.layout.rankSkip);
|
|
1168
|
+
rules: (packages, layout) => {
|
|
1169
|
+
if (layout.rankSkip === void 0) throw new ConformanceError("the 'import-rank-skip' check is enabled but the layout has no 'rankSkip'");
|
|
1170
|
+
return rankSkipRules(packages, layout.rankSkip);
|
|
1082
1171
|
},
|
|
1083
|
-
|
|
1084
|
-
|
|
1172
|
+
report: edgeReport({
|
|
1173
|
+
name: "import-rank-skip",
|
|
1174
|
+
reason: "skipped-rank",
|
|
1175
|
+
message: (edge) => `${describePackage(edge.from)} (rank ${String(edge.from.rank)}) imports ${describeTarget(edge)} (rank ${String(edge.to.rank)}), further below than the layout allows`
|
|
1176
|
+
})
|
|
1177
|
+
};
|
|
1178
|
+
/**
|
|
1179
|
+
* A package imports a package more than `rankSkip.maxDistance` ranks below it that is not in `rankSkip.exemptRanks`.
|
|
1180
|
+
*/
|
|
1181
|
+
const importRankSkip = async (context) => runImportCheck(context, importRankSkipSpec);
|
|
1085
1182
|
//#endregion
|
|
1086
1183
|
//#region src/checks/import-uphill.ts
|
|
1087
1184
|
/**
|
|
1088
|
-
*
|
|
1185
|
+
* How `import-uphill` joins a shared cruise.
|
|
1089
1186
|
*/
|
|
1090
|
-
const
|
|
1187
|
+
const importUphillSpec = {
|
|
1091
1188
|
name: "import-uphill",
|
|
1092
|
-
reason: "higher-rank",
|
|
1093
1189
|
rules: (packages) => uphillRules(packages),
|
|
1094
|
-
|
|
1095
|
-
|
|
1190
|
+
report: edgeReport({
|
|
1191
|
+
name: "import-uphill",
|
|
1192
|
+
reason: "higher-rank",
|
|
1193
|
+
message: (edge) => `${describePackage(edge.from)} (rank ${String(edge.from.rank)}) imports ${describeTarget(edge)} (rank ${String(edge.to.rank)}), a higher rank`
|
|
1194
|
+
})
|
|
1195
|
+
};
|
|
1196
|
+
/**
|
|
1197
|
+
* A package imports a package of a strictly higher rank: dependencies run downhill only.
|
|
1198
|
+
*/
|
|
1199
|
+
const importUphill = async (context) => runImportCheck(context, importUphillSpec);
|
|
1096
1200
|
async function git(cwd, args) {
|
|
1097
1201
|
return new Promise((resolve, reject) => {
|
|
1098
1202
|
(0, node_child_process.execFile)("git", [...args], {
|
|
@@ -1204,6 +1308,208 @@ const instructionSymlinks = async ({ cwd, options }) => {
|
|
|
1204
1308
|
return violations;
|
|
1205
1309
|
};
|
|
1206
1310
|
//#endregion
|
|
1311
|
+
//#region src/migrations/adapters.ts
|
|
1312
|
+
/**
|
|
1313
|
+
* The generators that have an adapter.
|
|
1314
|
+
*/
|
|
1315
|
+
const generatorNames = ["drizzle-kit"];
|
|
1316
|
+
/**
|
|
1317
|
+
* The deploy tools that have an adapter.
|
|
1318
|
+
*/
|
|
1319
|
+
const deployToolNames = ["wrangler"];
|
|
1320
|
+
function recordOf(config, file) {
|
|
1321
|
+
if (!isRecord(config)) throw new ConformanceError(`${file}: the config must be an object for the migrations directory to be read`);
|
|
1322
|
+
return config;
|
|
1323
|
+
}
|
|
1324
|
+
const DRIZZLE_DEFAULT_OUT = "drizzle";
|
|
1325
|
+
/**
|
|
1326
|
+
* 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.
|
|
1327
|
+
*/
|
|
1328
|
+
const drizzleKit = {
|
|
1329
|
+
configFiles: [
|
|
1330
|
+
"drizzle.config.ts",
|
|
1331
|
+
"drizzle.config.js",
|
|
1332
|
+
"drizzle.config.json"
|
|
1333
|
+
],
|
|
1334
|
+
applyCommand: /(?:^|[\s;&|(])drizzle-kit\s+migrate(?![\w:-])/u,
|
|
1335
|
+
applyCommandName: "drizzle-kit migrate",
|
|
1336
|
+
outputDirectory: (config, file) => {
|
|
1337
|
+
const out = recordOf(config, file)["out"];
|
|
1338
|
+
if (out === void 0) return DRIZZLE_DEFAULT_OUT;
|
|
1339
|
+
if (typeof out !== "string") throw new ConformanceError(`${file}: 'out' must be a string`);
|
|
1340
|
+
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`);
|
|
1341
|
+
return out;
|
|
1342
|
+
}
|
|
1343
|
+
};
|
|
1344
|
+
const WRANGLER_DEFAULT_MIGRATIONS_DIR = "migrations";
|
|
1345
|
+
function databasesOf(scope, file) {
|
|
1346
|
+
const databases = scope["d1_databases"];
|
|
1347
|
+
if (databases === void 0) return [];
|
|
1348
|
+
if (!Array.isArray(databases) || !databases.every(isRecord)) throw new ConformanceError(`${file}: 'd1_databases' must be an array of objects`);
|
|
1349
|
+
return databases;
|
|
1350
|
+
}
|
|
1351
|
+
/**
|
|
1352
|
+
* 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.
|
|
1353
|
+
*/
|
|
1354
|
+
const wrangler = {
|
|
1355
|
+
configFiles: [
|
|
1356
|
+
"wrangler.json",
|
|
1357
|
+
"wrangler.jsonc",
|
|
1358
|
+
"wrangler.toml"
|
|
1359
|
+
],
|
|
1360
|
+
migrationsDirectory: (config, file, selector) => {
|
|
1361
|
+
const top = recordOf(config, file);
|
|
1362
|
+
let scope = top;
|
|
1363
|
+
if (selector.environment !== void 0) {
|
|
1364
|
+
const environments = top["env"];
|
|
1365
|
+
const environment = isRecord(environments) ? environments[selector.environment] : void 0;
|
|
1366
|
+
if (!isRecord(environment)) throw new ConformanceError(`${file} has no environment '${selector.environment}' (checks.migrations-directory.environment)`);
|
|
1367
|
+
scope = environment;
|
|
1368
|
+
}
|
|
1369
|
+
const where = selector.environment === void 0 ? file : `${file} (environment '${selector.environment}')`;
|
|
1370
|
+
const databases = databasesOf(scope, file);
|
|
1371
|
+
const chosen = selector.database === void 0 ? databases : databases.filter((database) => database["binding"] === selector.database);
|
|
1372
|
+
if (chosen.length > 1) throw new ConformanceError(`${where} declares several D1 databases; name the one the generator writes for with checks.migrations-directory.database`);
|
|
1373
|
+
const [only] = chosen;
|
|
1374
|
+
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}'` };
|
|
1375
|
+
const directory = only["migrations_dir"];
|
|
1376
|
+
if (directory === void 0) return { directory: WRANGLER_DEFAULT_MIGRATIONS_DIR };
|
|
1377
|
+
if (typeof directory !== "string") throw new ConformanceError(`${where}: 'migrations_dir' must be a string`);
|
|
1378
|
+
return { directory };
|
|
1379
|
+
}
|
|
1380
|
+
};
|
|
1381
|
+
const generators = { "drizzle-kit": drizzleKit };
|
|
1382
|
+
const deployTools = { wrangler };
|
|
1383
|
+
//#endregion
|
|
1384
|
+
//#region src/migrations/config-data.ts
|
|
1385
|
+
/**
|
|
1386
|
+
* The first of `candidates` (relative to `cwd`) that exists, or `undefined` when none does.
|
|
1387
|
+
*/
|
|
1388
|
+
function firstExisting(cwd, candidates) {
|
|
1389
|
+
return candidates.find((candidate) => (0, node_fs.existsSync)((0, node_path.resolve)(cwd, candidate)));
|
|
1390
|
+
}
|
|
1391
|
+
function assertValid(errors, file) {
|
|
1392
|
+
const [first] = errors;
|
|
1393
|
+
if (first !== void 0) throw new ConformanceError(`${file}: not valid JSON (${(0, jsonc_parser.printParseErrorCode)(first.error)} at offset ${String(first.offset)})`);
|
|
1394
|
+
}
|
|
1395
|
+
/**
|
|
1396
|
+
* 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.
|
|
1397
|
+
*/
|
|
1398
|
+
function parseJsonc(text, file) {
|
|
1399
|
+
const errors = [];
|
|
1400
|
+
const data = (0, jsonc_parser.parse)(text, errors, { allowTrailingComma: true });
|
|
1401
|
+
assertValid(errors, file);
|
|
1402
|
+
return data;
|
|
1403
|
+
}
|
|
1404
|
+
/**
|
|
1405
|
+
* 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.
|
|
1406
|
+
*/
|
|
1407
|
+
function parseJsoncTree(text, file) {
|
|
1408
|
+
const errors = [];
|
|
1409
|
+
const tree = (0, jsonc_parser.parseTree)(text, errors, { allowTrailingComma: true });
|
|
1410
|
+
assertValid(errors, file);
|
|
1411
|
+
if (tree === void 0) throw new ConformanceError(`${file}: holds no JSON value`);
|
|
1412
|
+
return tree;
|
|
1413
|
+
}
|
|
1414
|
+
/**
|
|
1415
|
+
* 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.
|
|
1416
|
+
*/
|
|
1417
|
+
async function readConfigData(cwd, file, configFiles) {
|
|
1418
|
+
const extension = (0, node_path.extname)(file).toLowerCase();
|
|
1419
|
+
if (extension === ".json" || extension === ".jsonc") return parseJsonc(await (0, node_fs_promises.readFile)((0, node_path.resolve)(cwd, file), "utf8"), file);
|
|
1420
|
+
if (extension === ".toml") try {
|
|
1421
|
+
return (0, smol_toml.parse)(await (0, node_fs_promises.readFile)((0, node_path.resolve)(cwd, file), "utf8"));
|
|
1422
|
+
} catch (error) {
|
|
1423
|
+
throw new ConformanceError(`${file}: not valid TOML (${error instanceof Error ? error.message : String(error)})`);
|
|
1424
|
+
}
|
|
1425
|
+
return evaluateConfigFile(cwd, file, configFiles);
|
|
1426
|
+
}
|
|
1427
|
+
//#endregion
|
|
1428
|
+
//#region src/checks/migrations-directory.ts
|
|
1429
|
+
const DEFAULT_PACKAGE_JSONS = ["**/package.json"];
|
|
1430
|
+
/**
|
|
1431
|
+
* The 1-based line and column of the character at `offset` in `text`, which is not the first character.
|
|
1432
|
+
*/
|
|
1433
|
+
function positionOf(text, offset) {
|
|
1434
|
+
const lineStart = text.lastIndexOf("\n", offset - 1) + 1;
|
|
1435
|
+
return {
|
|
1436
|
+
line: text.slice(0, lineStart).split("\n").length,
|
|
1437
|
+
column: offset - lineStart + 1
|
|
1438
|
+
};
|
|
1439
|
+
}
|
|
1440
|
+
/**
|
|
1441
|
+
* 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.
|
|
1442
|
+
*/
|
|
1443
|
+
function locate(cwd, explicit, candidates) {
|
|
1444
|
+
return explicit === void 0 ? firstExisting(cwd, candidates) : firstExisting(cwd, [explicit]);
|
|
1445
|
+
}
|
|
1446
|
+
function missingConfig(explicit, candidates) {
|
|
1447
|
+
return explicit === void 0 ? {
|
|
1448
|
+
code: "migrations-directory/missing-config",
|
|
1449
|
+
message: `none of ${candidates.join(", ")} exists`,
|
|
1450
|
+
file: candidates[0]
|
|
1451
|
+
} : {
|
|
1452
|
+
code: "migrations-directory/missing-config",
|
|
1453
|
+
message: `${explicit} does not exist`,
|
|
1454
|
+
file: explicit
|
|
1455
|
+
};
|
|
1456
|
+
}
|
|
1457
|
+
/**
|
|
1458
|
+
* Violations for every script of the `package.json` at `file` that matches `command`.
|
|
1459
|
+
*/
|
|
1460
|
+
async function scriptViolations(cwd, file, command, describe) {
|
|
1461
|
+
const text = await (0, node_fs_promises.readFile)((0, node_path.resolve)(cwd, file), "utf8");
|
|
1462
|
+
const scripts = (0, jsonc_parser.findNodeAtLocation)(parseJsoncTree(text, file), ["scripts"]);
|
|
1463
|
+
const violations = [];
|
|
1464
|
+
for (const entry of scripts?.children ?? []) {
|
|
1465
|
+
const [key, value] = entry.children ?? [];
|
|
1466
|
+
if (key?.type === "string" && value?.type === "string" && typeof value.value === "string" && command.test(value.value)) violations.push({
|
|
1467
|
+
code: "migrations-directory/generator-apply-command",
|
|
1468
|
+
message: describe(value.value, String(key.value)),
|
|
1469
|
+
file,
|
|
1470
|
+
location: positionOf(text, value.offset)
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
1473
|
+
return violations;
|
|
1474
|
+
}
|
|
1475
|
+
/**
|
|
1476
|
+
* 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.
|
|
1477
|
+
*
|
|
1478
|
+
* 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.
|
|
1479
|
+
*/
|
|
1480
|
+
const migrationsDirectory = async ({ cwd, options, configFiles }) => {
|
|
1481
|
+
const generator = generators[options.generator];
|
|
1482
|
+
const deployTool = deployTools[options.deployTool];
|
|
1483
|
+
const violations = [];
|
|
1484
|
+
const generatorFile = locate(cwd, options.generatorConfig, generator.configFiles);
|
|
1485
|
+
const deployFile = locate(cwd, options.deployConfig, deployTool.configFiles);
|
|
1486
|
+
if (generatorFile === void 0) violations.push(missingConfig(options.generatorConfig, generator.configFiles));
|
|
1487
|
+
if (deployFile === void 0) violations.push(missingConfig(options.deployConfig, deployTool.configFiles));
|
|
1488
|
+
if (generatorFile !== void 0 && deployFile !== void 0) {
|
|
1489
|
+
const written = generator.outputDirectory(await readConfigData(cwd, generatorFile, configFiles), generatorFile);
|
|
1490
|
+
const applied = deployTool.migrationsDirectory(await readConfigData(cwd, deployFile, configFiles), deployFile, {
|
|
1491
|
+
...options.database === void 0 ? {} : { database: options.database },
|
|
1492
|
+
...options.environment === void 0 ? {} : { environment: options.environment }
|
|
1493
|
+
});
|
|
1494
|
+
if (applied.none !== void 0) violations.push({
|
|
1495
|
+
code: "migrations-directory/no-database",
|
|
1496
|
+
message: applied.none,
|
|
1497
|
+
file: deployFile
|
|
1498
|
+
});
|
|
1499
|
+
else {
|
|
1500
|
+
const generated = (0, node_path.resolve)(cwd, (0, node_path.dirname)(generatorFile), written);
|
|
1501
|
+
const deployed = (0, node_path.resolve)(cwd, (0, node_path.dirname)(deployFile), applied.directory);
|
|
1502
|
+
if (generated !== deployed) violations.push({
|
|
1503
|
+
code: "migrations-directory/mismatch",
|
|
1504
|
+
message: `${generatorFile} writes migrations to ${relativePosix(cwd, generated)}, but ${deployFile} applies them from ${relativePosix(cwd, deployed)}`,
|
|
1505
|
+
file: generatorFile
|
|
1506
|
+
});
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
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}`));
|
|
1510
|
+
return violations;
|
|
1511
|
+
};
|
|
1512
|
+
//#endregion
|
|
1207
1513
|
//#region src/checks/single-storybook.ts
|
|
1208
1514
|
const STORYBOOK_DIRECTORY = ".storybook";
|
|
1209
1515
|
/**
|
|
@@ -1285,6 +1591,15 @@ const checks = zod.z.strictObject({
|
|
|
1285
1591
|
dockerfiles: zod.z.exactOptional(paths),
|
|
1286
1592
|
exclude: zod.z.exactOptional(paths),
|
|
1287
1593
|
packageJson: zod.z.exactOptional(path)
|
|
1594
|
+
}))),
|
|
1595
|
+
"migrations-directory": zod.z.exactOptional(enabled(zod.z.strictObject({
|
|
1596
|
+
generator: zod.z.enum(generatorNames),
|
|
1597
|
+
deployTool: zod.z.enum(deployToolNames),
|
|
1598
|
+
generatorConfig: zod.z.exactOptional(path),
|
|
1599
|
+
deployConfig: zod.z.exactOptional(path),
|
|
1600
|
+
database: zod.z.exactOptional(zod.z.string().min(1)),
|
|
1601
|
+
environment: zod.z.exactOptional(zod.z.string().min(1)),
|
|
1602
|
+
packageJsons: zod.z.exactOptional(paths)
|
|
1288
1603
|
})))
|
|
1289
1604
|
});
|
|
1290
1605
|
/**
|
|
@@ -1320,21 +1635,15 @@ function plain(spec) {
|
|
|
1320
1635
|
}
|
|
1321
1636
|
};
|
|
1322
1637
|
}
|
|
1323
|
-
function
|
|
1638
|
+
function importCheck(spec) {
|
|
1324
1639
|
return {
|
|
1325
1640
|
name: spec.name,
|
|
1326
1641
|
description: spec.description,
|
|
1327
1642
|
requiresLayout: true,
|
|
1328
1643
|
isEnabled: (checks) => spec.select(checks) !== void 0,
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
if (layout === void 0) throw new ConformanceError(`the check '${spec.name}' reads the workspace layout, which was not supplied`);
|
|
1333
|
-
return spec.check({
|
|
1334
|
-
cwd,
|
|
1335
|
-
options,
|
|
1336
|
-
layout
|
|
1337
|
-
});
|
|
1644
|
+
importGraph: {
|
|
1645
|
+
spec: spec.importSpec,
|
|
1646
|
+
optionsOf: spec.select
|
|
1338
1647
|
}
|
|
1339
1648
|
};
|
|
1340
1649
|
}
|
|
@@ -1354,35 +1663,35 @@ const registry = {
|
|
|
1354
1663
|
select: (checks) => enabledOptions(checks["command-types"]),
|
|
1355
1664
|
check: commandTypes
|
|
1356
1665
|
}),
|
|
1357
|
-
"import-uphill":
|
|
1666
|
+
"import-uphill": importCheck({
|
|
1358
1667
|
name: "import-uphill",
|
|
1359
1668
|
description: "No package imports a package of a higher rank",
|
|
1360
1669
|
select: (checks) => enabledOptions(checks["import-uphill"]),
|
|
1361
|
-
|
|
1670
|
+
importSpec: importUphillSpec
|
|
1362
1671
|
}),
|
|
1363
|
-
"import-rank-skip":
|
|
1672
|
+
"import-rank-skip": importCheck({
|
|
1364
1673
|
name: "import-rank-skip",
|
|
1365
1674
|
description: "No package imports further below itself than the layout allows",
|
|
1366
1675
|
select: (checks) => enabledOptions(checks["import-rank-skip"]),
|
|
1367
|
-
|
|
1676
|
+
importSpec: importRankSkipSpec
|
|
1368
1677
|
}),
|
|
1369
|
-
"import-cross-slice":
|
|
1678
|
+
"import-cross-slice": importCheck({
|
|
1370
1679
|
name: "import-cross-slice",
|
|
1371
1680
|
description: "No package imports a package in a different slice",
|
|
1372
1681
|
select: (checks) => enabledOptions(checks["import-cross-slice"]),
|
|
1373
|
-
|
|
1682
|
+
importSpec: importCrossSliceSpec
|
|
1374
1683
|
}),
|
|
1375
|
-
"import-isolated-groups":
|
|
1684
|
+
"import-isolated-groups": importCheck({
|
|
1376
1685
|
name: "import-isolated-groups",
|
|
1377
1686
|
description: "No package imports a package in a group the layout isolates from its own",
|
|
1378
1687
|
select: (checks) => enabledOptions(checks["import-isolated-groups"]),
|
|
1379
|
-
|
|
1688
|
+
importSpec: importIsolatedGroupsSpec
|
|
1380
1689
|
}),
|
|
1381
|
-
"import-cycles":
|
|
1690
|
+
"import-cycles": importCheck({
|
|
1382
1691
|
name: "import-cycles",
|
|
1383
1692
|
description: "No files of the workspace packages import each other in a cycle",
|
|
1384
1693
|
select: (checks) => enabledOptions(checks["import-cycles"]),
|
|
1385
|
-
|
|
1694
|
+
importSpec: importCyclesSpec
|
|
1386
1695
|
}),
|
|
1387
1696
|
"instruction-symlinks": plain({
|
|
1388
1697
|
name: "instruction-symlinks",
|
|
@@ -1407,6 +1716,12 @@ const registry = {
|
|
|
1407
1716
|
description: "A package manager version pinned in a Dockerfile is the version packageManager names",
|
|
1408
1717
|
select: (checks) => enabledOptions(checks["dockerfile-package-manager"]),
|
|
1409
1718
|
check: dockerfilePackageManager
|
|
1719
|
+
}),
|
|
1720
|
+
"migrations-directory": plain({
|
|
1721
|
+
name: "migrations-directory",
|
|
1722
|
+
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",
|
|
1723
|
+
select: (checks) => enabledOptions(checks["migrations-directory"]),
|
|
1724
|
+
check: migrationsDirectory
|
|
1410
1725
|
})
|
|
1411
1726
|
};
|
|
1412
1727
|
/**
|
|
@@ -1459,6 +1774,35 @@ function selected(config, requested) {
|
|
|
1459
1774
|
return checkNames.filter((name) => requested.includes(name));
|
|
1460
1775
|
}
|
|
1461
1776
|
/**
|
|
1777
|
+
* 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.
|
|
1778
|
+
*/
|
|
1779
|
+
async function runImportGraphChecks(cwd, layout, names, checks) {
|
|
1780
|
+
const entries = names.flatMap((name) => {
|
|
1781
|
+
const check = registry[name];
|
|
1782
|
+
if (!("importGraph" in check)) return [];
|
|
1783
|
+
const options = check.importGraph.optionsOf(checks);
|
|
1784
|
+
return options === void 0 ? [] : [{
|
|
1785
|
+
spec: check.importGraph.spec,
|
|
1786
|
+
options
|
|
1787
|
+
}];
|
|
1788
|
+
});
|
|
1789
|
+
if (entries.length === 0) return /* @__PURE__ */ new Map();
|
|
1790
|
+
if (layout === void 0) throw new ConformanceError(`${entries.map((entry) => entry.spec.name).join(", ")} read the workspace layout, which was not supplied`);
|
|
1791
|
+
return runImportChecks({
|
|
1792
|
+
cwd,
|
|
1793
|
+
layout,
|
|
1794
|
+
entries
|
|
1795
|
+
});
|
|
1796
|
+
}
|
|
1797
|
+
/**
|
|
1798
|
+
* 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.
|
|
1799
|
+
*/
|
|
1800
|
+
function readImported(imported, name) {
|
|
1801
|
+
const found = imported.get(name);
|
|
1802
|
+
if (found === void 0) throw new ConformanceError(`the import check '${name}' produced no result`);
|
|
1803
|
+
return found;
|
|
1804
|
+
}
|
|
1805
|
+
/**
|
|
1462
1806
|
* Run the enabled checks, or the enabled ones among `checks`, and return what they found. Sections not supplied in the options are loaded from `cwd` with `@exadev/config`.
|
|
1463
1807
|
*
|
|
1464
1808
|
* 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.
|
|
@@ -1467,16 +1811,21 @@ async function runChecks(options) {
|
|
|
1467
1811
|
const config = await conformanceOf(options);
|
|
1468
1812
|
const names = selected(config, options.checks);
|
|
1469
1813
|
const layout = await layoutOf(options, names.filter((name) => registry[name].requiresLayout));
|
|
1814
|
+
const imported = await runImportGraphChecks(options.cwd, layout, names, config.checks);
|
|
1470
1815
|
const results = [];
|
|
1471
|
-
for (const name of names)
|
|
1472
|
-
check
|
|
1473
|
-
violations
|
|
1816
|
+
for (const name of names) {
|
|
1817
|
+
const check = registry[name];
|
|
1818
|
+
const violations = "run" in check ? await check.run({
|
|
1474
1819
|
cwd: options.cwd,
|
|
1475
1820
|
checks: config.checks,
|
|
1476
1821
|
layout,
|
|
1477
1822
|
configFiles: options.configFiles
|
|
1478
|
-
})
|
|
1479
|
-
|
|
1823
|
+
}) : readImported(imported, name);
|
|
1824
|
+
results.push({
|
|
1825
|
+
check: name,
|
|
1826
|
+
violations
|
|
1827
|
+
});
|
|
1828
|
+
}
|
|
1480
1829
|
const violations = results.flatMap((result) => result.violations);
|
|
1481
1830
|
return {
|
|
1482
1831
|
results,
|
|
@@ -1592,7 +1941,9 @@ exports.conformanceSchema = conformanceSchema;
|
|
|
1592
1941
|
exports.conformanceSection = conformanceSection;
|
|
1593
1942
|
exports.crossSliceRules = crossSliceRules;
|
|
1594
1943
|
exports.cycleRule = cycleRule;
|
|
1944
|
+
exports.deployTools = deployTools;
|
|
1595
1945
|
exports.dockerfilePackageManager = dockerfilePackageManager;
|
|
1946
|
+
exports.generators = generators;
|
|
1596
1947
|
exports.handlerCases = handlerCases;
|
|
1597
1948
|
exports.importCrossSlice = importCrossSlice;
|
|
1598
1949
|
exports.importCycles = importCycles;
|
|
@@ -1602,6 +1953,7 @@ exports.importUphill = importUphill;
|
|
|
1602
1953
|
exports.instructionSymlinks = instructionSymlinks;
|
|
1603
1954
|
exports.isCheckName = isCheckName;
|
|
1604
1955
|
exports.isolatedGroupRules = isolatedGroupRules;
|
|
1956
|
+
exports.migrationsDirectory = migrationsDirectory;
|
|
1605
1957
|
exports.packagesPattern = packagesPattern;
|
|
1606
1958
|
exports.proveExhaustive = proveExhaustive;
|
|
1607
1959
|
exports.rankSkipRules = rankSkipRules;
|