workspace-conformance 1.6.0 → 1.7.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
@@ -5,6 +5,7 @@ import { escapePath, glob, isDynamicPattern } from "tinyglobby";
5
5
  import { ModuleKind, ModuleResolutionKind, Node, Project, ScriptTarget, SyntaxKind, ts } from "ts-morph";
6
6
  import { createJitiLoader, validateStandard } from "cosmiconfig-extends";
7
7
  import { readFile, realpath } from "node:fs/promises";
8
+ import { Range, subset, valid, validRange } from "semver";
8
9
  import { LineCounter, isMap, isNode, isScalar, isSeq, parse, parseDocument } from "yaml";
9
10
  import { execFile } from "node:child_process";
10
11
  import { findNodeAtLocation, parse as parse$1, parseTree, printParseErrorCode } from "jsonc-parser";
@@ -781,6 +782,301 @@ const dockerfilePackageManager = async ({ cwd, options }) => {
781
782
  return violations;
782
783
  };
783
784
  //#endregion
785
+ //#region src/workspace/packages.ts
786
+ function segmentsOf(path) {
787
+ return path.split("/").filter((segment) => segment !== "" && segment !== ".");
788
+ }
789
+ function groupRoot(group) {
790
+ return segmentsOf(group.path ?? group.name);
791
+ }
792
+ function startsWithSegments(segments, prefix) {
793
+ return prefix.every((segment, index) => segments[index] === segment);
794
+ }
795
+ /**
796
+ * The group whose root is the longest prefix of `dir`; the first declared wins a tie. `undefined` when no group owns the directory.
797
+ */
798
+ function owningGroup(dir, groups) {
799
+ const segments = segmentsOf(dir);
800
+ let best;
801
+ let bestLength = -1;
802
+ for (const group of groups) {
803
+ const root = groupRoot(group);
804
+ if (root.length > bestLength && startsWithSegments(segments, root)) {
805
+ best = group;
806
+ bestLength = root.length;
807
+ }
808
+ }
809
+ return best;
810
+ }
811
+ function rankOf(name, group, layout) {
812
+ if (name !== void 0) {
813
+ const rule = layout.nameRanks?.find((candidate) => new RegExp(candidate.pattern, "u").test(name));
814
+ if (rule !== void 0) return rule.rank;
815
+ }
816
+ return group.rank ?? layout.defaultRank;
817
+ }
818
+ /**
819
+ * The name without its npm scope: `@scope/store-cli` gives `store-cli`.
820
+ */
821
+ function unscoped(name) {
822
+ return name.replace(/^@[^/]+\//u, "");
823
+ }
824
+ /**
825
+ * The longest slice value that is the whole unscoped name or its prefix up to a `-`.
826
+ */
827
+ function sliceByNamePrefix(name, known) {
828
+ const bare = unscoped(name);
829
+ return [...known].filter((candidate) => bare === candidate || bare.startsWith(`${candidate}-`)).sort((a, b) => b.length - a.length)[0];
830
+ }
831
+ /**
832
+ * Classify discovered packages by the layout: group, rank and slice. Pure.
833
+ *
834
+ * A package no group owns is a configuration error, because every check would otherwise skip it without saying so. Nested package directories are one too: the checks attribute a file to the package whose directory is its prefix, which is ambiguous for a package inside another. A `namePrefix` slice takes the longest slice value that a `segment` group produced and that prefixes the package's unscoped name; a package without a name has no such slice.
835
+ */
836
+ function classifyPackages(discovered, layout) {
837
+ const owned = discovered.map((found) => {
838
+ const group = owningGroup(found.dir, layout.groups);
839
+ if (group === void 0) throw new ConformanceError(`the package directory '${found.dir}' is not under any layout group's path; add a group whose path prefixes it, or narrow the workspace package globs`);
840
+ return {
841
+ found,
842
+ group
843
+ };
844
+ });
845
+ const knownSlices = /* @__PURE__ */ new Set();
846
+ for (const { found, group } of owned) if (group.slice !== void 0 && "segment" in group.slice) {
847
+ const value = segmentsOf(found.dir).slice(groupRoot(group).length)[group.slice.segment];
848
+ if (value !== void 0) knownSlices.add(value);
849
+ }
850
+ const packages = owned.map(({ found, group }) => {
851
+ let slice;
852
+ if (group.slice !== void 0 && "segment" in group.slice) slice = segmentsOf(found.dir).slice(groupRoot(group).length)[group.slice.segment];
853
+ else if (group.slice !== void 0 && found.name !== void 0) slice = sliceByNamePrefix(found.name, knownSlices);
854
+ return {
855
+ ...found,
856
+ group: group.name,
857
+ rank: rankOf(found.name, group, layout),
858
+ slice
859
+ };
860
+ });
861
+ for (const outer of packages) {
862
+ const nested = packages.find((inner) => inner.dir !== outer.dir && inner.dir.startsWith(`${outer.dir}/`));
863
+ if (nested !== void 0) throw new ConformanceError(`the package directory '${nested.dir}' is inside the package directory '${outer.dir}'; workspace packages must not nest`);
864
+ }
865
+ return packages;
866
+ }
867
+ /**
868
+ * The workspace root: `layout.root` resolved against `cwd`, or `cwd` itself.
869
+ */
870
+ function workspaceRoot(cwd, layout) {
871
+ return layout.root === void 0 ? cwd : resolve(cwd, layout.root);
872
+ }
873
+ const PNPM_WORKSPACE_FILE = "pnpm-workspace.yaml";
874
+ /**
875
+ * The `packages` list of `pnpm-workspace.yaml` in `root`, or `undefined` when there is no such file. A file that exists but cannot be read, or has no list of strings under `packages`, is a configuration error.
876
+ */
877
+ async function pnpmWorkspacePatterns(root) {
878
+ const file = join(root, PNPM_WORKSPACE_FILE);
879
+ if (!existsSync(file)) return;
880
+ let source;
881
+ try {
882
+ source = await readFile(file, "utf8");
883
+ } catch (error) {
884
+ throw new ConformanceError(`${file} cannot be read`, { cause: error });
885
+ }
886
+ const document = parse(source);
887
+ if (typeof document !== "object" || document === null || !("packages" in document) || !Array.isArray(document.packages)) throw new ConformanceError(`${file} has no 'packages' list`);
888
+ const patterns = document.packages;
889
+ if (!patterns.every((pattern) => typeof pattern === "string")) throw new ConformanceError(`${file}: every entry of 'packages' must be a string`);
890
+ return patterns;
891
+ }
892
+ /**
893
+ * The package globs: `layout.packages`, else the `packages` list of `pnpm-workspace.yaml` in `root`.
894
+ */
895
+ async function workspacePatterns(root, layout) {
896
+ if (layout.packages !== void 0) return layout.packages;
897
+ const patterns = await pnpmWorkspacePatterns(root);
898
+ if (patterns === void 0) throw new ConformanceError(`the layout has no 'packages' and ${join(root, PNPM_WORKSPACE_FILE)} does not exist`);
899
+ return patterns;
900
+ }
901
+ async function readName(manifest) {
902
+ let content;
903
+ try {
904
+ content = JSON.parse(await readFile(manifest, "utf8"));
905
+ } catch (error) {
906
+ throw new ConformanceError(`${manifest} cannot be read as JSON`, { cause: error });
907
+ }
908
+ if (typeof content === "object" && content !== null && "name" in content && typeof content.name === "string") return content.name;
909
+ }
910
+ /**
911
+ * The directories under `root` that the pnpm-style `patterns` select and that hold a `package.json`, sorted. A `!` pattern excludes. The root package itself (`.`) is not a member of any group and is left out.
912
+ */
913
+ async function discoverPackages(root, patterns) {
914
+ const manifests = patterns.map((pattern) => pattern.startsWith("!") ? `!${pattern.slice(1)}/**` : `${pattern}/package.json`);
915
+ const dirs = (await glob(manifests, {
916
+ cwd: root,
917
+ dot: true,
918
+ ignore: [INSTALLED_DEPENDENCIES_GLOB],
919
+ onlyFiles: true
920
+ })).map((manifest) => manifest.replace(/\/?package\.json$/u, "")).filter((dir) => dir !== "");
921
+ return Promise.all([...new Set(dirs)].sort().map(async (dir) => ({
922
+ dir,
923
+ name: await readName(join(root, dir, "package.json"))
924
+ })));
925
+ }
926
+ /**
927
+ * The layout's packages found under the workspace root.
928
+ */
929
+ async function readWorkspacePackages(cwd, layout) {
930
+ const root = workspaceRoot(cwd, layout);
931
+ return classifyPackages(await discoverPackages(root, await workspacePatterns(root, layout)), layout);
932
+ }
933
+ //#endregion
934
+ //#region src/checks/engines-floor.ts
935
+ /**
936
+ * The pattern that selects the root package, the `package.json` in the directory the checks run in.
937
+ */
938
+ const ROOT_PACKAGE = ".";
939
+ const MANIFEST = "package.json";
940
+ const ANY_INSTALLED_COPY = {};
941
+ const DEPENDENCY_FIELDS = [
942
+ {
943
+ field: "dependencies",
944
+ noun: "dependency",
945
+ mayBeAbsent: () => false,
946
+ judgedCopy: () => ANY_INSTALLED_COPY
947
+ },
948
+ {
949
+ field: "optionalDependencies",
950
+ noun: "optional dependency",
951
+ mayBeAbsent: () => true,
952
+ judgedCopy: () => ANY_INSTALLED_COPY
953
+ },
954
+ {
955
+ field: "peerDependencies",
956
+ noun: "peer dependency",
957
+ mayBeAbsent: (manifest, name) => {
958
+ const meta = manifest["peerDependenciesMeta"];
959
+ const entry = isRecord(meta) ? meta[name] : void 0;
960
+ return isRecord(entry) && entry["optional"] === true;
961
+ },
962
+ judgedCopy: (spec) => {
963
+ const version = onlyAdmittedVersion(spec);
964
+ return version === null ? void 0 : { version };
965
+ }
966
+ }
967
+ ];
968
+ /**
969
+ * The one version `spec` admits, when it is a semver range of a single comparator naming an exact version (`1.2.3` or `=1.2.3`); `null`, as semver gives for no version, for any other range, including one of a single major, and for a spec that is not a semver range (`workspace:^`, a tag or a URL).
970
+ */
971
+ function onlyAdmittedVersion(spec) {
972
+ if (validRange(spec) === null) return null;
973
+ const [comparators, ...others] = new Range(spec).set;
974
+ if (comparators === void 0 || others.length > 0 || comparators.length !== 1) return null;
975
+ const [comparator] = comparators;
976
+ if (comparator?.operator !== "") return null;
977
+ return valid(comparator.value);
978
+ }
979
+ async function readManifest(cwd, file) {
980
+ let content;
981
+ try {
982
+ content = JSON.parse(await readFile(resolve(cwd, file), "utf8"));
983
+ } catch (error) {
984
+ throw new ConformanceError(`${file} cannot be read as JSON`, { cause: error });
985
+ }
986
+ if (!isRecord(content)) throw new ConformanceError(`${file} is not a JSON object`);
987
+ return content;
988
+ }
989
+ /**
990
+ * The `engines.node` range the manifest at `file` declares, or `undefined` when it declares none. A range semver cannot parse is a configuration error, since nothing can be said about the versions it admits.
991
+ */
992
+ function nodeRange(manifest, file) {
993
+ const engines = manifest["engines"];
994
+ if (engines === void 0) return;
995
+ if (!isRecord(engines)) throw new ConformanceError(`${file}: engines must be an object`);
996
+ const node = engines["node"];
997
+ if (node === void 0) return;
998
+ if (typeof node !== "string") throw new ConformanceError(`${file}: engines.node must be a string, not ${JSON.stringify(node)}`);
999
+ if (validRange(node) === null) throw new ConformanceError(`${file}: engines.node '${node}' is not a valid semver range`);
1000
+ return node;
1001
+ }
1002
+ /**
1003
+ * The name and version spec of each dependency a dependency field of the manifest at `file` lists, sorted by name; none when the field is absent.
1004
+ */
1005
+ function dependencyEntries(manifest, field, file) {
1006
+ const listed = manifest[field];
1007
+ if (listed === void 0) return [];
1008
+ if (!isRecord(listed)) throw new ConformanceError(`${file}: ${field} must be an object`);
1009
+ return Object.keys(listed).sort().map((name) => {
1010
+ const spec = listed[name];
1011
+ if (typeof spec !== "string") throw new ConformanceError(`${file}: ${field}.${name} must be a string, not ${JSON.stringify(spec)}`);
1012
+ return [name, spec];
1013
+ });
1014
+ }
1015
+ /**
1016
+ * The `package.json` of the dependency `name` as installed for the package in `packageDirectory`, found the way Node finds a package directory: in `node_modules` of the package's own directory, then of each directory above it, stopping at the working directory, since a copy above the workspace is not one its install put there. A symbolic link, as pnpm installs every dependency, is followed. `undefined` when no directory has it.
1017
+ */
1018
+ function installedManifest(cwd, packageDirectory, name) {
1019
+ const top = resolve(cwd);
1020
+ for (let directory = resolve(cwd, packageDirectory);; directory = dirname(directory)) {
1021
+ const candidate = join(directory, "node_modules", name, MANIFEST);
1022
+ if (existsSync(candidate)) return relativePosix(cwd, candidate);
1023
+ if (directory === top) return;
1024
+ }
1025
+ }
1026
+ /**
1027
+ * The directories of the packages to judge: those `packages` selects, else the root package and the `packages` of `pnpm-workspace.yaml`. Selecting none is a configuration error, so a pattern that matches nothing never reads as a pass.
1028
+ */
1029
+ async function packageDirectories(cwd, options) {
1030
+ const patterns = options.packages ?? [ROOT_PACKAGE, ...await pnpmWorkspacePatterns(cwd) ?? []];
1031
+ const members = await discoverPackages(cwd, patterns.filter((pattern) => pattern !== ROOT_PACKAGE));
1032
+ const directories = [...patterns.includes(ROOT_PACKAGE) ? [ROOT_PACKAGE] : [], ...members.map((member) => member.dir)];
1033
+ if (directories.length === 0) throw new ConformanceError(`no package.json is selected by ${patterns.join(", ")}`);
1034
+ return directories;
1035
+ }
1036
+ /**
1037
+ * The `engines.node` range of each package is a subset of the `engines.node` range of each dependency and optional dependency it lists, and of each peer dependency whose range admits a single version, as installed in `node_modules`, so the package admits no Node version that something it needs at run time rejects. A package that declares no range admits every version, so it is reported for each dependency that declares one. A dependency that declares no range constrains nothing and is ignored.
1038
+ *
1039
+ * The comparison is `semver.subset(package, dependency)`: every version the package's range admits satisfies the dependency's range. A floor alone is not enough, because a range of several lines (`^20.19.0 || ^22.13.0 || >=24`) also rejects the versions between them, which `>=20.19.0` admits. semver compares a range of several comparator sets set by set, so each set of the package's range must lie within one set of the dependency's; a set that only two adjacent dependency sets cover together (`>=22` against `^22.0.0 || >=23`) is reported, and splitting it the same way (`^22.0.0 || >=23`) satisfies the check.
1040
+ *
1041
+ * A peer dependency is judged only when its range admits a single version (`1.2.3` or `=1.2.3`), and then by the copy installed, which must be that version. The consumer chooses which admitted version to install, so the package's range holds when each Node version in it is accepted by some admitted version; one installed copy that rejects a Node version does not show that every admitted version does, and the others are not on disk.
1042
+ *
1043
+ * A dependency that is not installed fails the run: its range cannot be read, and passing it unjudged would read as a pass. An optional dependency and a peer dependency marked optional in `peerDependenciesMeta` may be absent and are then skipped.
1044
+ */
1045
+ const enginesFloor = async ({ cwd, options }) => {
1046
+ const violations = [];
1047
+ for (const directory of await packageDirectories(cwd, options)) {
1048
+ const file = posix.join(directory, MANIFEST);
1049
+ const manifest = await readManifest(cwd, file);
1050
+ const own = nodeRange(manifest, file);
1051
+ const seen = /* @__PURE__ */ new Set();
1052
+ for (const { field, noun, mayBeAbsent, judgedCopy } of DEPENDENCY_FIELDS) for (const [name, spec] of dependencyEntries(manifest, field, file).filter(([candidate]) => !seen.has(candidate))) {
1053
+ seen.add(name);
1054
+ const copy = judgedCopy(spec);
1055
+ if (copy === void 0) continue;
1056
+ const installed = installedManifest(cwd, directory, name);
1057
+ if (installed === void 0) {
1058
+ if (mayBeAbsent(manifest, name)) continue;
1059
+ throw new ConformanceError(`${file}: the ${noun} '${name}' is not installed (no node_modules/${name}/package.json in its directory or any directory above it up to the working directory); install the workspace before running engines-floor`);
1060
+ }
1061
+ const dependency = await readManifest(cwd, installed);
1062
+ if (copy.version !== void 0 && dependency["version"] !== copy.version) throw new ConformanceError(`${file}: the ${noun} '${name}' admits only version ${copy.version}, but ${installed} is version ${String(dependency["version"])}; install the workspace before running engines-floor`);
1063
+ const theirs = nodeRange(dependency, installed);
1064
+ if (theirs === void 0) continue;
1065
+ if (own === void 0) violations.push({
1066
+ code: "engines-floor/no-engines",
1067
+ message: `no engines.node is declared, so every Node version is admitted, but the ${noun} '${name}' requires engines.node '${theirs}'; declare a subset of that range`,
1068
+ file
1069
+ });
1070
+ else if (!subset(own, theirs)) violations.push({
1071
+ code: "engines-floor/wider-than-dependency",
1072
+ message: `engines.node '${own}' admits Node versions that the ${noun} '${name}' rejects with engines.node '${theirs}'; narrow it to a subset of that range`,
1073
+ file
1074
+ });
1075
+ }
1076
+ }
1077
+ return violations;
1078
+ };
1079
+ //#endregion
784
1080
  //#region src/checks/eslint.ts
785
1081
  /**
786
1082
  * The patterns the full level lints when the options name none.
@@ -1109,145 +1405,6 @@ function unresolvedNameImportRule(packages) {
1109
1405
  };
1110
1406
  }
1111
1407
  //#endregion
1112
- //#region src/workspace/packages.ts
1113
- function segmentsOf(path) {
1114
- return path.split("/").filter((segment) => segment !== "" && segment !== ".");
1115
- }
1116
- function groupRoot(group) {
1117
- return segmentsOf(group.path ?? group.name);
1118
- }
1119
- function startsWithSegments(segments, prefix) {
1120
- return prefix.every((segment, index) => segments[index] === segment);
1121
- }
1122
- /**
1123
- * The group whose root is the longest prefix of `dir`; the first declared wins a tie. `undefined` when no group owns the directory.
1124
- */
1125
- function owningGroup(dir, groups) {
1126
- const segments = segmentsOf(dir);
1127
- let best;
1128
- let bestLength = -1;
1129
- for (const group of groups) {
1130
- const root = groupRoot(group);
1131
- if (root.length > bestLength && startsWithSegments(segments, root)) {
1132
- best = group;
1133
- bestLength = root.length;
1134
- }
1135
- }
1136
- return best;
1137
- }
1138
- function rankOf(name, group, layout) {
1139
- if (name !== void 0) {
1140
- const rule = layout.nameRanks?.find((candidate) => new RegExp(candidate.pattern, "u").test(name));
1141
- if (rule !== void 0) return rule.rank;
1142
- }
1143
- return group.rank ?? layout.defaultRank;
1144
- }
1145
- /**
1146
- * The name without its npm scope: `@scope/store-cli` gives `store-cli`.
1147
- */
1148
- function unscoped(name) {
1149
- return name.replace(/^@[^/]+\//u, "");
1150
- }
1151
- /**
1152
- * The longest slice value that is the whole unscoped name or its prefix up to a `-`.
1153
- */
1154
- function sliceByNamePrefix(name, known) {
1155
- const bare = unscoped(name);
1156
- return [...known].filter((candidate) => bare === candidate || bare.startsWith(`${candidate}-`)).sort((a, b) => b.length - a.length)[0];
1157
- }
1158
- /**
1159
- * Classify discovered packages by the layout: group, rank and slice. Pure.
1160
- *
1161
- * A package no group owns is a configuration error, because every check would otherwise skip it without saying so. Nested package directories are one too: the checks attribute a file to the package whose directory is its prefix, which is ambiguous for a package inside another. A `namePrefix` slice takes the longest slice value that a `segment` group produced and that prefixes the package's unscoped name; a package without a name has no such slice.
1162
- */
1163
- function classifyPackages(discovered, layout) {
1164
- const owned = discovered.map((found) => {
1165
- const group = owningGroup(found.dir, layout.groups);
1166
- if (group === void 0) throw new ConformanceError(`the package directory '${found.dir}' is not under any layout group's path; add a group whose path prefixes it, or narrow the workspace package globs`);
1167
- return {
1168
- found,
1169
- group
1170
- };
1171
- });
1172
- const knownSlices = /* @__PURE__ */ new Set();
1173
- for (const { found, group } of owned) if (group.slice !== void 0 && "segment" in group.slice) {
1174
- const value = segmentsOf(found.dir).slice(groupRoot(group).length)[group.slice.segment];
1175
- if (value !== void 0) knownSlices.add(value);
1176
- }
1177
- const packages = owned.map(({ found, group }) => {
1178
- let slice;
1179
- if (group.slice !== void 0 && "segment" in group.slice) slice = segmentsOf(found.dir).slice(groupRoot(group).length)[group.slice.segment];
1180
- else if (group.slice !== void 0 && found.name !== void 0) slice = sliceByNamePrefix(found.name, knownSlices);
1181
- return {
1182
- ...found,
1183
- group: group.name,
1184
- rank: rankOf(found.name, group, layout),
1185
- slice
1186
- };
1187
- });
1188
- for (const outer of packages) {
1189
- const nested = packages.find((inner) => inner.dir !== outer.dir && inner.dir.startsWith(`${outer.dir}/`));
1190
- if (nested !== void 0) throw new ConformanceError(`the package directory '${nested.dir}' is inside the package directory '${outer.dir}'; workspace packages must not nest`);
1191
- }
1192
- return packages;
1193
- }
1194
- /**
1195
- * The workspace root: `layout.root` resolved against `cwd`, or `cwd` itself.
1196
- */
1197
- function workspaceRoot(cwd, layout) {
1198
- return layout.root === void 0 ? cwd : resolve(cwd, layout.root);
1199
- }
1200
- /**
1201
- * The package globs: `layout.packages`, else the `packages` list of `pnpm-workspace.yaml` in `root`.
1202
- */
1203
- async function workspacePatterns(root, layout) {
1204
- if (layout.packages !== void 0) return layout.packages;
1205
- const file = join(root, "pnpm-workspace.yaml");
1206
- let source;
1207
- try {
1208
- source = await readFile(file, "utf8");
1209
- } catch (error) {
1210
- throw new ConformanceError(`the layout has no 'packages' and ${file} cannot be read`, { cause: error });
1211
- }
1212
- const document = parse(source);
1213
- if (typeof document !== "object" || document === null || !("packages" in document) || !Array.isArray(document.packages)) throw new ConformanceError(`${file} has no 'packages' list`);
1214
- const patterns = document.packages;
1215
- if (!patterns.every((pattern) => typeof pattern === "string")) throw new ConformanceError(`${file}: every entry of 'packages' must be a string`);
1216
- return patterns;
1217
- }
1218
- async function readName(manifest) {
1219
- let content;
1220
- try {
1221
- content = JSON.parse(await readFile(manifest, "utf8"));
1222
- } catch (error) {
1223
- throw new ConformanceError(`${manifest} cannot be read as JSON`, { cause: error });
1224
- }
1225
- if (typeof content === "object" && content !== null && "name" in content && typeof content.name === "string") return content.name;
1226
- }
1227
- /**
1228
- * The directories under `root` that the pnpm-style `patterns` select and that hold a `package.json`, sorted. A `!` pattern excludes. The root package itself (`.`) is not a member of any group and is left out.
1229
- */
1230
- async function discoverPackages(root, patterns) {
1231
- const manifests = patterns.map((pattern) => pattern.startsWith("!") ? `!${pattern.slice(1)}/**` : `${pattern}/package.json`);
1232
- const dirs = (await glob(manifests, {
1233
- cwd: root,
1234
- dot: true,
1235
- ignore: [INSTALLED_DEPENDENCIES_GLOB],
1236
- onlyFiles: true
1237
- })).map((manifest) => manifest.replace(/\/?package\.json$/u, "")).filter((dir) => dir !== "");
1238
- return Promise.all([...new Set(dirs)].sort().map(async (dir) => ({
1239
- dir,
1240
- name: await readName(join(root, dir, "package.json"))
1241
- })));
1242
- }
1243
- /**
1244
- * The layout's packages found under the workspace root.
1245
- */
1246
- async function readWorkspacePackages(cwd, layout) {
1247
- const root = workspaceRoot(cwd, layout);
1248
- return classifyPackages(await discoverPackages(root, await workspacePatterns(root, layout)), layout);
1249
- }
1250
- //#endregion
1251
1408
  //#region src/checks/import-graph.ts
1252
1409
  /**
1253
1410
  * Paths left out of the import graph unless the options say otherwise: a package's own installed dependencies.
@@ -3330,6 +3487,7 @@ const checks = z.strictObject({
3330
3487
  commitlint: z.exactOptional(path),
3331
3488
  release: z.exactOptional(path)
3332
3489
  }))),
3490
+ "engines-floor": z.exactOptional(enabled(z.strictObject({ packages: z.exactOptional(paths) }))),
3333
3491
  "dockerfile-package-manager": z.exactOptional(enabled(z.strictObject({
3334
3492
  dockerfiles: z.exactOptional(paths),
3335
3493
  exclude: z.exactOptional(paths),
@@ -3623,6 +3781,12 @@ const registry = {
3623
3781
  select: (checks) => enabledOptions(checks["commit-types"]),
3624
3782
  check: commitTypes
3625
3783
  }),
3784
+ "engines-floor": plain({
3785
+ name: "engines-floor",
3786
+ description: "A package's engines.node range admits no Node version that one of its installed dependencies rejects",
3787
+ select: (checks) => enabledOptions(checks["engines-floor"]),
3788
+ check: enginesFloor
3789
+ }),
3626
3790
  "dockerfile-package-manager": plain({
3627
3791
  name: "dockerfile-package-manager",
3628
3792
  description: "A package manager version pinned in a Dockerfile is the version packageManager names",
@@ -3928,4 +4092,4 @@ function proveExhaustive(input) {
3928
4092
  }) };
3929
4093
  }
3930
4094
  //#endregion
3931
- export { ConformanceError, DEFAULT_DERIVED_TYPE_INFERENCES, DEFAULT_INFERENCES, DEFAULT_WORKFLOWS, EXIT_CODES, ExhaustivenessError, PRESET_COMMIT_TYPES, accessOf, aggregateMappers, checkNames, classifyPackages, codecPairs, commandTypes, commitTypes, conformanceSchema, conformanceSection, createGitHubClient, crossSliceRules, cycleRule, deployTools, derivedTypes, dockerfilePackageManager, effectiveEnv, effectivePermissions, eslint, generators, handlerCases, importCrossSlice, importCycles, importIsolatedGroups, importRankSkip, importUphill, instructionSymlinks, isCheckName, isolatedGroupRules, loadWorkflows, migrationsDirectory, packagesPattern, parseUses, parseWorkflow, proveExhaustive, rankSkipRules, registry, removeCase, runChecks, settingsMergeMethods, settingsRequiredChecks, settingsReviewThreadResolution, singleStorybook, transitiveNeeds, typeErrors, uphillRules, workflowActionPinning, workflowCredentials, workflowJobOrdering, workflowMergeGroup, workflowReleaseJob, workflowRepositoryDispatch, workflowRunnerResolution, workflowSkippableJobs, workflowUpdateBotCooldown, workflowVersionSingleSource };
4095
+ export { ConformanceError, DEFAULT_DERIVED_TYPE_INFERENCES, DEFAULT_INFERENCES, DEFAULT_WORKFLOWS, EXIT_CODES, ExhaustivenessError, PRESET_COMMIT_TYPES, accessOf, aggregateMappers, checkNames, classifyPackages, codecPairs, commandTypes, commitTypes, conformanceSchema, conformanceSection, createGitHubClient, crossSliceRules, cycleRule, deployTools, derivedTypes, dockerfilePackageManager, effectiveEnv, effectivePermissions, enginesFloor, eslint, generators, handlerCases, importCrossSlice, importCycles, importIsolatedGroups, importRankSkip, importUphill, instructionSymlinks, isCheckName, isolatedGroupRules, loadWorkflows, migrationsDirectory, packagesPattern, parseUses, parseWorkflow, proveExhaustive, rankSkipRules, registry, removeCase, runChecks, settingsMergeMethods, settingsRequiredChecks, settingsReviewThreadResolution, singleStorybook, transitiveNeeds, typeErrors, uphillRules, workflowActionPinning, workflowCredentials, workflowJobOrdering, workflowMergeGroup, workflowReleaseJob, workflowRepositoryDispatch, workflowRunnerResolution, workflowSkippableJobs, workflowUpdateBotCooldown, workflowVersionSingleSource };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "workspace-conformance",
3
3
  "description": "Conformance checks for a pnpm workspace that ESLint cannot express: type-graph, import-graph and cross-file checks driven by the shared layout section of @exadev/config",
4
- "version": "1.6.0",
4
+ "version": "1.7.0",
5
5
  "bin": {
6
6
  "workspace-conformance": "./dist/cli.js"
7
7
  },
@@ -10,6 +10,7 @@
10
10
  "cosmiconfig-extends": "^1.0.1",
11
11
  "dependency-cruiser": "^18.4.0",
12
12
  "jsonc-parser": "^3.3.1",
13
+ "semver": "^7.8.5",
13
14
  "smol-toml": "^1.9.0",
14
15
  "tinyglobby": "^0.2.17",
15
16
  "ts-morph": "^28.0.0",
@@ -30,6 +31,7 @@
30
31
  "@stryker-mutator/typescript-checker": "10.0.0",
31
32
  "@stryker-mutator/vitest-runner": "10.0.0",
32
33
  "@types/node": "26.6.3",
34
+ "@types/semver": "7.8.0",
33
35
  "@vitest/coverage-v8": "4.1.11",
34
36
  "cosmiconfig": "10.0.1",
35
37
  "eslint": "10.11.0",