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/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";
|
|
@@ -25,6 +26,15 @@ var ConformanceError = class extends Error {
|
|
|
25
26
|
};
|
|
26
27
|
//#endregion
|
|
27
28
|
//#region src/github.ts
|
|
29
|
+
/**
|
|
30
|
+
* 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.
|
|
31
|
+
*/
|
|
32
|
+
var RulesetsUnavailableError = class extends ConformanceError {
|
|
33
|
+
constructor(message, options) {
|
|
34
|
+
super(message, options);
|
|
35
|
+
this.name = "RulesetsUnavailableError";
|
|
36
|
+
}
|
|
37
|
+
};
|
|
28
38
|
const repositorySchema = z.object({
|
|
29
39
|
default_branch: z.string(),
|
|
30
40
|
allow_rebase_merge: z.optional(z.boolean()),
|
|
@@ -40,6 +50,33 @@ const rulesSchema = z.array(z.looseObject({
|
|
|
40
50
|
allowed_merge_methods: z.optional(z.array(z.string()))
|
|
41
51
|
}))
|
|
42
52
|
}));
|
|
53
|
+
/**
|
|
54
|
+
* The body of an error response of the REST API: `message` explains the refusal.
|
|
55
|
+
*/
|
|
56
|
+
const errorBodySchema = z.looseObject({ message: z.string() });
|
|
57
|
+
/**
|
|
58
|
+
* The 403 status of a request that GitHub refuses, for a missing permission, a rate limit or a feature the plan lacks alike.
|
|
59
|
+
*/
|
|
60
|
+
const FORBIDDEN = 403;
|
|
61
|
+
/**
|
|
62
|
+
* 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.
|
|
63
|
+
*/
|
|
64
|
+
const PLAN_LIMIT_PREFIX = "Upgrade to GitHub ";
|
|
65
|
+
/**
|
|
66
|
+
* The plan-limit refusal of a branch's rules as {@link RulesetsUnavailableError}.
|
|
67
|
+
*/
|
|
68
|
+
const rulesRefusal = (status, githubMessage) => status === FORBIDDEN && githubMessage?.startsWith(PLAN_LIMIT_PREFIX) === true ? new RulesetsUnavailableError(githubMessage) : void 0;
|
|
69
|
+
async function githubMessageOf(response) {
|
|
70
|
+
const text = await response.text();
|
|
71
|
+
let body;
|
|
72
|
+
try {
|
|
73
|
+
body = JSON.parse(text);
|
|
74
|
+
} catch {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const parsed = errorBodySchema.safeParse(body);
|
|
78
|
+
return parsed.success ? parsed.data.message : void 0;
|
|
79
|
+
}
|
|
43
80
|
const API_ROOT = "https://api.github.com";
|
|
44
81
|
const API_VERSION = "2022-11-28";
|
|
45
82
|
/**
|
|
@@ -52,13 +89,16 @@ const PAGE_SIZE = 100;
|
|
|
52
89
|
function createGitHubClient(options) {
|
|
53
90
|
const request = options.fetch ?? fetch;
|
|
54
91
|
const root = options.apiUrl ?? API_ROOT;
|
|
55
|
-
async function get(path) {
|
|
92
|
+
async function get(path, refusal) {
|
|
56
93
|
const response = await request(`${root}${path}`, { headers: {
|
|
57
94
|
Accept: "application/vnd.github+json",
|
|
58
95
|
Authorization: `Bearer ${options.token}`,
|
|
59
96
|
"X-GitHub-Api-Version": API_VERSION
|
|
60
97
|
} });
|
|
61
|
-
if (!response.ok)
|
|
98
|
+
if (!response.ok) {
|
|
99
|
+
const githubMessage = await githubMessageOf(response);
|
|
100
|
+
throw refusal?.(response.status, githubMessage) ?? new ConformanceError(`GET ${path} failed: ${String(response.status)} ${response.statusText}${githubMessage === void 0 ? "" : `: ${githubMessage}`}`);
|
|
101
|
+
}
|
|
62
102
|
return response.json();
|
|
63
103
|
}
|
|
64
104
|
return {
|
|
@@ -76,7 +116,8 @@ function createGitHubClient(options) {
|
|
|
76
116
|
branchRules: async (slug, branch) => {
|
|
77
117
|
const rules = [];
|
|
78
118
|
for (let page = 1, more = true; more; page += 1) {
|
|
79
|
-
const
|
|
119
|
+
const body = await get(`/repos/${slug.owner}/${slug.name}/rules/branches/${encodeURIComponent(branch)}?per_page=${String(PAGE_SIZE)}&page=${String(page)}`, rulesRefusal);
|
|
120
|
+
const found = rulesSchema.parse(body);
|
|
80
121
|
rules.push(...found);
|
|
81
122
|
more = found.length === PAGE_SIZE;
|
|
82
123
|
}
|
|
@@ -94,6 +135,20 @@ function createGitHubClient(options) {
|
|
|
94
135
|
};
|
|
95
136
|
}
|
|
96
137
|
//#endregion
|
|
138
|
+
//#region src/paths.ts
|
|
139
|
+
/**
|
|
140
|
+
* `path` with the platform separator replaced by `/`, the form paths take in violations and in options.
|
|
141
|
+
*/
|
|
142
|
+
function toPosix(path) {
|
|
143
|
+
return path.split(sep).join("/");
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* `absolute` relative to `cwd`, with `/` separators.
|
|
147
|
+
*/
|
|
148
|
+
function relativePosix(cwd, absolute) {
|
|
149
|
+
return toPosix(relative(cwd, absolute));
|
|
150
|
+
}
|
|
151
|
+
//#endregion
|
|
97
152
|
//#region src/files.ts
|
|
98
153
|
/**
|
|
99
154
|
* Directories no check descends into: installed dependencies are not the workspace's own files.
|
|
@@ -145,20 +200,6 @@ async function findDirectories(cwd, patterns) {
|
|
|
145
200
|
})).map((directory) => directory.replace(/\/$/u, "")).sort();
|
|
146
201
|
}
|
|
147
202
|
//#endregion
|
|
148
|
-
//#region src/paths.ts
|
|
149
|
-
/**
|
|
150
|
-
* `path` with the platform separator replaced by `/`, the form paths take in violations and in options.
|
|
151
|
-
*/
|
|
152
|
-
function toPosix(path) {
|
|
153
|
-
return path.split(sep).join("/");
|
|
154
|
-
}
|
|
155
|
-
/**
|
|
156
|
-
* `absolute` relative to `cwd`, with `/` separators.
|
|
157
|
-
*/
|
|
158
|
-
function relativePosix(cwd, absolute) {
|
|
159
|
-
return toPosix(relative(cwd, absolute));
|
|
160
|
-
}
|
|
161
|
-
//#endregion
|
|
162
203
|
//#region src/template.ts
|
|
163
204
|
const PLACEHOLDER = /\{([a-z]+)\}/gu;
|
|
164
205
|
/**
|
|
@@ -852,6 +893,317 @@ const dockerfilePackageManager = async ({ cwd, options }) => {
|
|
|
852
893
|
}
|
|
853
894
|
return violations;
|
|
854
895
|
};
|
|
896
|
+
/**
|
|
897
|
+
* 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`.
|
|
898
|
+
*/
|
|
899
|
+
function packagePathPrefix(dir) {
|
|
900
|
+
return dir === "." ? "" : `${dir}/`;
|
|
901
|
+
}
|
|
902
|
+
/**
|
|
903
|
+
* Whether `path`, relative to the workspace root, is inside the package in `dir`.
|
|
904
|
+
*/
|
|
905
|
+
function isInPackage(dir, path) {
|
|
906
|
+
return path.startsWith(packagePathPrefix(dir));
|
|
907
|
+
}
|
|
908
|
+
function segmentsOf(path) {
|
|
909
|
+
return path.split("/").filter((segment) => segment !== "" && segment !== ".");
|
|
910
|
+
}
|
|
911
|
+
function groupRoot(group) {
|
|
912
|
+
return segmentsOf(group.path ?? group.name);
|
|
913
|
+
}
|
|
914
|
+
function startsWithSegments(segments, prefix) {
|
|
915
|
+
return prefix.every((segment, index) => segments[index] === segment);
|
|
916
|
+
}
|
|
917
|
+
/**
|
|
918
|
+
* The group whose root is the longest prefix of `dir`; the first declared wins a tie. `undefined` when no group owns the directory.
|
|
919
|
+
*/
|
|
920
|
+
function owningGroup(dir, groups) {
|
|
921
|
+
const segments = segmentsOf(dir);
|
|
922
|
+
let best;
|
|
923
|
+
let bestLength = -1;
|
|
924
|
+
for (const group of groups) {
|
|
925
|
+
const root = groupRoot(group);
|
|
926
|
+
if (root.length > bestLength && startsWithSegments(segments, root)) {
|
|
927
|
+
best = group;
|
|
928
|
+
bestLength = root.length;
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
return best;
|
|
932
|
+
}
|
|
933
|
+
function rankOf(name, group, layout) {
|
|
934
|
+
if (name !== void 0) {
|
|
935
|
+
const rule = layout.nameRanks?.find((candidate) => new RegExp(candidate.pattern, "u").test(name));
|
|
936
|
+
if (rule !== void 0) return rule.rank;
|
|
937
|
+
}
|
|
938
|
+
return group.rank ?? layout.defaultRank;
|
|
939
|
+
}
|
|
940
|
+
/**
|
|
941
|
+
* The name without its npm scope: `@scope/store-cli` gives `store-cli`.
|
|
942
|
+
*/
|
|
943
|
+
function unscoped(name) {
|
|
944
|
+
return name.replace(/^@[^/]+\//u, "");
|
|
945
|
+
}
|
|
946
|
+
/**
|
|
947
|
+
* The longest slice value that is the whole unscoped name or its prefix up to a `-`.
|
|
948
|
+
*/
|
|
949
|
+
function sliceByNamePrefix(name, known) {
|
|
950
|
+
const bare = unscoped(name);
|
|
951
|
+
return [...known].filter((candidate) => bare === candidate || bare.startsWith(`${candidate}-`)).sort((a, b) => b.length - a.length)[0];
|
|
952
|
+
}
|
|
953
|
+
/**
|
|
954
|
+
* Classify discovered packages by the layout: group, rank and slice. Pure.
|
|
955
|
+
*
|
|
956
|
+
* 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.
|
|
957
|
+
*/
|
|
958
|
+
function classifyPackages(discovered, layout) {
|
|
959
|
+
const owned = discovered.map((found) => {
|
|
960
|
+
const group = owningGroup(found.dir, layout.groups);
|
|
961
|
+
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`);
|
|
962
|
+
return {
|
|
963
|
+
found,
|
|
964
|
+
group
|
|
965
|
+
};
|
|
966
|
+
});
|
|
967
|
+
const knownSlices = /* @__PURE__ */ new Set();
|
|
968
|
+
for (const { found, group } of owned) if (group.slice !== void 0 && "segment" in group.slice) {
|
|
969
|
+
const value = segmentsOf(found.dir).slice(groupRoot(group).length)[group.slice.segment];
|
|
970
|
+
if (value !== void 0) knownSlices.add(value);
|
|
971
|
+
}
|
|
972
|
+
const packages = owned.map(({ found, group }) => {
|
|
973
|
+
let slice;
|
|
974
|
+
if (group.slice !== void 0 && "segment" in group.slice) slice = segmentsOf(found.dir).slice(groupRoot(group).length)[group.slice.segment];
|
|
975
|
+
else if (group.slice !== void 0 && found.name !== void 0) slice = sliceByNamePrefix(found.name, knownSlices);
|
|
976
|
+
return {
|
|
977
|
+
...found,
|
|
978
|
+
group: group.name,
|
|
979
|
+
rank: rankOf(found.name, group, layout),
|
|
980
|
+
slice
|
|
981
|
+
};
|
|
982
|
+
});
|
|
983
|
+
for (const outer of packages) {
|
|
984
|
+
const nested = packages.find((inner) => inner.dir !== outer.dir && isInPackage(outer.dir, inner.dir));
|
|
985
|
+
if (nested !== void 0) throw new ConformanceError(`the package directory '${nested.dir}' is inside the package directory '${outer.dir}'; workspace packages must not nest`);
|
|
986
|
+
}
|
|
987
|
+
return packages;
|
|
988
|
+
}
|
|
989
|
+
/**
|
|
990
|
+
* The workspace root: `layout.root` resolved against `cwd`, or `cwd` itself.
|
|
991
|
+
*/
|
|
992
|
+
function workspaceRoot(cwd, layout) {
|
|
993
|
+
return layout.root === void 0 ? cwd : resolve(cwd, layout.root);
|
|
994
|
+
}
|
|
995
|
+
const PNPM_WORKSPACE_FILE = "pnpm-workspace.yaml";
|
|
996
|
+
/**
|
|
997
|
+
* 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.
|
|
998
|
+
*/
|
|
999
|
+
async function pnpmWorkspacePatterns(root) {
|
|
1000
|
+
const file = join(root, PNPM_WORKSPACE_FILE);
|
|
1001
|
+
if (!existsSync(file)) return;
|
|
1002
|
+
let source;
|
|
1003
|
+
try {
|
|
1004
|
+
source = await readFile(file, "utf8");
|
|
1005
|
+
} catch (error) {
|
|
1006
|
+
throw new ConformanceError(`${file} cannot be read`, { cause: error });
|
|
1007
|
+
}
|
|
1008
|
+
const document = parse(source);
|
|
1009
|
+
if (typeof document !== "object" || document === null || !("packages" in document) || !Array.isArray(document.packages)) throw new ConformanceError(`${file} has no 'packages' list`);
|
|
1010
|
+
const patterns = document.packages;
|
|
1011
|
+
if (!patterns.every((pattern) => typeof pattern === "string")) throw new ConformanceError(`${file}: every entry of 'packages' must be a string`);
|
|
1012
|
+
return patterns;
|
|
1013
|
+
}
|
|
1014
|
+
/**
|
|
1015
|
+
* The package globs: `layout.packages`, else the `packages` list of `pnpm-workspace.yaml` in `root`.
|
|
1016
|
+
*/
|
|
1017
|
+
async function workspacePatterns(root, layout) {
|
|
1018
|
+
if (layout.packages !== void 0) return layout.packages;
|
|
1019
|
+
const patterns = await pnpmWorkspacePatterns(root);
|
|
1020
|
+
if (patterns === void 0) throw new ConformanceError(`the layout has no 'packages' and ${join(root, PNPM_WORKSPACE_FILE)} does not exist`);
|
|
1021
|
+
return patterns;
|
|
1022
|
+
}
|
|
1023
|
+
async function readName(manifest) {
|
|
1024
|
+
let content;
|
|
1025
|
+
try {
|
|
1026
|
+
content = JSON.parse(await readFile(manifest, "utf8"));
|
|
1027
|
+
} catch (error) {
|
|
1028
|
+
throw new ConformanceError(`${manifest} cannot be read as JSON`, { cause: error });
|
|
1029
|
+
}
|
|
1030
|
+
if (typeof content === "object" && content !== null && "name" in content && typeof content.name === "string") return content.name;
|
|
1031
|
+
}
|
|
1032
|
+
/**
|
|
1033
|
+
* 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 `**`).
|
|
1034
|
+
*/
|
|
1035
|
+
async function discoverPackages(root, patterns) {
|
|
1036
|
+
const manifests = patterns.map((pattern) => pattern.startsWith("!") ? `!${pattern.slice(1)}/**` : `${pattern}/package.json`);
|
|
1037
|
+
const dirs = (await glob(manifests, {
|
|
1038
|
+
cwd: root,
|
|
1039
|
+
dot: true,
|
|
1040
|
+
ignore: [INSTALLED_DEPENDENCIES_GLOB],
|
|
1041
|
+
onlyFiles: true
|
|
1042
|
+
})).map((manifest) => {
|
|
1043
|
+
const dir = manifest.replace(/\/?package\.json$/u, "");
|
|
1044
|
+
return dir === "" ? "." : dir;
|
|
1045
|
+
});
|
|
1046
|
+
return Promise.all([...new Set(dirs)].sort().map(async (dir) => ({
|
|
1047
|
+
dir,
|
|
1048
|
+
name: await readName(join(root, dir, "package.json"))
|
|
1049
|
+
})));
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* 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.
|
|
1053
|
+
*/
|
|
1054
|
+
async function readWorkspacePackages(cwd, layout) {
|
|
1055
|
+
const root = workspaceRoot(cwd, layout);
|
|
1056
|
+
const patterns = await workspacePatterns(root, layout);
|
|
1057
|
+
const discovered = await discoverPackages(root, patterns);
|
|
1058
|
+
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 ['.']`);
|
|
1059
|
+
return classifyPackages(discovered, layout);
|
|
1060
|
+
}
|
|
1061
|
+
//#endregion
|
|
1062
|
+
//#region src/checks/engines-floor.ts
|
|
1063
|
+
/**
|
|
1064
|
+
* The pattern that selects the root package, the `package.json` in the directory the checks run in.
|
|
1065
|
+
*/
|
|
1066
|
+
const ROOT_PACKAGE = ".";
|
|
1067
|
+
const MANIFEST = "package.json";
|
|
1068
|
+
const ANY_INSTALLED_COPY = {};
|
|
1069
|
+
const DEPENDENCY_FIELDS = [
|
|
1070
|
+
{
|
|
1071
|
+
field: "dependencies",
|
|
1072
|
+
noun: "dependency",
|
|
1073
|
+
mayBeAbsent: () => false,
|
|
1074
|
+
judgedCopy: () => ANY_INSTALLED_COPY
|
|
1075
|
+
},
|
|
1076
|
+
{
|
|
1077
|
+
field: "optionalDependencies",
|
|
1078
|
+
noun: "optional dependency",
|
|
1079
|
+
mayBeAbsent: () => true,
|
|
1080
|
+
judgedCopy: () => ANY_INSTALLED_COPY
|
|
1081
|
+
},
|
|
1082
|
+
{
|
|
1083
|
+
field: "peerDependencies",
|
|
1084
|
+
noun: "peer dependency",
|
|
1085
|
+
mayBeAbsent: (manifest, name) => {
|
|
1086
|
+
const meta = manifest["peerDependenciesMeta"];
|
|
1087
|
+
const entry = isRecord(meta) ? meta[name] : void 0;
|
|
1088
|
+
return isRecord(entry) && entry["optional"] === true;
|
|
1089
|
+
},
|
|
1090
|
+
judgedCopy: (spec) => {
|
|
1091
|
+
const version = onlyAdmittedVersion(spec);
|
|
1092
|
+
return version === null ? void 0 : { version };
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
];
|
|
1096
|
+
/**
|
|
1097
|
+
* 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).
|
|
1098
|
+
*/
|
|
1099
|
+
function onlyAdmittedVersion(spec) {
|
|
1100
|
+
if (validRange(spec) === null) return null;
|
|
1101
|
+
const [comparators, ...others] = new Range(spec).set;
|
|
1102
|
+
if (comparators === void 0 || others.length > 0 || comparators.length !== 1) return null;
|
|
1103
|
+
const [comparator] = comparators;
|
|
1104
|
+
if (comparator?.operator !== "") return null;
|
|
1105
|
+
return valid(comparator.value);
|
|
1106
|
+
}
|
|
1107
|
+
async function readManifest(cwd, file) {
|
|
1108
|
+
let content;
|
|
1109
|
+
try {
|
|
1110
|
+
content = JSON.parse(await readFile(resolve(cwd, file), "utf8"));
|
|
1111
|
+
} catch (error) {
|
|
1112
|
+
throw new ConformanceError(`${file} cannot be read as JSON`, { cause: error });
|
|
1113
|
+
}
|
|
1114
|
+
if (!isRecord(content)) throw new ConformanceError(`${file} is not a JSON object`);
|
|
1115
|
+
return content;
|
|
1116
|
+
}
|
|
1117
|
+
/**
|
|
1118
|
+
* 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.
|
|
1119
|
+
*/
|
|
1120
|
+
function nodeRange(manifest, file) {
|
|
1121
|
+
const engines = manifest["engines"];
|
|
1122
|
+
if (engines === void 0) return;
|
|
1123
|
+
if (!isRecord(engines)) throw new ConformanceError(`${file}: engines must be an object`);
|
|
1124
|
+
const node = engines["node"];
|
|
1125
|
+
if (node === void 0) return;
|
|
1126
|
+
if (typeof node !== "string") throw new ConformanceError(`${file}: engines.node must be a string, not ${JSON.stringify(node)}`);
|
|
1127
|
+
if (validRange(node) === null) throw new ConformanceError(`${file}: engines.node '${node}' is not a valid semver range`);
|
|
1128
|
+
return node;
|
|
1129
|
+
}
|
|
1130
|
+
/**
|
|
1131
|
+
* 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.
|
|
1132
|
+
*/
|
|
1133
|
+
function dependencyEntries(manifest, field, file) {
|
|
1134
|
+
const listed = manifest[field];
|
|
1135
|
+
if (listed === void 0) return [];
|
|
1136
|
+
if (!isRecord(listed)) throw new ConformanceError(`${file}: ${field} must be an object`);
|
|
1137
|
+
return Object.keys(listed).sort().map((name) => {
|
|
1138
|
+
const spec = listed[name];
|
|
1139
|
+
if (typeof spec !== "string") throw new ConformanceError(`${file}: ${field}.${name} must be a string, not ${JSON.stringify(spec)}`);
|
|
1140
|
+
return [name, spec];
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
/**
|
|
1144
|
+
* 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.
|
|
1145
|
+
*/
|
|
1146
|
+
function installedManifest(cwd, packageDirectory, name) {
|
|
1147
|
+
const top = resolve(cwd);
|
|
1148
|
+
for (let directory = resolve(cwd, packageDirectory);; directory = dirname(directory)) {
|
|
1149
|
+
const candidate = join(directory, "node_modules", name, MANIFEST);
|
|
1150
|
+
if (existsSync(candidate)) return relativePosix(cwd, candidate);
|
|
1151
|
+
if (directory === top) return;
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
/**
|
|
1155
|
+
* 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.
|
|
1156
|
+
*/
|
|
1157
|
+
async function packageDirectories(cwd, options) {
|
|
1158
|
+
const patterns = options.packages ?? [ROOT_PACKAGE, ...await pnpmWorkspacePatterns(cwd) ?? []];
|
|
1159
|
+
const members = await discoverPackages(cwd, patterns.filter((pattern) => pattern !== ROOT_PACKAGE));
|
|
1160
|
+
const directories = [...patterns.includes(ROOT_PACKAGE) ? [ROOT_PACKAGE] : [], ...members.map((member) => member.dir)];
|
|
1161
|
+
if (directories.length === 0) throw new ConformanceError(`no package.json is selected by ${patterns.join(", ")}`);
|
|
1162
|
+
return directories;
|
|
1163
|
+
}
|
|
1164
|
+
/**
|
|
1165
|
+
* 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.
|
|
1166
|
+
*
|
|
1167
|
+
* 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.
|
|
1168
|
+
*
|
|
1169
|
+
* 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.
|
|
1170
|
+
*
|
|
1171
|
+
* 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.
|
|
1172
|
+
*/
|
|
1173
|
+
const enginesFloor = async ({ cwd, options }) => {
|
|
1174
|
+
const violations = [];
|
|
1175
|
+
for (const directory of await packageDirectories(cwd, options)) {
|
|
1176
|
+
const file = posix.join(directory, MANIFEST);
|
|
1177
|
+
const manifest = await readManifest(cwd, file);
|
|
1178
|
+
const own = nodeRange(manifest, file);
|
|
1179
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1180
|
+
for (const { field, noun, mayBeAbsent, judgedCopy } of DEPENDENCY_FIELDS) for (const [name, spec] of dependencyEntries(manifest, field, file).filter(([candidate]) => !seen.has(candidate))) {
|
|
1181
|
+
seen.add(name);
|
|
1182
|
+
const copy = judgedCopy(spec);
|
|
1183
|
+
if (copy === void 0) continue;
|
|
1184
|
+
const installed = installedManifest(cwd, directory, name);
|
|
1185
|
+
if (installed === void 0) {
|
|
1186
|
+
if (mayBeAbsent(manifest, name)) continue;
|
|
1187
|
+
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`);
|
|
1188
|
+
}
|
|
1189
|
+
const dependency = await readManifest(cwd, installed);
|
|
1190
|
+
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`);
|
|
1191
|
+
const theirs = nodeRange(dependency, installed);
|
|
1192
|
+
if (theirs === void 0) continue;
|
|
1193
|
+
if (own === void 0) violations.push({
|
|
1194
|
+
code: "engines-floor/no-engines",
|
|
1195
|
+
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`,
|
|
1196
|
+
file
|
|
1197
|
+
});
|
|
1198
|
+
else if (!subset(own, theirs)) violations.push({
|
|
1199
|
+
code: "engines-floor/wider-than-dependency",
|
|
1200
|
+
message: `engines.node '${own}' admits Node versions that the ${noun} '${name}' rejects with engines.node '${theirs}'; narrow it to a subset of that range`,
|
|
1201
|
+
file
|
|
1202
|
+
});
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
return violations;
|
|
1206
|
+
};
|
|
855
1207
|
async function git(cwd, args) {
|
|
856
1208
|
return new Promise((resolve, reject) => {
|
|
857
1209
|
execFile("git", [...args], {
|
|
@@ -939,6 +1291,24 @@ async function branchOf(github, slug, options) {
|
|
|
939
1291
|
function repositoryFile(slug) {
|
|
940
1292
|
return `${slug.owner}/${slug.name}`;
|
|
941
1293
|
}
|
|
1294
|
+
/**
|
|
1295
|
+
* 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.
|
|
1296
|
+
*/
|
|
1297
|
+
async function branchRulesOf(github, slug, options) {
|
|
1298
|
+
const branch = await branchOf(github, slug, options);
|
|
1299
|
+
try {
|
|
1300
|
+
return { rules: await github.branchRules(slug, branch) };
|
|
1301
|
+
} catch (error) {
|
|
1302
|
+
if (error instanceof RulesetsUnavailableError) return { unavailable: error.message };
|
|
1303
|
+
throw error;
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
/**
|
|
1307
|
+
* 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.
|
|
1308
|
+
*/
|
|
1309
|
+
function rulesUnavailableMessage(file, what, githubMessage) {
|
|
1310
|
+
return `the branch rules of ${file} cannot be read or enforced on its plan, so ${what} cannot be required (GitHub: ${githubMessage})`;
|
|
1311
|
+
}
|
|
942
1312
|
//#endregion
|
|
943
1313
|
//#region src/checks/settings-merge-methods.ts
|
|
944
1314
|
const METHODS = [
|
|
@@ -949,20 +1319,23 @@ const METHODS = [
|
|
|
949
1319
|
/**
|
|
950
1320
|
* 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.
|
|
951
1321
|
*
|
|
1322
|
+
* When the repository's plan has no rulesets, no rule applies to the branch and the repository settings alone decide.
|
|
1323
|
+
*
|
|
952
1324
|
* 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.
|
|
953
1325
|
*/
|
|
954
1326
|
const settingsMergeMethods = async ({ cwd, options, github }) => {
|
|
955
1327
|
const slug = await repositoryOf(cwd, options);
|
|
956
1328
|
const file = repositoryFile(slug);
|
|
957
1329
|
const settings = await github.repository(slug);
|
|
958
|
-
const
|
|
1330
|
+
const read = await branchRulesOf(github, slug, options);
|
|
1331
|
+
const pullRequests = "unavailable" in read ? [] : read.rules.pullRequests;
|
|
959
1332
|
const wanted = options.allowed ?? "rebase";
|
|
960
1333
|
const repositoryAllows = {
|
|
961
1334
|
rebase: settings.allowRebaseMerge,
|
|
962
1335
|
squash: settings.allowSquashMerge,
|
|
963
1336
|
merge: settings.allowMergeCommit
|
|
964
1337
|
};
|
|
965
|
-
const available = METHODS.filter((method) => repositoryAllows[method] &&
|
|
1338
|
+
const available = METHODS.filter((method) => repositoryAllows[method] && pullRequests.every((rule) => rule.allowedMergeMethods === void 0 || rule.allowedMergeMethods.includes(method)));
|
|
966
1339
|
const violations = available.filter((method) => method !== wanted).map((method) => ({
|
|
967
1340
|
code: "settings-merge-methods/method-enabled",
|
|
968
1341
|
message: `${file} allows ${method} merges; only ${wanted} should be allowed`,
|
|
@@ -1361,14 +1734,20 @@ const REGISTRY_TOKEN_VARIABLES = ["NODE_AUTH_TOKEN", "NPM_TOKEN"];
|
|
|
1361
1734
|
/**
|
|
1362
1735
|
* 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.
|
|
1363
1736
|
*
|
|
1364
|
-
* 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)
|
|
1737
|
+
* 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).
|
|
1365
1738
|
*
|
|
1366
1739
|
* 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.
|
|
1367
1740
|
*/
|
|
1368
1741
|
const settingsRequiredChecks = async ({ cwd, options, github }) => {
|
|
1369
1742
|
const slug = await repositoryOf(cwd, options);
|
|
1370
1743
|
const file = repositoryFile(slug);
|
|
1371
|
-
const
|
|
1744
|
+
const read = await branchRulesOf(github, slug, options);
|
|
1745
|
+
if ("unavailable" in read) return [{
|
|
1746
|
+
code: "settings-required-checks/rules-unavailable",
|
|
1747
|
+
message: rulesUnavailableMessage(file, "a status check", read.unavailable),
|
|
1748
|
+
file
|
|
1749
|
+
}];
|
|
1750
|
+
const { rules } = read;
|
|
1372
1751
|
const junctions = (await loadWorkflows(cwd, options)).flatMap((workflow) => jobsNamed(workflow, options.junctionJobs ?? DEFAULT_JUNCTION_JOBS).map((job) => ({
|
|
1373
1752
|
workflow,
|
|
1374
1753
|
name: job.name ?? job.id
|
|
@@ -1405,13 +1784,20 @@ const settingsRequiredChecks = async ({ cwd, options, github }) => {
|
|
|
1405
1784
|
/**
|
|
1406
1785
|
* 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`.
|
|
1407
1786
|
*
|
|
1787
|
+
* When the repository's plan has no rulesets it reports `rules-unavailable` with GitHub's message, since nothing can require it.
|
|
1788
|
+
*
|
|
1408
1789
|
* Limits: it reads the rules of rulesets; classic branch protection is not read.
|
|
1409
1790
|
*/
|
|
1410
1791
|
const settingsReviewThreadResolution = async ({ cwd, options, github }) => {
|
|
1411
1792
|
const slug = await repositoryOf(cwd, options);
|
|
1412
|
-
const
|
|
1793
|
+
const read = await branchRulesOf(github, slug, options);
|
|
1413
1794
|
const file = repositoryFile(slug);
|
|
1414
|
-
|
|
1795
|
+
if ("unavailable" in read) return [{
|
|
1796
|
+
code: "settings-review-thread-resolution/rules-unavailable",
|
|
1797
|
+
message: rulesUnavailableMessage(file, "resolving review conversations", read.unavailable),
|
|
1798
|
+
file
|
|
1799
|
+
}];
|
|
1800
|
+
return read.rules.pullRequests.some((rule) => rule.requiredReviewThreadResolution) ? [] : [{
|
|
1415
1801
|
code: "settings-review-thread-resolution/not-required",
|
|
1416
1802
|
message: `no rule requires review conversations to be resolved before merging on the branch of ${file}`,
|
|
1417
1803
|
file
|
|
@@ -2196,10 +2582,10 @@ function escapeRegExp(text) {
|
|
|
2196
2582
|
return text.replace(REGEXP_SPECIAL, "\\$&");
|
|
2197
2583
|
}
|
|
2198
2584
|
/**
|
|
2199
|
-
* A regular expression, as source text, matching every file inside any of the packages
|
|
2585
|
+
* A regular expression, as source text, matching every file inside any of the packages: the path starts with one's {@link packagePathPrefix}.
|
|
2200
2586
|
*/
|
|
2201
2587
|
function packagesPattern(packages) {
|
|
2202
|
-
return `^(${packages.map((member) =>
|
|
2588
|
+
return `^(${packages.map((member) => escapeRegExp(packagePathPrefix(member.dir))).join("|")})`;
|
|
2203
2589
|
}
|
|
2204
2590
|
/**
|
|
2205
2591
|
* 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.
|
|
@@ -2345,145 +2731,6 @@ function unresolvedNameImportRule(packages) {
|
|
|
2345
2731
|
};
|
|
2346
2732
|
}
|
|
2347
2733
|
//#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
2734
|
//#region src/checks/import-graph.ts
|
|
2488
2735
|
/**
|
|
2489
2736
|
* Paths left out of the import graph unless the options say otherwise: a package's own installed dependencies.
|
|
@@ -2497,7 +2744,7 @@ const DEFAULT_DO_NOT_FOLLOW = ["(^|/)dist/"];
|
|
|
2497
2744
|
* 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.
|
|
2498
2745
|
*/
|
|
2499
2746
|
function packageOfPath(packages, path) {
|
|
2500
|
-
const found = packages.find((member) =>
|
|
2747
|
+
const found = packages.find((member) => isInPackage(member.dir, path)) ?? packages.find((member) => member.name !== void 0 && (path === member.name || path.startsWith(`${member.name}/`)));
|
|
2501
2748
|
if (found === void 0) throw new ConformanceError(`${path} is in no workspace package, although the graph is limited to the packages' files and names`);
|
|
2502
2749
|
return found;
|
|
2503
2750
|
}
|
|
@@ -2640,7 +2887,7 @@ function describePackage(member) {
|
|
|
2640
2887
|
*/
|
|
2641
2888
|
function describeTarget(edge) {
|
|
2642
2889
|
const { to, toFile } = edge;
|
|
2643
|
-
return !
|
|
2890
|
+
return !isInPackage(to.dir, toFile) && to.name !== void 0 && (toFile === to.name || toFile.startsWith(`${to.name}/`)) ? toFile : `${toFile} in ${describePackage(to)}`;
|
|
2644
2891
|
}
|
|
2645
2892
|
//#endregion
|
|
2646
2893
|
//#region src/checks/import-cross-slice.ts
|
|
@@ -2915,40 +3162,61 @@ async function sampleViolations(eslint, sample, shared) {
|
|
|
2915
3162
|
function isNothingLinted(error) {
|
|
2916
3163
|
return error instanceof Error && isRecord(error) && (error["messageTemplate"] === "file-not-found" || error["messageTemplate"] === "all-matched-files-ignored");
|
|
2917
3164
|
}
|
|
3165
|
+
/**
|
|
3166
|
+
* 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.
|
|
3167
|
+
*/
|
|
3168
|
+
function locationOf(message) {
|
|
3169
|
+
const line = message.line;
|
|
3170
|
+
const column = message.column;
|
|
3171
|
+
return typeof line === "number" && typeof column === "number" ? {
|
|
3172
|
+
line,
|
|
3173
|
+
column
|
|
3174
|
+
} : void 0;
|
|
3175
|
+
}
|
|
2918
3176
|
function messageViolation(cwd, result, message) {
|
|
2919
3177
|
const file = relativePosix(cwd, result.filePath);
|
|
2920
|
-
const location =
|
|
2921
|
-
|
|
2922
|
-
|
|
3178
|
+
const location = locationOf(message);
|
|
3179
|
+
const at = location === void 0 ? { file } : {
|
|
3180
|
+
file,
|
|
3181
|
+
location
|
|
2923
3182
|
};
|
|
2924
3183
|
if (message.ruleId !== null) {
|
|
2925
3184
|
const severity = message.severity === 1 ? "warn" : "error";
|
|
2926
3185
|
return {
|
|
2927
3186
|
code: `eslint/lint/${message.ruleId}`,
|
|
2928
3187
|
message: `${message.ruleId} (${severity}): ${message.message}`,
|
|
2929
|
-
|
|
2930
|
-
location
|
|
3188
|
+
...at
|
|
2931
3189
|
};
|
|
2932
3190
|
}
|
|
2933
3191
|
return message.fatal === true ? {
|
|
2934
3192
|
code: "eslint/fatal",
|
|
2935
3193
|
message: message.message,
|
|
2936
|
-
|
|
2937
|
-
location
|
|
3194
|
+
...at
|
|
2938
3195
|
} : {
|
|
2939
3196
|
code: "eslint/lint-message",
|
|
2940
3197
|
message: message.message,
|
|
2941
|
-
|
|
2942
|
-
location
|
|
3198
|
+
...at
|
|
2943
3199
|
};
|
|
2944
3200
|
}
|
|
2945
3201
|
function byPosition(left, right) {
|
|
2946
3202
|
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);
|
|
2947
3203
|
}
|
|
3204
|
+
function files(count) {
|
|
3205
|
+
return count === 1 ? "1 file" : `${String(count)} files`;
|
|
3206
|
+
}
|
|
3207
|
+
/**
|
|
3208
|
+
* 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.
|
|
3209
|
+
*/
|
|
3210
|
+
async function unreachedFiles(cwd, eslint, linted) {
|
|
3211
|
+
const candidates = (await findFiles(cwd, ["**"])).filter((file) => !linted.has(resolve(cwd, file)));
|
|
3212
|
+
const unreached = [];
|
|
3213
|
+
for (const file of candidates) if (!await eslint.isPathIgnored(file)) unreached.push(file);
|
|
3214
|
+
return unreached;
|
|
3215
|
+
}
|
|
2948
3216
|
/**
|
|
2949
|
-
* 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.
|
|
3217
|
+
* 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.
|
|
2950
3218
|
*/
|
|
2951
|
-
async function lintViolations(cwd, eslint, patterns) {
|
|
3219
|
+
async function lintViolations(cwd, eslint, patterns, note) {
|
|
2952
3220
|
const violations = [];
|
|
2953
3221
|
const results = /* @__PURE__ */ new Map();
|
|
2954
3222
|
for (const pattern of patterns) try {
|
|
@@ -2962,6 +3230,10 @@ async function lintViolations(cwd, eslint, patterns) {
|
|
|
2962
3230
|
});
|
|
2963
3231
|
}
|
|
2964
3232
|
for (const result of results.values()) for (const message of result.messages) violations.push(messageViolation(cwd, result, message));
|
|
3233
|
+
if (note !== void 0) {
|
|
3234
|
+
const unreached = await unreachedFiles(cwd, eslint, new Set(results.keys()));
|
|
3235
|
+
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(", ")})`);
|
|
3236
|
+
}
|
|
2965
3237
|
return violations.sort(byPosition);
|
|
2966
3238
|
}
|
|
2967
3239
|
/**
|
|
@@ -2971,7 +3243,9 @@ async function lintViolations(cwd, eslint, patterns) {
|
|
|
2971
3243
|
*
|
|
2972
3244
|
* 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.
|
|
2973
3245
|
*/
|
|
2974
|
-
const eslint = async (
|
|
3246
|
+
const eslint = async (context) => {
|
|
3247
|
+
const { options } = context;
|
|
3248
|
+
const cwd = resolve(context.cwd);
|
|
2975
3249
|
const instance = new (loadEslint(cwd))({
|
|
2976
3250
|
cwd,
|
|
2977
3251
|
...options.configFile === void 0 ? {} : { overrideConfigFile: resolve(cwd, options.configFile) }
|
|
@@ -2979,7 +3253,7 @@ const eslint = async ({ cwd, options }) => {
|
|
|
2979
3253
|
const shared = options.rules ?? {};
|
|
2980
3254
|
const violations = [];
|
|
2981
3255
|
for (const sample of options.samples.map(sampleOf)) violations.push(...await sampleViolations(instance, sample, shared));
|
|
2982
|
-
if (options.lint === true) violations.push(...await lintViolations(cwd, instance, options.lintPatterns ?? DEFAULT_LINT_PATTERNS));
|
|
3256
|
+
if (options.lint === true) violations.push(...await lintViolations(cwd, instance, options.lintPatterns ?? DEFAULT_LINT_PATTERNS, context.note));
|
|
2983
3257
|
return violations;
|
|
2984
3258
|
};
|
|
2985
3259
|
//#endregion
|
|
@@ -3016,7 +3290,7 @@ async function leadsTo(cwd, file, entry, destination) {
|
|
|
3016
3290
|
}
|
|
3017
3291
|
}
|
|
3018
3292
|
/**
|
|
3019
|
-
* 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.
|
|
3293
|
+
* 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.
|
|
3020
3294
|
*/
|
|
3021
3295
|
const instructionSymlinks = async ({ cwd, options }) => {
|
|
3022
3296
|
const files = options.files ?? DEFAULT_INSTRUCTION_FILES;
|
|
@@ -3024,15 +3298,17 @@ const instructionSymlinks = async ({ cwd, options }) => {
|
|
|
3024
3298
|
const violations = [];
|
|
3025
3299
|
for (const directory of await directoriesOf(cwd, options.directories ?? ["."])) {
|
|
3026
3300
|
const readme = posix.join(directory, target);
|
|
3301
|
+
const readmeTracked = await indexEntry(cwd, readme) !== void 0;
|
|
3027
3302
|
for (const name of files) {
|
|
3028
3303
|
const file = posix.join(directory, name);
|
|
3029
3304
|
const entry = await indexEntry(cwd, file);
|
|
3030
|
-
if (entry === void 0)
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
3305
|
+
if (entry === void 0) {
|
|
3306
|
+
if (readmeTracked) violations.push({
|
|
3307
|
+
code: "instruction-symlinks/missing",
|
|
3308
|
+
message: `${file} is not tracked; commit it as a symbolic link to ${target}`,
|
|
3309
|
+
file
|
|
3310
|
+
});
|
|
3311
|
+
} else if (entry.mode !== "120000") violations.push({
|
|
3036
3312
|
code: "instruction-symlinks/not-a-symlink",
|
|
3037
3313
|
message: `${file} is committed as a regular file (mode ${entry.mode}); commit it as a symbolic link to ${target}`,
|
|
3038
3314
|
file
|
|
@@ -3044,7 +3320,7 @@ const instructionSymlinks = async ({ cwd, options }) => {
|
|
|
3044
3320
|
message: `${file} links to ${linked}; it should link to ${target}`,
|
|
3045
3321
|
file
|
|
3046
3322
|
});
|
|
3047
|
-
} else if (
|
|
3323
|
+
} else if (!readmeTracked) violations.push({
|
|
3048
3324
|
code: "instruction-symlinks/dangling",
|
|
3049
3325
|
message: `${file} links to ${target}, which is not tracked`,
|
|
3050
3326
|
file
|
|
@@ -3285,12 +3561,13 @@ function plain(spec) {
|
|
|
3285
3561
|
requiresLayout: false,
|
|
3286
3562
|
requiresGitHub: false,
|
|
3287
3563
|
isEnabled: (checks) => spec.select(checks) !== void 0,
|
|
3288
|
-
run: async ({ cwd, checks, configFiles }) => {
|
|
3564
|
+
run: async ({ cwd, checks, configFiles, note }) => {
|
|
3289
3565
|
const options = spec.select(checks);
|
|
3290
3566
|
if (options === void 0) throw new ConformanceError(`the check '${spec.name}' is not enabled`);
|
|
3291
3567
|
return spec.check({
|
|
3292
3568
|
cwd,
|
|
3293
3569
|
options,
|
|
3570
|
+
note,
|
|
3294
3571
|
...configFiles === void 0 ? {} : { configFiles }
|
|
3295
3572
|
});
|
|
3296
3573
|
}
|
|
@@ -3303,7 +3580,7 @@ function online(spec) {
|
|
|
3303
3580
|
requiresLayout: false,
|
|
3304
3581
|
requiresGitHub: true,
|
|
3305
3582
|
isEnabled: (checks) => spec.select(checks) !== void 0,
|
|
3306
|
-
run: async ({ cwd, checks, configFiles, github }) => {
|
|
3583
|
+
run: async ({ cwd, checks, configFiles, github, note }) => {
|
|
3307
3584
|
const options = spec.select(checks);
|
|
3308
3585
|
if (options === void 0) throw new ConformanceError(`the check '${spec.name}' is not enabled`);
|
|
3309
3586
|
if (github === void 0) throw new ConformanceError(`the check '${spec.name}' reads the repository's settings and needs a GitHub client`);
|
|
@@ -3311,6 +3588,7 @@ function online(spec) {
|
|
|
3311
3588
|
cwd,
|
|
3312
3589
|
options,
|
|
3313
3590
|
github,
|
|
3591
|
+
note,
|
|
3314
3592
|
...configFiles === void 0 ? {} : { configFiles }
|
|
3315
3593
|
});
|
|
3316
3594
|
}
|
|
@@ -3405,6 +3683,12 @@ const registry = {
|
|
|
3405
3683
|
select: (checks) => enabledOptions(checks["commit-types"]),
|
|
3406
3684
|
check: commitTypes
|
|
3407
3685
|
}),
|
|
3686
|
+
"engines-floor": plain({
|
|
3687
|
+
name: "engines-floor",
|
|
3688
|
+
description: "A package's engines.node range admits no Node version that one of its installed dependencies rejects",
|
|
3689
|
+
select: (checks) => enabledOptions(checks["engines-floor"]),
|
|
3690
|
+
check: enginesFloor
|
|
3691
|
+
}),
|
|
3408
3692
|
"dockerfile-package-manager": plain({
|
|
3409
3693
|
name: "dockerfile-package-manager",
|
|
3410
3694
|
description: "A package manager version pinned in a Dockerfile is the version packageManager names",
|
|
@@ -3609,6 +3893,7 @@ const checks = z.strictObject({
|
|
|
3609
3893
|
commitlint: z.exactOptional(path),
|
|
3610
3894
|
release: z.exactOptional(path)
|
|
3611
3895
|
}))),
|
|
3896
|
+
"engines-floor": z.exactOptional(enabled(z.strictObject({ packages: z.exactOptional(paths) }))),
|
|
3612
3897
|
"dockerfile-package-manager": z.exactOptional(enabled(z.strictObject({
|
|
3613
3898
|
dockerfiles: z.exactOptional(paths),
|
|
3614
3899
|
exclude: z.exactOptional(paths),
|
|
@@ -3714,7 +3999,7 @@ async function conformanceOf(options) {
|
|
|
3714
3999
|
cwd: options.cwd
|
|
3715
4000
|
});
|
|
3716
4001
|
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`);
|
|
3717
|
-
return loaded;
|
|
4002
|
+
return loaded.value;
|
|
3718
4003
|
}
|
|
3719
4004
|
async function layoutOf(options, needed) {
|
|
3720
4005
|
if (options.layout !== void 0) return validateStandard(layoutSection.schema, options.layout, `'${layoutSection.name}' section passed to runChecks`);
|
|
@@ -3724,7 +4009,7 @@ async function layoutOf(options, needed) {
|
|
|
3724
4009
|
cwd: options.cwd
|
|
3725
4010
|
});
|
|
3726
4011
|
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`);
|
|
3727
|
-
return loaded;
|
|
4012
|
+
return loaded.value;
|
|
3728
4013
|
}
|
|
3729
4014
|
function selected(config, requested, online) {
|
|
3730
4015
|
const enabled = checkNames.filter((name) => registry[name].isEnabled(config.checks));
|
|
@@ -3774,7 +4059,11 @@ function readImported(imported, name) {
|
|
|
3774
4059
|
*
|
|
3775
4060
|
* 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.
|
|
3776
4061
|
*/
|
|
3777
|
-
async function runChecks(
|
|
4062
|
+
async function runChecks(given) {
|
|
4063
|
+
const options = {
|
|
4064
|
+
...given,
|
|
4065
|
+
cwd: resolve(given.cwd)
|
|
4066
|
+
};
|
|
3778
4067
|
const config = await conformanceOf(options);
|
|
3779
4068
|
const names = selected(config, options.checks, options.github !== void 0);
|
|
3780
4069
|
const layout = await layoutOf(options, names.filter((name) => registry[name].requiresLayout));
|
|
@@ -3782,16 +4071,22 @@ async function runChecks(options) {
|
|
|
3782
4071
|
const results = [];
|
|
3783
4072
|
for (const name of names) {
|
|
3784
4073
|
const check = registry[name];
|
|
4074
|
+
const notes = [];
|
|
4075
|
+
const note = (text) => {
|
|
4076
|
+
notes.push(text);
|
|
4077
|
+
};
|
|
3785
4078
|
const violations = "run" in check ? await check.run({
|
|
3786
4079
|
cwd: options.cwd,
|
|
3787
4080
|
checks: config.checks,
|
|
3788
4081
|
layout,
|
|
3789
4082
|
configFiles: options.configFiles,
|
|
3790
|
-
github: options.github
|
|
4083
|
+
github: options.github,
|
|
4084
|
+
note
|
|
3791
4085
|
}) : readImported(imported, name);
|
|
3792
4086
|
results.push({
|
|
3793
4087
|
check: name,
|
|
3794
|
-
violations
|
|
4088
|
+
violations,
|
|
4089
|
+
notes
|
|
3795
4090
|
});
|
|
3796
4091
|
}
|
|
3797
4092
|
const violations = results.flatMap((result) => result.violations);
|
|
@@ -3803,7 +4098,18 @@ async function runChecks(options) {
|
|
|
3803
4098
|
}
|
|
3804
4099
|
//#endregion
|
|
3805
4100
|
//#region src/cli-command.ts
|
|
3806
|
-
|
|
4101
|
+
/**
|
|
4102
|
+
* How the command prints a result.
|
|
4103
|
+
*/
|
|
4104
|
+
const FORMATS = [
|
|
4105
|
+
"text",
|
|
4106
|
+
"json",
|
|
4107
|
+
"github"
|
|
4108
|
+
];
|
|
4109
|
+
function isFormat(value) {
|
|
4110
|
+
return FORMATS.some((format) => format === value);
|
|
4111
|
+
}
|
|
4112
|
+
const USAGE = `Usage: workspace-conformance check [--cwd <directory>] [--check <name>]... [--list] [--settings] [--format <text|json|github>]
|
|
3807
4113
|
|
|
3808
4114
|
Runs the conformance checks enabled in the 'conformance' section of exadev.config.ts (or exadev.conformance.config.ts).
|
|
3809
4115
|
|
|
@@ -3811,6 +4117,7 @@ Runs the conformance checks enabled in the 'conformance' section of exadev.confi
|
|
|
3811
4117
|
--check <name> Run only this check, which must be enabled. Repeatable.
|
|
3812
4118
|
--list Print the names of all checks and stop.
|
|
3813
4119
|
--settings Also run the enabled settings-* checks, which read the repository's settings through the GitHub API with the token in GITHUB_TOKEN or GH_TOKEN. Offline otherwise.
|
|
4120
|
+
--format <format> text (the default): violations on standard error, notes on standard output. json: the result as one JSON document on standard output. github: a GitHub Actions error annotation per violation on standard output.
|
|
3814
4121
|
--help, -h Show this message.
|
|
3815
4122
|
|
|
3816
4123
|
Exit status: ${String(EXIT_CODES.clean)} when nothing is found, ${String(EXIT_CODES.violations)} when a check finds a violation, ${String(EXIT_CODES.failed)} when the checks could not run.
|
|
@@ -3821,6 +4128,46 @@ function messageOf(error) {
|
|
|
3821
4128
|
function formatViolation(violation) {
|
|
3822
4129
|
return `${violation.location === void 0 ? violation.file : `${violation.file}:${String(violation.location.line)}:${String(violation.location.column)}`}: ${violation.code}: ${violation.message}\n`;
|
|
3823
4130
|
}
|
|
4131
|
+
/**
|
|
4132
|
+
* A workflow command property value with the characters the runner reserves percent-encoded, as `escapeProperty` of `@actions/core` encodes them.
|
|
4133
|
+
*/
|
|
4134
|
+
function escapeProperty(value) {
|
|
4135
|
+
return value.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A").replaceAll(":", "%3A").replaceAll(",", "%2C");
|
|
4136
|
+
}
|
|
4137
|
+
/**
|
|
4138
|
+
* A workflow command message with the characters the runner reserves percent-encoded, as `escapeData` of `@actions/core` encodes them.
|
|
4139
|
+
*/
|
|
4140
|
+
function escapeData(value) {
|
|
4141
|
+
return value.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A");
|
|
4142
|
+
}
|
|
4143
|
+
/**
|
|
4144
|
+
* The GitHub Actions `error` workflow command for a violation at `file` (relative to the directory the job runs in, which is how the runner places an annotation), with the violation's code as its title and its position when it has one.
|
|
4145
|
+
*/
|
|
4146
|
+
function githubAnnotation(violation, file) {
|
|
4147
|
+
const position = violation.location === void 0 ? "" : `,line=${String(violation.location.line)},col=${String(violation.location.column)}`;
|
|
4148
|
+
return `::error file=${escapeProperty(file)}${position},title=${escapeProperty(violation.code)}::${escapeData(violation.message)}\n`;
|
|
4149
|
+
}
|
|
4150
|
+
function formatOf(value) {
|
|
4151
|
+
if (value === void 0) return "text";
|
|
4152
|
+
if (!isFormat(value)) throw new TypeError(`unknown format '${value}'; the formats are ${FORMATS.join(", ")}`);
|
|
4153
|
+
return value;
|
|
4154
|
+
}
|
|
4155
|
+
function summaryOf(result) {
|
|
4156
|
+
return `no violations from ${result.results.map((entry) => entry.check).join(", ")}\n`;
|
|
4157
|
+
}
|
|
4158
|
+
/**
|
|
4159
|
+
* Print `result` in `format`. A violation's file is relative to `cwd`; an annotation needs it relative to the directory the command runs in.
|
|
4160
|
+
*/
|
|
4161
|
+
function printResult(result, format, cwd, output) {
|
|
4162
|
+
if (format === "json") {
|
|
4163
|
+
output.stdout(`${JSON.stringify(result, void 0, 2)}\n`);
|
|
4164
|
+
return;
|
|
4165
|
+
}
|
|
4166
|
+
for (const entry of result.results) for (const note of entry.notes) output.stdout(format === "github" ? `::notice title=${escapeProperty(entry.check)}::${escapeData(note)}\n` : `${entry.check}: ${note}\n`);
|
|
4167
|
+
for (const violation of result.violations) if (format === "github") output.stdout(githubAnnotation(violation, relativePosix(process.cwd(), resolve(cwd, violation.file))));
|
|
4168
|
+
else output.stderr(formatViolation(violation));
|
|
4169
|
+
if (result.violations.length === 0) output.stdout(summaryOf(result));
|
|
4170
|
+
}
|
|
3824
4171
|
function requestedChecks(names) {
|
|
3825
4172
|
return names.map((name) => {
|
|
3826
4173
|
if (!isCheckName(name)) throw new TypeError(`unknown check '${name}'; the checks are ${checkNames.join(", ")}`);
|
|
@@ -3846,6 +4193,7 @@ async function runCheck(args, output) {
|
|
|
3846
4193
|
},
|
|
3847
4194
|
list: { type: "boolean" },
|
|
3848
4195
|
settings: { type: "boolean" },
|
|
4196
|
+
format: { type: "string" },
|
|
3849
4197
|
help: {
|
|
3850
4198
|
type: "boolean",
|
|
3851
4199
|
short: "h"
|
|
@@ -3862,13 +4210,14 @@ async function runCheck(args, output) {
|
|
|
3862
4210
|
for (const name of checkNames) output.stdout(`${name}\t${registry[name].description}\n`);
|
|
3863
4211
|
return EXIT_CODES.clean;
|
|
3864
4212
|
}
|
|
4213
|
+
const format = formatOf(values.format);
|
|
4214
|
+
const cwd = values.cwd ?? process.cwd();
|
|
3865
4215
|
const result = await runChecks({
|
|
3866
|
-
cwd
|
|
4216
|
+
cwd,
|
|
3867
4217
|
...requested === void 0 ? {} : { checks: requested },
|
|
3868
4218
|
...values.settings === true ? { github: clientFromEnvironment() } : {}
|
|
3869
4219
|
});
|
|
3870
|
-
|
|
3871
|
-
if (result.violations.length === 0) output.stdout(`no violations from ${result.results.map((entry) => entry.check).join(", ")}\n`);
|
|
4220
|
+
printResult(result, format, cwd, output);
|
|
3872
4221
|
return result.exitCode;
|
|
3873
4222
|
}
|
|
3874
4223
|
/**
|