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/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
7
  import { parse } 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
@@ -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: "import-cycle",
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: "unresolved-name-import",
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 violations of `rules` among the files of `packages`. The graph is limited to the packages' own files, so nothing outside them is a dependant or a dependency.
869
- *
870
- * 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.
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 result is read from the cruise summary: `cruise()` reports success in its exit code whatever the summary holds, so the code is not consulted.
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 import check: generate its rules from the layout, cruise the packages and report each finding.
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
- const root = workspaceRoot(context.cwd, context.layout);
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
- root,
904
- packages,
905
- rules,
906
- options: context.options
907
- })).map((finding) => {
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(context.cwd, join(root, finding.from))
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
- * A package imports a package in a different slice.
1013
+ * How `import-cross-slice` joins a shared cruise.
939
1014
  */
940
- const importCrossSlice = async (context) => runImportCheck(context, {
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
- message: (edge) => `${describePackage(edge.from)} (slice '${String(edge.from.slice)}') imports ${describeTarget(edge)} (slice '${String(edge.to.slice)}')`
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
- * A package imports a package in a group the layout isolates from its own.
1142
+ * How `import-isolated-groups` joins a shared cruise.
1060
1143
  */
1061
- const importIsolatedGroups = async (context) => runImportCheck(context, {
1144
+ const importIsolatedGroupsSpec = {
1062
1145
  name: "import-isolated-groups",
1063
- reason: "isolated-group",
1064
- rules: (packages) => {
1065
- if (context.layout.isolatedGroups === void 0) throw new ConformanceError("the 'import-isolated-groups' check is enabled but the layout has no 'isolatedGroups'");
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
- message: (edge) => `${describePackage(edge.from)} (group '${edge.from.group}') imports ${describeTarget(edge)} (group '${edge.to.group}'), which the layout isolates from it`
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
- * A package imports a package more than `rankSkip.maxDistance` ranks below it that is not in `rankSkip.exemptRanks`.
1163
+ * How `import-rank-skip` joins a shared cruise.
1074
1164
  */
1075
- const importRankSkip = async (context) => runImportCheck(context, {
1165
+ const importRankSkipSpec = {
1076
1166
  name: "import-rank-skip",
1077
- reason: "skipped-rank",
1078
- rules: (packages) => {
1079
- if (context.layout.rankSkip === void 0) throw new ConformanceError("the 'import-rank-skip' check is enabled but the layout has no 'rankSkip'");
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
- message: (edge) => `${describePackage(edge.from)} (rank ${String(edge.from.rank)}) imports ${describeTarget(edge)} (rank ${String(edge.to.rank)}), further below than the layout allows`
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
- * A package imports a package of a strictly higher rank: dependencies run downhill only.
1184
+ * How `import-uphill` joins a shared cruise.
1088
1185
  */
1089
- const importUphill = async (context) => runImportCheck(context, {
1186
+ const importUphillSpec = {
1090
1187
  name: "import-uphill",
1091
- reason: "higher-rank",
1092
1188
  rules: (packages) => uphillRules(packages),
1093
- message: (edge) => `${describePackage(edge.from)} (rank ${String(edge.from.rank)}) imports ${describeTarget(edge)} (rank ${String(edge.to.rank)}), a higher rank`
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], {
@@ -1203,6 +1307,208 @@ const instructionSymlinks = async ({ cwd, options }) => {
1203
1307
  return violations;
1204
1308
  };
1205
1309
  //#endregion
1310
+ //#region src/migrations/adapters.ts
1311
+ /**
1312
+ * The generators that have an adapter.
1313
+ */
1314
+ const generatorNames = ["drizzle-kit"];
1315
+ /**
1316
+ * The deploy tools that have an adapter.
1317
+ */
1318
+ const deployToolNames = ["wrangler"];
1319
+ function recordOf(config, file) {
1320
+ if (!isRecord(config)) throw new ConformanceError(`${file}: the config must be an object for the migrations directory to be read`);
1321
+ return config;
1322
+ }
1323
+ const DRIZZLE_DEFAULT_OUT = "drizzle";
1324
+ /**
1325
+ * 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.
1326
+ */
1327
+ const drizzleKit = {
1328
+ configFiles: [
1329
+ "drizzle.config.ts",
1330
+ "drizzle.config.js",
1331
+ "drizzle.config.json"
1332
+ ],
1333
+ applyCommand: /(?:^|[\s;&|(])drizzle-kit\s+migrate(?![\w:-])/u,
1334
+ applyCommandName: "drizzle-kit migrate",
1335
+ outputDirectory: (config, file) => {
1336
+ const out = recordOf(config, file)["out"];
1337
+ if (out === void 0) return DRIZZLE_DEFAULT_OUT;
1338
+ if (typeof out !== "string") throw new ConformanceError(`${file}: 'out' must be a string`);
1339
+ 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`);
1340
+ return out;
1341
+ }
1342
+ };
1343
+ const WRANGLER_DEFAULT_MIGRATIONS_DIR = "migrations";
1344
+ function databasesOf(scope, file) {
1345
+ const databases = scope["d1_databases"];
1346
+ if (databases === void 0) return [];
1347
+ if (!Array.isArray(databases) || !databases.every(isRecord)) throw new ConformanceError(`${file}: 'd1_databases' must be an array of objects`);
1348
+ return databases;
1349
+ }
1350
+ /**
1351
+ * 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.
1352
+ */
1353
+ const wrangler = {
1354
+ configFiles: [
1355
+ "wrangler.json",
1356
+ "wrangler.jsonc",
1357
+ "wrangler.toml"
1358
+ ],
1359
+ migrationsDirectory: (config, file, selector) => {
1360
+ const top = recordOf(config, file);
1361
+ let scope = top;
1362
+ if (selector.environment !== void 0) {
1363
+ const environments = top["env"];
1364
+ const environment = isRecord(environments) ? environments[selector.environment] : void 0;
1365
+ if (!isRecord(environment)) throw new ConformanceError(`${file} has no environment '${selector.environment}' (checks.migrations-directory.environment)`);
1366
+ scope = environment;
1367
+ }
1368
+ const where = selector.environment === void 0 ? file : `${file} (environment '${selector.environment}')`;
1369
+ const databases = databasesOf(scope, file);
1370
+ const chosen = selector.database === void 0 ? databases : databases.filter((database) => database["binding"] === selector.database);
1371
+ if (chosen.length > 1) throw new ConformanceError(`${where} declares several D1 databases; name the one the generator writes for with checks.migrations-directory.database`);
1372
+ const [only] = chosen;
1373
+ 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}'` };
1374
+ const directory = only["migrations_dir"];
1375
+ if (directory === void 0) return { directory: WRANGLER_DEFAULT_MIGRATIONS_DIR };
1376
+ if (typeof directory !== "string") throw new ConformanceError(`${where}: 'migrations_dir' must be a string`);
1377
+ return { directory };
1378
+ }
1379
+ };
1380
+ const generators = { "drizzle-kit": drizzleKit };
1381
+ const deployTools = { wrangler };
1382
+ //#endregion
1383
+ //#region src/migrations/config-data.ts
1384
+ /**
1385
+ * The first of `candidates` (relative to `cwd`) that exists, or `undefined` when none does.
1386
+ */
1387
+ function firstExisting(cwd, candidates) {
1388
+ return candidates.find((candidate) => existsSync(resolve(cwd, candidate)));
1389
+ }
1390
+ function assertValid(errors, file) {
1391
+ const [first] = errors;
1392
+ if (first !== void 0) throw new ConformanceError(`${file}: not valid JSON (${printParseErrorCode(first.error)} at offset ${String(first.offset)})`);
1393
+ }
1394
+ /**
1395
+ * 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.
1396
+ */
1397
+ function parseJsonc(text, file) {
1398
+ const errors = [];
1399
+ const data = parse$1(text, errors, { allowTrailingComma: true });
1400
+ assertValid(errors, file);
1401
+ return data;
1402
+ }
1403
+ /**
1404
+ * 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.
1405
+ */
1406
+ function parseJsoncTree(text, file) {
1407
+ const errors = [];
1408
+ const tree = parseTree(text, errors, { allowTrailingComma: true });
1409
+ assertValid(errors, file);
1410
+ if (tree === void 0) throw new ConformanceError(`${file}: holds no JSON value`);
1411
+ return tree;
1412
+ }
1413
+ /**
1414
+ * 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.
1415
+ */
1416
+ async function readConfigData(cwd, file, configFiles) {
1417
+ const extension = extname(file).toLowerCase();
1418
+ if (extension === ".json" || extension === ".jsonc") return parseJsonc(await readFile(resolve(cwd, file), "utf8"), file);
1419
+ if (extension === ".toml") try {
1420
+ return parse$2(await readFile(resolve(cwd, file), "utf8"));
1421
+ } catch (error) {
1422
+ throw new ConformanceError(`${file}: not valid TOML (${error instanceof Error ? error.message : String(error)})`);
1423
+ }
1424
+ return evaluateConfigFile(cwd, file, configFiles);
1425
+ }
1426
+ //#endregion
1427
+ //#region src/checks/migrations-directory.ts
1428
+ const DEFAULT_PACKAGE_JSONS = ["**/package.json"];
1429
+ /**
1430
+ * The 1-based line and column of the character at `offset` in `text`, which is not the first character.
1431
+ */
1432
+ function positionOf(text, offset) {
1433
+ const lineStart = text.lastIndexOf("\n", offset - 1) + 1;
1434
+ return {
1435
+ line: text.slice(0, lineStart).split("\n").length,
1436
+ column: offset - lineStart + 1
1437
+ };
1438
+ }
1439
+ /**
1440
+ * 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.
1441
+ */
1442
+ function locate(cwd, explicit, candidates) {
1443
+ return explicit === void 0 ? firstExisting(cwd, candidates) : firstExisting(cwd, [explicit]);
1444
+ }
1445
+ function missingConfig(explicit, candidates) {
1446
+ return explicit === void 0 ? {
1447
+ code: "migrations-directory/missing-config",
1448
+ message: `none of ${candidates.join(", ")} exists`,
1449
+ file: candidates[0]
1450
+ } : {
1451
+ code: "migrations-directory/missing-config",
1452
+ message: `${explicit} does not exist`,
1453
+ file: explicit
1454
+ };
1455
+ }
1456
+ /**
1457
+ * Violations for every script of the `package.json` at `file` that matches `command`.
1458
+ */
1459
+ async function scriptViolations(cwd, file, command, describe) {
1460
+ const text = await readFile(resolve(cwd, file), "utf8");
1461
+ const scripts = findNodeAtLocation(parseJsoncTree(text, file), ["scripts"]);
1462
+ const violations = [];
1463
+ for (const entry of scripts?.children ?? []) {
1464
+ const [key, value] = entry.children ?? [];
1465
+ if (key?.type === "string" && value?.type === "string" && typeof value.value === "string" && command.test(value.value)) violations.push({
1466
+ code: "migrations-directory/generator-apply-command",
1467
+ message: describe(value.value, String(key.value)),
1468
+ file,
1469
+ location: positionOf(text, value.offset)
1470
+ });
1471
+ }
1472
+ return violations;
1473
+ }
1474
+ /**
1475
+ * 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.
1476
+ *
1477
+ * 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.
1478
+ */
1479
+ const migrationsDirectory = async ({ cwd, options, configFiles }) => {
1480
+ const generator = generators[options.generator];
1481
+ const deployTool = deployTools[options.deployTool];
1482
+ const violations = [];
1483
+ const generatorFile = locate(cwd, options.generatorConfig, generator.configFiles);
1484
+ const deployFile = locate(cwd, options.deployConfig, deployTool.configFiles);
1485
+ if (generatorFile === void 0) violations.push(missingConfig(options.generatorConfig, generator.configFiles));
1486
+ if (deployFile === void 0) violations.push(missingConfig(options.deployConfig, deployTool.configFiles));
1487
+ if (generatorFile !== void 0 && deployFile !== void 0) {
1488
+ const written = generator.outputDirectory(await readConfigData(cwd, generatorFile, configFiles), generatorFile);
1489
+ const applied = deployTool.migrationsDirectory(await readConfigData(cwd, deployFile, configFiles), deployFile, {
1490
+ ...options.database === void 0 ? {} : { database: options.database },
1491
+ ...options.environment === void 0 ? {} : { environment: options.environment }
1492
+ });
1493
+ if (applied.none !== void 0) violations.push({
1494
+ code: "migrations-directory/no-database",
1495
+ message: applied.none,
1496
+ file: deployFile
1497
+ });
1498
+ else {
1499
+ const generated = resolve(cwd, dirname(generatorFile), written);
1500
+ const deployed = resolve(cwd, dirname(deployFile), applied.directory);
1501
+ if (generated !== deployed) violations.push({
1502
+ code: "migrations-directory/mismatch",
1503
+ message: `${generatorFile} writes migrations to ${relativePosix(cwd, generated)}, but ${deployFile} applies them from ${relativePosix(cwd, deployed)}`,
1504
+ file: generatorFile
1505
+ });
1506
+ }
1507
+ }
1508
+ 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}`));
1509
+ return violations;
1510
+ };
1511
+ //#endregion
1206
1512
  //#region src/checks/single-storybook.ts
1207
1513
  const STORYBOOK_DIRECTORY = ".storybook";
1208
1514
  /**
@@ -1284,6 +1590,15 @@ const checks = z.strictObject({
1284
1590
  dockerfiles: z.exactOptional(paths),
1285
1591
  exclude: z.exactOptional(paths),
1286
1592
  packageJson: z.exactOptional(path)
1593
+ }))),
1594
+ "migrations-directory": z.exactOptional(enabled(z.strictObject({
1595
+ generator: z.enum(generatorNames),
1596
+ deployTool: z.enum(deployToolNames),
1597
+ generatorConfig: z.exactOptional(path),
1598
+ deployConfig: z.exactOptional(path),
1599
+ database: z.exactOptional(z.string().min(1)),
1600
+ environment: z.exactOptional(z.string().min(1)),
1601
+ packageJsons: z.exactOptional(paths)
1287
1602
  })))
1288
1603
  });
1289
1604
  /**
@@ -1319,21 +1634,15 @@ function plain(spec) {
1319
1634
  }
1320
1635
  };
1321
1636
  }
1322
- function withLayout(spec) {
1637
+ function importCheck(spec) {
1323
1638
  return {
1324
1639
  name: spec.name,
1325
1640
  description: spec.description,
1326
1641
  requiresLayout: true,
1327
1642
  isEnabled: (checks) => spec.select(checks) !== void 0,
1328
- run: async ({ cwd, checks, layout }) => {
1329
- const options = spec.select(checks);
1330
- if (options === void 0) throw new ConformanceError(`the check '${spec.name}' is not enabled`);
1331
- if (layout === void 0) throw new ConformanceError(`the check '${spec.name}' reads the workspace layout, which was not supplied`);
1332
- return spec.check({
1333
- cwd,
1334
- options,
1335
- layout
1336
- });
1643
+ importGraph: {
1644
+ spec: spec.importSpec,
1645
+ optionsOf: spec.select
1337
1646
  }
1338
1647
  };
1339
1648
  }
@@ -1353,35 +1662,35 @@ const registry = {
1353
1662
  select: (checks) => enabledOptions(checks["command-types"]),
1354
1663
  check: commandTypes
1355
1664
  }),
1356
- "import-uphill": withLayout({
1665
+ "import-uphill": importCheck({
1357
1666
  name: "import-uphill",
1358
1667
  description: "No package imports a package of a higher rank",
1359
1668
  select: (checks) => enabledOptions(checks["import-uphill"]),
1360
- check: importUphill
1669
+ importSpec: importUphillSpec
1361
1670
  }),
1362
- "import-rank-skip": withLayout({
1671
+ "import-rank-skip": importCheck({
1363
1672
  name: "import-rank-skip",
1364
1673
  description: "No package imports further below itself than the layout allows",
1365
1674
  select: (checks) => enabledOptions(checks["import-rank-skip"]),
1366
- check: importRankSkip
1675
+ importSpec: importRankSkipSpec
1367
1676
  }),
1368
- "import-cross-slice": withLayout({
1677
+ "import-cross-slice": importCheck({
1369
1678
  name: "import-cross-slice",
1370
1679
  description: "No package imports a package in a different slice",
1371
1680
  select: (checks) => enabledOptions(checks["import-cross-slice"]),
1372
- check: importCrossSlice
1681
+ importSpec: importCrossSliceSpec
1373
1682
  }),
1374
- "import-isolated-groups": withLayout({
1683
+ "import-isolated-groups": importCheck({
1375
1684
  name: "import-isolated-groups",
1376
1685
  description: "No package imports a package in a group the layout isolates from its own",
1377
1686
  select: (checks) => enabledOptions(checks["import-isolated-groups"]),
1378
- check: importIsolatedGroups
1687
+ importSpec: importIsolatedGroupsSpec
1379
1688
  }),
1380
- "import-cycles": withLayout({
1689
+ "import-cycles": importCheck({
1381
1690
  name: "import-cycles",
1382
1691
  description: "No files of the workspace packages import each other in a cycle",
1383
1692
  select: (checks) => enabledOptions(checks["import-cycles"]),
1384
- check: importCycles
1693
+ importSpec: importCyclesSpec
1385
1694
  }),
1386
1695
  "instruction-symlinks": plain({
1387
1696
  name: "instruction-symlinks",
@@ -1406,6 +1715,12 @@ const registry = {
1406
1715
  description: "A package manager version pinned in a Dockerfile is the version packageManager names",
1407
1716
  select: (checks) => enabledOptions(checks["dockerfile-package-manager"]),
1408
1717
  check: dockerfilePackageManager
1718
+ }),
1719
+ "migrations-directory": plain({
1720
+ name: "migrations-directory",
1721
+ 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",
1722
+ select: (checks) => enabledOptions(checks["migrations-directory"]),
1723
+ check: migrationsDirectory
1409
1724
  })
1410
1725
  };
1411
1726
  /**
@@ -1458,6 +1773,35 @@ function selected(config, requested) {
1458
1773
  return checkNames.filter((name) => requested.includes(name));
1459
1774
  }
1460
1775
  /**
1776
+ * 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.
1777
+ */
1778
+ async function runImportGraphChecks(cwd, layout, names, checks) {
1779
+ const entries = names.flatMap((name) => {
1780
+ const check = registry[name];
1781
+ if (!("importGraph" in check)) return [];
1782
+ const options = check.importGraph.optionsOf(checks);
1783
+ return options === void 0 ? [] : [{
1784
+ spec: check.importGraph.spec,
1785
+ options
1786
+ }];
1787
+ });
1788
+ if (entries.length === 0) return /* @__PURE__ */ new Map();
1789
+ if (layout === void 0) throw new ConformanceError(`${entries.map((entry) => entry.spec.name).join(", ")} read the workspace layout, which was not supplied`);
1790
+ return runImportChecks({
1791
+ cwd,
1792
+ layout,
1793
+ entries
1794
+ });
1795
+ }
1796
+ /**
1797
+ * 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.
1798
+ */
1799
+ function readImported(imported, name) {
1800
+ const found = imported.get(name);
1801
+ if (found === void 0) throw new ConformanceError(`the import check '${name}' produced no result`);
1802
+ return found;
1803
+ }
1804
+ /**
1461
1805
  * 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
1806
  *
1463
1807
  * Violations do not throw. It throws `ConformanceError` when the checks cannot run (nothing enabled, a requested check disabled, a section missing) and `ConfigValidationError` when a section fails its schema, whether it was loaded from `cwd` or passed in.
@@ -1466,16 +1810,21 @@ async function runChecks(options) {
1466
1810
  const config = await conformanceOf(options);
1467
1811
  const names = selected(config, options.checks);
1468
1812
  const layout = await layoutOf(options, names.filter((name) => registry[name].requiresLayout));
1813
+ const imported = await runImportGraphChecks(options.cwd, layout, names, config.checks);
1469
1814
  const results = [];
1470
- for (const name of names) results.push({
1471
- check: name,
1472
- violations: await registry[name].run({
1815
+ for (const name of names) {
1816
+ const check = registry[name];
1817
+ const violations = "run" in check ? await check.run({
1473
1818
  cwd: options.cwd,
1474
1819
  checks: config.checks,
1475
1820
  layout,
1476
1821
  configFiles: options.configFiles
1477
- })
1478
- });
1822
+ }) : readImported(imported, name);
1823
+ results.push({
1824
+ check: name,
1825
+ violations
1826
+ });
1827
+ }
1479
1828
  const violations = results.flatMap((result) => result.violations);
1480
1829
  return {
1481
1830
  results,
@@ -1577,4 +1926,4 @@ function proveExhaustive(input) {
1577
1926
  }) };
1578
1927
  }
1579
1928
  //#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 };
1929
+ export { ConformanceError, DEFAULT_INFERENCES, EXIT_CODES, ExhaustivenessError, PRESET_COMMIT_TYPES, aggregateMappers, checkNames, classifyPackages, commandTypes, commitTypes, conformanceSchema, conformanceSection, crossSliceRules, cycleRule, deployTools, dockerfilePackageManager, generators, handlerCases, importCrossSlice, importCycles, importIsolatedGroups, importRankSkip, importUphill, instructionSymlinks, isCheckName, isolatedGroupRules, migrationsDirectory, packagesPattern, proveExhaustive, rankSkipRules, registry, removeCase, runChecks, singleStorybook, typeErrors, uphillRules };