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/cli.js CHANGED
@@ -8,8 +8,9 @@ import { escapePath, glob, isDynamicPattern } from "tinyglobby";
8
8
  import { Node, Project, SyntaxKind } from "ts-morph";
9
9
  import { createJitiLoader, validateStandard } from "cosmiconfig-extends";
10
10
  import { readFile, realpath } from "node:fs/promises";
11
- import { execFile } from "node:child_process";
11
+ import { Range, subset, valid, validRange } from "semver";
12
12
  import { LineCounter, isMap, isNode, isScalar, isSeq, parse, parseDocument } from "yaml";
13
+ import { execFile } from "node:child_process";
13
14
  import { findNodeAtLocation, parse as parse$1, parseTree, printParseErrorCode } from "jsonc-parser";
14
15
  import { parse as parse$2 } from "smol-toml";
15
16
  import { defineSection, layoutSection, loadSection } from "@exadev/config";
@@ -852,6 +853,301 @@ const dockerfilePackageManager = async ({ cwd, options }) => {
852
853
  }
853
854
  return violations;
854
855
  };
856
+ //#endregion
857
+ //#region src/workspace/packages.ts
858
+ function segmentsOf(path) {
859
+ return path.split("/").filter((segment) => segment !== "" && segment !== ".");
860
+ }
861
+ function groupRoot(group) {
862
+ return segmentsOf(group.path ?? group.name);
863
+ }
864
+ function startsWithSegments(segments, prefix) {
865
+ return prefix.every((segment, index) => segments[index] === segment);
866
+ }
867
+ /**
868
+ * The group whose root is the longest prefix of `dir`; the first declared wins a tie. `undefined` when no group owns the directory.
869
+ */
870
+ function owningGroup(dir, groups) {
871
+ const segments = segmentsOf(dir);
872
+ let best;
873
+ let bestLength = -1;
874
+ for (const group of groups) {
875
+ const root = groupRoot(group);
876
+ if (root.length > bestLength && startsWithSegments(segments, root)) {
877
+ best = group;
878
+ bestLength = root.length;
879
+ }
880
+ }
881
+ return best;
882
+ }
883
+ function rankOf(name, group, layout) {
884
+ if (name !== void 0) {
885
+ const rule = layout.nameRanks?.find((candidate) => new RegExp(candidate.pattern, "u").test(name));
886
+ if (rule !== void 0) return rule.rank;
887
+ }
888
+ return group.rank ?? layout.defaultRank;
889
+ }
890
+ /**
891
+ * The name without its npm scope: `@scope/store-cli` gives `store-cli`.
892
+ */
893
+ function unscoped(name) {
894
+ return name.replace(/^@[^/]+\//u, "");
895
+ }
896
+ /**
897
+ * The longest slice value that is the whole unscoped name or its prefix up to a `-`.
898
+ */
899
+ function sliceByNamePrefix(name, known) {
900
+ const bare = unscoped(name);
901
+ return [...known].filter((candidate) => bare === candidate || bare.startsWith(`${candidate}-`)).sort((a, b) => b.length - a.length)[0];
902
+ }
903
+ /**
904
+ * Classify discovered packages by the layout: group, rank and slice. Pure.
905
+ *
906
+ * 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.
907
+ */
908
+ function classifyPackages(discovered, layout) {
909
+ const owned = discovered.map((found) => {
910
+ const group = owningGroup(found.dir, layout.groups);
911
+ 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`);
912
+ return {
913
+ found,
914
+ group
915
+ };
916
+ });
917
+ const knownSlices = /* @__PURE__ */ new Set();
918
+ for (const { found, group } of owned) if (group.slice !== void 0 && "segment" in group.slice) {
919
+ const value = segmentsOf(found.dir).slice(groupRoot(group).length)[group.slice.segment];
920
+ if (value !== void 0) knownSlices.add(value);
921
+ }
922
+ const packages = owned.map(({ found, group }) => {
923
+ let slice;
924
+ if (group.slice !== void 0 && "segment" in group.slice) slice = segmentsOf(found.dir).slice(groupRoot(group).length)[group.slice.segment];
925
+ else if (group.slice !== void 0 && found.name !== void 0) slice = sliceByNamePrefix(found.name, knownSlices);
926
+ return {
927
+ ...found,
928
+ group: group.name,
929
+ rank: rankOf(found.name, group, layout),
930
+ slice
931
+ };
932
+ });
933
+ for (const outer of packages) {
934
+ const nested = packages.find((inner) => inner.dir !== outer.dir && inner.dir.startsWith(`${outer.dir}/`));
935
+ if (nested !== void 0) throw new ConformanceError(`the package directory '${nested.dir}' is inside the package directory '${outer.dir}'; workspace packages must not nest`);
936
+ }
937
+ return packages;
938
+ }
939
+ /**
940
+ * The workspace root: `layout.root` resolved against `cwd`, or `cwd` itself.
941
+ */
942
+ function workspaceRoot(cwd, layout) {
943
+ return layout.root === void 0 ? cwd : resolve(cwd, layout.root);
944
+ }
945
+ const PNPM_WORKSPACE_FILE = "pnpm-workspace.yaml";
946
+ /**
947
+ * 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.
948
+ */
949
+ async function pnpmWorkspacePatterns(root) {
950
+ const file = join(root, PNPM_WORKSPACE_FILE);
951
+ if (!existsSync(file)) return;
952
+ let source;
953
+ try {
954
+ source = await readFile(file, "utf8");
955
+ } catch (error) {
956
+ throw new ConformanceError(`${file} cannot be read`, { cause: error });
957
+ }
958
+ const document = parse(source);
959
+ if (typeof document !== "object" || document === null || !("packages" in document) || !Array.isArray(document.packages)) throw new ConformanceError(`${file} has no 'packages' list`);
960
+ const patterns = document.packages;
961
+ if (!patterns.every((pattern) => typeof pattern === "string")) throw new ConformanceError(`${file}: every entry of 'packages' must be a string`);
962
+ return patterns;
963
+ }
964
+ /**
965
+ * The package globs: `layout.packages`, else the `packages` list of `pnpm-workspace.yaml` in `root`.
966
+ */
967
+ async function workspacePatterns(root, layout) {
968
+ if (layout.packages !== void 0) return layout.packages;
969
+ const patterns = await pnpmWorkspacePatterns(root);
970
+ if (patterns === void 0) throw new ConformanceError(`the layout has no 'packages' and ${join(root, PNPM_WORKSPACE_FILE)} does not exist`);
971
+ return patterns;
972
+ }
973
+ async function readName(manifest) {
974
+ let content;
975
+ try {
976
+ content = JSON.parse(await readFile(manifest, "utf8"));
977
+ } catch (error) {
978
+ throw new ConformanceError(`${manifest} cannot be read as JSON`, { cause: error });
979
+ }
980
+ if (typeof content === "object" && content !== null && "name" in content && typeof content.name === "string") return content.name;
981
+ }
982
+ /**
983
+ * 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.
984
+ */
985
+ async function discoverPackages(root, patterns) {
986
+ const manifests = patterns.map((pattern) => pattern.startsWith("!") ? `!${pattern.slice(1)}/**` : `${pattern}/package.json`);
987
+ const dirs = (await glob(manifests, {
988
+ cwd: root,
989
+ dot: true,
990
+ ignore: [INSTALLED_DEPENDENCIES_GLOB],
991
+ onlyFiles: true
992
+ })).map((manifest) => manifest.replace(/\/?package\.json$/u, "")).filter((dir) => dir !== "");
993
+ return Promise.all([...new Set(dirs)].sort().map(async (dir) => ({
994
+ dir,
995
+ name: await readName(join(root, dir, "package.json"))
996
+ })));
997
+ }
998
+ /**
999
+ * The layout's packages found under the workspace root.
1000
+ */
1001
+ async function readWorkspacePackages(cwd, layout) {
1002
+ const root = workspaceRoot(cwd, layout);
1003
+ return classifyPackages(await discoverPackages(root, await workspacePatterns(root, layout)), layout);
1004
+ }
1005
+ //#endregion
1006
+ //#region src/checks/engines-floor.ts
1007
+ /**
1008
+ * The pattern that selects the root package, the `package.json` in the directory the checks run in.
1009
+ */
1010
+ const ROOT_PACKAGE = ".";
1011
+ const MANIFEST = "package.json";
1012
+ const ANY_INSTALLED_COPY = {};
1013
+ const DEPENDENCY_FIELDS = [
1014
+ {
1015
+ field: "dependencies",
1016
+ noun: "dependency",
1017
+ mayBeAbsent: () => false,
1018
+ judgedCopy: () => ANY_INSTALLED_COPY
1019
+ },
1020
+ {
1021
+ field: "optionalDependencies",
1022
+ noun: "optional dependency",
1023
+ mayBeAbsent: () => true,
1024
+ judgedCopy: () => ANY_INSTALLED_COPY
1025
+ },
1026
+ {
1027
+ field: "peerDependencies",
1028
+ noun: "peer dependency",
1029
+ mayBeAbsent: (manifest, name) => {
1030
+ const meta = manifest["peerDependenciesMeta"];
1031
+ const entry = isRecord(meta) ? meta[name] : void 0;
1032
+ return isRecord(entry) && entry["optional"] === true;
1033
+ },
1034
+ judgedCopy: (spec) => {
1035
+ const version = onlyAdmittedVersion(spec);
1036
+ return version === null ? void 0 : { version };
1037
+ }
1038
+ }
1039
+ ];
1040
+ /**
1041
+ * 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).
1042
+ */
1043
+ function onlyAdmittedVersion(spec) {
1044
+ if (validRange(spec) === null) return null;
1045
+ const [comparators, ...others] = new Range(spec).set;
1046
+ if (comparators === void 0 || others.length > 0 || comparators.length !== 1) return null;
1047
+ const [comparator] = comparators;
1048
+ if (comparator?.operator !== "") return null;
1049
+ return valid(comparator.value);
1050
+ }
1051
+ async function readManifest(cwd, file) {
1052
+ let content;
1053
+ try {
1054
+ content = JSON.parse(await readFile(resolve(cwd, file), "utf8"));
1055
+ } catch (error) {
1056
+ throw new ConformanceError(`${file} cannot be read as JSON`, { cause: error });
1057
+ }
1058
+ if (!isRecord(content)) throw new ConformanceError(`${file} is not a JSON object`);
1059
+ return content;
1060
+ }
1061
+ /**
1062
+ * 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.
1063
+ */
1064
+ function nodeRange(manifest, file) {
1065
+ const engines = manifest["engines"];
1066
+ if (engines === void 0) return;
1067
+ if (!isRecord(engines)) throw new ConformanceError(`${file}: engines must be an object`);
1068
+ const node = engines["node"];
1069
+ if (node === void 0) return;
1070
+ if (typeof node !== "string") throw new ConformanceError(`${file}: engines.node must be a string, not ${JSON.stringify(node)}`);
1071
+ if (validRange(node) === null) throw new ConformanceError(`${file}: engines.node '${node}' is not a valid semver range`);
1072
+ return node;
1073
+ }
1074
+ /**
1075
+ * 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.
1076
+ */
1077
+ function dependencyEntries(manifest, field, file) {
1078
+ const listed = manifest[field];
1079
+ if (listed === void 0) return [];
1080
+ if (!isRecord(listed)) throw new ConformanceError(`${file}: ${field} must be an object`);
1081
+ return Object.keys(listed).sort().map((name) => {
1082
+ const spec = listed[name];
1083
+ if (typeof spec !== "string") throw new ConformanceError(`${file}: ${field}.${name} must be a string, not ${JSON.stringify(spec)}`);
1084
+ return [name, spec];
1085
+ });
1086
+ }
1087
+ /**
1088
+ * 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.
1089
+ */
1090
+ function installedManifest(cwd, packageDirectory, name) {
1091
+ const top = resolve(cwd);
1092
+ for (let directory = resolve(cwd, packageDirectory);; directory = dirname(directory)) {
1093
+ const candidate = join(directory, "node_modules", name, MANIFEST);
1094
+ if (existsSync(candidate)) return relativePosix(cwd, candidate);
1095
+ if (directory === top) return;
1096
+ }
1097
+ }
1098
+ /**
1099
+ * 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.
1100
+ */
1101
+ async function packageDirectories(cwd, options) {
1102
+ const patterns = options.packages ?? [ROOT_PACKAGE, ...await pnpmWorkspacePatterns(cwd) ?? []];
1103
+ const members = await discoverPackages(cwd, patterns.filter((pattern) => pattern !== ROOT_PACKAGE));
1104
+ const directories = [...patterns.includes(ROOT_PACKAGE) ? [ROOT_PACKAGE] : [], ...members.map((member) => member.dir)];
1105
+ if (directories.length === 0) throw new ConformanceError(`no package.json is selected by ${patterns.join(", ")}`);
1106
+ return directories;
1107
+ }
1108
+ /**
1109
+ * 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.
1110
+ *
1111
+ * 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.
1112
+ *
1113
+ * 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.
1114
+ *
1115
+ * 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.
1116
+ */
1117
+ const enginesFloor = async ({ cwd, options }) => {
1118
+ const violations = [];
1119
+ for (const directory of await packageDirectories(cwd, options)) {
1120
+ const file = posix.join(directory, MANIFEST);
1121
+ const manifest = await readManifest(cwd, file);
1122
+ const own = nodeRange(manifest, file);
1123
+ const seen = /* @__PURE__ */ new Set();
1124
+ for (const { field, noun, mayBeAbsent, judgedCopy } of DEPENDENCY_FIELDS) for (const [name, spec] of dependencyEntries(manifest, field, file).filter(([candidate]) => !seen.has(candidate))) {
1125
+ seen.add(name);
1126
+ const copy = judgedCopy(spec);
1127
+ if (copy === void 0) continue;
1128
+ const installed = installedManifest(cwd, directory, name);
1129
+ if (installed === void 0) {
1130
+ if (mayBeAbsent(manifest, name)) continue;
1131
+ 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`);
1132
+ }
1133
+ const dependency = await readManifest(cwd, installed);
1134
+ 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`);
1135
+ const theirs = nodeRange(dependency, installed);
1136
+ if (theirs === void 0) continue;
1137
+ if (own === void 0) violations.push({
1138
+ code: "engines-floor/no-engines",
1139
+ 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`,
1140
+ file
1141
+ });
1142
+ else if (!subset(own, theirs)) violations.push({
1143
+ code: "engines-floor/wider-than-dependency",
1144
+ message: `engines.node '${own}' admits Node versions that the ${noun} '${name}' rejects with engines.node '${theirs}'; narrow it to a subset of that range`,
1145
+ file
1146
+ });
1147
+ }
1148
+ }
1149
+ return violations;
1150
+ };
855
1151
  async function git(cwd, args) {
856
1152
  return new Promise((resolve, reject) => {
857
1153
  execFile("git", [...args], {
@@ -1098,6 +1394,9 @@ function runsOnOf(reader, value, what) {
1098
1394
  group
1099
1395
  };
1100
1396
  }
1397
+ function environmentOf(reader, value, what) {
1398
+ return isRecord(value) ? reader.text(value["name"], `${what}.name`) : reader.text(value, what);
1399
+ }
1101
1400
  function stepOf(reader, value, jobPath, index) {
1102
1401
  const path = [
1103
1402
  ...jobPath,
@@ -1130,6 +1429,7 @@ function jobOf(reader, id, value) {
1130
1429
  needs: reader.list(job["needs"], `${what}.needs`),
1131
1430
  condition: reader.text(job["if"], `${what}.if`),
1132
1431
  permissions: permissionsOf(reader, job["permissions"], `${what}.permissions`),
1432
+ environment: environmentOf(reader, job["environment"], `${what}.environment`),
1133
1433
  env: reader.texts(job["env"], `${what}.env`),
1134
1434
  runsOn: runsOnOf(reader, job["runs-on"], `${what}.runs-on`),
1135
1435
  timeoutMinutes: reader.text(job["timeout-minutes"], `${what}.timeout-minutes`),
@@ -1165,6 +1465,7 @@ function parseWorkflow(file, source) {
1165
1465
  triggers,
1166
1466
  triggerLocations: triggerLocationsOf(reader, triggers, root["on"]),
1167
1467
  permissions: permissionsOf(reader, root["permissions"], "permissions"),
1468
+ permissionsLocation: root["permissions"] === void 0 ? void 0 : reader.location(["permissions"]),
1168
1469
  env: reader.texts(root["env"], "env"),
1169
1470
  jobs: Object.entries(reader.record(root["jobs"], "jobs")).map(([id, job]) => jobOf(reader, id, job)),
1170
1471
  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`) ?? ""]))
@@ -1343,6 +1644,14 @@ function callsAlways(condition) {
1343
1644
  * The default ids of junction jobs, shared by the checks that look for one.
1344
1645
  */
1345
1646
  const DEFAULT_JUNCTION_JOBS = ["required-checks"];
1647
+ /**
1648
+ * The default ids of release jobs, shared by the checks that look for one.
1649
+ */
1650
+ const DEFAULT_RELEASE_JOBS = ["release"];
1651
+ /**
1652
+ * The variables a token for the npm registry is passed in.
1653
+ */
1654
+ const REGISTRY_TOKEN_VARIABLES = ["NODE_AUTH_TOKEN", "NPM_TOKEN"];
1346
1655
  //#endregion
1347
1656
  //#region src/checks/settings-required-checks.ts
1348
1657
  /**
@@ -1478,10 +1787,6 @@ const PACKAGE_MANAGER_OPTIONS = String.raw`(?:\s+(?:(?:--filter|-F|--workspace|-
1478
1787
  * Commands that publish a package to a registry.
1479
1788
  */
1480
1789
  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");
1481
- /**
1482
- * The variables a token for the registry is passed in.
1483
- */
1484
- const TOKEN_VARIABLES = ["NODE_AUTH_TOKEN", "NPM_TOKEN"];
1485
1790
  const PROVENANCE_FLAG = /--provenance(?:=true)?(?:\s|$)/u;
1486
1791
  /**
1487
1792
  * Whether the `package.json` in the working directory asks for provenance with `publishConfig.provenance: true`, which applies to every publish of that package.
@@ -1505,7 +1810,7 @@ function attestationViolations(workflow, job) {
1505
1810
  * 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.
1506
1811
  */
1507
1812
  function passesToken(workflow, job) {
1508
- return [void 0, ...job.steps].some((step) => TOKEN_VARIABLES.some((variable) => (effectiveEnv(workflow, job, step)[variable] ?? "") !== ""));
1813
+ return [void 0, ...job.steps].some((step) => REGISTRY_TOKEN_VARIABLES.some((variable) => (effectiveEnv(workflow, job, step)[variable] ?? "") !== ""));
1509
1814
  }
1510
1815
  function tokenlessPublishViolations(workflow, job, provenanceInManifest) {
1511
1816
  if (accessOf(effectivePermissions(workflow, job), "id-token") !== "write" || passesToken(workflow, job)) return [];
@@ -1513,9 +1818,9 @@ function tokenlessPublishViolations(workflow, job, provenanceInManifest) {
1513
1818
  const publishes = scriptCommands(step.run).filter((line) => PUBLISH_COMMAND.test(line));
1514
1819
  if (publishes.length === 0) return [];
1515
1820
  const env = effectiveEnv(workflow, job, step);
1516
- const blanked = TOKEN_VARIABLES.every((variable) => env[variable] === "");
1821
+ const blanked = REGISTRY_TOKEN_VARIABLES.every((variable) => env[variable] === "");
1517
1822
  const provenance = provenanceInManifest || publishes.some((line) => PROVENANCE_FLAG.test(line)) || env["NPM_CONFIG_PROVENANCE"] === "true";
1518
- 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)];
1823
+ 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)];
1519
1824
  });
1520
1825
  }
1521
1826
  /**
@@ -1533,7 +1838,6 @@ const workflowCredentials = async ({ cwd, options }) => {
1533
1838
  };
1534
1839
  //#endregion
1535
1840
  //#region src/checks/workflow-job-ordering.ts
1536
- const DEFAULT_RELEASE_JOBS = ["release"];
1537
1841
  const DEFAULT_DEPLOY_JOBS = ["deploy"];
1538
1842
  const DEFAULT_DOCS_DEPLOY_JOBS = ["docs-deploy"];
1539
1843
  const DEFAULT_BRANCH = "main";
@@ -1681,6 +1985,71 @@ const workflowMergeGroup = async ({ cwd, options }) => (await loadWorkflows(cwd,
1681
1985
  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)];
1682
1986
  });
1683
1987
  //#endregion
1988
+ //#region src/checks/workflow-release-job.ts
1989
+ const CHECKOUT_ACTION$1 = "actions/checkout";
1990
+ /**
1991
+ * A value that reads the `secrets` context inside an expression.
1992
+ */
1993
+ const FROM_SECRET = /\$\{\{[^}]*\bsecrets\./u;
1994
+ /**
1995
+ * The release jobs named for a message: `release job 'a'` or `release jobs 'a' and 'b'`.
1996
+ */
1997
+ function releaseJobsText(releases) {
1998
+ const ids = releases.map((job) => `'${job.id}'`);
1999
+ return `release job${ids.length === 1 ? "" : "s"} ${ids.join(" and ")}`;
2000
+ }
2001
+ function grantsIdToken(permissions) {
2002
+ return accessOf(permissions, "id-token") === "write";
2003
+ }
2004
+ function idTokenViolations(workflow, releases) {
2005
+ const releaseIds = new Set(releases.map((job) => job.id));
2006
+ const named = releaseJobsText(releases);
2007
+ 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)] : [];
2008
+ 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));
2009
+ return [...workflowLevel, ...jobLevel];
2010
+ }
2011
+ /**
2012
+ * What a checkout that does not set `persist-credentials: false` leaves in git config.
2013
+ */
2014
+ function persistedCredential(step) {
2015
+ if (step.with["ssh-key"] !== void 0) return "the SSH key";
2016
+ return step.with["token"] === void 0 ? "the job token" : "the token it was given";
2017
+ }
2018
+ function releaseJobViolations(workflow, job) {
2019
+ if (job.uses !== void 0) return [];
2020
+ 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)] : [];
2021
+ 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));
2022
+ return [...environment, ...checkouts];
2023
+ }
2024
+ /**
2025
+ * The token variables an environment sets from a secret.
2026
+ */
2027
+ function secretTokens(env) {
2028
+ return REGISTRY_TOKEN_VARIABLES.filter((variable) => FROM_SECRET.test(env[variable] ?? ""));
2029
+ }
2030
+ function tokenBesideOidcViolations(workflow, job) {
2031
+ if (!grantsIdToken(effectivePermissions(workflow, job))) return [];
2032
+ 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)];
2033
+ return [...report(secretTokens(effectiveEnv(workflow, job, void 0)), job.location), ...job.steps.flatMap((step) => report(secretTokens(step.env), step.location))];
2034
+ }
2035
+ /**
2036
+ * 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.
2037
+ *
2038
+ * 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.
2039
+ *
2040
+ * 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`.
2041
+ *
2042
+ * 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.
2043
+ */
2044
+ const workflowReleaseJob = async ({ cwd, options }) => {
2045
+ return (await loadWorkflows(cwd, options)).flatMap((workflow) => {
2046
+ const releases = jobsNamed(workflow, options.releaseJobs ?? DEFAULT_RELEASE_JOBS);
2047
+ const releaseFindings = releases.length === 0 ? [] : idTokenViolations(workflow, releases);
2048
+ const jobFindings = workflow.jobs.flatMap((job) => [...releases.includes(job) ? releaseJobViolations(workflow, job) : [], ...tokenBesideOidcViolations(workflow, job)]);
2049
+ return [...releaseFindings, ...jobFindings];
2050
+ });
2051
+ };
2052
+ //#endregion
1684
2053
  //#region src/checks/workflow-repository-dispatch.ts
