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/cli.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { dirname, join, posix, relative, resolve, sep } from "node:path";
|
|
2
|
+
import { dirname, extname, join, posix, relative, resolve, sep } from "node:path";
|
|
3
3
|
import { parseArgs } from "node:util";
|
|
4
4
|
import { existsSync } from "node:fs";
|
|
5
5
|
import { escapePath, glob, isDynamicPattern } from "tinyglobby";
|
|
@@ -8,6 +8,8 @@ import { createJitiLoader, validateStandard } from "cosmiconfig-extends";
|
|
|
8
8
|
import { readFile, realpath } from "node:fs/promises";
|
|
9
9
|
import { parse } from "yaml";
|
|
10
10
|
import { execFile } from "node:child_process";
|
|
11
|
+
import { findNodeAtLocation, parse as parse$1, parseTree, printParseErrorCode } from "jsonc-parser";
|
|
12
|
+
import { parse as parse$2 } from "smol-toml";
|
|
11
13
|
import { defineSection, layoutSection, loadSection } from "@exadev/config";
|
|
12
14
|
import { z } from "zod";
|
|
13
15
|
//#region src/files.ts
|
|
@@ -659,11 +661,19 @@ function isolatedGroupRules(packages, isolatedGroups) {
|
|
|
659
661
|
}));
|
|
660
662
|
}
|
|
661
663
|
/**
|
|
664
|
+
* The name of {@link cycleRule}, which a check reads its findings by.
|
|
665
|
+
*/
|
|
666
|
+
const CYCLE_RULE_NAME = "import-cycle";
|
|
667
|
+
/**
|
|
668
|
+
* The name of {@link unresolvedNameImportRule}, which a check reads its findings by.
|
|
669
|
+
*/
|
|
670
|
+
const UNRESOLVED_NAME_IMPORT_RULE_NAME = "unresolved-name-import";
|
|
671
|
+
/**
|
|
662
672
|
* The rule that forbids any import cycle among the packages' files.
|
|
663
673
|
*/
|
|
664
674
|
function cycleRule() {
|
|
665
675
|
return {
|
|
666
|
-
name:
|
|
676
|
+
name: CYCLE_RULE_NAME,
|
|
667
677
|
severity: "error",
|
|
668
678
|
comment: "files may not import each other in a cycle",
|
|
669
679
|
from: {},
|
|
@@ -678,7 +688,7 @@ function cycleRule() {
|
|
|
678
688
|
function unresolvedNameImportRule(packages) {
|
|
679
689
|
const names = packageNamesPattern(packages);
|
|
680
690
|
return names === void 0 ? void 0 : {
|
|
681
|
-
name:
|
|
691
|
+
name: UNRESOLVED_NAME_IMPORT_RULE_NAME,
|
|
682
692
|
severity: "error",
|
|
683
693
|
comment: "marks an import of a workspace package by a name that resolves to no file",
|
|
684
694
|
from: { path: packagesPattern(packages) },
|
|
@@ -867,15 +877,22 @@ function assertTypeScriptAvailable(transpilers) {
|
|
|
867
877
|
if (typescript?.available !== true) throw new ConformanceError(`dependency-cruiser cannot load TypeScript, so the import checks would find no TypeScript file and report nothing; install ${typescript === void 0 ? "a version it supports" : `a version in ${typescript.version}`} as the 'typescript' of this workspace`);
|
|
868
878
|
}
|
|
869
879
|
/**
|
|
870
|
-
* The
|
|
871
|
-
|
|
872
|
-
|
|
880
|
+
* 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.
|
|
881
|
+
*/
|
|
882
|
+
function cruiseKey(cwd, options) {
|
|
883
|
+
return JSON.stringify({
|
|
884
|
+
exclude: options.exclude ?? DEFAULT_GRAPH_EXCLUDES,
|
|
885
|
+
doNotFollow: options.doNotFollow ?? DEFAULT_DO_NOT_FOLLOW,
|
|
886
|
+
tsConfig: options.tsConfig === void 0 ? null : resolve(cwd, options.tsConfig)
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
/**
|
|
890
|
+
* One cruise of the packages' files with `rules`, whose findings are read from the cruise summary: `cruise()` reports success in its exit code whatever the summary holds, so the code is not consulted.
|
|
873
891
|
*
|
|
874
|
-
* The
|
|
892
|
+
* The graph is limited to the packages' own files, so nothing outside them is a dependant or a dependency. The base directory is the real path of the root: dependency-cruiser resolves symbolic links, so the path of a file behind one would otherwise not be relative to it.
|
|
875
893
|
*/
|
|
876
894
|
async function cruiseViolations(input) {
|
|
877
895
|
const { cwd, root, packages, rules, options } = input;
|
|
878
|
-
if (rules.length === 0) return [];
|
|
879
896
|
const tsConfigFile = options.tsConfig === void 0 ? void 0 : resolve(cwd, options.tsConfig);
|
|
880
897
|
const { cruise, getAvailableTranspilers } = await import("dependency-cruiser");
|
|
881
898
|
assertTypeScriptAvailable(getAvailableTranspilers());
|
|
@@ -894,19 +911,64 @@ async function cruiseViolations(input) {
|
|
|
894
911
|
return result.output.summary.violations;
|
|
895
912
|
}
|
|
896
913
|
/**
|
|
897
|
-
* Run one
|
|
914
|
+
* 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.
|
|
915
|
+
*
|
|
916
|
+
* The result has an entry for every check, in the order of `entries`. A check whose layout yields no rules reports nothing and adds nothing to a cruise; a group none of whose checks has rules is not cruised.
|
|
898
917
|
*/
|
|
899
|
-
async function
|
|
900
|
-
const
|
|
901
|
-
const
|
|
902
|
-
const
|
|
903
|
-
|
|
904
|
-
cwd
|
|
918
|
+
async function runImportChecks(input) {
|
|
919
|
+
const { cwd, layout, entries } = input;
|
|
920
|
+
const root = workspaceRoot(cwd, layout);
|
|
921
|
+
const packages = await readWorkspacePackages(cwd, layout);
|
|
922
|
+
const scope = {
|
|
923
|
+
cwd,
|
|
905
924
|
root,
|
|
906
|
-
packages
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
925
|
+
packages
|
|
926
|
+
};
|
|
927
|
+
const contributions = entries.map((entry) => ({
|
|
928
|
+
...entry,
|
|
929
|
+
rules: entry.spec.rules(packages, layout)
|
|
930
|
+
}));
|
|
931
|
+
const groups = /* @__PURE__ */ new Map();
|
|
932
|
+
for (const contribution of contributions) {
|
|
933
|
+
const key = cruiseKey(cwd, contribution.options);
|
|
934
|
+
const group = groups.get(key);
|
|
935
|
+
if (group === void 0) groups.set(key, {
|
|
936
|
+
options: contribution.options,
|
|
937
|
+
members: [contribution]
|
|
938
|
+
});
|
|
939
|
+
else group.members.push(contribution);
|
|
940
|
+
}
|
|
941
|
+
const found = /* @__PURE__ */ new Map();
|
|
942
|
+
for (const { options, members } of groups.values()) {
|
|
943
|
+
const rules = members.flatMap((member) => member.rules);
|
|
944
|
+
if (rules.length === 0) continue;
|
|
945
|
+
const owner = /* @__PURE__ */ new Map();
|
|
946
|
+
for (const member of members) for (const rule of member.rules) {
|
|
947
|
+
const existing = owner.get(rule.name);
|
|
948
|
+
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`);
|
|
949
|
+
owner.set(rule.name, member.spec.name);
|
|
950
|
+
}
|
|
951
|
+
for (const finding of await cruiseViolations({
|
|
952
|
+
cwd,
|
|
953
|
+
root,
|
|
954
|
+
packages,
|
|
955
|
+
rules,
|
|
956
|
+
options
|
|
957
|
+
})) {
|
|
958
|
+
const name = owner.get(finding.rule.name);
|
|
959
|
+
if (name === void 0) throw new ConformanceError(`dependency-cruiser reported the rule '${finding.rule.name}', which no check generated`);
|
|
960
|
+
const own = found.get(name);
|
|
961
|
+
if (own === void 0) found.set(name, [finding]);
|
|
962
|
+
else own.push(finding);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
return new Map(contributions.map(({ spec }) => [spec.name, spec.report(found.get(spec.name) ?? [], scope)]));
|
|
966
|
+
}
|
|
967
|
+
/**
|
|
968
|
+
* 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.
|
|
969
|
+
*/
|
|
970
|
+
function edgeReport(spec) {
|
|
971
|
+
return (findings, { cwd, root, packages }) => findings.map((finding) => {
|
|
910
972
|
const from = packageOfPath(packages, finding.from);
|
|
911
973
|
const to = packageOfPath(packages, finding.to);
|
|
912
974
|
return {
|
|
@@ -917,7 +979,7 @@ async function runImportCheck(context, spec) {
|
|
|
917
979
|
fromFile: finding.from,
|
|
918
980
|
toFile: finding.to
|
|
919
981
|
}),
|
|
920
|
-
file: relativePosix(
|
|
982
|
+
file: relativePosix(cwd, join(root, finding.from))
|
|
921
983
|
};
|
|
922
984
|
}).sort((a, b) => a.file.localeCompare(b.file) || a.message.localeCompare(b.message));
|
|
923
985
|
}
|
|
@@ -937,17 +999,20 @@ function describeTarget(edge) {
|
|
|
937
999
|
//#endregion
|
|
938
1000
|
//#region src/checks/import-cross-slice.ts
|
|
939
1001
|
/**
|
|
940
|
-
*
|
|
1002
|
+
* How `import-cross-slice` joins a shared cruise.
|
|
941
1003
|
*/
|
|
942
|
-
const
|
|
1004
|
+
const importCrossSliceSpec = {
|
|
943
1005
|
name: "import-cross-slice",
|
|
944
|
-
reason: "other-slice",
|
|
945
1006
|
rules: (packages) => {
|
|
946
1007
|
if (!packages.some((member) => member.slice !== void 0)) throw new ConformanceError("the 'import-cross-slice' check is enabled but no package in the layout has a slice");
|
|
947
1008
|
return crossSliceRules(packages);
|
|
948
1009
|
},
|
|
949
|
-
|
|
950
|
-
|
|
1010
|
+
report: edgeReport({
|
|
1011
|
+
name: "import-cross-slice",
|
|
1012
|
+
reason: "other-slice",
|
|
1013
|
+
message: (edge) => `${describePackage(edge.from)} (slice '${String(edge.from.slice)}') imports ${describeTarget(edge)} (slice '${String(edge.to.slice)}')`
|
|
1014
|
+
})
|
|
1015
|
+
};
|
|
951
1016
|
//#endregion
|
|
952
1017
|
//#region src/checks/import-cycles.ts
|
|
953
1018
|
/**
|
|
@@ -1016,84 +1081,88 @@ function nameCycles(imports) {
|
|
|
1016
1081
|
return cycles;
|
|
1017
1082
|
}
|
|
1018
1083
|
/**
|
|
1019
|
-
*
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
code: "import-cycles/cycle",
|
|
1053
|
-
message: `import cycle between packages that import each other by a name that resolves to no file: ${[...ring.map((edge) => describePackage(edge.from)), describePackage(first.from)].join(" -> ")}`,
|
|
1054
|
-
file: relativePosix(context.cwd, join(root, first.file))
|
|
1055
|
-
};
|
|
1056
|
-
})].sort((a, b) => a.file.localeCompare(b.file) || a.message.localeCompare(b.message));
|
|
1084
|
+
* How `import-cycles` joins a shared cruise.
|
|
1085
|
+
*/
|
|
1086
|
+
const importCyclesSpec = {
|
|
1087
|
+
name: "import-cycles",
|
|
1088
|
+
rules: (packages) => {
|
|
1089
|
+
const nameRule = unresolvedNameImportRule(packages);
|
|
1090
|
+
return nameRule === void 0 ? [cycleRule()] : [cycleRule(), nameRule];
|
|
1091
|
+
},
|
|
1092
|
+
report: (found, { cwd, root, packages }) => {
|
|
1093
|
+
const inFiles = fileCycles(found.filter((finding) => finding.rule.name === CYCLE_RULE_NAME));
|
|
1094
|
+
const inPackages = nameCycles(found.filter((finding) => finding.rule.name === UNRESOLVED_NAME_IMPORT_RULE_NAME).map((finding) => ({
|
|
1095
|
+
from: packageOfPath(packages, finding.from),
|
|
1096
|
+
to: packageOfPath(packages, finding.to),
|
|
1097
|
+
file: finding.from
|
|
1098
|
+
})));
|
|
1099
|
+
return [...[...inFiles.values()].map((ordered) => {
|
|
1100
|
+
const [first] = ordered;
|
|
1101
|
+
if (first === void 0) throw new ConformanceError("an import cycle with no files was reported");
|
|
1102
|
+
return {
|
|
1103
|
+
code: "import-cycles/cycle",
|
|
1104
|
+
message: `import cycle: ${[...ordered, first].join(" -> ")}`,
|
|
1105
|
+
file: relativePosix(cwd, join(root, first))
|
|
1106
|
+
};
|
|
1107
|
+
}), ...[...inPackages.values()].map((ring) => {
|
|
1108
|
+
const [first] = ring;
|
|
1109
|
+
if (first === void 0) throw new ConformanceError("an import cycle with no packages was reported");
|
|
1110
|
+
return {
|
|
1111
|
+
code: "import-cycles/cycle",
|
|
1112
|
+
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(" -> ")}`,
|
|
1113
|
+
file: relativePosix(cwd, join(root, first.file))
|
|
1114
|
+
};
|
|
1115
|
+
})].sort((a, b) => a.file.localeCompare(b.file) || a.message.localeCompare(b.message));
|
|
1116
|
+
}
|
|
1057
1117
|
};
|
|
1058
1118
|
//#endregion
|
|
1059
1119
|
//#region src/checks/import-isolated-groups.ts
|
|
1060
1120
|
/**
|
|
1061
|
-
*
|
|
1121
|
+
* How `import-isolated-groups` joins a shared cruise.
|
|
1062
1122
|
*/
|
|
1063
|
-
const
|
|
1123
|
+
const importIsolatedGroupsSpec = {
|
|
1064
1124
|
name: "import-isolated-groups",
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
return isolatedGroupRules(packages, context.layout.isolatedGroups);
|
|
1125
|
+
rules: (packages, layout) => {
|
|
1126
|
+
if (layout.isolatedGroups === void 0) throw new ConformanceError("the 'import-isolated-groups' check is enabled but the layout has no 'isolatedGroups'");
|
|
1127
|
+
return isolatedGroupRules(packages, layout.isolatedGroups);
|
|
1069
1128
|
},
|
|
1070
|
-
|
|
1071
|
-
|
|
1129
|
+
report: edgeReport({
|
|
1130
|
+
name: "import-isolated-groups",
|
|
1131
|
+
reason: "isolated-group",
|
|
1132
|
+
message: (edge) => `${describePackage(edge.from)} (group '${edge.from.group}') imports ${describeTarget(edge)} (group '${edge.to.group}'), which the layout isolates from it`
|
|
1133
|
+
})
|
|
1134
|
+
};
|
|
1072
1135
|
//#endregion
|
|
1073
1136
|
//#region src/checks/import-rank-skip.ts
|
|
1074
1137
|
/**
|
|
1075
|
-
*
|
|
1138
|
+
* How `import-rank-skip` joins a shared cruise.
|
|
1076
1139
|
*/
|
|
1077
|
-
const
|
|
1140
|
+
const importRankSkipSpec = {
|
|
1078
1141
|
name: "import-rank-skip",
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
return rankSkipRules(packages, context.layout.rankSkip);
|
|
1142
|
+
rules: (packages, layout) => {
|
|
1143
|
+
if (layout.rankSkip === void 0) throw new ConformanceError("the 'import-rank-skip' check is enabled but the layout has no 'rankSkip'");
|
|
1144
|
+
return rankSkipRules(packages, layout.rankSkip);
|
|
1083
1145
|
},
|
|
1084
|
-
|
|
1085
|
-
|
|
1146
|
+
report: edgeReport({
|
|
1147
|
+
name: "import-rank-skip",
|
|
1148
|
+
reason: "skipped-rank",
|
|
1149
|
+
message: (edge) => `${describePackage(edge.from)} (rank ${String(edge.from.rank)}) imports ${describeTarget(edge)} (rank ${String(edge.to.rank)}), further below than the layout allows`
|
|
1150
|
+
})
|
|
1151
|
+
};
|
|
1086
1152
|
//#endregion
|
|
1087
1153
|
//#region src/checks/import-uphill.ts
|
|
1088
1154
|
/**
|
|
1089
|
-
*
|
|
1155
|
+
* How `import-uphill` joins a shared cruise.
|
|
1090
1156
|
*/
|
|
1091
|
-
const
|
|
1157
|
+
const importUphillSpec = {
|
|
1092
1158
|
name: "import-uphill",
|
|
1093
|
-
reason: "higher-rank",
|
|
1094
1159
|
rules: (packages) => uphillRules(packages),
|
|
1095
|
-
|
|
1096
|
-
|
|
1160
|
+
report: edgeReport({
|
|
1161
|
+
name: "import-uphill",
|
|
1162
|
+
reason: "higher-rank",
|
|
1163
|
+
message: (edge) => `${describePackage(edge.from)} (rank ${String(edge.from.rank)}) imports ${describeTarget(edge)} (rank ${String(edge.to.rank)}), a higher rank`
|
|
1164
|
+
})
|
|
1165
|
+
};
|
|
1097
1166
|
async function git(cwd, args) {
|
|
1098
1167
|
return new Promise((resolve, reject) => {
|
|
1099
1168
|
execFile("git", [...args], {
|
|
@@ -1205,6 +1274,208 @@ const instructionSymlinks = async ({ cwd, options }) => {
|
|
|
1205
1274
|
return violations;
|
|
1206
1275
|
};
|
|
1207
1276
|
//#endregion
|
|
1277
|
+
//#region src/migrations/adapters.ts
|
|
1278
|
+
/**
|
|
1279
|
+
* The generators that have an adapter.
|
|
1280
|
+
*/
|
|
1281
|
+
const generatorNames = ["drizzle-kit"];
|
|
1282
|
+
/**
|
|
1283
|
+
* The deploy tools that have an adapter.
|
|
1284
|
+
*/
|
|
1285
|
+
const deployToolNames = ["wrangler"];
|
|
1286
|
+
function recordOf(config, file) {
|
|
1287
|
+
if (!isRecord(config)) throw new ConformanceError(`${file}: the config must be an object for the migrations directory to be read`);
|
|
1288
|
+
return config;
|
|
1289
|
+
}
|
|
1290
|
+
const DRIZZLE_DEFAULT_OUT = "drizzle";
|
|
1291
|
+
/**
|
|
1292
|
+
* 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.
|
|
1293
|
+
*/
|
|
1294
|
+
const drizzleKit = {
|
|
1295
|
+
configFiles: [
|
|
1296
|
+
"drizzle.config.ts",
|
|
1297
|
+
"drizzle.config.js",
|
|
1298
|
+
"drizzle.config.json"
|
|
1299
|
+
],
|
|
1300
|
+
applyCommand: /(?:^|[\s;&|(])drizzle-kit\s+migrate(?![\w:-])/u,
|
|
1301
|
+
applyCommandName: "drizzle-kit migrate",
|
|
1302
|
+
outputDirectory: (config, file) => {
|
|
1303
|
+
const out = recordOf(config, file)["out"];
|
|
1304
|
+
if (out === void 0) return DRIZZLE_DEFAULT_OUT;
|
|
1305
|
+
if (typeof out !== "string") throw new ConformanceError(`${file}: 'out' must be a string`);
|
|
1306
|
+
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`);
|
|
1307
|
+
return out;
|
|
1308
|
+
}
|
|
1309
|
+
};
|
|
1310
|
+
const WRANGLER_DEFAULT_MIGRATIONS_DIR = "migrations";
|
|
1311
|
+
function databasesOf(scope, file) {
|
|
1312
|
+
const databases = scope["d1_databases"];
|
|
1313
|
+
if (databases === void 0) return [];
|
|
1314
|
+
if (!Array.isArray(databases) || !databases.every(isRecord)) throw new ConformanceError(`${file}: 'd1_databases' must be an array of objects`);
|
|
1315
|
+
return databases;
|
|
1316
|
+
}
|
|
1317
|
+
/**
|
|
1318
|
+
* 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.
|
|
1319
|
+
*/
|
|
1320
|
+
const wrangler = {
|
|
1321
|
+
configFiles: [
|
|
1322
|
+
"wrangler.json",
|
|
1323
|
+
"wrangler.jsonc",
|
|
1324
|
+
"wrangler.toml"
|
|
1325
|
+
],
|
|
1326
|
+
migrationsDirectory: (config, file, selector) => {
|
|
1327
|
+
const top = recordOf(config, file);
|
|
1328
|
+
let scope = top;
|
|
1329
|
+
if (selector.environment !== void 0) {
|
|
1330
|
+
const environments = top["env"];
|
|
1331
|
+
const environment = isRecord(environments) ? environments[selector.environment] : void 0;
|
|
1332
|
+
if (!isRecord(environment)) throw new ConformanceError(`${file} has no environment '${selector.environment}' (checks.migrations-directory.environment)`);
|
|
1333
|
+
scope = environment;
|
|
1334
|
+
}
|
|
1335
|
+
const where = selector.environment === void 0 ? file : `${file} (environment '${selector.environment}')`;
|
|
1336
|
+
const databases = databasesOf(scope, file);
|
|
1337
|
+
const chosen = selector.database === void 0 ? databases : databases.filter((database) => database["binding"] === selector.database);
|
|
1338
|
+
if (chosen.length > 1) throw new ConformanceError(`${where} declares several D1 databases; name the one the generator writes for with checks.migrations-directory.database`);
|
|
1339
|
+
const [only] = chosen;
|
|
1340
|
+
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}'` };
|
|
1341
|
+
const directory = only["migrations_dir"];
|
|
1342
|
+
if (directory === void 0) return { directory: WRANGLER_DEFAULT_MIGRATIONS_DIR };
|
|
1343
|
+
if (typeof directory !== "string") throw new ConformanceError(`${where}: 'migrations_dir' must be a string`);
|
|
1344
|
+
return { directory };
|
|
1345
|
+
}
|
|
1346
|
+
};
|
|
1347
|
+
const generators = { "drizzle-kit": drizzleKit };
|
|
1348
|
+
const deployTools = { wrangler };
|
|
1349
|
+
//#endregion
|
|
1350
|
+
//#region src/migrations/config-data.ts
|
|
1351
|
+
/**
|
|
1352
|
+
* The first of `candidates` (relative to `cwd`) that exists, or `undefined` when none does.
|
|
1353
|
+
*/
|
|
1354
|
+
function firstExisting(cwd, candidates) {
|
|
1355
|
+
return candidates.find((candidate) => existsSync(resolve(cwd, candidate)));
|
|
1356
|
+
}
|
|
1357
|
+
function assertValid(errors, file) {
|
|
1358
|
+
const [first] = errors;
|
|
1359
|
+
if (first !== void 0) throw new ConformanceError(`${file}: not valid JSON (${printParseErrorCode(first.error)} at offset ${String(first.offset)})`);
|
|
1360
|
+
}
|
|
1361
|
+
/**
|
|
1362
|
+
* 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.
|
|
1363
|
+
*/
|
|
1364
|
+
function parseJsonc(text, file) {
|
|
1365
|
+
const errors = [];
|
|
1366
|
+
const data = parse$1(text, errors, { allowTrailingComma: true });
|
|
1367
|
+
assertValid(errors, file);
|
|
1368
|
+
return data;
|
|
1369
|
+
}
|
|
1370
|
+
/**
|
|
1371
|
+
* 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.
|
|
1372
|
+
*/
|
|
1373
|
+
function parseJsoncTree(text, file) {
|
|
1374
|
+
const errors = [];
|
|
1375
|
+
const tree = parseTree(text, errors, { allowTrailingComma: true });
|
|
1376
|
+
assertValid(errors, file);
|
|
1377
|
+
if (tree === void 0) throw new ConformanceError(`${file}: holds no JSON value`);
|
|
1378
|
+
return tree;
|
|
1379
|
+
}
|
|
1380
|
+
/**
|
|
1381
|
+
* 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.
|
|
1382
|
+
*/
|
|
1383
|
+
async function readConfigData(cwd, file, configFiles) {
|
|
1384
|
+
const extension = extname(file).toLowerCase();
|
|
1385
|
+
if (extension === ".json" || extension === ".jsonc") return parseJsonc(await readFile(resolve(cwd, file), "utf8"), file);
|
|
1386
|
+
if (extension === ".toml") try {
|
|
1387
|
+
return parse$2(await readFile(resolve(cwd, file), "utf8"));
|
|
1388
|
+
} catch (error) {
|
|
1389
|
+
throw new ConformanceError(`${file}: not valid TOML (${error instanceof Error ? error.message : String(error)})`);
|
|
1390
|
+
}
|
|
1391
|
+
return evaluateConfigFile(cwd, file, configFiles);
|
|
1392
|
+
}
|
|
1393
|
+
//#endregion
|
|
1394
|
+
//#region src/checks/migrations-directory.ts
|
|
1395
|
+
const DEFAULT_PACKAGE_JSONS = ["**/package.json"];
|
|
1396
|
+
/**
|
|
1397
|
+
* The 1-based line and column of the character at `offset` in `text`, which is not the first character.
|
|
1398
|
+
*/
|
|
1399
|
+
function positionOf(text, offset) {
|
|
1400
|
+
const lineStart = text.lastIndexOf("\n", offset - 1) + 1;
|
|
1401
|
+
return {
|
|
1402
|
+
line: text.slice(0, lineStart).split("\n").length,
|
|
1403
|
+
column: offset - lineStart + 1
|
|
1404
|
+
};
|
|
1405
|
+
}
|
|
1406
|
+
/**
|
|
1407
|
+
* 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.
|
|
1408
|
+
*/
|
|
1409
|
+
function locate(cwd, explicit, candidates) {
|
|
1410
|
+
return explicit === void 0 ? firstExisting(cwd, candidates) : firstExisting(cwd, [explicit]);
|
|
1411
|
+
}
|
|
1412
|
+
function missingConfig(explicit, candidates) {
|
|
1413
|
+
return explicit === void 0 ? {
|
|
1414
|
+
code: "migrations-directory/missing-config",
|
|
1415
|
+
message: `none of ${candidates.join(", ")} exists`,
|
|
1416
|
+
file: candidates[0]
|
|
1417
|
+
} : {
|
|
1418
|
+
code: "migrations-directory/missing-config",
|
|
1419
|
+
message: `${explicit} does not exist`,
|
|
1420
|
+
file: explicit
|
|
1421
|
+
};
|
|
1422
|
+
}
|
|
1423
|
+
/**
|
|
1424
|
+
* Violations for every script of the `package.json` at `file` that matches `command`.
|
|
1425
|
+
*/
|
|
1426
|
+
async function scriptViolations(cwd, file, command, describe) {
|
|
1427
|
+
const text = await readFile(resolve(cwd, file), "utf8");
|
|
1428
|
+
const scripts = findNodeAtLocation(parseJsoncTree(text, file), ["scripts"]);
|
|
1429
|
+
const violations = [];
|
|
1430
|
+
for (const entry of scripts?.children ?? []) {
|
|
1431
|
+
const [key, value] = entry.children ?? [];
|
|
1432
|
+
if (key?.type === "string" && value?.type === "string" && typeof value.value === "string" && command.test(value.value)) violations.push({
|
|
1433
|
+
code: "migrations-directory/generator-apply-command",
|
|
1434
|
+
message: describe(value.value, String(key.value)),
|
|
1435
|
+
file,
|
|
1436
|
+
location: positionOf(text, value.offset)
|
|
1437
|
+
});
|
|
1438
|
+
}
|
|
1439
|
+
return violations;
|
|
1440
|
+
}
|
|
1441
|
+
/**
|
|
1442
|
+
* 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.
|
|
1443
|
+
*
|
|
1444
|
+
* 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.
|
|
1445
|
+
*/
|
|
1446
|
+
const migrationsDirectory = async ({ cwd, options, configFiles }) => {
|
|
1447
|
+
const generator = generators[options.generator];
|
|
1448
|
+
const deployTool = deployTools[options.deployTool];
|
|
1449
|
+
const violations = [];
|
|
1450
|
+
const generatorFile = locate(cwd, options.generatorConfig, generator.configFiles);
|
|
1451
|
+
const deployFile = locate(cwd, options.deployConfig, deployTool.configFiles);
|
|
1452
|
+
if (generatorFile === void 0) violations.push(missingConfig(options.generatorConfig, generator.configFiles));
|
|
1453
|
+
if (deployFile === void 0) violations.push(missingConfig(options.deployConfig, deployTool.configFiles));
|
|
1454
|
+
if (generatorFile !== void 0 && deployFile !== void 0) {
|
|
1455
|
+
const written = generator.outputDirectory(await readConfigData(cwd, generatorFile, configFiles), generatorFile);
|
|
1456
|
+
const applied = deployTool.migrationsDirectory(await readConfigData(cwd, deployFile, configFiles), deployFile, {
|
|
1457
|
+
...options.database === void 0 ? {} : { database: options.database },
|
|
1458
|
+
...options.environment === void 0 ? {} : { environment: options.environment }
|
|
1459
|
+
});
|
|
1460
|
+
if (applied.none !== void 0) violations.push({
|
|
1461
|
+
code: "migrations-directory/no-database",
|
|
1462
|
+
message: applied.none,
|
|
1463
|
+
file: deployFile
|
|
1464
|
+
});
|
|
1465
|
+
else {
|
|
1466
|
+
const generated = resolve(cwd, dirname(generatorFile), written);
|
|
1467
|
+
const deployed = resolve(cwd, dirname(deployFile), applied.directory);
|
|
1468
|
+
if (generated !== deployed) violations.push({
|
|
1469
|
+
code: "migrations-directory/mismatch",
|
|
1470
|
+
message: `${generatorFile} writes migrations to ${relativePosix(cwd, generated)}, but ${deployFile} applies them from ${relativePosix(cwd, deployed)}`,
|
|
1471
|
+
file: generatorFile
|
|
1472
|
+
});
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
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}`));
|
|
1476
|
+
return violations;
|
|
1477
|
+
};
|
|
1478
|
+
//#endregion
|
|
1208
1479
|
//#region src/checks/single-storybook.ts
|
|
1209
1480
|
const STORYBOOK_DIRECTORY = ".storybook";
|
|
1210
1481
|
/**
|
|
@@ -1244,21 +1515,15 @@ function plain(spec) {
|
|
|
1244
1515
|
}
|
|
1245
1516
|
};
|
|
1246
1517
|
}
|
|
1247
|
-
function
|
|
1518
|
+
function importCheck(spec) {
|
|
1248
1519
|
return {
|
|
1249
1520
|
name: spec.name,
|
|
1250
1521
|
description: spec.description,
|
|
1251
1522
|
requiresLayout: true,
|
|
1252
1523
|
isEnabled: (checks) => spec.select(checks) !== void 0,
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
if (layout === void 0) throw new ConformanceError(`the check '${spec.name}' reads the workspace layout, which was not supplied`);
|
|
1257
|
-
return spec.check({
|
|
1258
|
-
cwd,
|
|
1259
|
-
options,
|
|
1260
|
-
layout
|
|
1261
|
-
});
|
|
1524
|
+
importGraph: {
|
|
1525
|
+
spec: spec.importSpec,
|
|
1526
|
+
optionsOf: spec.select
|
|
1262
1527
|
}
|
|
1263
1528
|
};
|
|
1264
1529
|
}
|
|
@@ -1278,35 +1543,35 @@ const registry = {
|
|
|
1278
1543
|
select: (checks) => enabledOptions(checks["command-types"]),
|
|
1279
1544
|
check: commandTypes
|
|
1280
1545
|
}),
|
|
1281
|
-
"import-uphill":
|
|
1546
|
+
"import-uphill": importCheck({
|
|
1282
1547
|
name: "import-uphill",
|
|
1283
1548
|
description: "No package imports a package of a higher rank",
|
|
1284
1549
|
select: (checks) => enabledOptions(checks["import-uphill"]),
|
|
1285
|
-
|
|
1550
|
+
importSpec: importUphillSpec
|
|
1286
1551
|
}),
|
|
1287
|
-
"import-rank-skip":
|
|
1552
|
+
"import-rank-skip": importCheck({
|
|
1288
1553
|
name: "import-rank-skip",
|
|
1289
1554
|
description: "No package imports further below itself than the layout allows",
|
|
1290
1555
|
select: (checks) => enabledOptions(checks["import-rank-skip"]),
|
|
1291
|
-
|
|
1556
|
+
importSpec: importRankSkipSpec
|
|
1292
1557
|
}),
|
|
1293
|
-
"import-cross-slice":
|
|
1558
|
+
"import-cross-slice": importCheck({
|
|
1294
1559
|
name: "import-cross-slice",
|
|
1295
1560
|
description: "No package imports a package in a different slice",
|
|
1296
1561
|
select: (checks) => enabledOptions(checks["import-cross-slice"]),
|
|
1297
|
-
|
|
1562
|
+
importSpec: importCrossSliceSpec
|
|
1298
1563
|
}),
|
|
1299
|
-
"import-isolated-groups":
|
|
1564
|
+
"import-isolated-groups": importCheck({
|
|
1300
1565
|
name: "import-isolated-groups",
|
|
1301
1566
|
description: "No package imports a package in a group the layout isolates from its own",
|
|
1302
1567
|
select: (checks) => enabledOptions(checks["import-isolated-groups"]),
|
|
1303
|
-
|
|
1568
|
+
importSpec: importIsolatedGroupsSpec
|
|
1304
1569
|
}),
|
|
1305
|
-
"import-cycles":
|
|
1570
|
+
"import-cycles": importCheck({
|
|
1306
1571
|
name: "import-cycles",
|
|
1307
1572
|
description: "No files of the workspace packages import each other in a cycle",
|
|
1308
1573
|
select: (checks) => enabledOptions(checks["import-cycles"]),
|
|
1309
|
-
|
|
1574
|
+
importSpec: importCyclesSpec
|
|
1310
1575
|
}),
|
|
1311
1576
|
"instruction-symlinks": plain({
|
|
1312
1577
|
name: "instruction-symlinks",
|
|
@@ -1331,6 +1596,12 @@ const registry = {
|
|
|
1331
1596
|
description: "A package manager version pinned in a Dockerfile is the version packageManager names",
|
|
1332
1597
|
select: (checks) => enabledOptions(checks["dockerfile-package-manager"]),
|
|
1333
1598
|
check: dockerfilePackageManager
|
|
1599
|
+
}),
|
|
1600
|
+
"migrations-directory": plain({
|
|
1601
|
+
name: "migrations-directory",
|
|
1602
|
+
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",
|
|
1603
|
+
select: (checks) => enabledOptions(checks["migrations-directory"]),
|
|
1604
|
+
check: migrationsDirectory
|
|
1334
1605
|
})
|
|
1335
1606
|
};
|
|
1336
1607
|
/**
|
|
@@ -1410,6 +1681,15 @@ const checks = z.strictObject({
|
|
|
1410
1681
|
dockerfiles: z.exactOptional(paths),
|
|
1411
1682
|
exclude: z.exactOptional(paths),
|
|
1412
1683
|
packageJson: z.exactOptional(path)
|
|
1684
|
+
}))),
|
|
1685
|
+
"migrations-directory": z.exactOptional(enabled(z.strictObject({
|
|
1686
|
+
generator: z.enum(generatorNames),
|
|
1687
|
+
deployTool: z.enum(deployToolNames),
|
|
1688
|
+
generatorConfig: z.exactOptional(path),
|
|
1689
|
+
deployConfig: z.exactOptional(path),
|
|
1690
|
+
database: z.exactOptional(z.string().min(1)),
|
|
1691
|
+
environment: z.exactOptional(z.string().min(1)),
|
|
1692
|
+
packageJsons: z.exactOptional(paths)
|
|
1413
1693
|
})))
|
|
1414
1694
|
});
|
|
1415
1695
|
/**
|
|
@@ -1460,6 +1740,35 @@ function selected(config, requested) {
|
|
|
1460
1740
|
return checkNames.filter((name) => requested.includes(name));
|
|
1461
1741
|
}
|
|
1462
1742
|
/**
|
|
1743
|
+
* 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.
|
|
1744
|
+
*/
|
|
1745
|
+
async function runImportGraphChecks(cwd, layout, names, checks) {
|
|
1746
|
+
const entries = names.flatMap((name) => {
|
|
1747
|
+
const check = registry[name];
|
|
1748
|
+
if (!("importGraph" in check)) return [];
|
|
1749
|
+
const options = check.importGraph.optionsOf(checks);
|
|
1750
|
+
return options === void 0 ? [] : [{
|
|
1751
|
+
spec: check.importGraph.spec,
|
|
1752
|
+
options
|
|
1753
|
+
}];
|
|
1754
|
+
});
|
|
1755
|
+
if (entries.length === 0) return /* @__PURE__ */ new Map();
|
|
1756
|
+
if (layout === void 0) throw new ConformanceError(`${entries.map((entry) => entry.spec.name).join(", ")} read the workspace layout, which was not supplied`);
|
|
1757
|
+
return runImportChecks({
|
|
1758
|
+
cwd,
|
|
1759
|
+
layout,
|
|
1760
|
+
entries
|
|
1761
|
+
});
|
|
1762
|
+
}
|
|
1763
|
+
/**
|
|
1764
|
+
* 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.
|
|
1765
|
+
*/
|
|
1766
|
+
function readImported(imported, name) {
|
|
1767
|
+
const found = imported.get(name);
|
|
1768
|
+
if (found === void 0) throw new ConformanceError(`the import check '${name}' produced no result`);
|
|
1769
|
+
return found;
|
|
1770
|
+
}
|
|
1771
|
+
/**
|
|
1463
1772
|
* Run the enabled checks, or the enabled ones among `checks`, and return what they found. Sections not supplied in the options are loaded from `cwd` with `@exadev/config`.
|
|
1464
1773
|
*
|
|
1465
1774
|
* 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.
|
|
@@ -1468,16 +1777,21 @@ async function runChecks(options) {
|
|
|
1468
1777
|
const config = await conformanceOf(options);
|
|
1469
1778
|
const names = selected(config, options.checks);
|
|
1470
1779
|
const layout = await layoutOf(options, names.filter((name) => registry[name].requiresLayout));
|
|
1780
|
+
const imported = await runImportGraphChecks(options.cwd, layout, names, config.checks);
|
|
1471
1781
|
const results = [];
|
|
1472
|
-
for (const name of names)
|
|
1473
|
-
check
|
|
1474
|
-
violations
|
|
1782
|
+
for (const name of names) {
|
|
1783
|
+
const check = registry[name];
|
|
1784
|
+
const violations = "run" in check ? await check.run({
|
|
1475
1785
|
cwd: options.cwd,
|
|
1476
1786
|
checks: config.checks,
|
|
1477
1787
|
layout,
|
|
1478
1788
|
configFiles: options.configFiles
|
|
1479
|
-
})
|
|
1480
|
-
|
|
1789
|
+
}) : readImported(imported, name);
|
|
1790
|
+
results.push({
|
|
1791
|
+
check: name,
|
|
1792
|
+
violations
|
|
1793
|
+
});
|
|
1794
|
+
}
|
|
1481
1795
|
const violations = results.flatMap((result) => result.violations);
|
|
1482
1796
|
return {
|
|
1483
1797
|
results,
|