workspace-conformance 1.7.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 +27 -13
- package/dist/cli.js +252 -67
- package/dist/index.cjs +249 -117
- package/dist/index.d.cts +40 -18
- package/dist/index.d.ts +41 -19
- package/dist/index.js +249 -118
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -26,6 +26,15 @@ var ConformanceError = class extends Error {
|
|
|
26
26
|
};
|
|
27
27
|
//#endregion
|
|
28
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
|
+
};
|
|
29
38
|
const repositorySchema = z.object({
|
|
30
39
|
default_branch: z.string(),
|
|
31
40
|
allow_rebase_merge: z.optional(z.boolean()),
|
|
@@ -41,6 +50,33 @@ const rulesSchema = z.array(z.looseObject({
|
|
|
41
50
|
allowed_merge_methods: z.optional(z.array(z.string()))
|
|
42
51
|
}))
|
|
43
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
|
+
}
|
|
44
80
|
const API_ROOT = "https://api.github.com";
|
|
45
81
|
const API_VERSION = "2022-11-28";
|
|
46
82
|
/**
|
|
@@ -53,13 +89,16 @@ const PAGE_SIZE = 100;
|
|
|
53
89
|
function createGitHubClient(options) {
|
|
54
90
|
const request = options.fetch ?? fetch;
|
|
55
91
|
const root = options.apiUrl ?? API_ROOT;
|
|
56
|
-
async function get(path) {
|
|
92
|
+
async function get(path, refusal) {
|
|
57
93
|
const response = await request(`${root}${path}`, { headers: {
|
|
58
94
|
Accept: "application/vnd.github+json",
|
|
59
95
|
Authorization: `Bearer ${options.token}`,
|
|
60
96
|
"X-GitHub-Api-Version": API_VERSION
|
|
61
97
|
} });
|
|
62
|
-
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
|
+
}
|
|
63
102
|
return response.json();
|
|
64
103
|
}
|
|
65
104
|
return {
|
|
@@ -77,7 +116,8 @@ function createGitHubClient(options) {
|
|
|
77
116
|
branchRules: async (slug, branch) => {
|
|
78
117
|
const rules = [];
|
|
79
118
|
for (let page = 1, more = true; more; page += 1) {
|
|
80
|
-
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);
|
|
81
121
|
rules.push(...found);
|
|
82
122
|
more = found.length === PAGE_SIZE;
|
|
83
123
|
}
|
|
@@ -95,6 +135,20 @@ function createGitHubClient(options) {
|
|
|
95
135
|
};
|
|
96
136
|
}
|
|
97
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
|
|
98
152
|
//#region src/files.ts
|
|
99
153
|
/**
|
|
100
154
|
* Directories no check descends into: installed dependencies are not the workspace's own files.
|
|
@@ -146,20 +200,6 @@ async function findDirectories(cwd, patterns) {
|
|
|
146
200
|
})).map((directory) => directory.replace(/\/$/u, "")).sort();
|
|
147
201
|
}
|
|
148
202
|
//#endregion
|
|
149
|
-
//#region src/paths.ts
|
|
150
|
-
/**
|
|
151
|
-
* `path` with the platform separator replaced by `/`, the form paths take in violations and in options.
|
|
152
|
-
*/
|
|
153
|
-
function toPosix(path) {
|
|
154
|
-
return path.split(sep).join("/");
|
|
155
|
-
}
|
|
156
|
-
/**
|
|
157
|
-
* `absolute` relative to `cwd`, with `/` separators.
|
|
158
|
-
*/
|
|
159
|
-
function relativePosix(cwd, absolute) {
|
|
160
|
-
return toPosix(relative(cwd, absolute));
|
|
161
|
-
}
|
|
162
|
-
//#endregion
|
|
163
203
|
//#region src/template.ts
|
|
164
204
|
const PLACEHOLDER = /\{([a-z]+)\}/gu;
|
|
165
205
|
/**
|
|
@@ -853,8 +893,18 @@ const dockerfilePackageManager = async ({ cwd, options }) => {
|
|
|
853
893
|
}
|
|
854
894
|
return violations;
|
|
855
895
|
};
|
|
856
|
-
|
|
857
|
-
|
|
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
|
+
}
|
|
858
908
|
function segmentsOf(path) {
|
|
859
909
|
return path.split("/").filter((segment) => segment !== "" && segment !== ".");
|
|
860
910
|
}
|
|
@@ -903,7 +953,7 @@ function sliceByNamePrefix(name, known) {
|
|
|
903
953
|
/**
|
|
904
954
|
* Classify discovered packages by the layout: group, rank and slice. Pure.
|
|
905
955
|
*
|
|
906
|
-
* A package no group owns is a configuration error, because every check would otherwise skip it without saying so. Nested package directories are one too: the checks attribute a file to the package whose directory is its prefix, which is ambiguous for a package inside another. A `namePrefix` slice takes the longest slice value that a `segment` group produced and that prefixes the package's unscoped name; a package without a name has no such slice.
|
|
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.
|
|
907
957
|
*/
|
|
908
958
|
function classifyPackages(discovered, layout) {
|
|
909
959
|
const owned = discovered.map((found) => {
|
|
@@ -931,7 +981,7 @@ function classifyPackages(discovered, layout) {
|
|
|
931
981
|
};
|
|
932
982
|
});
|
|
933
983
|
for (const outer of packages) {
|
|
934
|
-
const nested = packages.find((inner) => inner.dir !== outer.dir &&
|
|
984
|
+
const nested = packages.find((inner) => inner.dir !== outer.dir && isInPackage(outer.dir, inner.dir));
|
|
935
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`);
|
|
936
986
|
}
|
|
937
987
|
return packages;
|
|
@@ -980,7 +1030,7 @@ async function readName(manifest) {
|
|
|
980
1030
|
if (typeof content === "object" && content !== null && "name" in content && typeof content.name === "string") return content.name;
|
|
981
1031
|
}
|
|
982
1032
|
/**
|
|
983
|
-
* The directories under `root` that the pnpm-style `patterns` select and that hold a `package.json`, sorted. A `!` pattern excludes. The root package
|
|
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 `**`).
|
|
984
1034
|
*/
|
|
985
1035
|
async function discoverPackages(root, patterns) {
|
|
986
1036
|
const manifests = patterns.map((pattern) => pattern.startsWith("!") ? `!${pattern.slice(1)}/**` : `${pattern}/package.json`);
|
|
@@ -989,18 +1039,24 @@ async function discoverPackages(root, patterns) {
|
|
|
989
1039
|
dot: true,
|
|
990
1040
|
ignore: [INSTALLED_DEPENDENCIES_GLOB],
|
|
991
1041
|
onlyFiles: true
|
|
992
|
-
})).map((manifest) =>
|
|
1042
|
+
})).map((manifest) => {
|
|
1043
|
+
const dir = manifest.replace(/\/?package\.json$/u, "");
|
|
1044
|
+
return dir === "" ? "." : dir;
|
|
1045
|
+
});
|
|
993
1046
|
return Promise.all([...new Set(dirs)].sort().map(async (dir) => ({
|
|
994
1047
|
dir,
|
|
995
1048
|
name: await readName(join(root, dir, "package.json"))
|
|
996
1049
|
})));
|
|
997
1050
|
}
|
|
998
1051
|
/**
|
|
999
|
-
* The layout's packages found under the workspace root.
|
|
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.
|
|
1000
1053
|
*/
|
|
1001
1054
|
async function readWorkspacePackages(cwd, layout) {
|
|
1002
1055
|
const root = workspaceRoot(cwd, layout);
|
|
1003
|
-
|
|
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);
|
|
1004
1060
|
}
|
|
1005
1061
|
//#endregion
|
|
1006
1062
|
//#region src/checks/engines-floor.ts
|
|
@@ -1235,6 +1291,24 @@ async function branchOf(github, slug, options) {
|
|
|
1235
1291
|
function repositoryFile(slug) {
|
|
1236
1292
|
return `${slug.owner}/${slug.name}`;
|
|
1237
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
|
+
}
|
|
1238
1312
|
//#endregion
|
|
1239
1313
|
//#region src/checks/settings-merge-methods.ts
|
|
1240
1314
|
const METHODS = [
|
|
@@ -1245,20 +1319,23 @@ const METHODS = [
|
|
|
1245
1319
|
/**
|
|
1246
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.
|
|
1247
1321
|
*
|
|
1322
|
+
* When the repository's plan has no rulesets, no rule applies to the branch and the repository settings alone decide.
|
|
1323
|
+
*
|
|
1248
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.
|
|
1249
1325
|
*/
|
|
1250
1326
|
const settingsMergeMethods = async ({ cwd, options, github }) => {
|
|
1251
1327
|
const slug = await repositoryOf(cwd, options);
|
|
1252
1328
|
const file = repositoryFile(slug);
|
|
1253
1329
|
const settings = await github.repository(slug);
|
|
1254
|
-
const
|
|
1330
|
+
const read = await branchRulesOf(github, slug, options);
|
|
1331
|
+
const pullRequests = "unavailable" in read ? [] : read.rules.pullRequests;
|
|
1255
1332
|
const wanted = options.allowed ?? "rebase";
|
|
1256
1333
|
const repositoryAllows = {
|
|
1257
1334
|
rebase: settings.allowRebaseMerge,
|
|
1258
1335
|
squash: settings.allowSquashMerge,
|
|
1259
1336
|
merge: settings.allowMergeCommit
|
|
1260
1337
|
};
|
|
1261
|
-
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)));
|
|
1262
1339
|
const violations = available.filter((method) => method !== wanted).map((method) => ({
|
|
1263
1340
|
code: "settings-merge-methods/method-enabled",
|
|
1264
1341
|
message: `${file} allows ${method} merges; only ${wanted} should be allowed`,
|
|
@@ -1657,14 +1734,20 @@ const REGISTRY_TOKEN_VARIABLES = ["NODE_AUTH_TOKEN", "NPM_TOKEN"];
|
|
|
1657
1734
|
/**
|
|
1658
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.
|
|
1659
1736
|
*
|
|
1660
|
-
* 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).
|
|
1661
1738
|
*
|
|
1662
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.
|
|
1663
1740
|
*/
|
|
1664
1741
|
const settingsRequiredChecks = async ({ cwd, options, github }) => {
|
|
1665
1742
|
const slug = await repositoryOf(cwd, options);
|
|
1666
1743
|
const file = repositoryFile(slug);
|
|
1667
|
-
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;
|
|
1668
1751
|
const junctions = (await loadWorkflows(cwd, options)).flatMap((workflow) => jobsNamed(workflow, options.junctionJobs ?? DEFAULT_JUNCTION_JOBS).map((job) => ({
|
|
1669
1752
|
workflow,
|
|
1670
1753
|
name: job.name ?? job.id
|
|
@@ -1701,13 +1784,20 @@ const settingsRequiredChecks = async ({ cwd, options, github }) => {
|
|
|
1701
1784
|
/**
|
|
1702
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`.
|
|
1703
1786
|
*
|
|
1787
|
+
* When the repository's plan has no rulesets it reports `rules-unavailable` with GitHub's message, since nothing can require it.
|
|
1788
|
+
*
|
|
1704
1789
|
* Limits: it reads the rules of rulesets; classic branch protection is not read.
|
|
1705
1790
|
*/
|
|
1706
1791
|
const settingsReviewThreadResolution = async ({ cwd, options, github }) => {
|
|
1707
1792
|
const slug = await repositoryOf(cwd, options);
|
|
1708
|
-
const
|
|
1793
|
+
const read = await branchRulesOf(github, slug, options);
|
|
1709
1794
|
const file = repositoryFile(slug);
|
|
1710
|
-
|
|
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) ? [] : [{
|
|
1711
1801
|
code: "settings-review-thread-resolution/not-required",
|
|
1712
1802
|
message: `no rule requires review conversations to be resolved before merging on the branch of ${file}`,
|
|
1713
1803
|
file
|
|
@@ -2492,10 +2582,10 @@ function escapeRegExp(text) {
|
|
|
2492
2582
|
return text.replace(REGEXP_SPECIAL, "\\$&");
|
|
2493
2583
|
}
|
|
2494
2584
|
/**
|
|
2495
|
-
* 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}.
|
|
2496
2586
|
*/
|
|
2497
2587
|
function packagesPattern(packages) {
|
|
2498
|
-
return `^(${packages.map((member) =>
|
|
2588
|
+
return `^(${packages.map((member) => escapeRegExp(packagePathPrefix(member.dir))).join("|")})`;
|
|
2499
2589
|
}
|
|
2500
2590
|
/**
|
|
2501
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.
|
|
@@ -2654,7 +2744,7 @@ const DEFAULT_DO_NOT_FOLLOW = ["(^|/)dist/"];
|
|
|
2654
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.
|
|
2655
2745
|
*/
|
|
2656
2746
|
function packageOfPath(packages, path) {
|
|
2657
|
-
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}/`)));
|
|
2658
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`);
|
|
2659
2749
|
return found;
|
|
2660
2750
|
}
|
|
@@ -2797,7 +2887,7 @@ function describePackage(member) {
|
|
|
2797
2887
|
*/
|
|
2798
2888
|
function describeTarget(edge) {
|
|
2799
2889
|
const { to, toFile } = edge;
|
|
2800
|
-
return !
|
|
2890
|
+
return !isInPackage(to.dir, toFile) && to.name !== void 0 && (toFile === to.name || toFile.startsWith(`${to.name}/`)) ? toFile : `${toFile} in ${describePackage(to)}`;
|
|
2801
2891
|
}
|
|
2802
2892
|
//#endregion
|
|
2803
2893
|
//#region src/checks/import-cross-slice.ts
|
|
@@ -3072,40 +3162,61 @@ async function sampleViolations(eslint, sample, shared) {
|
|
|
3072
3162
|
function isNothingLinted(error) {
|
|
3073
3163
|
return error instanceof Error && isRecord(error) && (error["messageTemplate"] === "file-not-found" || error["messageTemplate"] === "all-matched-files-ignored");
|
|
3074
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
|
+
}
|
|
3075
3176
|
function messageViolation(cwd, result, message) {
|
|
3076
3177
|
const file = relativePosix(cwd, result.filePath);
|
|
3077
|
-
const location =
|
|
3078
|
-
|
|
3079
|
-
|
|
3178
|
+
const location = locationOf(message);
|
|
3179
|
+
const at = location === void 0 ? { file } : {
|
|
3180
|
+
file,
|
|
3181
|
+
location
|
|
3080
3182
|
};
|
|
3081
3183
|
if (message.ruleId !== null) {
|
|
3082
3184
|
const severity = message.severity === 1 ? "warn" : "error";
|
|
3083
3185
|
return {
|
|
3084
3186
|
code: `eslint/lint/${message.ruleId}`,
|
|
3085
3187
|
message: `${message.ruleId} (${severity}): ${message.message}`,
|
|
3086
|
-
|
|
3087
|
-
location
|
|
3188
|
+
...at
|
|
3088
3189
|
};
|
|
3089
3190
|
}
|
|
3090
3191
|
return message.fatal === true ? {
|
|
3091
3192
|
code: "eslint/fatal",
|
|
3092
3193
|
message: message.message,
|
|
3093
|
-
|
|
3094
|
-
location
|
|
3194
|
+
...at
|
|
3095
3195
|
} : {
|
|
3096
3196
|
code: "eslint/lint-message",
|
|
3097
3197
|
message: message.message,
|
|
3098
|
-
|
|
3099
|
-
location
|
|
3198
|
+
...at
|
|
3100
3199
|
};
|
|
3101
3200
|
}
|
|
3102
3201
|
function byPosition(left, right) {
|
|
3103
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);
|
|
3104
3203
|
}
|
|
3204
|
+
function files(count) {
|
|
3205
|
+
return count === 1 ? "1 file" : `${String(count)} files`;
|
|
3206
|
+
}
|
|
3105
3207
|
/**
|
|
3106
|
-
*
|
|
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.
|
|
3107
3209
|
*/
|
|
3108
|
-
async function
|
|
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
|
+
}
|
|
3216
|
+
/**
|
|
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.
|
|
3218
|
+
*/
|
|
3219
|
+
async function lintViolations(cwd, eslint, patterns, note) {
|
|
3109
3220
|
const violations = [];
|
|
3110
3221
|
const results = /* @__PURE__ */ new Map();
|
|
3111
3222
|
for (const pattern of patterns) try {
|
|
@@ -3119,6 +3230,10 @@ async function lintViolations(cwd, eslint, patterns) {
|
|
|
3119
3230
|
});
|
|
3120
3231
|
}
|
|
3121
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
|
+
}
|
|
3122
3237
|
return violations.sort(byPosition);
|
|
3123
3238
|
}
|
|
3124
3239
|
/**
|
|
@@ -3128,7 +3243,9 @@ async function lintViolations(cwd, eslint, patterns) {
|
|
|
3128
3243
|
*
|
|
3129
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.
|
|
3130
3245
|
*/
|
|
3131
|
-
const eslint = async (
|
|
3246
|
+
const eslint = async (context) => {
|
|
3247
|
+
const { options } = context;
|
|
3248
|
+
const cwd = resolve(context.cwd);
|
|
3132
3249
|
const instance = new (loadEslint(cwd))({
|
|
3133
3250
|
cwd,
|
|
3134
3251
|
...options.configFile === void 0 ? {} : { overrideConfigFile: resolve(cwd, options.configFile) }
|
|
@@ -3136,7 +3253,7 @@ const eslint = async ({ cwd, options }) => {
|
|
|
3136
3253
|
const shared = options.rules ?? {};
|
|
3137
3254
|
const violations = [];
|
|
3138
3255
|
for (const sample of options.samples.map(sampleOf)) violations.push(...await sampleViolations(instance, sample, shared));
|
|
3139
|
-
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));
|
|
3140
3257
|
return violations;
|
|
3141
3258
|
};
|
|
3142
3259
|
//#endregion
|
|
@@ -3173,7 +3290,7 @@ async function leadsTo(cwd, file, entry, destination) {
|
|
|
3173
3290
|
}
|
|
3174
3291
|
}
|
|
3175
3292
|
/**
|
|
3176
|
-
* 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.
|
|
3177
3294
|
*/
|
|
3178
3295
|
const instructionSymlinks = async ({ cwd, options }) => {
|
|
3179
3296
|
const files = options.files ?? DEFAULT_INSTRUCTION_FILES;
|
|
@@ -3181,15 +3298,17 @@ const instructionSymlinks = async ({ cwd, options }) => {
|
|
|
3181
3298
|
const violations = [];
|
|
3182
3299
|
for (const directory of await directoriesOf(cwd, options.directories ?? ["."])) {
|
|
3183
3300
|
const readme = posix.join(directory, target);
|
|
3301
|
+
const readmeTracked = await indexEntry(cwd, readme) !== void 0;
|
|
3184
3302
|
for (const name of files) {
|
|
3185
3303
|
const file = posix.join(directory, name);
|
|
3186
3304
|
const entry = await indexEntry(cwd, file);
|
|
3187
|
-
if (entry === void 0)
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
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({
|
|
3193
3312
|
code: "instruction-symlinks/not-a-symlink",
|
|
3194
3313
|
message: `${file} is committed as a regular file (mode ${entry.mode}); commit it as a symbolic link to ${target}`,
|
|
3195
3314
|
file
|
|
@@ -3201,7 +3320,7 @@ const instructionSymlinks = async ({ cwd, options }) => {
|
|
|
3201
3320
|
message: `${file} links to ${linked}; it should link to ${target}`,
|
|
3202
3321
|
file
|
|
3203
3322
|
});
|
|
3204
|
-
} else if (
|
|
3323
|
+
} else if (!readmeTracked) violations.push({
|
|
3205
3324
|
code: "instruction-symlinks/dangling",
|
|
3206
3325
|
message: `${file} links to ${target}, which is not tracked`,
|
|
3207
3326
|
file
|
|
@@ -3442,12 +3561,13 @@ function plain(spec) {
|
|
|
3442
3561
|
requiresLayout: false,
|
|
3443
3562
|
requiresGitHub: false,
|
|
3444
3563
|
isEnabled: (checks) => spec.select(checks) !== void 0,
|
|
3445
|
-
run: async ({ cwd, checks, configFiles }) => {
|
|
3564
|
+
run: async ({ cwd, checks, configFiles, note }) => {
|
|
3446
3565
|
const options = spec.select(checks);
|
|
3447
3566
|
if (options === void 0) throw new ConformanceError(`the check '${spec.name}' is not enabled`);
|
|
3448
3567
|
return spec.check({
|
|
3449
3568
|
cwd,
|
|
3450
3569
|
options,
|
|
3570
|
+
note,
|
|
3451
3571
|
...configFiles === void 0 ? {} : { configFiles }
|
|
3452
3572
|
});
|
|
3453
3573
|
}
|
|
@@ -3460,7 +3580,7 @@ function online(spec) {
|
|
|
3460
3580
|
requiresLayout: false,
|
|
3461
3581
|
requiresGitHub: true,
|
|
3462
3582
|
isEnabled: (checks) => spec.select(checks) !== void 0,
|
|
3463
|
-
run: async ({ cwd, checks, configFiles, github }) => {
|
|
3583
|
+
run: async ({ cwd, checks, configFiles, github, note }) => {
|
|
3464
3584
|
const options = spec.select(checks);
|
|
3465
3585
|
if (options === void 0) throw new ConformanceError(`the check '${spec.name}' is not enabled`);
|
|
3466
3586
|
if (github === void 0) throw new ConformanceError(`the check '${spec.name}' reads the repository's settings and needs a GitHub client`);
|
|
@@ -3468,6 +3588,7 @@ function online(spec) {
|
|
|
3468
3588
|
cwd,
|
|
3469
3589
|
options,
|
|
3470
3590
|
github,
|
|
3591
|
+
note,
|
|
3471
3592
|
...configFiles === void 0 ? {} : { configFiles }
|
|
3472
3593
|
});
|
|
3473
3594
|
}
|
|
@@ -3878,7 +3999,7 @@ async function conformanceOf(options) {
|
|
|
3878
3999
|
cwd: options.cwd
|
|
3879
4000
|
});
|
|
3880
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`);
|
|
3881
|
-
return loaded;
|
|
4002
|
+
return loaded.value;
|
|
3882
4003
|
}
|
|
3883
4004
|
async function layoutOf(options, needed) {
|
|
3884
4005
|
if (options.layout !== void 0) return validateStandard(layoutSection.schema, options.layout, `'${layoutSection.name}' section passed to runChecks`);
|
|
@@ -3888,7 +4009,7 @@ async function layoutOf(options, needed) {
|
|
|
3888
4009
|
cwd: options.cwd
|
|
3889
4010
|
});
|
|
3890
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`);
|
|
3891
|
-
return loaded;
|
|
4012
|
+
return loaded.value;
|
|
3892
4013
|
}
|
|
3893
4014
|
function selected(config, requested, online) {
|
|
3894
4015
|
const enabled = checkNames.filter((name) => registry[name].isEnabled(config.checks));
|
|
@@ -3938,7 +4059,11 @@ function readImported(imported, name) {
|
|
|
3938
4059
|
*
|
|
3939
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.
|
|
3940
4061
|
*/
|
|
3941
|
-
async function runChecks(
|
|
4062
|
+
async function runChecks(given) {
|
|
4063
|
+
const options = {
|
|
4064
|
+
...given,
|
|
4065
|
+
cwd: resolve(given.cwd)
|
|
4066
|
+
};
|
|
3942
4067
|
const config = await conformanceOf(options);
|
|
3943
4068
|
const names = selected(config, options.checks, options.github !== void 0);
|
|
3944
4069
|
const layout = await layoutOf(options, names.filter((name) => registry[name].requiresLayout));
|
|
@@ -3946,16 +4071,22 @@ async function runChecks(options) {
|
|
|
3946
4071
|
const results = [];
|
|
3947
4072
|
for (const name of names) {
|
|
3948
4073
|
const check = registry[name];
|
|
4074
|
+
const notes = [];
|
|
4075
|
+
const note = (text) => {
|
|
4076
|
+
notes.push(text);
|
|
4077
|
+
};
|
|
3949
4078
|
const violations = "run" in check ? await check.run({
|
|
3950
4079
|
cwd: options.cwd,
|
|
3951
4080
|
checks: config.checks,
|
|
3952
4081
|
layout,
|
|
3953
4082
|
configFiles: options.configFiles,
|
|
3954
|
-
github: options.github
|
|
4083
|
+
github: options.github,
|
|
4084
|
+
note
|
|
3955
4085
|
}) : readImported(imported, name);
|
|
3956
4086
|
results.push({
|
|
3957
4087
|
check: name,
|
|
3958
|
-
violations
|
|
4088
|
+
violations,
|
|
4089
|
+
notes
|
|
3959
4090
|
});
|
|
3960
4091
|
}
|
|
3961
4092
|
const violations = results.flatMap((result) => result.violations);
|
|
@@ -3967,7 +4098,18 @@ async function runChecks(options) {
|
|
|
3967
4098
|
}
|
|
3968
4099
|
//#endregion
|
|
3969
4100
|
//#region src/cli-command.ts
|
|
3970
|
-
|
|
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>]
|
|
3971
4113
|
|
|
3972
4114
|
Runs the conformance checks enabled in the 'conformance' section of exadev.config.ts (or exadev.conformance.config.ts).
|
|
3973
4115
|
|
|
@@ -3975,6 +4117,7 @@ Runs the conformance checks enabled in the 'conformance' section of exadev.confi
|
|
|
3975
4117
|
--check <name> Run only this check, which must be enabled. Repeatable.
|
|
3976
4118
|
--list Print the names of all checks and stop.
|
|
3977
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.
|
|
3978
4121
|
--help, -h Show this message.
|
|
3979
4122
|
|
|
3980
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.
|
|
@@ -3985,6 +4128,46 @@ function messageOf(error) {
|
|
|
3985
4128
|
function formatViolation(violation) {
|
|
3986
4129
|
return `${violation.location === void 0 ? violation.file : `${violation.file}:${String(violation.location.line)}:${String(violation.location.column)}`}: ${violation.code}: ${violation.message}\n`;
|
|
3987
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
|
+
}
|
|
3988
4171
|
function requestedChecks(names) {
|
|
3989
4172
|
return names.map((name) => {
|
|
3990
4173
|
if (!isCheckName(name)) throw new TypeError(`unknown check '${name}'; the checks are ${checkNames.join(", ")}`);
|
|
@@ -4010,6 +4193,7 @@ async function runCheck(args, output) {
|
|
|
4010
4193
|
},
|
|
4011
4194
|
list: { type: "boolean" },
|
|
4012
4195
|
settings: { type: "boolean" },
|
|
4196
|
+
format: { type: "string" },
|
|
4013
4197
|
help: {
|
|
4014
4198
|
type: "boolean",
|
|
4015
4199
|
short: "h"
|
|
@@ -4026,13 +4210,14 @@ async function runCheck(args, output) {
|
|
|
4026
4210
|
for (const name of checkNames) output.stdout(`${name}\t${registry[name].description}\n`);
|
|
4027
4211
|
return EXIT_CODES.clean;
|
|
4028
4212
|
}
|
|
4213
|
+
const format = formatOf(values.format);
|
|
4214
|
+
const cwd = values.cwd ?? process.cwd();
|
|
4029
4215
|
const result = await runChecks({
|
|
4030
|
-
cwd
|
|
4216
|
+
cwd,
|
|
4031
4217
|
...requested === void 0 ? {} : { checks: requested },
|
|
4032
4218
|
...values.settings === true ? { github: clientFromEnvironment() } : {}
|
|
4033
4219
|
});
|
|
4034
|
-
|
|
4035
|
-
if (result.violations.length === 0) output.stdout(`no violations from ${result.results.map((entry) => entry.check).join(", ")}\n`);
|
|
4220
|
+
printResult(result, format, cwd, output);
|
|
4036
4221
|
return result.exitCode;
|
|
4037
4222
|
}
|
|
4038
4223
|
/**
|