workspace-conformance 1.5.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.
@@ -2127,6 +2284,9 @@ function runsOnOf(reader, value, what) {
2127
2284
  group
2128
2285
  };
2129
2286
  }
2287
+ function environmentOf(reader, value, what) {
2288
+ return isRecord(value) ? reader.text(value["name"], `${what}.name`) : reader.text(value, what);
2289
+ }
2130
2290
  function stepOf(reader, value, jobPath, index) {
2131
2291
  const path = [
2132
2292
  ...jobPath,
@@ -2159,6 +2319,7 @@ function jobOf(reader, id, value) {
2159
2319
  needs: reader.list(job["needs"], `${what}.needs`),
2160
2320
  condition: reader.text(job["if"], `${what}.if`),
2161
2321
  permissions: permissionsOf(reader, job["permissions"], `${what}.permissions`),
2322
+ environment: environmentOf(reader, job["environment"], `${what}.environment`),
2162
2323
  env: reader.texts(job["env"], `${what}.env`),
2163
2324
  runsOn: runsOnOf(reader, job["runs-on"], `${what}.runs-on`),
2164
2325
  timeoutMinutes: reader.text(job["timeout-minutes"], `${what}.timeout-minutes`),
@@ -2194,6 +2355,7 @@ function parseWorkflow(file, source) {
2194
2355
  triggers,
2195
2356
  triggerLocations: triggerLocationsOf(reader, triggers, root["on"]),
2196
2357
  permissions: permissionsOf(reader, root["permissions"], "permissions"),
2358
+ permissionsLocation: root["permissions"] === void 0 ? void 0 : reader.location(["permissions"]),
2197
2359
  env: reader.texts(root["env"], "env"),
2198
2360
  jobs: Object.entries(reader.record(root["jobs"], "jobs")).map(([id, job]) => jobOf(reader, id, job)),
2199
2361
  callOutputs: Object.fromEntries(Object.entries(outputs).map(([name, output]) => [name, reader.text(reader.record(output, `on.workflow_call.outputs.${name}`)["value"], `on.workflow_call.outputs.${name}.value`) ?? ""]))
@@ -2372,6 +2534,14 @@ function callsAlways(condition) {
2372
2534
  * The default ids of junction jobs, shared by the checks that look for one.
2373
2535
  */
2374
2536
  const DEFAULT_JUNCTION_JOBS = ["required-checks"];
2537
+ /**
2538
+ * The default ids of release jobs, shared by the checks that look for one.
2539
+ */
2540
+ const DEFAULT_RELEASE_JOBS = ["release"];
2541
+ /**
2542
+ * The variables a token for the npm registry is passed in.
2543
+ */
2544
+ const REGISTRY_TOKEN_VARIABLES = ["NODE_AUTH_TOKEN", "NPM_TOKEN"];
2375
2545
  //#endregion
2376
2546
  //#region src/checks/settings-required-checks.ts
2377
2547
  /**
@@ -2522,10 +2692,6 @@ const PACKAGE_MANAGER_OPTIONS = String.raw`(?:\s+(?:(?:--filter|-F|--workspace|-
2522
2692
  * Commands that publish a package to a registry.
2523
2693
  */
2524
2694
  const PUBLISH_COMMAND = new RegExp(String.raw`^(?:(?:npm|pnpm|yarn)${PACKAGE_MANAGER_OPTIONS}(?:\s+npm)?\s+publish|(?:(?:pnpm|yarn)(?:\s+exec)?\s+|npx\s+)?(?:semantic-release|changeset\s+publish|lerna\s+publish))(?:\s|$)`, "u");
2525
- /**
2526
- * The variables a token for the registry is passed in.
2527
- */
2528
- const TOKEN_VARIABLES = ["NODE_AUTH_TOKEN", "NPM_TOKEN"];
2529
2695
  const PROVENANCE_FLAG = /--provenance(?:=true)?(?:\s|$)/u;
2530
2696
  /**
2531
2697
  * Whether the `package.json` in the working directory asks for provenance with `publishConfig.provenance: true`, which applies to every publish of that package.
@@ -2549,7 +2715,7 @@ function attestationViolations(workflow, job) {
2549
2715
  * Whether the job passes a registry token to a step, which makes its publish not tokenless: a token variable with a value other than the empty string, anywhere in the environment.
2550
2716
  */
2551
2717
  function passesToken(workflow, job) {
2552
- return [void 0, ...job.steps].some((step) => TOKEN_VARIABLES.some((variable) => (effectiveEnv(workflow, job, step)[variable] ?? "") !== ""));
2718
+ return [void 0, ...job.steps].some((step) => REGISTRY_TOKEN_VARIABLES.some((variable) => (effectiveEnv(workflow, job, step)[variable] ?? "") !== ""));
2553
2719
  }
2554
2720
  function tokenlessPublishViolations(workflow, job, provenanceInManifest) {
2555
2721
  if (accessOf(effectivePermissions(workflow, job), "id-token") !== "write" || passesToken(workflow, job)) return [];
@@ -2557,9 +2723,9 @@ function tokenlessPublishViolations(workflow, job, provenanceInManifest) {
2557
2723
  const publishes = scriptCommands(step.run).filter((line) => PUBLISH_COMMAND.test(line));
2558
2724
  if (publishes.length === 0) return [];
2559
2725
  const env = effectiveEnv(workflow, job, step);
2560
- const blanked = TOKEN_VARIABLES.every((variable) => env[variable] === "");
2726
+ const blanked = REGISTRY_TOKEN_VARIABLES.every((variable) => env[variable] === "");
2561
2727
  const provenance = provenanceInManifest || publishes.some((line) => PROVENANCE_FLAG.test(line)) || env["NPM_CONFIG_PROVENANCE"] === "true";
2562
- return blanked || provenance ? [] : [workflowViolation(workflow, "workflow-credentials/tokenless-publish-unprotected", `job '${job.id}' publishes with an OIDC identity and no token, but does not set ${TOKEN_VARIABLES.join(" and ")} to the empty string, so a token inherited from the environment is used when the OIDC exchange fails; set them to the empty string to prevent that, or request provenance so that such a publish at least carries a traceable attestation`, step.location)];
2728
+ return blanked || provenance ? [] : [workflowViolation(workflow, "workflow-credentials/tokenless-publish-unprotected", `job '${job.id}' publishes with an OIDC identity and no token, but does not set ${REGISTRY_TOKEN_VARIABLES.join(" and ")} to the empty string, so a token inherited from the environment is used when the OIDC exchange fails; set them to the empty string to prevent that, or request provenance so that such a publish at least carries a traceable attestation`, step.location)];
2563
2729
  });
2564
2730
  }
2565
2731
  /**
@@ -2577,7 +2743,6 @@ const workflowCredentials = async ({ cwd, options }) => {
2577
2743
  };
2578
2744
  //#endregion
2579
2745
  //#region src/checks/workflow-job-ordering.ts
2580
- const DEFAULT_RELEASE_JOBS = ["release"];
2581
2746
  const DEFAULT_DEPLOY_JOBS = ["deploy"];
2582
2747
  const DEFAULT_DOCS_DEPLOY_JOBS = ["docs-deploy"];
2583
2748
  const DEFAULT_BRANCH = "main";
@@ -2725,6 +2890,71 @@ const workflowMergeGroup = async ({ cwd, options }) => (await loadWorkflows(cwd,
2725
2890
  return location === void 0 || workflow.triggers.has("merge_group") ? [] : [workflowViolation(workflow, "workflow-merge-group/missing-trigger", "the workflow runs for pull requests but has no merge_group trigger, so its checks do not report for a pull request in the merge queue", location)];
2726
2891
  });
2727
2892
  //#endregion
2893
+ //#region src/checks/workflow-release-job.ts
2894
+ const CHECKOUT_ACTION$1 = "actions/checkout";
2895
+ /**
2896
+ * A value that reads the `secrets` context inside an expression.
2897
+ */
2898
+ const FROM_SECRET = /\$\{\{[^}]*\bsecrets\./u;
2899
+ /**
2900
+ * The release jobs named for a message: `release job 'a'` or `release jobs 'a' and 'b'`.
2901
+ */
2902
+ function releaseJobsText(releases) {
2903
+ const ids = releases.map((job) => `'${job.id}'`);
2904
+ return `release job${ids.length === 1 ? "" : "s"} ${ids.join(" and ")}`;
2905
+ }
2906
+ function grantsIdToken(permissions) {
2907
+ return accessOf(permissions, "id-token") === "write";
2908
+ }
2909
+ function idTokenViolations(workflow, releases) {
2910
+ const releaseIds = new Set(releases.map((job) => job.id));
2911
+ const named = releaseJobsText(releases);
2912
+ const workflowLevel = grantsIdToken(workflow.permissions) && workflow.permissionsLocation !== void 0 ? [workflowViolation(workflow, "workflow-release-job/id-token-workflow-level", `the workflow's permissions grant id-token: write to every job that declares none of its own; grant it on ${named} only`, workflow.permissionsLocation)] : [];
2913
+ const jobLevel = workflow.jobs.filter((job) => !releaseIds.has(job.id) && grantsIdToken(job.permissions)).map((job) => workflowViolation(workflow, "workflow-release-job/id-token-outside-release", `job '${job.id}' is granted id-token: write but is not a release job, so it can mint the OIDC identity npm trusted publishing accepts for this workflow; only ${named} may hold it`, job.location));
2914
+ return [...workflowLevel, ...jobLevel];
2915
+ }
2916
+ /**
2917
+ * What a checkout that does not set `persist-credentials: false` leaves in git config.
2918
+ */
2919
+ function persistedCredential(step) {
2920
+ if (step.with["ssh-key"] !== void 0) return "the SSH key";
2921
+ return step.with["token"] === void 0 ? "the job token" : "the token it was given";
2922
+ }
2923
+ function releaseJobViolations(workflow, job) {
2924
+ if (job.uses !== void 0) return [];
2925
+ const environment = job.environment === void 0 ? [workflowViolation(workflow, "workflow-release-job/missing-environment", `release job '${job.id}' declares no environment, so nothing outside the workflow file stops a branch's copy of it publishing; run it in a protected environment restricted to the default branch`, job.location)] : [];
2926
+ const checkouts = job.steps.filter((step) => stepUses(step, CHECKOUT_ACTION$1) && step.with["persist-credentials"] !== "false").map((step) => workflowViolation(workflow, "workflow-release-job/persisted-credentials", `release job '${job.id}' checks out without persist-credentials: false, which leaves ${persistedCredential(step)} in git config for every later step`, step.location));
2927
+ return [...environment, ...checkouts];
2928
+ }
2929
+ /**
2930
+ * The token variables an environment sets from a secret.
2931
+ */
2932
+ function secretTokens(env) {
2933
+ return REGISTRY_TOKEN_VARIABLES.filter((variable) => FROM_SECRET.test(env[variable] ?? ""));
2934
+ }
2935
+ function tokenBesideOidcViolations(workflow, job) {
2936
+ if (!grantsIdToken(effectivePermissions(workflow, job))) return [];
2937
+ const report = (variables, location) => variables.length === 0 ? [] : [workflowViolation(workflow, "workflow-release-job/token-beside-oidc", `job '${job.id}' holds id-token: write and passes ${variables.join(" and ")} from a secret, a long-lived npm token beside the OIDC identity; publish through trusted publishing and blank the token variables`, location)];
2938
+ return [...report(secretTokens(effectiveEnv(workflow, job, void 0)), job.location), ...job.steps.flatMap((step) => report(secretTokens(step.env), step.location))];
2939
+ }
2940
+ /**
2941
+ * npm binds a trusted publisher to the repository and the workflow file, not to a branch, so anyone who can push a branch can edit that branch's copy of the workflow, drop an `if` that keeps publishing to the default branch, and publish. What stops that has to live outside the file: a protected environment that only the default branch may deploy to, named in npm's trusted publisher too. This check holds the release job to the shape that makes that protection hold.
2942
+ *
2943
+ * In a workflow with a release job (by id, `releaseJobs`, `release` by default): `workflow-release-job/id-token-workflow-level`, the workflow's own `permissions` grant `id-token: write` (directly or through `write-all`), which every job that declares none inherits; `id-token-outside-release`, a job that is not a release job declares `id-token: write` itself, a job that calls a reusable workflow included; `missing-environment`, a release job declares no `environment`; `persisted-credentials`, a release job runs `actions/checkout` without `persist-credentials: false` (literally; an expression is unknown and reported), so the job token, a `token` input or an `ssh-key` stays in git config for every later step. A release job that pushes as a deploy key provisions the key in a step and hands it to git through that step's `GIT_SSH_COMMAND`, after a checkout that persists nothing, and is accepted as written; semantic-release authenticates its own push (`repositoryUrl` as written, else the URL with `GITHUB_TOKEN` or `GH_TOKEN` from the environment), so it needs nothing from the checkout.
2944
+ *
2945
+ * In every workflow: `token-beside-oidc`, a job whose effective permissions grant `id-token: write` sets `NODE_AUTH_TOKEN` or `NPM_TOKEN` from the `secrets` context in its workflow, job or step `env`.
2946
+ *
2947
+ * Limits: the environment's protection rules and npm's trusted publisher settings are not visible in the file and are not read; a release job that calls a reusable workflow is judged only for `id-token`, since `environment` and steps belong to the called workflow; a token passed through `with`, written to an `.npmrc` by a script, or inherited through a composite action is not seen.
2948
+ */
2949
+ const workflowReleaseJob = async ({ cwd, options }) => {
2950
+ return (await loadWorkflows(cwd, options)).flatMap((workflow) => {
2951
+ const releases = jobsNamed(workflow, options.releaseJobs ?? DEFAULT_RELEASE_JOBS);
2952
+ const releaseFindings = releases.length === 0 ? [] : idTokenViolations(workflow, releases);
2953
+ const jobFindings = workflow.jobs.flatMap((job) => [...releases.includes(job) ? releaseJobViolations(workflow, job) : [], ...tokenBesideOidcViolations(workflow, job)]);
2954
+ return [...releaseFindings, ...jobFindings];
2955
+ });
2956
+ };
2957
+ //#endregion
2728
2958
  //#region src/checks/workflow-repository-dispatch.ts
2729
2959
  const DEFAULT_BRANCHES = ["main", "master"];
2730
2960
  const DEFAULT_BRANCH_EXPRESSION = /\$\{\{\s*github\.event\.repository\.default_branch\s*\}\}/u;
@@ -3257,6 +3487,7 @@ const checks = z.strictObject({
3257
3487
  commitlint: z.exactOptional(path),
3258
3488
  release: z.exactOptional(path)
3259
3489
  }))),
3490
+ "engines-floor": z.exactOptional(enabled(z.strictObject({ packages: z.exactOptional(paths) }))),
3260
3491
  "dockerfile-package-manager": z.exactOptional(enabled(z.strictObject({
3261
3492
  dockerfiles: z.exactOptional(paths),
3262
3493
  exclude: z.exactOptional(paths),
@@ -3301,6 +3532,10 @@ const checks = z.strictObject({
3301
3532
  }))),
3302
3533
  "workflow-version-single-source": z.exactOptional(enabled(z.strictObject(selection))),
3303
3534
  "workflow-credentials": z.exactOptional(enabled(z.strictObject(selection))),
3535
+ "workflow-release-job": z.exactOptional(enabled(z.strictObject({
3536
+ ...selection,
3537
+ releaseJobs: z.exactOptional(names)
3538
+ }))),
3304
3539
  "workflow-repository-dispatch": z.exactOptional(enabled(z.strictObject({
3305
3540
  ...selection,
3306
3541
  defaultBranches: z.exactOptional(names),
@@ -3546,6 +3781,12 @@ const registry = {
3546
3781
  select: (checks) => enabledOptions(checks["commit-types"]),
3547
3782
  check: commitTypes
3548
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
+ }),
3549
3790
  "dockerfile-package-manager": plain({
3550
3791
  name: "dockerfile-package-manager",
3551
3792
  description: "A package manager version pinned in a Dockerfile is the version packageManager names",
@@ -3594,6 +3835,12 @@ const registry = {
3594
3835
  select: (checks) => enabledOptions(checks["workflow-credentials"]),
3595
3836
  check: workflowCredentials
3596
3837
  }),
3838
+ "workflow-release-job": plain({
3839
+ name: "workflow-release-job",
3840
+ description: "The release job alone holds id-token, runs in an environment and persists no credentials, and no job with id-token passes an npm token secret",
3841
+ select: (checks) => enabledOptions(checks["workflow-release-job"]),
3842
+ check: workflowReleaseJob
3843
+ }),
3597
3844
  "workflow-repository-dispatch": plain({
3598
3845
  name: "workflow-repository-dispatch",
3599
3846
  description: "A repository_dispatch handler does not push to the default branch, auto-merge unguarded, or rely on the default token",
@@ -3845,4 +4092,4 @@ function proveExhaustive(input) {
3845
4092
  }) };
3846
4093
  }
3847
4094
  //#endregion
3848
- 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, 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 };