1685
2054
  const DEFAULT_BRANCHES = ["main", "master"];
1686
2055
  const DEFAULT_BRANCH_EXPRESSION = /\$\{\{\s*github\.event\.repository\.default_branch\s*\}\}/u;
@@ -2272,145 +2641,6 @@ function unresolvedNameImportRule(packages) {
2272
2641
  };
2273
2642
  }
2274
2643
  //#endregion
2275
- //#region src/workspace/packages.ts
2276
- function segmentsOf(path) {
2277
- return path.split("/").filter((segment) => segment !== "" && segment !== ".");
2278
- }
2279
- function groupRoot(group) {
2280
- return segmentsOf(group.path ?? group.name);
2281
- }
2282
- function startsWithSegments(segments, prefix) {
2283
- return prefix.every((segment, index) => segments[index] === segment);
2284
- }
2285
- /**
2286
- * The group whose root is the longest prefix of `dir`; the first declared wins a tie. `undefined` when no group owns the directory.
2287
- */
2288
- function owningGroup(dir, groups) {
2289
- const segments = segmentsOf(dir);
2290
- let best;
2291
- let bestLength = -1;
2292
- for (const group of groups) {
2293
- const root = groupRoot(group);
2294
- if (root.length > bestLength && startsWithSegments(segments, root)) {
2295
- best = group;
2296
- bestLength = root.length;
2297
- }
2298
- }
2299
- return best;
2300
- }
2301
- function rankOf(name, group, layout) {
2302
- if (name !== void 0) {
2303
- const rule = layout.nameRanks?.find((candidate) => new RegExp(candidate.pattern, "u").test(name));
2304
- if (rule !== void 0) return rule.rank;
2305
- }
2306
- return group.rank ?? layout.defaultRank;
2307
- }
2308
- /**
2309
- * The name without its npm scope: `@scope/store-cli` gives `store-cli`.
2310
- */
2311
- function unscoped(name) {
2312
- return name.replace(/^@[^/]+\//u, "");
2313
- }
2314
- /**
2315
- * The longest slice value that is the whole unscoped name or its prefix up to a `-`.
2316
- */
2317
- function sliceByNamePrefix(name, known) {
2318
- const bare = unscoped(name);
2319
- return [...known].filter((candidate) => bare === candidate || bare.startsWith(`${candidate}-`)).sort((a, b) => b.length - a.length)[0];
2320
- }
2321
- /**
2322
- * Classify discovered packages by the layout: group, rank and slice. Pure.
2323
- *
2324
- * 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.
2325
- */
2326
- function classifyPackages(discovered, layout) {
2327
- const owned = discovered.map((found) => {
2328
- const group = owningGroup(found.dir, layout.groups);
2329
- 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`);
2330
- return {
2331
- found,
2332
- group
2333
- };
2334
- });
2335
- const knownSlices = /* @__PURE__ */ new Set();
2336
- for (const { found, group } of owned) if (group.slice !== void 0 && "segment" in group.slice) {
2337
- const value = segmentsOf(found.dir).slice(groupRoot(group).length)[group.slice.segment];
2338
- if (value !== void 0) knownSlices.add(value);
2339
- }
2340
- const packages = owned.map(({ found, group }) => {
2341
- let slice;
2342
- if (group.slice !== void 0 && "segment" in group.slice) slice = segmentsOf(found.dir).slice(groupRoot(group).length)[group.slice.segment];
2343
- else if (group.slice !== void 0 && found.name !== void 0) slice = sliceByNamePrefix(found.name, knownSlices);
2344
- return {
2345
- ...found,
2346
- group: group.name,
2347
- rank: rankOf(found.name, group, layout),
2348
- slice
2349
- };
2350
- });
2351
- for (const outer of packages) {
2352
- const nested = packages.find((inner) => inner.dir !== outer.dir && inner.dir.startsWith(`${outer.dir}/`));
2353
- if (nested !== void 0) throw new ConformanceError(`the package directory '${nested.dir}' is inside the package directory '${outer.dir}'; workspace packages must not nest`);
2354
- }
2355
- return packages;
2356
- }
2357
- /**
2358
- * The workspace root: `layout.root` resolved against `cwd`, or `cwd` itself.
2359
- */
2360
- function workspaceRoot(cwd, layout) {
2361
- return layout.root === void 0 ? cwd : resolve(cwd, layout.root);
2362
- }
2363
- /**
2364
- * The package globs: `layout.packages`, else the `packages` list of `pnpm-workspace.yaml` in `root`.
2365
- */
2366
- async function workspacePatterns(root, layout) {
2367
- if (layout.packages !== void 0) return layout.packages;
2368
- const file = join(root, "pnpm-workspace.yaml");
2369
- let source;
2370
- try {
2371
- source = await readFile(file, "utf8");
2372
- } catch (error) {
2373
- throw new ConformanceError(`the layout has no 'packages' and ${file} cannot be read`, { cause: error });
2374
- }
2375
- const document = parse(source);
2376
- if (typeof document !== "object" || document === null || !("packages" in document) || !Array.isArray(document.packages)) throw new ConformanceError(`${file} has no 'packages' list`);
2377
- const patterns = document.packages;
2378
- if (!patterns.every((pattern) => typeof pattern === "string")) throw new ConformanceError(`${file}: every entry of 'packages' must be a string`);
2379
- return patterns;
2380
- }
2381
- async function readName(manifest) {
2382
- let content;
2383
- try {
2384
- content = JSON.parse(await readFile(manifest, "utf8"));
2385
- } catch (error) {
2386
- throw new ConformanceError(`${manifest} cannot be read as JSON`, { cause: error });
2387
- }
2388
- if (typeof content === "object" && content !== null && "name" in content && typeof content.name === "string") return content.name;
2389
- }
2390
- /**
2391
- * 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.
2392
- */
2393
- async function discoverPackages(root, patterns) {
2394
- const manifests = patterns.map((pattern) => pattern.startsWith("!") ? `!${pattern.slice(1)}/**` : `${pattern}/package.json`);
2395
- const dirs = (await glob(manifests, {
2396
- cwd: root,
2397
- dot: true,
2398
- ignore: [INSTALLED_DEPENDENCIES_GLOB],
2399
- onlyFiles: true
2400
- })).map((manifest) => manifest.replace(/\/?package\.json$/u, "")).filter((dir) => dir !== "");
2401
- return Promise.all([...new Set(dirs)].sort().map(async (dir) => ({
2402
- dir,
2403
- name: await readName(join(root, dir, "package.json"))
2404
- })));
2405
- }
2406
- /**
2407
- * The layout's packages found under the workspace root.
2408
- */
2409
- async function readWorkspacePackages(cwd, layout) {
2410
- const root = workspaceRoot(cwd, layout);
2411
- return classifyPackages(await discoverPackages(root, await workspacePatterns(root, layout)), layout);
2412
- }
2413
- //#endregion
2414
2644
  //#region src/checks/import-graph.ts
2415
2645
  /**
2416
2646
  * Paths left out of the import graph unless the options say otherwise: a package's own installed dependencies.
@@ -3332,6 +3562,12 @@ const registry = {
3332
3562
  select: (checks) => enabledOptions(checks["commit-types"]),
3333
3563
  check: commitTypes
3334
3564
  }),
3565
+ "engines-floor": plain({
3566
+ name: "engines-floor",
3567
+ description: "A package's engines.node range admits no Node version that one of its installed dependencies rejects",
3568
+ select: (checks) => enabledOptions(checks["engines-floor"]),
3569
+ check: enginesFloor
3570
+ }),
3335
3571
  "dockerfile-package-manager": plain({
3336
3572
  name: "dockerfile-package-manager",
3337
3573
  description: "A package manager version pinned in a Dockerfile is the version packageManager names",
@@ -3380,6 +3616,12 @@ const registry = {
3380
3616
  select: (checks) => enabledOptions(checks["workflow-credentials"]),
3381
3617
  check: workflowCredentials
3382
3618
  }),
3619
+ "workflow-release-job": plain({
3620
+ name: "workflow-release-job",
3621
+ 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",
3622
+ select: (checks) => enabledOptions(checks["workflow-release-job"]),
3623
+ check: workflowReleaseJob
3624
+ }),
3383
3625
  "workflow-repository-dispatch": plain({
3384
3626
  name: "workflow-repository-dispatch",
3385
3627
  description: "A repository_dispatch handler does not push to the default branch, auto-merge unguarded, or rely on the default token",
@@ -3530,6 +3772,7 @@ const checks = z.strictObject({
3530
3772
  commitlint: z.exactOptional(path),
3531
3773
  release: z.exactOptional(path)
3532
3774
  }))),
3775
+ "engines-floor": z.exactOptional(enabled(z.strictObject({ packages: z.exactOptional(paths) }))),
3533
3776
  "dockerfile-package-manager": z.exactOptional(enabled(z.strictObject({
3534
3777
  dockerfiles: z.exactOptional(paths),
3535
3778
  exclude: z.exactOptional(paths),
@@ -3574,6 +3817,10 @@ const checks = z.strictObject({
3574
3817
  }))),
3575
3818
  "workflow-version-single-source": z.exactOptional(enabled(z.strictObject(selection))),
3576
3819
  "workflow-credentials": z.exactOptional(enabled(z.strictObject(selection))),
3820
+ "workflow-release-job": z.exactOptional(enabled(z.strictObject({
3821
+ ...selection,
3822
+ releaseJobs: z.exactOptional(names)
3823
+ }))),
3577
3824
  "workflow-repository-dispatch": z.exactOptional(enabled(z.strictObject({
3578
3825
  ...selection,
3579
3826
  defaultBranches: z.exactOptional(names),