workspace-conformance 1.6.0 → 2.0.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 +30 -14
- package/dist/cli.js +548 -199
- package/dist/index.cjs +587 -290
- package/dist/index.d.cts +63 -19
- package/dist/index.d.ts +64 -20
- package/dist/index.js +585 -290
- package/package.json +4 -2
package/dist/index.cjs
CHANGED
|
@@ -5,13 +5,14 @@ let tinyglobby = require("tinyglobby");
|
|
|
5
5
|
let ts_morph = require("ts-morph");
|
|
6
6
|
let cosmiconfig_extends = require("cosmiconfig-extends");
|
|
7
7
|
let node_fs_promises = require("node:fs/promises");
|
|
8
|
-
let
|
|
8
|
+
let semver = require("semver");
|
|
9
9
|
let yaml = require("yaml");
|
|
10
|
+
let node_module = require("node:module");
|
|
10
11
|
let node_child_process = require("node:child_process");
|
|
11
12
|
let jsonc_parser = require("jsonc-parser");
|
|
12
13
|
let smol_toml = require("smol-toml");
|
|
13
|
-
let _exadev_config = require("@exadev/config");
|
|
14
14
|
let zod = require("zod");
|
|
15
|
+
let _exadev_config = require("@exadev/config");
|
|
15
16
|
//#region src/files.ts
|
|
16
17
|
/**
|
|
17
18
|
* Directories no check descends into: installed dependencies are not the workspace's own files.
|
|
@@ -781,6 +782,317 @@ const dockerfilePackageManager = async ({ cwd, options }) => {
|
|
|
781
782
|
}
|
|
782
783
|
return violations;
|
|
783
784
|
};
|
|
785
|
+
/**
|
|
786
|
+
* What every path inside the package in `dir` starts with, relative to the workspace root: `dir` and a `/`, or nothing for the root package, which holds every path. The trailing `/` keeps `packages/a` from holding `packages/ab`.
|
|
787
|
+
*/
|
|
788
|
+
function packagePathPrefix(dir) {
|
|
789
|
+
return dir === "." ? "" : `${dir}/`;
|
|
790
|
+
}
|
|
791
|
+
/**
|
|
792
|
+
* Whether `path`, relative to the workspace root, is inside the package in `dir`.
|
|
793
|
+
*/
|
|
794
|
+
function isInPackage(dir, path) {
|
|
795
|
+
return path.startsWith(packagePathPrefix(dir));
|
|
796
|
+
}
|
|
797
|
+
function segmentsOf(path) {
|
|
798
|
+
return path.split("/").filter((segment) => segment !== "" && segment !== ".");
|
|
799
|
+
}
|
|
800
|
+
function groupRoot(group) {
|
|
801
|
+
return segmentsOf(group.path ?? group.name);
|
|
802
|
+
}
|
|
803
|
+
function startsWithSegments(segments, prefix) {
|
|
804
|
+
return prefix.every((segment, index) => segments[index] === segment);
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* The group whose root is the longest prefix of `dir`; the first declared wins a tie. `undefined` when no group owns the directory.
|
|
808
|
+
*/
|
|
809
|
+
function owningGroup(dir, groups) {
|
|
810
|
+
const segments = segmentsOf(dir);
|
|
811
|
+
let best;
|
|
812
|
+
let bestLength = -1;
|
|
813
|
+
for (const group of groups) {
|
|
814
|
+
const root = groupRoot(group);
|
|
815
|
+
if (root.length > bestLength && startsWithSegments(segments, root)) {
|
|
816
|
+
best = group;
|
|
817
|
+
bestLength = root.length;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
return best;
|
|
821
|
+
}
|
|
822
|
+
function rankOf(name, group, layout) {
|
|
823
|
+
if (name !== void 0) {
|
|
824
|
+
const rule = layout.nameRanks?.find((candidate) => new RegExp(candidate.pattern, "u").test(name));
|
|
825
|
+
if (rule !== void 0) return rule.rank;
|
|
826
|
+
}
|
|
827
|
+
return group.rank ?? layout.defaultRank;
|
|
828
|
+
}
|
|
829
|
+
/**
|
|
830
|
+
* The name without its npm scope: `@scope/store-cli` gives `store-cli`.
|
|
831
|
+
*/
|
|
832
|
+
function unscoped(name) {
|
|
833
|
+
return name.replace(/^@[^/]+\//u, "");
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* The longest slice value that is the whole unscoped name or its prefix up to a `-`.
|
|
837
|
+
*/
|
|
838
|
+
function sliceByNamePrefix(name, known) {
|
|
839
|
+
const bare = unscoped(name);
|
|
840
|
+
return [...known].filter((candidate) => bare === candidate || bare.startsWith(`${candidate}-`)).sort((a, b) => b.length - a.length)[0];
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* Classify discovered packages by the layout: group, rank and slice. Pure.
|
|
844
|
+
*
|
|
845
|
+
* 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. The root package holds every other directory, so it is a package only in a single-package repository. 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.
|
|
846
|
+
*/
|
|
847
|
+
function classifyPackages(discovered, layout) {
|
|
848
|
+
const owned = discovered.map((found) => {
|
|
849
|
+
const group = owningGroup(found.dir, layout.groups);
|
|
850
|
+
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`);
|
|
851
|
+
return {
|
|
852
|
+
found,
|
|
853
|
+
group
|
|
854
|
+
};
|
|
855
|
+
});
|
|
856
|
+
const knownSlices = /* @__PURE__ */ new Set();
|
|
857
|
+
for (const { found, group } of owned) if (group.slice !== void 0 && "segment" in group.slice) {
|
|
858
|
+
const value = segmentsOf(found.dir).slice(groupRoot(group).length)[group.slice.segment];
|
|
859
|
+
if (value !== void 0) knownSlices.add(value);
|
|
860
|
+
}
|
|
861
|
+
const packages = owned.map(({ found, group }) => {
|
|
862
|
+
let slice;
|
|
863
|
+
if (group.slice !== void 0 && "segment" in group.slice) slice = segmentsOf(found.dir).slice(groupRoot(group).length)[group.slice.segment];
|
|
864
|
+
else if (group.slice !== void 0 && found.name !== void 0) slice = sliceByNamePrefix(found.name, knownSlices);
|
|
865
|
+
return {
|
|
866
|
+
...found,
|
|
867
|
+
group: group.name,
|
|
868
|
+
rank: rankOf(found.name, group, layout),
|
|
869
|
+
slice
|
|
870
|
+
};
|
|
871
|
+
});
|
|
872
|
+
for (const outer of packages) {
|
|
873
|
+
const nested = packages.find((inner) => inner.dir !== outer.dir && isInPackage(outer.dir, inner.dir));
|
|
874
|
+
if (nested !== void 0) throw new ConformanceError(`the package directory '${nested.dir}' is inside the package directory '${outer.dir}'; workspace packages must not nest`);
|
|
875
|
+
}
|
|
876
|
+
return packages;
|
|
877
|
+
}
|
|
878
|
+
/**
|
|
879
|
+
* The workspace root: `layout.root` resolved against `cwd`, or `cwd` itself.
|
|
880
|
+
*/
|
|
881
|
+
function workspaceRoot(cwd, layout) {
|
|
882
|
+
return layout.root === void 0 ? cwd : (0, node_path.resolve)(cwd, layout.root);
|
|
883
|
+
}
|
|
884
|
+
const PNPM_WORKSPACE_FILE = "pnpm-workspace.yaml";
|
|
885
|
+
/**
|
|
886
|
+
* 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.
|
|
887
|
+
*/
|
|
888
|
+
async function pnpmWorkspacePatterns(root) {
|
|
889
|
+
const file = (0, node_path.join)(root, PNPM_WORKSPACE_FILE);
|
|
890
|
+
if (!(0, node_fs.existsSync)(file)) return;
|
|
891
|
+
let source;
|
|
892
|
+
try {
|
|
893
|
+
source = await (0, node_fs_promises.readFile)(file, "utf8");
|
|
894
|
+
} catch (error) {
|
|
895
|
+
throw new ConformanceError(`${file} cannot be read`, { cause: error });
|
|
896
|
+
}
|
|
897
|
+
const document = (0, yaml.parse)(source);
|
|
898
|
+
if (typeof document !== "object" || document === null || !("packages" in document) || !Array.isArray(document.packages)) throw new ConformanceError(`${file} has no 'packages' list`);
|
|
899
|
+
const patterns = document.packages;
|
|
900
|
+
if (!patterns.every((pattern) => typeof pattern === "string")) throw new ConformanceError(`${file}: every entry of 'packages' must be a string`);
|
|
901
|
+
return patterns;
|
|
902
|
+
}
|
|
903
|
+
/**
|
|
904
|
+
* The package globs: `layout.packages`, else the `packages` list of `pnpm-workspace.yaml` in `root`.
|
|
905
|
+
*/
|
|
906
|
+
async function workspacePatterns(root, layout) {
|
|
907
|
+
if (layout.packages !== void 0) return layout.packages;
|
|
908
|
+
const patterns = await pnpmWorkspacePatterns(root);
|
|
909
|
+
if (patterns === void 0) throw new ConformanceError(`the layout has no 'packages' and ${(0, node_path.join)(root, PNPM_WORKSPACE_FILE)} does not exist`);
|
|
910
|
+
return patterns;
|
|
911
|
+
}
|
|
912
|
+
async function readName(manifest) {
|
|
913
|
+
let content;
|
|
914
|
+
try {
|
|
915
|
+
content = JSON.parse(await (0, node_fs_promises.readFile)(manifest, "utf8"));
|
|
916
|
+
} catch (error) {
|
|
917
|
+
throw new ConformanceError(`${manifest} cannot be read as JSON`, { cause: error });
|
|
918
|
+
}
|
|
919
|
+
if (typeof content === "object" && content !== null && "name" in content && typeof content.name === "string") return content.name;
|
|
920
|
+
}
|
|
921
|
+
/**
|
|
922
|
+
* The directories under `root` that the pnpm-style `patterns` select and that hold a `package.json`, sorted. A `!` pattern excludes. The root package is found, as {@link ROOT_PACKAGE_DIR}, when a pattern selects it (`.`, or `**`).
|
|
923
|
+
*/
|
|
924
|
+
async function discoverPackages(root, patterns) {
|
|
925
|
+
const manifests = patterns.map((pattern) => pattern.startsWith("!") ? `!${pattern.slice(1)}/**` : `${pattern}/package.json`);
|
|
926
|
+
const dirs = (await (0, tinyglobby.glob)(manifests, {
|
|
927
|
+
cwd: root,
|
|
928
|
+
dot: true,
|
|
929
|
+
ignore: [INSTALLED_DEPENDENCIES_GLOB],
|
|
930
|
+
onlyFiles: true
|
|
931
|
+
})).map((manifest) => {
|
|
932
|
+
const dir = manifest.replace(/\/?package\.json$/u, "");
|
|
933
|
+
return dir === "" ? "." : dir;
|
|
934
|
+
});
|
|
935
|
+
return Promise.all([...new Set(dirs)].sort().map(async (dir) => ({
|
|
936
|
+
dir,
|
|
937
|
+
name: await readName((0, node_path.join)(root, dir, "package.json"))
|
|
938
|
+
})));
|
|
939
|
+
}
|
|
940
|
+
/**
|
|
941
|
+
* The layout's packages found under the workspace root. Finding none is a configuration error: every check that reads the packages would otherwise look at nothing and report a clean workspace.
|
|
942
|
+
*/
|
|
943
|
+
async function readWorkspacePackages(cwd, layout) {
|
|
944
|
+
const root = workspaceRoot(cwd, layout);
|
|
945
|
+
const patterns = await workspacePatterns(root, layout);
|
|
946
|
+
const discovered = await discoverPackages(root, patterns);
|
|
947
|
+
if (discovered.length === 0) throw new ConformanceError(`no workspace package found: ${patterns.length === 0 ? "the package patterns are empty, so no directory is selected" : `no directory the patterns ${patterns.join(", ")} select holds a package.json`}; for a single-package repository, set the layout's packages to ['.']`);
|
|
948
|
+
return classifyPackages(discovered, layout);
|
|
949
|
+
}
|
|
950
|
+
//#endregion
|
|
951
|
+
//#region src/checks/engines-floor.ts
|
|
952
|
+
/**
|
|
953
|
+
* The pattern that selects the root package, the `package.json` in the directory the checks run in.
|
|
954
|
+
*/
|
|
955
|
+
const ROOT_PACKAGE = ".";
|
|
956
|
+
const MANIFEST = "package.json";
|
|
957
|
+
const ANY_INSTALLED_COPY = {};
|
|
958
|
+
const DEPENDENCY_FIELDS = [
|
|
959
|
+
{
|
|
960
|
+
field: "dependencies",
|
|
961
|
+
noun: "dependency",
|
|
962
|
+
mayBeAbsent: () => false,
|
|
963
|
+
judgedCopy: () => ANY_INSTALLED_COPY
|
|
964
|
+
},
|
|
965
|
+
{
|
|
966
|
+
field: "optionalDependencies",
|
|
967
|
+
noun: "optional dependency",
|
|
968
|
+
mayBeAbsent: () => true,
|
|
969
|
+
judgedCopy: () => ANY_INSTALLED_COPY
|
|
970
|
+
},
|
|
971
|
+
{
|
|
972
|
+
field: "peerDependencies",
|
|
973
|
+
noun: "peer dependency",
|
|
974
|
+
mayBeAbsent: (manifest, name) => {
|
|
975
|
+
const meta = manifest["peerDependenciesMeta"];
|
|
976
|
+
const entry = isRecord(meta) ? meta[name] : void 0;
|
|
977
|
+
return isRecord(entry) && entry["optional"] === true;
|
|
978
|
+
},
|
|
979
|
+
judgedCopy: (spec) => {
|
|
980
|
+
const version = onlyAdmittedVersion(spec);
|
|
981
|
+
return version === null ? void 0 : { version };
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
];
|
|
985
|
+
/**
|
|
986
|
+
* 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).
|
|
987
|
+
*/
|
|
988
|
+
function onlyAdmittedVersion(spec) {
|
|
989
|
+
if ((0, semver.validRange)(spec) === null) return null;
|
|
990
|
+
const [comparators, ...others] = new semver.Range(spec).set;
|
|
991
|
+
if (comparators === void 0 || others.length > 0 || comparators.length !== 1) return null;
|
|
992
|
+
const [comparator] = comparators;
|
|
993
|
+
if (comparator?.operator !== "") return null;
|
|
994
|
+
return (0, semver.valid)(comparator.value);
|
|
995
|
+
}
|
|
996
|
+
async function readManifest(cwd, file) {
|
|
997
|
+
let content;
|
|
998
|
+
try {
|
|
999
|
+
content = JSON.parse(await (0, node_fs_promises.readFile)((0, node_path.resolve)(cwd, file), "utf8"));
|
|
1000
|
+
} catch (error) {
|
|
1001
|
+
throw new ConformanceError(`${file} cannot be read as JSON`, { cause: error });
|
|
1002
|
+
}
|
|
1003
|
+
if (!isRecord(content)) throw new ConformanceError(`${file} is not a JSON object`);
|
|
1004
|
+
return content;
|
|
1005
|
+
}
|
|
1006
|
+
/**
|
|
1007
|
+
* 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.
|
|
1008
|
+
*/
|
|
1009
|
+
function nodeRange(manifest, file) {
|
|
1010
|
+
const engines = manifest["engines"];
|
|
1011
|
+
if (engines === void 0) return;
|
|
1012
|
+
if (!isRecord(engines)) throw new ConformanceError(`${file}: engines must be an object`);
|
|
1013
|
+
const node = engines["node"];
|
|
1014
|
+
if (node === void 0) return;
|
|
1015
|
+
if (typeof node !== "string") throw new ConformanceError(`${file}: engines.node must be a string, not ${JSON.stringify(node)}`);
|
|
1016
|
+
if ((0, semver.validRange)(node) === null) throw new ConformanceError(`${file}: engines.node '${node}' is not a valid semver range`);
|
|
1017
|
+
return node;
|
|
1018
|
+
}
|
|
1019
|
+
/**
|
|
1020
|
+
* 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.
|
|
1021
|
+
*/
|
|
1022
|
+
function dependencyEntries(manifest, field, file) {
|
|
1023
|
+
const listed = manifest[field];
|
|
1024
|
+
if (listed === void 0) return [];
|
|
1025
|
+
if (!isRecord(listed)) throw new ConformanceError(`${file}: ${field} must be an object`);
|
|
1026
|
+
return Object.keys(listed).sort().map((name) => {
|
|
1027
|
+
const spec = listed[name];
|
|
1028
|
+
if (typeof spec !== "string") throw new ConformanceError(`${file}: ${field}.${name} must be a string, not ${JSON.stringify(spec)}`);
|
|
1029
|
+
return [name, spec];
|
|
1030
|
+
});
|
|
1031
|
+
}
|
|
1032
|
+
/**
|
|
1033
|
+
* 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.
|
|
1034
|
+
*/
|
|
1035
|
+
function installedManifest(cwd, packageDirectory, name) {
|
|
1036
|
+
const top = (0, node_path.resolve)(cwd);
|
|
1037
|
+
for (let directory = (0, node_path.resolve)(cwd, packageDirectory);; directory = (0, node_path.dirname)(directory)) {
|
|
1038
|
+
const candidate = (0, node_path.join)(directory, "node_modules", name, MANIFEST);
|
|
1039
|
+
if ((0, node_fs.existsSync)(candidate)) return relativePosix(cwd, candidate);
|
|
1040
|
+
if (directory === top) return;
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
/**
|
|
1044
|
+
* 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.
|
|
1045
|
+
*/
|
|
1046
|
+
async function packageDirectories(cwd, options) {
|
|
1047
|
+
const patterns = options.packages ?? [ROOT_PACKAGE, ...await pnpmWorkspacePatterns(cwd) ?? []];
|
|
1048
|
+
const members = await discoverPackages(cwd, patterns.filter((pattern) => pattern !== ROOT_PACKAGE));
|
|
1049
|
+
const directories = [...patterns.includes(ROOT_PACKAGE) ? [ROOT_PACKAGE] : [], ...members.map((member) => member.dir)];
|
|
1050
|
+
if (directories.length === 0) throw new ConformanceError(`no package.json is selected by ${patterns.join(", ")}`);
|
|
1051
|
+
return directories;
|
|
1052
|
+
}
|
|
1053
|
+
/**
|
|
1054
|
+
* 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.
|
|
1055
|
+
*
|
|
1056
|
+
* 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.
|
|
1057
|
+
*
|
|
1058
|
+
* 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.
|
|
1059
|
+
*
|
|
1060
|
+
* 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.
|
|
1061
|
+
*/
|
|
1062
|
+
const enginesFloor = async ({ cwd, options }) => {
|
|
1063
|
+
const violations = [];
|
|
1064
|
+
for (const directory of await packageDirectories(cwd, options)) {
|
|
1065
|
+
const file = node_path.posix.join(directory, MANIFEST);
|
|
1066
|
+
const manifest = await readManifest(cwd, file);
|
|
1067
|
+
const own = nodeRange(manifest, file);
|
|
1068
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1069
|
+
for (const { field, noun, mayBeAbsent, judgedCopy } of DEPENDENCY_FIELDS) for (const [name, spec] of dependencyEntries(manifest, field, file).filter(([candidate]) => !seen.has(candidate))) {
|
|
1070
|
+
seen.add(name);
|
|
1071
|
+
const copy = judgedCopy(spec);
|
|
1072
|
+
if (copy === void 0) continue;
|
|
1073
|
+
const installed = installedManifest(cwd, directory, name);
|
|
1074
|
+
if (installed === void 0) {
|
|
1075
|
+
if (mayBeAbsent(manifest, name)) continue;
|
|
1076
|
+
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`);
|
|
1077
|
+
}
|
|
1078
|
+
const dependency = await readManifest(cwd, installed);
|
|
1079
|
+
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`);
|
|
1080
|
+
const theirs = nodeRange(dependency, installed);
|
|
1081
|
+
if (theirs === void 0) continue;
|
|
1082
|
+
if (own === void 0) violations.push({
|
|
1083
|
+
code: "engines-floor/no-engines",
|
|
1084
|
+
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`,
|
|
1085
|
+
file
|
|
1086
|
+
});
|
|
1087
|
+
else if (!(0, semver.subset)(own, theirs)) violations.push({
|
|
1088
|
+
code: "engines-floor/wider-than-dependency",
|
|
1089
|
+
message: `engines.node '${own}' admits Node versions that the ${noun} '${name}' rejects with engines.node '${theirs}'; narrow it to a subset of that range`,
|
|
1090
|
+
file
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
return violations;
|
|
1095
|
+
};
|
|
784
1096
|
//#endregion
|
|
785
1097
|
//#region src/checks/eslint.ts
|
|
786
1098
|
/**
|
|
@@ -887,40 +1199,61 @@ async function sampleViolations(eslint, sample, shared) {
|
|
|
887
1199
|
function isNothingLinted(error) {
|
|
888
1200
|
return error instanceof Error && isRecord(error) && (error["messageTemplate"] === "file-not-found" || error["messageTemplate"] === "all-matched-files-ignored");
|
|
889
1201
|
}
|
|
1202
|
+
/**
|
|
1203
|
+
* Where a message is, or `undefined` for a message about the whole file. ESLint types `line` and `column` as numbers, but a parsing error copies them from the parser's error, which may carry none: typescript-eslint's project service reports a file outside every tsconfig that way.
|
|
1204
|
+
*/
|
|
1205
|
+
function locationOf(message) {
|
|
1206
|
+
const line = message.line;
|
|
1207
|
+
const column = message.column;
|
|
1208
|
+
return typeof line === "number" && typeof column === "number" ? {
|
|
1209
|
+
line,
|
|
1210
|
+
column
|
|
1211
|
+
} : void 0;
|
|
1212
|
+
}
|
|
890
1213
|
function messageViolation(cwd, result, message) {
|
|
891
1214
|
const file = relativePosix(cwd, result.filePath);
|
|
892
|
-
const location =
|
|
893
|
-
|
|
894
|
-
|
|
1215
|
+
const location = locationOf(message);
|
|
1216
|
+
const at = location === void 0 ? { file } : {
|
|
1217
|
+
file,
|
|
1218
|
+
location
|
|
895
1219
|
};
|
|
896
1220
|
if (message.ruleId !== null) {
|
|
897
1221
|
const severity = message.severity === 1 ? "warn" : "error";
|
|
898
1222
|
return {
|
|
899
1223
|
code: `eslint/lint/${message.ruleId}`,
|
|
900
1224
|
message: `${message.ruleId} (${severity}): ${message.message}`,
|
|
901
|
-
|
|
902
|
-
location
|
|
1225
|
+
...at
|
|
903
1226
|
};
|
|
904
1227
|
}
|
|
905
1228
|
return message.fatal === true ? {
|
|
906
1229
|
code: "eslint/fatal",
|
|
907
1230
|
message: message.message,
|
|
908
|
-
|
|
909
|
-
location
|
|
1231
|
+
...at
|
|
910
1232
|
} : {
|
|
911
1233
|
code: "eslint/lint-message",
|
|
912
1234
|
message: message.message,
|
|
913
|
-
|
|
914
|
-
location
|
|
1235
|
+
...at
|
|
915
1236
|
};
|
|
916
1237
|
}
|
|
917
1238
|
function byPosition(left, right) {
|
|
918
1239
|
return left.file.localeCompare(right.file) || (left.location?.line ?? 0) - (right.location?.line ?? 0) || (left.location?.column ?? 0) - (right.location?.column ?? 0) || left.code.localeCompare(right.code);
|
|
919
1240
|
}
|
|
1241
|
+
function files(count) {
|
|
1242
|
+
return count === 1 ? "1 file" : `${String(count)} files`;
|
|
1243
|
+
}
|
|
1244
|
+
/**
|
|
1245
|
+
* The files below `cwd` that ESLint has a configuration for (not ignored, and matched by a configuration block) and that the lint did not reach: the patterns are narrower than what the config covers.
|
|
1246
|
+
*/
|
|
1247
|
+
async function unreachedFiles(cwd, eslint, linted) {
|
|
1248
|
+
const candidates = (await findFiles(cwd, ["**"])).filter((file) => !linted.has((0, node_path.resolve)(cwd, file)));
|
|
1249
|
+
const unreached = [];
|
|
1250
|
+
for (const file of candidates) if (!await eslint.isPathIgnored(file)) unreached.push(file);
|
|
1251
|
+
return unreached;
|
|
1252
|
+
}
|
|
920
1253
|
/**
|
|
921
|
-
* Lints the workspace through the Node API, with the repository's own config, and maps every message to a violation. A pattern that selects no linted file is itself a violation, since the workspace is then not linted at all.
|
|
1254
|
+
* Lints the workspace through the Node API, with the repository's own config, and maps every message to a violation. A pattern that selects no linted file is itself a violation, since the workspace is then not linted at all. It notes how many files it linted and how many files the config covers that the patterns did not reach.
|
|
922
1255
|
*/
|
|
923
|
-
async function lintViolations(cwd, eslint, patterns) {
|
|
1256
|
+
async function lintViolations(cwd, eslint, patterns, note) {
|
|
924
1257
|
const violations = [];
|
|
925
1258
|
const results = /* @__PURE__ */ new Map();
|
|
926
1259
|
for (const pattern of patterns) try {
|
|
@@ -934,6 +1267,10 @@ async function lintViolations(cwd, eslint, patterns) {
|
|
|
934
1267
|
});
|
|
935
1268
|
}
|
|
936
1269
|
for (const result of results.values()) for (const message of result.messages) violations.push(messageViolation(cwd, result, message));
|
|
1270
|
+
if (note !== void 0) {
|
|
1271
|
+
const unreached = await unreachedFiles(cwd, eslint, new Set(results.keys()));
|
|
1272
|
+
note(unreached.length === 0 ? `linted ${files(results.size)}` : `linted ${files(results.size)}; ${unreached.length === 1 ? "1 other file has" : `${String(unreached.length)} other files have`} an ESLint configuration and ${unreached.length === 1 ? "was" : "were"} not reached by lintPatterns (${patterns.join(", ")})`);
|
|
1273
|
+
}
|
|
937
1274
|
return violations.sort(byPosition);
|
|
938
1275
|
}
|
|
939
1276
|
/**
|
|
@@ -943,7 +1280,9 @@ async function lintViolations(cwd, eslint, patterns) {
|
|
|
943
1280
|
*
|
|
944
1281
|
* The config is a black box: the check sees which rules are active on a file, not why. It does not embed a config, so a repository without one is reported and nothing is substituted.
|
|
945
1282
|
*/
|
|
946
|
-
const eslint = async (
|
|
1283
|
+
const eslint = async (context) => {
|
|
1284
|
+
const { options } = context;
|
|
1285
|
+
const cwd = (0, node_path.resolve)(context.cwd);
|
|
947
1286
|
const instance = new (loadEslint(cwd))({
|
|
948
1287
|
cwd,
|
|
949
1288
|
...options.configFile === void 0 ? {} : { overrideConfigFile: (0, node_path.resolve)(cwd, options.configFile) }
|
|
@@ -951,7 +1290,7 @@ const eslint = async ({ cwd, options }) => {
|
|
|
951
1290
|
const shared = options.rules ?? {};
|
|
952
1291
|
const violations = [];
|
|
953
1292
|
for (const sample of options.samples.map(sampleOf)) violations.push(...await sampleViolations(instance, sample, shared));
|
|
954
|
-
if (options.lint === true) violations.push(...await lintViolations(cwd, instance, options.lintPatterns ?? DEFAULT_LINT_PATTERNS));
|
|
1293
|
+
if (options.lint === true) violations.push(...await lintViolations(cwd, instance, options.lintPatterns ?? DEFAULT_LINT_PATTERNS, context.note));
|
|
955
1294
|
return violations;
|
|
956
1295
|
};
|
|
957
1296
|
//#endregion
|
|
@@ -961,10 +1300,10 @@ function escapeRegExp(text) {
|
|
|
961
1300
|
return text.replace(REGEXP_SPECIAL, "\\$&");
|
|
962
1301
|
}
|
|
963
1302
|
/**
|
|
964
|
-
* A regular expression, as source text, matching every file inside any of the packages
|
|
1303
|
+
* A regular expression, as source text, matching every file inside any of the packages: the path starts with one's {@link packagePathPrefix}.
|
|
965
1304
|
*/
|
|
966
1305
|
function packagesPattern(packages) {
|
|
967
|
-
return `^(${packages.map((member) =>
|
|
1306
|
+
return `^(${packages.map((member) => escapeRegExp(packagePathPrefix(member.dir))).join("|")})`;
|
|
968
1307
|
}
|
|
969
1308
|
/**
|
|
970
1309
|
* A regular expression, as source text, matching the name an import uses for any of the named packages: the name itself or a subpath of it. It matches nothing but those names, and is `undefined` when no package has a name.
|
|
@@ -1057,196 +1396,57 @@ function crossSliceRules(packages) {
|
|
|
1057
1396
|
/**
|
|
1058
1397
|
* Two rules per isolated pair, one for each direction.
|
|
1059
1398
|
*/
|
|
1060
|
-
function isolatedGroupRules(packages, isolatedGroups) {
|
|
1061
|
-
const byGroup = groupBy(packages, (member) => member.group);
|
|
1062
|
-
return isolatedGroups.flatMap(([first, second]) => [{
|
|
1063
|
-
from: first,
|
|
1064
|
-
to: second
|
|
1065
|
-
}, {
|
|
1066
|
-
from: second,
|
|
1067
|
-
to: first
|
|
1068
|
-
}].flatMap(({ from, to }) => {
|
|
1069
|
-
const own = byGroup.get(from);
|
|
1070
|
-
const other = byGroup.get(to);
|
|
1071
|
-
return own === void 0 || other === void 0 ? [] : forbid(`isolated-${from}-${to}`, `the '${from}' group may not import the '${to}' group`, own, other);
|
|
1072
|
-
}));
|
|
1073
|
-
}
|
|
1074
|
-
/**
|
|
1075
|
-
* The name of {@link cycleRule}, which a check reads its findings by.
|
|
1076
|
-
*/
|
|
1077
|
-
const CYCLE_RULE_NAME = "import-cycle";
|
|
1078
|
-
/**
|
|
1079
|
-
* The name of {@link unresolvedNameImportRule}, which a check reads its findings by.
|
|
1080
|
-
*/
|
|
1081
|
-
const UNRESOLVED_NAME_IMPORT_RULE_NAME = "unresolved-name-import";
|
|
1082
|
-
/**
|
|
1083
|
-
* The rule that forbids any import cycle among the packages' files.
|
|
1084
|
-
*/
|
|
1085
|
-
function cycleRule() {
|
|
1086
|
-
return {
|
|
1087
|
-
name: CYCLE_RULE_NAME,
|
|
1088
|
-
severity: "error",
|
|
1089
|
-
comment: "files may not import each other in a cycle",
|
|
1090
|
-
from: {},
|
|
1091
|
-
to: { circular: true }
|
|
1092
|
-
};
|
|
1093
|
-
}
|
|
1094
|
-
/**
|
|
1095
|
-
* The rule that reports every import of a named package by a name that resolves to no file. It marks edges for the cycle check to follow, so a finding of it is an import and not a defect.
|
|
1096
|
-
*
|
|
1097
|
-
* It is `undefined` when no package has a name.
|
|
1098
|
-
*/
|
|
1099
|
-
function unresolvedNameImportRule(packages) {
|
|
1100
|
-
const names = packageNamesPattern(packages);
|
|
1101
|
-
return names === void 0 ? void 0 : {
|
|
1102
|
-
name: UNRESOLVED_NAME_IMPORT_RULE_NAME,
|
|
1103
|
-
severity: "error",
|
|
1104
|
-
comment: "marks an import of a workspace package by a name that resolves to no file",
|
|
1105
|
-
from: { path: packagesPattern(packages) },
|
|
1106
|
-
to: {
|
|
1107
|
-
path: names,
|
|
1108
|
-
couldNotResolve: true
|
|
1109
|
-
}
|
|
1110
|
-
};
|
|
1111
|
-
}
|
|
1112
|
-
//#endregion
|
|
1113
|
-
//#region src/workspace/packages.ts
|
|
1114
|
-
function segmentsOf(path) {
|
|
1115
|
-
return path.split("/").filter((segment) => segment !== "" && segment !== ".");
|
|
1116
|
-
}
|
|
1117
|
-
function groupRoot(group) {
|
|
1118
|
-
return segmentsOf(group.path ?? group.name);
|
|
1119
|
-
}
|
|
1120
|
-
function startsWithSegments(segments, prefix) {
|
|
1121
|
-
return prefix.every((segment, index) => segments[index] === segment);
|
|
1122
|
-
}
|
|
1123
|
-
/**
|
|
1124
|
-
* The group whose root is the longest prefix of `dir`; the first declared wins a tie. `undefined` when no group owns the directory.
|
|
1125
|
-
*/
|
|
1126
|
-
function owningGroup(dir, groups) {
|
|
1127
|
-
const segments = segmentsOf(dir);
|
|
1128
|
-
let best;
|
|
1129
|
-
let bestLength = -1;
|
|
1130
|
-
for (const group of groups) {
|
|
1131
|
-
const root = groupRoot(group);
|
|
1132
|
-
if (root.length > bestLength && startsWithSegments(segments, root)) {
|
|
1133
|
-
best = group;
|
|
1134
|
-
bestLength = root.length;
|
|
1135
|
-
}
|
|
1136
|
-
}
|
|
1137
|
-
return best;
|
|
1138
|
-
}
|
|
1139
|
-
function rankOf(name, group, layout) {
|
|
1140
|
-
if (name !== void 0) {
|
|
1141
|
-
const rule = layout.nameRanks?.find((candidate) => new RegExp(candidate.pattern, "u").test(name));
|
|
1142
|
-
if (rule !== void 0) return rule.rank;
|
|
1143
|
-
}
|
|
1144
|
-
return group.rank ?? layout.defaultRank;
|
|
1145
|
-
}
|
|
1146
|
-
/**
|
|
1147
|
-
* The name without its npm scope: `@scope/store-cli` gives `store-cli`.
|
|
1148
|
-
*/
|
|
1149
|
-
function unscoped(name) {
|
|
1150
|
-
return name.replace(/^@[^/]+\//u, "");
|
|
1151
|
-
}
|
|
1152
|
-
/**
|
|
1153
|
-
* The longest slice value that is the whole unscoped name or its prefix up to a `-`.
|
|
1154
|
-
*/
|
|
1155
|
-
function sliceByNamePrefix(name, known) {
|
|
1156
|
-
const bare = unscoped(name);
|
|
1157
|
-
return [...known].filter((candidate) => bare === candidate || bare.startsWith(`${candidate}-`)).sort((a, b) => b.length - a.length)[0];
|
|
1158
|
-
}
|
|
1159
|
-
/**
|
|
1160
|
-
* Classify discovered packages by the layout: group, rank and slice. Pure.
|
|
1161
|
-
*
|
|
1162
|
-
* 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.
|
|
1163
|
-
*/
|
|
1164
|
-
function classifyPackages(discovered, layout) {
|
|
1165
|
-
const owned = discovered.map((found) => {
|
|
1166
|
-
const group = owningGroup(found.dir, layout.groups);
|
|
1167
|
-
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`);
|
|
1168
|
-
return {
|
|
1169
|
-
found,
|
|
1170
|
-
group
|
|
1171
|
-
};
|
|
1172
|
-
});
|
|
1173
|
-
const knownSlices = /* @__PURE__ */ new Set();
|
|
1174
|
-
for (const { found, group } of owned) if (group.slice !== void 0 && "segment" in group.slice) {
|
|
1175
|
-
const value = segmentsOf(found.dir).slice(groupRoot(group).length)[group.slice.segment];
|
|
1176
|
-
if (value !== void 0) knownSlices.add(value);
|
|
1177
|
-
}
|
|
1178
|
-
const packages = owned.map(({ found, group }) => {
|
|
1179
|
-
let slice;
|
|
1180
|
-
if (group.slice !== void 0 && "segment" in group.slice) slice = segmentsOf(found.dir).slice(groupRoot(group).length)[group.slice.segment];
|
|
1181
|
-
else if (group.slice !== void 0 && found.name !== void 0) slice = sliceByNamePrefix(found.name, knownSlices);
|
|
1182
|
-
return {
|
|
1183
|
-
...found,
|
|
1184
|
-
group: group.name,
|
|
1185
|
-
rank: rankOf(found.name, group, layout),
|
|
1186
|
-
slice
|
|
1187
|
-
};
|
|
1188
|
-
});
|
|
1189
|
-
for (const outer of packages) {
|
|
1190
|
-
const nested = packages.find((inner) => inner.dir !== outer.dir && inner.dir.startsWith(`${outer.dir}/`));
|
|
1191
|
-
if (nested !== void 0) throw new ConformanceError(`the package directory '${nested.dir}' is inside the package directory '${outer.dir}'; workspace packages must not nest`);
|
|
1192
|
-
}
|
|
1193
|
-
return packages;
|
|
1399
|
+
function isolatedGroupRules(packages, isolatedGroups) {
|
|
1400
|
+
const byGroup = groupBy(packages, (member) => member.group);
|
|
1401
|
+
return isolatedGroups.flatMap(([first, second]) => [{
|
|
1402
|
+
from: first,
|
|
1403
|
+
to: second
|
|
1404
|
+
}, {
|
|
1405
|
+
from: second,
|
|
1406
|
+
to: first
|
|
1407
|
+
}].flatMap(({ from, to }) => {
|
|
1408
|
+
const own = byGroup.get(from);
|
|
1409
|
+
const other = byGroup.get(to);
|
|
1410
|
+
return own === void 0 || other === void 0 ? [] : forbid(`isolated-${from}-${to}`, `the '${from}' group may not import the '${to}' group`, own, other);
|
|
1411
|
+
}));
|
|
1194
1412
|
}
|
|
1195
1413
|
/**
|
|
1196
|
-
* The
|
|
1414
|
+
* The name of {@link cycleRule}, which a check reads its findings by.
|
|
1197
1415
|
*/
|
|
1198
|
-
|
|
1199
|
-
return layout.root === void 0 ? cwd : (0, node_path.resolve)(cwd, layout.root);
|
|
1200
|
-
}
|
|
1416
|
+
const CYCLE_RULE_NAME = "import-cycle";
|
|
1201
1417
|
/**
|
|
1202
|
-
* The
|
|
1418
|
+
* The name of {@link unresolvedNameImportRule}, which a check reads its findings by.
|
|
1203
1419
|
*/
|
|
1204
|
-
|
|
1205
|
-
if (layout.packages !== void 0) return layout.packages;
|
|
1206
|
-
const file = (0, node_path.join)(root, "pnpm-workspace.yaml");
|
|
1207
|
-
let source;
|
|
1208
|
-
try {
|
|
1209
|
-
source = await (0, node_fs_promises.readFile)(file, "utf8");
|
|
1210
|
-
} catch (error) {
|
|
1211
|
-
throw new ConformanceError(`the layout has no 'packages' and ${file} cannot be read`, { cause: error });
|
|
1212
|
-
}
|
|
1213
|
-
const document = (0, yaml.parse)(source);
|
|
1214
|
-
if (typeof document !== "object" || document === null || !("packages" in document) || !Array.isArray(document.packages)) throw new ConformanceError(`${file} has no 'packages' list`);
|
|
1215
|
-
const patterns = document.packages;
|
|
1216
|
-
if (!patterns.every((pattern) => typeof pattern === "string")) throw new ConformanceError(`${file}: every entry of 'packages' must be a string`);
|
|
1217
|
-
return patterns;
|
|
1218
|
-
}
|
|
1219
|
-
async function readName(manifest) {
|
|
1220
|
-
let content;
|
|
1221
|
-
try {
|
|
1222
|
-
content = JSON.parse(await (0, node_fs_promises.readFile)(manifest, "utf8"));
|
|
1223
|
-
} catch (error) {
|
|
1224
|
-
throw new ConformanceError(`${manifest} cannot be read as JSON`, { cause: error });
|
|
1225
|
-
}
|
|
1226
|
-
if (typeof content === "object" && content !== null && "name" in content && typeof content.name === "string") return content.name;
|
|
1227
|
-
}
|
|
1420
|
+
const UNRESOLVED_NAME_IMPORT_RULE_NAME = "unresolved-name-import";
|
|
1228
1421
|
/**
|
|
1229
|
-
* The
|
|
1422
|
+
* The rule that forbids any import cycle among the packages' files.
|
|
1230
1423
|
*/
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
}
|
|
1239
|
-
return Promise.all([...new Set(dirs)].sort().map(async (dir) => ({
|
|
1240
|
-
dir,
|
|
1241
|
-
name: await readName((0, node_path.join)(root, dir, "package.json"))
|
|
1242
|
-
})));
|
|
1424
|
+
function cycleRule() {
|
|
1425
|
+
return {
|
|
1426
|
+
name: CYCLE_RULE_NAME,
|
|
1427
|
+
severity: "error",
|
|
1428
|
+
comment: "files may not import each other in a cycle",
|
|
1429
|
+
from: {},
|
|
1430
|
+
to: { circular: true }
|
|
1431
|
+
};
|
|
1243
1432
|
}
|
|
1244
1433
|
/**
|
|
1245
|
-
* The
|
|
1434
|
+
* The rule that reports every import of a named package by a name that resolves to no file. It marks edges for the cycle check to follow, so a finding of it is an import and not a defect.
|
|
1435
|
+
*
|
|
1436
|
+
* It is `undefined` when no package has a name.
|
|
1246
1437
|
*/
|
|
1247
|
-
|
|
1248
|
-
const
|
|
1249
|
-
return
|
|
1438
|
+
function unresolvedNameImportRule(packages) {
|
|
1439
|
+
const names = packageNamesPattern(packages);
|
|
1440
|
+
return names === void 0 ? void 0 : {
|
|
1441
|
+
name: UNRESOLVED_NAME_IMPORT_RULE_NAME,
|
|
1442
|
+
severity: "error",
|
|
1443
|
+
comment: "marks an import of a workspace package by a name that resolves to no file",
|
|
1444
|
+
from: { path: packagesPattern(packages) },
|
|
1445
|
+
to: {
|
|
1446
|
+
path: names,
|
|
1447
|
+
couldNotResolve: true
|
|
1448
|
+
}
|
|
1449
|
+
};
|
|
1250
1450
|
}
|
|
1251
1451
|
//#endregion
|
|
1252
1452
|
//#region src/checks/import-graph.ts
|
|
@@ -1262,7 +1462,7 @@ const DEFAULT_DO_NOT_FOLLOW = ["(^|/)dist/"];
|
|
|
1262
1462
|
* The package a path in the graph belongs to: the one whose directory holds the file, and only when no directory does, the one whose name the path is (an import that resolved to no file is known only by the name it was written with, or a subpath of it). The directory wins because an unscoped package name can equal the leading directory of another package's files. It throws `ConformanceError` for a path the graph should not contain.
|
|
1263
1463
|
*/
|
|
1264
1464
|
function packageOfPath(packages, path) {
|
|
1265
|
-
const found = packages.find((member) =>
|
|
1465
|
+
const found = packages.find((member) => isInPackage(member.dir, path)) ?? packages.find((member) => member.name !== void 0 && (path === member.name || path.startsWith(`${member.name}/`)));
|
|
1266
1466
|
if (found === void 0) throw new ConformanceError(`${path} is in no workspace package, although the graph is limited to the packages' files and names`);
|
|
1267
1467
|
return found;
|
|
1268
1468
|
}
|
|
@@ -1418,7 +1618,7 @@ function describePackage(member) {
|
|
|
1418
1618
|
*/
|
|
1419
1619
|
function describeTarget(edge) {
|
|
1420
1620
|
const { to, toFile } = edge;
|
|
1421
|
-
return !
|
|
1621
|
+
return !isInPackage(to.dir, toFile) && to.name !== void 0 && (toFile === to.name || toFile.startsWith(`${to.name}/`)) ? toFile : `${toFile} in ${describePackage(to)}`;
|
|
1422
1622
|
}
|
|
1423
1623
|
//#endregion
|
|
1424
1624
|
//#region src/checks/import-cross-slice.ts
|
|
@@ -1693,7 +1893,7 @@ async function leadsTo(cwd, file, entry, destination) {
|
|
|
1693
1893
|
}
|
|
1694
1894
|
}
|
|
1695
1895
|
/**
|
|
1696
|
-
* Each agent instruction file is tracked by git as a symbolic link (mode 120000) whose target is the README beside it, so the two cannot diverge. The link may be written in any form that resolves there (`./README.md`), and may lead through other tracked links, as in `CLAUDE.md` to `AGENTS.md` to `README.md`. It reads the index, not the working tree, so a checkout with `core.symlinks` false, where a link is written out as a plain file, is judged by what is committed.
|
|
1896
|
+
* Each agent instruction file is tracked by git as a symbolic link (mode 120000) whose target is the README beside it, so the two cannot diverge. A directory without a tracked README has nothing to link to, so a missing instruction file there is not reported; one that exists is still judged. The link may be written in any form that resolves there (`./README.md`), and may lead through other tracked links, as in `CLAUDE.md` to `AGENTS.md` to `README.md`. It reads the index, not the working tree, so a checkout with `core.symlinks` false, where a link is written out as a plain file, is judged by what is committed.
|
|
1697
1897
|
*/
|
|
1698
1898
|
const instructionSymlinks = async ({ cwd, options }) => {
|
|
1699
1899
|
const files = options.files ?? DEFAULT_INSTRUCTION_FILES;
|
|
@@ -1701,15 +1901,17 @@ const instructionSymlinks = async ({ cwd, options }) => {
|
|
|
1701
1901
|
const violations = [];
|
|
1702
1902
|
for (const directory of await directoriesOf(cwd, options.directories ?? ["."])) {
|
|
1703
1903
|
const readme = node_path.posix.join(directory, target);
|
|
1904
|
+
const readmeTracked = await indexEntry(cwd, readme) !== void 0;
|
|
1704
1905
|
for (const name of files) {
|
|
1705
1906
|
const file = node_path.posix.join(directory, name);
|
|
1706
1907
|
const entry = await indexEntry(cwd, file);
|
|
1707
|
-
if (entry === void 0)
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1908
|
+
if (entry === void 0) {
|
|
1909
|
+
if (readmeTracked) violations.push({
|
|
1910
|
+
code: "instruction-symlinks/missing",
|
|
1911
|
+
message: `${file} is not tracked; commit it as a symbolic link to ${target}`,
|
|
1912
|
+
file
|
|
1913
|
+
});
|
|
1914
|
+
} else if (entry.mode !== "120000") violations.push({
|
|
1713
1915
|
code: "instruction-symlinks/not-a-symlink",
|
|
1714
1916
|
message: `${file} is committed as a regular file (mode ${entry.mode}); commit it as a symbolic link to ${target}`,
|
|
1715
1917
|
file
|
|
@@ -1721,7 +1923,7 @@ const instructionSymlinks = async ({ cwd, options }) => {
|
|
|
1721
1923
|
message: `${file} links to ${linked}; it should link to ${target}`,
|
|
1722
1924
|
file
|
|
1723
1925
|
});
|
|
1724
|
-
} else if (
|
|
1926
|
+
} else if (!readmeTracked) violations.push({
|
|
1725
1927
|
code: "instruction-symlinks/dangling",
|
|
1726
1928
|
message: `${file} links to ${target}, which is not tracked`,
|
|
1727
1929
|
file
|
|
@@ -1933,6 +2135,116 @@ const migrationsDirectory = async ({ cwd, options, configFiles }) => {
|
|
|
1933
2135
|
return violations;
|
|
1934
2136
|
};
|
|
1935
2137
|
//#endregion
|
|
2138
|
+
//#region src/github.ts
|
|
2139
|
+
/**
|
|
2140
|
+
* GitHub's refusal to read a branch's rules because the repository's plan does not offer rulesets, as for a private repository on a plan without them: no ruleset applies to its branches, so a check that requires one cannot be met. The message is GitHub's own. A {@link GitHubClient} rejects `branchRules` with it, and with any other error for every other refusal.
|
|
2141
|
+
*/
|
|
2142
|
+
var RulesetsUnavailableError = class extends ConformanceError {
|
|
2143
|
+
constructor(message, options) {
|
|
2144
|
+
super(message, options);
|
|
2145
|
+
this.name = "RulesetsUnavailableError";
|
|
2146
|
+
}
|
|
2147
|
+
};
|
|
2148
|
+
const repositorySchema = zod.z.object({
|
|
2149
|
+
default_branch: zod.z.string(),
|
|
2150
|
+
allow_rebase_merge: zod.z.optional(zod.z.boolean()),
|
|
2151
|
+
allow_squash_merge: zod.z.optional(zod.z.boolean()),
|
|
2152
|
+
allow_merge_commit: zod.z.optional(zod.z.boolean())
|
|
2153
|
+
});
|
|
2154
|
+
const rulesSchema = zod.z.array(zod.z.looseObject({
|
|
2155
|
+
type: zod.z.string(),
|
|
2156
|
+
parameters: zod.z.optional(zod.z.looseObject({
|
|
2157
|
+
required_status_checks: zod.z.optional(zod.z.array(zod.z.object({ context: zod.z.string() }))),
|
|
2158
|
+
strict_required_status_checks_policy: zod.z.optional(zod.z.boolean()),
|
|
2159
|
+
required_review_thread_resolution: zod.z.optional(zod.z.boolean()),
|
|
2160
|
+
allowed_merge_methods: zod.z.optional(zod.z.array(zod.z.string()))
|
|
2161
|
+
}))
|
|
2162
|
+
}));
|
|
2163
|
+
/**
|
|
2164
|
+
* The body of an error response of the REST API: `message` explains the refusal.
|
|
2165
|
+
*/
|
|
2166
|
+
const errorBodySchema = zod.z.looseObject({ message: zod.z.string() });
|
|
2167
|
+
/**
|
|
2168
|
+
* The 403 status of a request that GitHub refuses, for a missing permission, a rate limit or a feature the plan lacks alike.
|
|
2169
|
+
*/
|
|
2170
|
+
const FORBIDDEN = 403;
|
|
2171
|
+
/**
|
|
2172
|
+
* How GitHub's message starts when it refuses a request for a feature the repository's plan does not include ('Upgrade to GitHub Pro or make this repository public to enable this feature.'). The REST documentation of the branch rules endpoint lists no error status, and a 403 also means a missing permission ('Resource not accessible by personal access token') or a rate limit, so the message is the only thing that tells a plan limit apart.
|
|
2173
|
+
*/
|
|
2174
|
+
const PLAN_LIMIT_PREFIX = "Upgrade to GitHub ";
|
|
2175
|
+
/**
|
|
2176
|
+
* The plan-limit refusal of a branch's rules as {@link RulesetsUnavailableError}.
|
|
2177
|
+
*/
|
|
2178
|
+
const rulesRefusal = (status, githubMessage) => status === FORBIDDEN && githubMessage?.startsWith(PLAN_LIMIT_PREFIX) === true ? new RulesetsUnavailableError(githubMessage) : void 0;
|
|
2179
|
+
async function githubMessageOf(response) {
|
|
2180
|
+
const text = await response.text();
|
|
2181
|
+
let body;
|
|
2182
|
+
try {
|
|
2183
|
+
body = JSON.parse(text);
|
|
2184
|
+
} catch {
|
|
2185
|
+
return;
|
|
2186
|
+
}
|
|
2187
|
+
const parsed = errorBodySchema.safeParse(body);
|
|
2188
|
+
return parsed.success ? parsed.data.message : void 0;
|
|
2189
|
+
}
|
|
2190
|
+
const API_ROOT = "https://api.github.com";
|
|
2191
|
+
const API_VERSION = "2022-11-28";
|
|
2192
|
+
/**
|
|
2193
|
+
* How many rules the API returns in one page, its maximum.
|
|
2194
|
+
*/
|
|
2195
|
+
const PAGE_SIZE = 100;
|
|
2196
|
+
/**
|
|
2197
|
+
* A {@link GitHubClient} over the REST API.
|
|
2198
|
+
*/
|
|
2199
|
+
function createGitHubClient(options) {
|
|
2200
|
+
const request = options.fetch ?? fetch;
|
|
2201
|
+
const root = options.apiUrl ?? API_ROOT;
|
|
2202
|
+
async function get(path, refusal) {
|
|
2203
|
+
const response = await request(`${root}${path}`, { headers: {
|
|
2204
|
+
Accept: "application/vnd.github+json",
|
|
2205
|
+
Authorization: `Bearer ${options.token}`,
|
|
2206
|
+
"X-GitHub-Api-Version": API_VERSION
|
|
2207
|
+
} });
|
|
2208
|
+
if (!response.ok) {
|
|
2209
|
+
const githubMessage = await githubMessageOf(response);
|
|
2210
|
+
throw refusal?.(response.status, githubMessage) ?? new ConformanceError(`GET ${path} failed: ${String(response.status)} ${response.statusText}${githubMessage === void 0 ? "" : `: ${githubMessage}`}`);
|
|
2211
|
+
}
|
|
2212
|
+
return response.json();
|
|
2213
|
+
}
|
|
2214
|
+
return {
|
|
2215
|
+
repository: async (slug) => {
|
|
2216
|
+
const parsed = repositorySchema.parse(await get(`/repos/${slug.owner}/${slug.name}`));
|
|
2217
|
+
const { allow_rebase_merge: allowRebaseMerge, allow_squash_merge: allowSquashMerge, allow_merge_commit: allowMergeCommit } = parsed;
|
|
2218
|
+
if (allowRebaseMerge === void 0 || allowSquashMerge === void 0 || allowMergeCommit === void 0) throw new ConformanceError(`GET /repos/${slug.owner}/${slug.name} did not report the merge-method settings, which GitHub shows only to a token with write access to the repository`);
|
|
2219
|
+
return {
|
|
2220
|
+
defaultBranch: parsed.default_branch,
|
|
2221
|
+
allowRebaseMerge,
|
|
2222
|
+
allowSquashMerge,
|
|
2223
|
+
allowMergeCommit
|
|
2224
|
+
};
|
|
2225
|
+
},
|
|
2226
|
+
branchRules: async (slug, branch) => {
|
|
2227
|
+
const rules = [];
|
|
2228
|
+
for (let page = 1, more = true; more; page += 1) {
|
|
2229
|
+
const body = await get(`/repos/${slug.owner}/${slug.name}/rules/branches/${encodeURIComponent(branch)}?per_page=${String(PAGE_SIZE)}&page=${String(page)}`, rulesRefusal);
|
|
2230
|
+
const found = rulesSchema.parse(body);
|
|
2231
|
+
rules.push(...found);
|
|
2232
|
+
more = found.length === PAGE_SIZE;
|
|
2233
|
+
}
|
|
2234
|
+
return {
|
|
2235
|
+
requiredStatusChecks: rules.filter((rule) => rule.type === "required_status_checks").map((rule) => ({
|
|
2236
|
+
contexts: (rule.parameters?.required_status_checks ?? []).map((check) => check.context),
|
|
2237
|
+
strict: rule.parameters?.strict_required_status_checks_policy === true
|
|
2238
|
+
})),
|
|
2239
|
+
pullRequests: rules.filter((rule) => rule.type === "pull_request").map((rule) => ({
|
|
2240
|
+
requiredReviewThreadResolution: rule.parameters?.required_review_thread_resolution === true,
|
|
2241
|
+
allowedMergeMethods: rule.parameters?.allowed_merge_methods
|
|
2242
|
+
}))
|
|
2243
|
+
};
|
|
2244
|
+
}
|
|
2245
|
+
};
|
|
2246
|
+
}
|
|
2247
|
+
//#endregion
|
|
1936
2248
|
//#region src/settings/repository.ts
|
|
1937
2249
|
const SLUG = /^([^/\s]+)\/([^/\s]+)$/u;
|
|
1938
2250
|
const REMOTE = /github\.com[:/]([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/u;
|
|
@@ -1969,6 +2281,24 @@ async function branchOf(github, slug, options) {
|
|
|
1969
2281
|
function repositoryFile(slug) {
|
|
1970
2282
|
return `${slug.owner}/${slug.name}`;
|
|
1971
2283
|
}
|
|
2284
|
+
/**
|
|
2285
|
+
* The rules of the branch, or GitHub's message when the repository's plan has no rulesets, in which case no rule applies to the branch and none can be added. Any other refusal rejects.
|
|
2286
|
+
*/
|
|
2287
|
+
async function branchRulesOf(github, slug, options) {
|
|
2288
|
+
const branch = await branchOf(github, slug, options);
|
|
2289
|
+
try {
|
|
2290
|
+
return { rules: await github.branchRules(slug, branch) };
|
|
2291
|
+
} catch (error) {
|
|
2292
|
+
if (error instanceof RulesetsUnavailableError) return { unavailable: error.message };
|
|
2293
|
+
throw error;
|
|
2294
|
+
}
|
|
2295
|
+
}
|
|
2296
|
+
/**
|
|
2297
|
+
* The message of a `rules-unavailable` violation: the branch rules of the repository cannot be read or enforced on its plan, so `what` cannot be required.
|
|
2298
|
+
*/
|
|
2299
|
+
function rulesUnavailableMessage(file, what, githubMessage) {
|
|
2300
|
+
return `the branch rules of ${file} cannot be read or enforced on its plan, so ${what} cannot be required (GitHub: ${githubMessage})`;
|
|
2301
|
+
}
|
|
1972
2302
|
//#endregion
|
|
1973
2303
|
//#region src/checks/settings-merge-methods.ts
|
|
1974
2304
|
const METHODS = [
|
|
@@ -1979,20 +2309,23 @@ const METHODS = [
|
|
|
1979
2309
|
/**
|
|
1980
2310
|
* Only one merge method is available for a pull request into the branch, `rebase` unless the options say otherwise. A method is available when the repository allows it and no `pull_request` rule that applies to the branch leaves it out of its allowed methods.
|
|
1981
2311
|
*
|
|
2312
|
+
* When the repository's plan has no rulesets, no rule applies to the branch and the repository settings alone decide.
|
|
2313
|
+
*
|
|
1982
2314
|
* Limits: it reads the repository settings and the rules of rulesets that apply to the branch; classic branch protection and a merge queue's own merge method are not read, so a repository whose queue owns the strategy is judged by what it still allows for a direct merge.
|
|
1983
2315
|
*/
|
|
1984
2316
|
const settingsMergeMethods = async ({ cwd, options, github }) => {
|
|
1985
2317
|
const slug = await repositoryOf(cwd, options);
|
|
1986
2318
|
const file = repositoryFile(slug);
|
|
1987
2319
|
const settings = await github.repository(slug);
|
|
1988
|
-
const
|
|
2320
|
+
const read = await branchRulesOf(github, slug, options);
|
|
2321
|
+
const pullRequests = "unavailable" in read ? [] : read.rules.pullRequests;
|
|
1989
2322
|
const wanted = options.allowed ?? "rebase";
|
|
1990
2323
|
const repositoryAllows = {
|
|
1991
2324
|
rebase: settings.allowRebaseMerge,
|
|
1992
2325
|
squash: settings.allowSquashMerge,
|
|
1993
2326
|
merge: settings.allowMergeCommit
|
|
1994
2327
|
};
|
|
1995
|
-
const available = METHODS.filter((method) => repositoryAllows[method] &&
|
|
2328
|
+
const available = METHODS.filter((method) => repositoryAllows[method] && pullRequests.every((rule) => rule.allowedMergeMethods === void 0 || rule.allowedMergeMethods.includes(method)));
|
|
1996
2329
|
const violations = available.filter((method) => method !== wanted).map((method) => ({
|
|
1997
2330
|
code: "settings-merge-methods/method-enabled",
|
|
1998
2331
|
message: `${file} allows ${method} merges; only ${wanted} should be allowed`,
|
|
@@ -2391,14 +2724,20 @@ const REGISTRY_TOKEN_VARIABLES = ["NODE_AUTH_TOKEN", "NPM_TOKEN"];
|
|
|
2391
2724
|
/**
|
|
2392
2725
|
* The branch requires the check of every junction job of the workflows, and requires branches to be up to date before merging (strict mode). The check name of a job is its `name`, else its id.
|
|
2393
2726
|
*
|
|
2394
|
-
* The junction job is the one that reports for the whole workflow, so requiring it requires everything it needs, including jobs a path filter may skip. Codes: `no-required-checks` (no `required_status_checks` rule applies), `required-check-missing` (a junction job's check is not among the required ones), `not-strict` (no rule sets the strict policy)
|
|
2727
|
+
* The junction job is the one that reports for the whole workflow, so requiring it requires everything it needs, including jobs a path filter may skip. Codes: `no-required-checks` (no `required_status_checks` rule applies), `required-check-missing` (a junction job's check is not among the required ones), `not-strict` (no rule sets the strict policy), `no-junction-job` (no workflow has a job with an id from `junctionJobs`) and `rules-unavailable` (the repository's plan has no rulesets, so no check can be required; reported alone, with GitHub's message).
|
|
2395
2728
|
*
|
|
2396
2729
|
* Limits: it reads the rules of rulesets that apply to the branch; classic branch protection is not read, so a repository that uses it is reported as having no required checks. A junction job in a reusable workflow, or a name that is an expression, is not resolved.
|
|
2397
2730
|
*/
|
|
2398
2731
|
const settingsRequiredChecks = async ({ cwd, options, github }) => {
|
|
2399
2732
|
const slug = await repositoryOf(cwd, options);
|
|
2400
2733
|
const file = repositoryFile(slug);
|
|
2401
|
-
const
|
|
2734
|
+
const read = await branchRulesOf(github, slug, options);
|
|
2735
|
+
if ("unavailable" in read) return [{
|
|
2736
|
+
code: "settings-required-checks/rules-unavailable",
|
|
2737
|
+
message: rulesUnavailableMessage(file, "a status check", read.unavailable),
|
|
2738
|
+
file
|
|
2739
|
+
}];
|
|
2740
|
+
const { rules } = read;
|
|
2402
2741
|
const junctions = (await loadWorkflows(cwd, options)).flatMap((workflow) => jobsNamed(workflow, options.junctionJobs ?? DEFAULT_JUNCTION_JOBS).map((job) => ({
|
|
2403
2742
|
workflow,
|
|
2404
2743
|
name: job.name ?? job.id
|
|
@@ -2435,13 +2774,20 @@ const settingsRequiredChecks = async ({ cwd, options, github }) => {
|
|
|
2435
2774
|
/**
|
|
2436
2775
|
* A pull request cannot merge while a review conversation is unresolved: a `pull_request` rule that applies to the branch sets `required_review_thread_resolution`.
|
|
2437
2776
|
*
|
|
2777
|
+
* When the repository's plan has no rulesets it reports `rules-unavailable` with GitHub's message, since nothing can require it.
|
|
2778
|
+
*
|
|
2438
2779
|
* Limits: it reads the rules of rulesets; classic branch protection is not read.
|
|
2439
2780
|
*/
|
|
2440
2781
|
const settingsReviewThreadResolution = async ({ cwd, options, github }) => {
|
|
2441
2782
|
const slug = await repositoryOf(cwd, options);
|
|
2442
|
-
const
|
|
2783
|
+
const read = await branchRulesOf(github, slug, options);
|
|
2443
2784
|
const file = repositoryFile(slug);
|
|
2444
|
-
|
|
2785
|
+
if ("unavailable" in read) return [{
|
|
2786
|
+
code: "settings-review-thread-resolution/rules-unavailable",
|
|
2787
|
+
message: rulesUnavailableMessage(file, "resolving review conversations", read.unavailable),
|
|
2788
|
+
file
|
|
2789
|
+
}];
|
|
2790
|
+
return read.rules.pullRequests.some((rule) => rule.requiredReviewThreadResolution) ? [] : [{
|
|
2445
2791
|
code: "settings-review-thread-resolution/not-required",
|
|
2446
2792
|
message: `no rule requires review conversations to be resolved before merging on the branch of ${file}`,
|
|
2447
2793
|
file
|
|
@@ -3331,6 +3677,7 @@ const checks = zod.z.strictObject({
|
|
|
3331
3677
|
commitlint: zod.z.exactOptional(path),
|
|
3332
3678
|
release: zod.z.exactOptional(path)
|
|
3333
3679
|
}))),
|
|
3680
|
+
"engines-floor": zod.z.exactOptional(enabled(zod.z.strictObject({ packages: zod.z.exactOptional(paths) }))),
|
|
3334
3681
|
"dockerfile-package-manager": zod.z.exactOptional(enabled(zod.z.strictObject({
|
|
3335
3682
|
dockerfiles: zod.z.exactOptional(paths),
|
|
3336
3683
|
exclude: zod.z.exactOptional(paths),
|
|
@@ -3420,76 +3767,6 @@ const conformanceSchema = zod.z.strictObject({ checks });
|
|
|
3420
3767
|
*/
|
|
3421
3768
|
const conformanceSection = (0, _exadev_config.defineSection)("conformance", conformanceSchema);
|
|
3422
3769
|
//#endregion
|
|
3423
|
-
//#region src/github.ts
|
|
3424
|
-
const repositorySchema = zod.z.object({
|
|
3425
|
-
default_branch: zod.z.string(),
|
|
3426
|
-
allow_rebase_merge: zod.z.optional(zod.z.boolean()),
|
|
3427
|
-
allow_squash_merge: zod.z.optional(zod.z.boolean()),
|
|
3428
|
-
allow_merge_commit: zod.z.optional(zod.z.boolean())
|
|
3429
|
-
});
|
|
3430
|
-
const rulesSchema = zod.z.array(zod.z.looseObject({
|
|
3431
|
-
type: zod.z.string(),
|
|
3432
|
-
parameters: zod.z.optional(zod.z.looseObject({
|
|
3433
|
-
required_status_checks: zod.z.optional(zod.z.array(zod.z.object({ context: zod.z.string() }))),
|
|
3434
|
-
strict_required_status_checks_policy: zod.z.optional(zod.z.boolean()),
|
|
3435
|
-
required_review_thread_resolution: zod.z.optional(zod.z.boolean()),
|
|
3436
|
-
allowed_merge_methods: zod.z.optional(zod.z.array(zod.z.string()))
|
|
3437
|
-
}))
|
|
3438
|
-
}));
|
|
3439
|
-
const API_ROOT = "https://api.github.com";
|
|
3440
|
-
const API_VERSION = "2022-11-28";
|
|
3441
|
-
/**
|
|
3442
|
-
* How many rules the API returns in one page, its maximum.
|
|
3443
|
-
*/
|
|
3444
|
-
const PAGE_SIZE = 100;
|
|
3445
|
-
/**
|
|
3446
|
-
* A {@link GitHubClient} over the REST API.
|
|
3447
|
-
*/
|
|
3448
|
-
function createGitHubClient(options) {
|
|
3449
|
-
const request = options.fetch ?? fetch;
|
|
3450
|
-
const root = options.apiUrl ?? API_ROOT;
|
|
3451
|
-
async function get(path) {
|
|
3452
|
-
const response = await request(`${root}${path}`, { headers: {
|
|
3453
|
-
Accept: "application/vnd.github+json",
|
|
3454
|
-
Authorization: `Bearer ${options.token}`,
|
|
3455
|
-
"X-GitHub-Api-Version": API_VERSION
|
|
3456
|
-
} });
|
|
3457
|
-
if (!response.ok) throw new ConformanceError(`GET ${path} failed: ${String(response.status)} ${response.statusText}`);
|
|
3458
|
-
return response.json();
|
|
3459
|
-
}
|
|
3460
|
-
return {
|
|
3461
|
-
repository: async (slug) => {
|
|
3462
|
-
const parsed = repositorySchema.parse(await get(`/repos/${slug.owner}/${slug.name}`));
|
|
3463
|
-
const { allow_rebase_merge: allowRebaseMerge, allow_squash_merge: allowSquashMerge, allow_merge_commit: allowMergeCommit } = parsed;
|
|
3464
|
-
if (allowRebaseMerge === void 0 || allowSquashMerge === void 0 || allowMergeCommit === void 0) throw new ConformanceError(`GET /repos/${slug.owner}/${slug.name} did not report the merge-method settings, which GitHub shows only to a token with write access to the repository`);
|
|
3465
|
-
return {
|
|
3466
|
-
defaultBranch: parsed.default_branch,
|
|
3467
|
-
allowRebaseMerge,
|
|
3468
|
-
allowSquashMerge,
|
|
3469
|
-
allowMergeCommit
|
|
3470
|
-
};
|
|
3471
|
-
},
|
|
3472
|
-
branchRules: async (slug, branch) => {
|
|
3473
|
-
const rules = [];
|
|
3474
|
-
for (let page = 1, more = true; more; page += 1) {
|
|
3475
|
-
const found = rulesSchema.parse(await get(`/repos/${slug.owner}/${slug.name}/rules/branches/${encodeURIComponent(branch)}?per_page=${String(PAGE_SIZE)}&page=${String(page)}`));
|
|
3476
|
-
rules.push(...found);
|
|
3477
|
-
more = found.length === PAGE_SIZE;
|
|
3478
|
-
}
|
|
3479
|
-
return {
|
|
3480
|
-
requiredStatusChecks: rules.filter((rule) => rule.type === "required_status_checks").map((rule) => ({
|
|
3481
|
-
contexts: (rule.parameters?.required_status_checks ?? []).map((check) => check.context),
|
|
3482
|
-
strict: rule.parameters?.strict_required_status_checks_policy === true
|
|
3483
|
-
})),
|
|
3484
|
-
pullRequests: rules.filter((rule) => rule.type === "pull_request").map((rule) => ({
|
|
3485
|
-
requiredReviewThreadResolution: rule.parameters?.required_review_thread_resolution === true,
|
|
3486
|
-
allowedMergeMethods: rule.parameters?.allowed_merge_methods
|
|
3487
|
-
}))
|
|
3488
|
-
};
|
|
3489
|
-
}
|
|
3490
|
-
};
|
|
3491
|
-
}
|
|
3492
|
-
//#endregion
|
|
3493
3770
|
//#region src/registry.ts
|
|
3494
3771
|
/**
|
|
3495
3772
|
* The options of an enabled check, or `undefined` when the setting is absent or `false`.
|
|
@@ -3504,12 +3781,13 @@ function plain(spec) {
|
|
|
3504
3781
|
requiresLayout: false,
|
|
3505
3782
|
requiresGitHub: false,
|
|
3506
3783
|
isEnabled: (checks) => spec.select(checks) !== void 0,
|
|
3507
|
-
run: async ({ cwd, checks, configFiles }) => {
|
|
3784
|
+
run: async ({ cwd, checks, configFiles, note }) => {
|
|
3508
3785
|
const options = spec.select(checks);
|
|
3509
3786
|
if (options === void 0) throw new ConformanceError(`the check '${spec.name}' is not enabled`);
|
|
3510
3787
|
return spec.check({
|
|
3511
3788
|
cwd,
|
|
3512
3789
|
options,
|
|
3790
|
+
note,
|
|
3513
3791
|
...configFiles === void 0 ? {} : { configFiles }
|
|
3514
3792
|
});
|
|
3515
3793
|
}
|
|
@@ -3522,7 +3800,7 @@ function online(spec) {
|
|
|
3522
3800
|
requiresLayout: false,
|
|
3523
3801
|
requiresGitHub: true,
|
|
3524
3802
|
isEnabled: (checks) => spec.select(checks) !== void 0,
|
|
3525
|
-
run: async ({ cwd, checks, configFiles, github }) => {
|
|
3803
|
+
run: async ({ cwd, checks, configFiles, github, note }) => {
|
|
3526
3804
|
const options = spec.select(checks);
|
|
3527
3805
|
if (options === void 0) throw new ConformanceError(`the check '${spec.name}' is not enabled`);
|
|
3528
3806
|
if (github === void 0) throw new ConformanceError(`the check '${spec.name}' reads the repository's settings and needs a GitHub client`);
|
|
@@ -3530,6 +3808,7 @@ function online(spec) {
|
|
|
3530
3808
|
cwd,
|
|
3531
3809
|
options,
|
|
3532
3810
|
github,
|
|
3811
|
+
note,
|
|
3533
3812
|
...configFiles === void 0 ? {} : { configFiles }
|
|
3534
3813
|
});
|
|
3535
3814
|
}
|
|
@@ -3624,6 +3903,12 @@ const registry = {
|
|
|
3624
3903
|
select: (checks) => enabledOptions(checks["commit-types"]),
|
|
3625
3904
|
check: commitTypes
|
|
3626
3905
|
}),
|
|
3906
|
+
"engines-floor": plain({
|
|
3907
|
+
name: "engines-floor",
|
|
3908
|
+
description: "A package's engines.node range admits no Node version that one of its installed dependencies rejects",
|
|
3909
|
+
select: (checks) => enabledOptions(checks["engines-floor"]),
|
|
3910
|
+
check: enginesFloor
|
|
3911
|
+
}),
|
|
3627
3912
|
"dockerfile-package-manager": plain({
|
|
3628
3913
|
name: "dockerfile-package-manager",
|
|
3629
3914
|
description: "A package manager version pinned in a Dockerfile is the version packageManager names",
|
|
@@ -3748,7 +4033,7 @@ async function conformanceOf(options) {
|
|
|
3748
4033
|
cwd: options.cwd
|
|
3749
4034
|
});
|
|
3750
4035
|
if (loaded === void 0) throw new ConformanceError(`no 'conformance' section in ${options.cwd}; define it in exadev.config.ts or in exadev.conformance.config.ts`);
|
|
3751
|
-
return loaded;
|
|
4036
|
+
return loaded.value;
|
|
3752
4037
|
}
|
|
3753
4038
|
async function layoutOf(options, needed) {
|
|
3754
4039
|
if (options.layout !== void 0) return (0, cosmiconfig_extends.validateStandard)(_exadev_config.layoutSection.schema, options.layout, `'${_exadev_config.layoutSection.name}' section passed to runChecks`);
|
|
@@ -3758,7 +4043,7 @@ async function layoutOf(options, needed) {
|
|
|
3758
4043
|
cwd: options.cwd
|
|
3759
4044
|
});
|
|
3760
4045
|
if (loaded === void 0) throw new ConformanceError(`${needed.join(", ")} read the workspace layout, and there is no 'layout' section in ${options.cwd}; define it in exadev.config.ts or in exadev.layout.config.ts`);
|
|
3761
|
-
return loaded;
|
|
4046
|
+
return loaded.value;
|
|
3762
4047
|
}
|
|
3763
4048
|
function selected(config, requested, online) {
|
|
3764
4049
|
const enabled = checkNames.filter((name) => registry[name].isEnabled(config.checks));
|
|
@@ -3808,7 +4093,11 @@ function readImported(imported, name) {
|
|
|
3808
4093
|
*
|
|
3809
4094
|
* Violations do not throw. It throws `ConformanceError` when the checks cannot run (nothing enabled, a requested check disabled, a section missing) and `ConfigValidationError` when a section fails its schema, whether it was loaded from `cwd` or passed in.
|
|
3810
4095
|
*/
|
|
3811
|
-
async function runChecks(
|
|
4096
|
+
async function runChecks(given) {
|
|
4097
|
+
const options = {
|
|
4098
|
+
...given,
|
|
4099
|
+
cwd: (0, node_path.resolve)(given.cwd)
|
|
4100
|
+
};
|
|
3812
4101
|
const config = await conformanceOf(options);
|
|
3813
4102
|
const names = selected(config, options.checks, options.github !== void 0);
|
|
3814
4103
|
const layout = await layoutOf(options, names.filter((name) => registry[name].requiresLayout));
|
|
@@ -3816,16 +4105,22 @@ async function runChecks(options) {
|
|
|
3816
4105
|
const results = [];
|
|
3817
4106
|
for (const name of names) {
|
|
3818
4107
|
const check = registry[name];
|
|
4108
|
+
const notes = [];
|
|
4109
|
+
const note = (text) => {
|
|
4110
|
+
notes.push(text);
|
|
4111
|
+
};
|
|
3819
4112
|
const violations = "run" in check ? await check.run({
|
|
3820
4113
|
cwd: options.cwd,
|
|
3821
4114
|
checks: config.checks,
|
|
3822
4115
|
layout,
|
|
3823
4116
|
configFiles: options.configFiles,
|
|
3824
|
-
github: options.github
|
|
4117
|
+
github: options.github,
|
|
4118
|
+
note
|
|
3825
4119
|
}) : readImported(imported, name);
|
|
3826
4120
|
results.push({
|
|
3827
4121
|
check: name,
|
|
3828
|
-
violations
|
|
4122
|
+
violations,
|
|
4123
|
+
notes
|
|
3829
4124
|
});
|
|
3830
4125
|
}
|
|
3831
4126
|
const violations = results.flatMap((result) => result.violations);
|
|
@@ -3936,6 +4231,7 @@ exports.DEFAULT_WORKFLOWS = DEFAULT_WORKFLOWS;
|
|
|
3936
4231
|
exports.EXIT_CODES = EXIT_CODES;
|
|
3937
4232
|
exports.ExhaustivenessError = ExhaustivenessError;
|
|
3938
4233
|
exports.PRESET_COMMIT_TYPES = PRESET_COMMIT_TYPES;
|
|
4234
|
+
exports.RulesetsUnavailableError = RulesetsUnavailableError;
|
|
3939
4235
|
exports.accessOf = accessOf;
|
|
3940
4236
|
exports.aggregateMappers = aggregateMappers;
|
|
3941
4237
|
exports.checkNames = checkNames;
|
|
@@ -3953,6 +4249,7 @@ exports.derivedTypes = derivedTypes;
|
|
|
3953
4249
|
exports.dockerfilePackageManager = dockerfilePackageManager;
|
|
3954
4250
|
exports.effectiveEnv = effectiveEnv;
|
|
3955
4251
|
exports.effectivePermissions = effectivePermissions;
|
|
4252
|
+
exports.enginesFloor = enginesFloor;
|
|
3956
4253
|
exports.eslint = eslint;
|
|
3957
4254
|
exports.generators = generators;
|
|
3958
4255
|
exports.handlerCases = handlerCases;
|