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/README.md +3 -1
- package/dist/cli.js +304 -140
- package/dist/index.cjs +305 -140
- package/dist/index.d.cts +23 -1
- package/dist/index.d.ts +23 -1
- package/dist/index.js +304 -140
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -159,6 +159,8 @@ Code: `derived-types/not-derived`, reported at the type's declaration. Limits: a
|
|
|
159
159
|
|
|
160
160
|
**`commit-types`** verifies that the commit types commitlint accepts are accounted for in the semantic-release config. Options: `commitlint` (`commitlint.config.ts`) and `release` (`release.config.ts`). Both files are evaluated through the shared jiti loader, so a list derived from one shared constant compares as its values. The `alias` and `fsCache` of `runChecks`' `configFiles` apply to them as to the sections; `trust` and `merge` are for `extends` and do not. A commit type that no conventional preset knows (`PRESET_COMMIT_TYPES` lists the ones that do) must have a `releaseRules` entry in the `@semantic-release/commit-analyzer` options and, when `presetConfig.types` of `@semantic-release/release-notes-generator` is set, a changelog section. Every `releaseRules` type and every changelog type must be a commit type, or it can never apply. A preset type may be left out of either list, which is how a release rule list normally omits the types that do not release. Codes: `commit-types/no-release-rule`, `release-rule-not-a-commit-type`, `no-changelog-section`, `changelog-section-not-a-commit-type`, `no-type-enum` (a type no preset knows is listed but commitlint sets no `type-enum`, so the commit types cannot be read) and `missing-config`. Without `presetConfig.types` (or without a release notes generator) no changelog sections are listed, so none is required. Limits: `type-enum` is read as `[level, 'always', [types]]`, the only accepted types; `'never'` forbids the types it lists (a release rule or section for one is reported) and enumerates none, so nothing is required of any other type, and level 0 accepts every type; any other shape of the rule fails the check; `releaseRules` given as a module path (a string, which semantic-release supports) is not read and fails the check; types that come only from an extended preset are not read; rules that name no type (`{ breaking: true }`) are ignored; a changelog list that omits a preset type is not reported; the configs are executed, so they must be trusted.
|
|
161
161
|
|
|
162
|
+
**`engines-floor`** verifies that 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, so the package admits no Node version that something it needs at run time rejects. A floor comparison is not enough: a package declaring `>=20` over a dependency requiring `^20.19.0 || ^22.13.0 || >=24` admits 20.0 to 20.18, 21 and 23, which the dependency rejects, so the test is `semver.subset(package, dependency)`. Option: `packages`, globs of the package directories in the form of the `packages` list of `pnpm-workspace.yaml` (a `!` pattern excludes, `.` is the root package); the root package and the `packages` of `pnpm-workspace.yaml`, when it exists, when omitted. Codes: `engines-floor/wider-than-dependency` (the range admits a version the dependency rejects) and `no-engines` (the package declares no range, so admits every version, while a dependency declares one), each reported at the package's `package.json` and naming the dependency and both ranges. A dependency that declares no range constrains nothing and is ignored. `devDependencies` are not judged: a consumer never installs them, and the Node the development toolchain needs is the repository's own concern, not a claim the published package makes. A dependency is found the way Node finds a package directory, in `node_modules` of the package's directory and then of each directory above it up to the working directory, following the symbolic links pnpm installs; it reads the dependency's `package.json` directly rather than resolving an entry point, since an `exports` map need not expose `package.json`. A peer dependency is judged only when its range is a single version (`1.2.3` or `=1.2.3`), by the installed copy, which must be that version or the run fails. The consumer, not the package, chooses which version of a peer to install, so the package's range holds when each Node version in it is accepted by some version the peer range admits. The copy installed in the workspace is one of those versions: if it rejects a Node version the package admits, another admitted version may accept it (`^9.0.0 || ^10.0.0` with cosmiconfig 10 requiring `^22.18 || >= 24` while cosmiconfig 9 accepts 22.13, and equally an earlier minor of a single major), and the versions that are not installed cannot be read without the registry, so judging the range by that copy reports packages that are correct. A range of one version leaves the consumer no choice, so its installed copy decides; a peer that is not judged need not be installed. A dependency that is not installed fails the run with a configuration error, because its range cannot be read; an optional dependency, or a peer dependency marked optional in `peerDependenciesMeta`, may be absent and is then skipped. A range semver cannot parse, in the package or a dependency, and options that select no package also fail the run. Limits: 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, and a set that two adjacent dependency sets only cover together (`>=22` against `^22.0.0 || >=23`) is reported until it is split the same way; a peer dependency whose range admits more than one version is not judged at all, so a package can claim a Node version that no admitted version of a peer accepts; prerelease Node versions are not considered.
|
|
163
|
+
|
|
162
164
|
**`dockerfile-package-manager`** verifies that a package manager version pinned in a Dockerfile is the one `packageManager` names, ignoring a `+sha` suffix on either side. Options: `dockerfiles` (`Dockerfile`, `Dockerfile.*` and `*.Dockerfile` at any depth), `exclude` (globs left out of the search) and `packageJson` (`package.json`). Only pins of the package manager `packageManager` names are compared: `pnpm@12.4.1` on a `RUN` line is compared, `npm@latest` is not. Codes: `dockerfile-package-manager/version-mismatch` (with line and column) and `no-package-manager`. A version that refers to a variable (`pnpm@${PNPM_VERSION}` or `pnpm@$PNPM_VERSION`) is compared with the value an `ARG` or `ENV` earlier in the same file gives it. Limits: it reads `<manager>@<version>` as written on a line that is not a comment; a variable the file gives no value (an `ARG` without a default, whose value the build supplies) or whose value refers to another variable is not compared; the value in effect is the latest one set before the pin, whichever build stage set it; and an unpinned `pnpm@latest` is a mismatch.
|
|
163
165
|
|
|
164
166
|
**`migrations-directory`** verifies that the directory a schema generator writes migrations to is the directory the deploy tool applies them from, and that no `package.json` script applies them with the generator in place of the deploy tool. It is tool specific, so the generator and the deploy tool are named in the options, each has an adapter that knows its config file names and where in the config the directory is, and the supported pair is `drizzle-kit` (`out` of `drizzle.config.ts`, `.js` or `.json`, `drizzle` when unset) with `wrangler` (`migrations_dir` of a `d1_databases` entry of `wrangler.json`, `.jsonc` or `.toml`, `migrations` when unset). Both directories are resolved against the directory of the file that sets them, and `..` segments and trailing separators are normalised. Adapters are two small interfaces (`GeneratorAdapter` and `DeployToolAdapter`, with `generators` and `deployTools` as the registries), so another tool is one object and an entry in `generatorNames` or `deployToolNames`.
|
|
@@ -311,7 +313,7 @@ it('conforms to the workspace layout', async () => {
|
|
|
311
313
|
});
|
|
312
314
|
```
|
|
313
315
|
|
|
314
|
-
Every check is also exported as a function taking `{ cwd, options }` (and `layout` for the import checks): `aggregateMappers`, `codecPairs`, `commandTypes`, `derivedTypes`, `importUphill`, `importRankSkip`, `importCrossSlice`, `importIsolatedGroups`, `importCycles`, `instructionSymlinks`, `singleStorybook`, `commitTypes`, `dockerfilePackageManager`, `migrationsDirectory`, `eslint`, the workflow checks (`workflowJobOrdering`, `workflowSkippableJobs`, `workflowRunnerResolution`, `workflowVersionSingleSource`, `workflowCredentials`, `workflowReleaseJob`, `workflowRepositoryDispatch`, `workflowUpdateBotCooldown`, `workflowMergeGroup` and `workflowActionPinning`) and, taking `{ cwd, options, github }`, the settings checks (`settingsMergeMethods`, `settingsRequiredChecks` and `settingsReviewThreadResolution`). These take options and a layout as already validated and do not check them again; go through `runChecks` for validation. `parseWorkflow` and `loadWorkflows` expose the workflow model the workflow checks read. A check is a function that returns `Promise<readonly Violation[]>`, where a `Violation` is `{ code, message, file, location? }`.
|
|
316
|
+
Every check is also exported as a function taking `{ cwd, options }` (and `layout` for the import checks): `aggregateMappers`, `codecPairs`, `commandTypes`, `derivedTypes`, `importUphill`, `importRankSkip`, `importCrossSlice`, `importIsolatedGroups`, `importCycles`, `instructionSymlinks`, `singleStorybook`, `commitTypes`, `enginesFloor`, `dockerfilePackageManager`, `migrationsDirectory`, `eslint`, the workflow checks (`workflowJobOrdering`, `workflowSkippableJobs`, `workflowRunnerResolution`, `workflowVersionSingleSource`, `workflowCredentials`, `workflowReleaseJob`, `workflowRepositoryDispatch`, `workflowUpdateBotCooldown`, `workflowMergeGroup` and `workflowActionPinning`) and, taking `{ cwd, options, github }`, the settings checks (`settingsMergeMethods`, `settingsRequiredChecks` and `settingsReviewThreadResolution`). These take options and a layout as already validated and do not check them again; go through `runChecks` for validation. `parseWorkflow` and `loadWorkflows` expose the workflow model the workflow checks read. A check is a function that returns `Promise<readonly Violation[]>`, where a `Violation` is `{ code, message, file, location? }`.
|
|
315
317
|
|
|
316
318
|
### Proving a handler map is exhaustive
|
|
317
319
|
|
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 {
|
|
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], {
|
|
@@ -2345,145 +2641,6 @@ function unresolvedNameImportRule(packages) {
|
|
|
2345
2641
|
};
|
|
2346
2642
|
}
|
|
2347
2643
|
//#endregion
|
|
2348
|
-
//#region src/workspace/packages.ts
|
|
2349
|
-
function segmentsOf(path) {
|
|
2350
|
-
return path.split("/").filter((segment) => segment !== "" && segment !== ".");
|
|
2351
|
-
}
|
|
2352
|
-
function groupRoot(group) {
|
|
2353
|
-
return segmentsOf(group.path ?? group.name);
|
|
2354
|
-
}
|
|
2355
|
-
function startsWithSegments(segments, prefix) {
|
|
2356
|
-
return prefix.every((segment, index) => segments[index] === segment);
|
|
2357
|
-
}
|
|
2358
|
-
/**
|
|
2359
|
-
* The group whose root is the longest prefix of `dir`; the first declared wins a tie. `undefined` when no group owns the directory.
|
|
2360
|
-
*/
|
|
2361
|
-
function owningGroup(dir, groups) {
|
|
2362
|
-
const segments = segmentsOf(dir);
|
|
2363
|
-
let best;
|
|
2364
|
-
let bestLength = -1;
|
|
2365
|
-
for (const group of groups) {
|
|
2366
|
-
const root = groupRoot(group);
|
|
2367
|
-
if (root.length > bestLength && startsWithSegments(segments, root)) {
|
|
2368
|
-
best = group;
|
|
2369
|
-
bestLength = root.length;
|
|
2370
|
-
}
|
|
2371
|
-
}
|
|
2372
|
-
return best;
|
|
2373
|
-
}
|
|
2374
|
-
function rankOf(name, group, layout) {
|
|
2375
|
-
if (name !== void 0) {
|
|
2376
|
-
const rule = layout.nameRanks?.find((candidate) => new RegExp(candidate.pattern, "u").test(name));
|
|
2377
|
-
if (rule !== void 0) return rule.rank;
|
|
2378
|
-
}
|
|
2379
|
-
return group.rank ?? layout.defaultRank;
|
|
2380
|
-
}
|
|
2381
|
-
/**
|
|
2382
|
-
* The name without its npm scope: `@scope/store-cli` gives `store-cli`.
|
|
2383
|
-
*/
|
|
2384
|
-
function unscoped(name) {
|
|
2385
|
-
return name.replace(/^@[^/]+\//u, "");
|
|
2386
|
-
}
|
|
2387
|
-
/**
|
|
2388
|
-
* The longest slice value that is the whole unscoped name or its prefix up to a `-`.
|
|
2389
|
-
*/
|
|
2390
|
-
function sliceByNamePrefix(name, known) {
|
|
2391
|
-
const bare = unscoped(name);
|
|
2392
|
-
return [...known].filter((candidate) => bare === candidate || bare.startsWith(`${candidate}-`)).sort((a, b) => b.length - a.length)[0];
|
|
2393
|
-
}
|
|
2394
|
-
/**
|
|
2395
|
-
* Classify discovered packages by the layout: group, rank and slice. Pure.
|
|
2396
|
-
*
|
|
2397
|
-
* 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.
|
|
2398
|
-
*/
|
|
2399
|
-
function classifyPackages(discovered, layout) {
|
|
2400
|
-
const owned = discovered.map((found) => {
|
|
2401
|
-
const group = owningGroup(found.dir, layout.groups);
|
|
2402
|
-
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`);
|
|
2403
|
-
return {
|
|
2404
|
-
found,
|
|
2405
|
-
group
|
|
2406
|
-
};
|
|
2407
|
-
});
|
|
2408
|
-
const knownSlices = /* @__PURE__ */ new Set();
|
|
2409
|
-
for (const { found, group } of owned) if (group.slice !== void 0 && "segment" in group.slice) {
|
|
2410
|
-
const value = segmentsOf(found.dir).slice(groupRoot(group).length)[group.slice.segment];
|
|
2411
|
-
if (value !== void 0) knownSlices.add(value);
|
|
2412
|
-
}
|
|
2413
|
-
const packages = owned.map(({ found, group }) => {
|
|
2414
|
-
let slice;
|
|
2415
|
-
if (group.slice !== void 0 && "segment" in group.slice) slice = segmentsOf(found.dir).slice(groupRoot(group).length)[group.slice.segment];
|
|
2416
|
-
else if (group.slice !== void 0 && found.name !== void 0) slice = sliceByNamePrefix(found.name, knownSlices);
|
|
2417
|
-
return {
|
|
2418
|
-
...found,
|
|
2419
|
-
group: group.name,
|
|
2420
|
-
rank: rankOf(found.name, group, layout),
|
|
2421
|
-
slice
|
|
2422
|
-
};
|
|
2423
|
-
});
|
|
2424
|
-
for (const outer of packages) {
|
|
2425
|
-
const nested = packages.find((inner) => inner.dir !== outer.dir && inner.dir.startsWith(`${outer.dir}/`));
|
|
2426
|
-
if (nested !== void 0) throw new ConformanceError(`the package directory '${nested.dir}' is inside the package directory '${outer.dir}'; workspace packages must not nest`);
|
|
2427
|
-
}
|
|
2428
|
-
return packages;
|
|
2429
|
-
}
|
|
2430
|
-
/**
|
|
2431
|
-
* The workspace root: `layout.root` resolved against `cwd`, or `cwd` itself.
|
|
2432
|
-
*/
|
|
2433
|
-
function workspaceRoot(cwd, layout) {
|
|
2434
|
-
return layout.root === void 0 ? cwd : resolve(cwd, layout.root);
|
|
2435
|
-
}
|
|
2436
|
-
/**
|
|
2437
|
-
* The package globs: `layout.packages`, else the `packages` list of `pnpm-workspace.yaml` in `root`.
|
|
2438
|
-
*/
|
|
2439
|
-
async function workspacePatterns(root, layout) {
|
|
2440
|
-
if (layout.packages !== void 0) return layout.packages;
|
|
2441
|
-
const file = join(root, "pnpm-workspace.yaml");
|
|
2442
|
-
let source;
|
|
2443
|
-
try {
|
|
2444
|
-
source = await readFile(file, "utf8");
|
|
2445
|
-
} catch (error) {
|
|
2446
|
-
throw new ConformanceError(`the layout has no 'packages' and ${file} cannot be read`, { cause: error });
|
|
2447
|
-
}
|
|
2448
|
-
const document = parse(source);
|
|
2449
|
-
if (typeof document !== "object" || document === null || !("packages" in document) || !Array.isArray(document.packages)) throw new ConformanceError(`${file} has no 'packages' list`);
|
|
2450
|
-
const patterns = document.packages;
|
|
2451
|
-
if (!patterns.every((pattern) => typeof pattern === "string")) throw new ConformanceError(`${file}: every entry of 'packages' must be a string`);
|
|
2452
|
-
return patterns;
|
|
2453
|
-
}
|
|
2454
|
-
async function readName(manifest) {
|
|
2455
|
-
let content;
|
|
2456
|
-
try {
|
|
2457
|
-
content = JSON.parse(await readFile(manifest, "utf8"));
|
|
2458
|
-
} catch (error) {
|
|
2459
|
-
throw new ConformanceError(`${manifest} cannot be read as JSON`, { cause: error });
|
|
2460
|
-
}
|
|
2461
|
-
if (typeof content === "object" && content !== null && "name" in content && typeof content.name === "string") return content.name;
|
|
2462
|
-
}
|
|
2463
|
-
/**
|
|
2464
|
-
* 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.
|
|
2465
|
-
*/
|
|
2466
|
-
async function discoverPackages(root, patterns) {
|
|
2467
|
-
const manifests = patterns.map((pattern) => pattern.startsWith("!") ? `!${pattern.slice(1)}/**` : `${pattern}/package.json`);
|
|
2468
|
-
const dirs = (await glob(manifests, {
|
|
2469
|
-
cwd: root,
|
|
2470
|
-
dot: true,
|
|
2471
|
-
ignore: [INSTALLED_DEPENDENCIES_GLOB],
|
|
2472
|
-
onlyFiles: true
|
|
2473
|
-
})).map((manifest) => manifest.replace(/\/?package\.json$/u, "")).filter((dir) => dir !== "");
|
|
2474
|
-
return Promise.all([...new Set(dirs)].sort().map(async (dir) => ({
|
|
2475
|
-
dir,
|
|
2476
|
-
name: await readName(join(root, dir, "package.json"))
|
|
2477
|
-
})));
|
|
2478
|
-
}
|
|
2479
|
-
/**
|
|
2480
|
-
* The layout's packages found under the workspace root.
|
|
2481
|
-
*/
|
|
2482
|
-
async function readWorkspacePackages(cwd, layout) {
|
|
2483
|
-
const root = workspaceRoot(cwd, layout);
|
|
2484
|
-
return classifyPackages(await discoverPackages(root, await workspacePatterns(root, layout)), layout);
|
|
2485
|
-
}
|
|
2486
|
-
//#endregion
|
|
2487
2644
|
//#region src/checks/import-graph.ts
|
|
2488
2645
|
/**
|
|
2489
2646
|
* Paths left out of the import graph unless the options say otherwise: a package's own installed dependencies.
|
|
@@ -3405,6 +3562,12 @@ const registry = {
|
|
|
3405
3562
|
select: (checks) => enabledOptions(checks["commit-types"]),
|
|
3406
3563
|
check: commitTypes
|
|
3407
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
|
+
}),
|
|
3408
3571
|
"dockerfile-package-manager": plain({
|
|
3409
3572
|
name: "dockerfile-package-manager",
|
|
3410
3573
|
description: "A package manager version pinned in a Dockerfile is the version packageManager names",
|
|
@@ -3609,6 +3772,7 @@ const checks = z.strictObject({
|
|
|
3609
3772
|
commitlint: z.exactOptional(path),
|
|
3610
3773
|
release: z.exactOptional(path)
|
|
3611
3774
|
}))),
|
|
3775
|
+
"engines-floor": z.exactOptional(enabled(z.strictObject({ packages: z.exactOptional(paths) }))),
|
|
3612
3776
|
"dockerfile-package-manager": z.exactOptional(enabled(z.strictObject({
|
|
3613
3777
|
dockerfiles: z.exactOptional(paths),
|
|
3614
3778
|
exclude: z.exactOptional(paths),
|