workspace-conformance 1.7.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -11,8 +11,8 @@ let node_module = require("node:module");
11
11
  let node_child_process = require("node:child_process");
12
12
  let jsonc_parser = require("jsonc-parser");
13
13
  let smol_toml = require("smol-toml");
14
- let _exadev_config = require("@exadev/config");
15
14
  let zod = require("zod");
15
+ let _exadev_config = require("@exadev/config");
16
16
  //#region src/files.ts
17
17
  /**
18
18
  * Directories no check descends into: installed dependencies are not the workspace's own files.
@@ -782,8 +782,18 @@ const dockerfilePackageManager = async ({ cwd, options }) => {
782
782
  }
783
783
  return violations;
784
784
  };
785
- //#endregion
786
- //#region src/workspace/packages.ts
785
+ /**
786
+ * What every path inside the package in `dir` starts with, relative to the workspace root: `dir` and a `/`, or nothing for the root package, which holds every path. The trailing `/` keeps `packages/a` from holding `packages/ab`.
787
+ */
788
+ function packagePathPrefix(dir) {
789
+ return dir === "." ? "" : `${dir}/`;
790
+ }
791
+ /**
792
+ * Whether `path`, relative to the workspace root, is inside the package in `dir`.
793
+ */
794
+ function isInPackage(dir, path) {
795
+ return path.startsWith(packagePathPrefix(dir));
796
+ }
787
797
  function segmentsOf(path) {
788
798
  return path.split("/").filter((segment) => segment !== "" && segment !== ".");
789
799
  }
@@ -832,7 +842,7 @@ function sliceByNamePrefix(name, known) {
832
842
  /**
833
843
  * Classify discovered packages by the layout: group, rank and slice. Pure.
834
844
  *
835
- * 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.
845
+ * A package no group owns is a configuration error, because every check would otherwise skip it without saying so. Nested package directories are one too: the checks attribute a file to the package whose directory is its prefix, which is ambiguous for a package inside another. The root package holds every other directory, so it is a package only in a single-package repository. A `namePrefix` slice takes the longest slice value that a `segment` group produced and that prefixes the package's unscoped name; a package without a name has no such slice.
836
846
  */
837
847
  function classifyPackages(discovered, layout) {
838
848
  const owned = discovered.map((found) => {
@@ -860,7 +870,7 @@ function classifyPackages(discovered, layout) {
860
870
  };
861
871
  });
862
872
  for (const outer of packages) {
863
- const nested = packages.find((inner) => inner.dir !== outer.dir && inner.dir.startsWith(`${outer.dir}/`));
873
+ const nested = packages.find((inner) => inner.dir !== outer.dir && isInPackage(outer.dir, inner.dir));
864
874
  if (nested !== void 0) throw new ConformanceError(`the package directory '${nested.dir}' is inside the package directory '${outer.dir}'; workspace packages must not nest`);
865
875
  }
866
876
  return packages;
@@ -909,7 +919,7 @@ async function readName(manifest) {
909
919
  if (typeof content === "object" && content !== null && "name" in content && typeof content.name === "string") return content.name;
910
920
  }
911
921
  /**
912
- * 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.
922
+ * The directories under `root` that the pnpm-style `patterns` select and that hold a `package.json`, sorted. A `!` pattern excludes. The root package is found, as {@link ROOT_PACKAGE_DIR}, when a pattern selects it (`.`, or `**`).
913
923
  */
914
924
  async function discoverPackages(root, patterns) {
915
925
  const manifests = patterns.map((pattern) => pattern.startsWith("!") ? `!${pattern.slice(1)}/**` : `${pattern}/package.json`);
@@ -918,18 +928,24 @@ async function discoverPackages(root, patterns) {
918
928
  dot: true,
919
929
  ignore: [INSTALLED_DEPENDENCIES_GLOB],
920
930
  onlyFiles: true
921
- })).map((manifest) => manifest.replace(/\/?package\.json$/u, "")).filter((dir) => dir !== "");
931
+ })).map((manifest) => {
932
+ const dir = manifest.replace(/\/?package\.json$/u, "");
933
+ return dir === "" ? "." : dir;
934
+ });
922
935
  return Promise.all([...new Set(dirs)].sort().map(async (dir) => ({
923
936
  dir,
924
937
  name: await readName((0, node_path.join)(root, dir, "package.json"))
925
938
  })));
926
939
  }
927
940
  /**
928
- * The layout's packages found under the workspace root.
941
+ * The layout's packages found under the workspace root. Finding none is a configuration error: every check that reads the packages would otherwise look at nothing and report a clean workspace.
929
942
  */
930
943
  async function readWorkspacePackages(cwd, layout) {
931
944
  const root = workspaceRoot(cwd, layout);
932
- return classifyPackages(await discoverPackages(root, await workspacePatterns(root, layout)), layout);
945
+ const patterns = await workspacePatterns(root, layout);
946
+ const discovered = await discoverPackages(root, patterns);
947
+ if (discovered.length === 0) throw new ConformanceError(`no workspace package found: ${patterns.length === 0 ? "the package patterns are empty, so no directory is selected" : `no directory the patterns ${patterns.join(", ")} select holds a package.json`}; for a single-package repository, set the layout's packages to ['.']`);
948
+ return classifyPackages(discovered, layout);
933
949
  }
934
950
  //#endregion
935
951
  //#region src/checks/engines-floor.ts
@@ -1183,40 +1199,61 @@ async function sampleViolations(eslint, sample, shared) {
1183
1199
  function isNothingLinted(error) {
1184
1200
  return error instanceof Error && isRecord(error) && (error["messageTemplate"] === "file-not-found" || error["messageTemplate"] === "all-matched-files-ignored");
1185
1201
  }
1202
+ /**
1203
+ * Where a message is, or `undefined` for a message about the whole file. ESLint types `line` and `column` as numbers, but a parsing error copies them from the parser's error, which may carry none: typescript-eslint's project service reports a file outside every tsconfig that way.
1204
+ */
1205
+ function locationOf(message) {
1206
+ const line = message.line;
1207
+ const column = message.column;
1208
+ return typeof line === "number" && typeof column === "number" ? {
1209
+ line,
1210
+ column
1211
+ } : void 0;
1212
+ }
1186
1213
  function messageViolation(cwd, result, message) {
1187
1214
  const file = relativePosix(cwd, result.filePath);
1188
- const location = {
1189
- line: message.line,
1190
- column: message.column
1215
+ const location = locationOf(message);
1216
+ const at = location === void 0 ? { file } : {
1217
+ file,
1218
+ location
1191
1219
  };
1192
1220
  if (message.ruleId !== null) {
1193
1221
  const severity = message.severity === 1 ? "warn" : "error";
1194
1222
  return {
1195
1223
  code: `eslint/lint/${message.ruleId}`,
1196
1224
  message: `${message.ruleId} (${severity}): ${message.message}`,
1197
- file,
1198
- location
1225
+ ...at
1199
1226
  };
1200
1227
  }
1201
1228
  return message.fatal === true ? {
1202
1229
  code: "eslint/fatal",
1203
1230
  message: message.message,
1204
- file,
1205
- location
1231
+ ...at
1206
1232
  } : {
1207
1233
  code: "eslint/lint-message",
1208
1234
  message: message.message,
1209
- file,
1210
- location
1235
+ ...at
1211
1236
  };
1212
1237
  }
1213
1238
  function byPosition(left, right) {
1214
1239
  return left.file.localeCompare(right.file) || (left.location?.line ?? 0) - (right.location?.line ?? 0) || (left.location?.column ?? 0) - (right.location?.column ?? 0) || left.code.localeCompare(right.code);
1215
1240
  }
1241
+ function files(count) {
1242
+ return count === 1 ? "1 file" : `${String(count)} files`;
1243
+ }
1216
1244
  /**
1217
- * 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.
1245
+ * The files below `cwd` that ESLint has a configuration for (not ignored, and matched by a configuration block) and that the lint did not reach: the patterns are narrower than what the config covers.
1218
1246
  */
1219
- async function lintViolations(cwd, eslint, patterns) {
1247
+ async function unreachedFiles(cwd, eslint, linted) {
1248
+ const candidates = (await findFiles(cwd, ["**"])).filter((file) => !linted.has((0, node_path.resolve)(cwd, file)));
1249
+ const unreached = [];
1250
+ for (const file of candidates) if (!await eslint.isPathIgnored(file)) unreached.push(file);
1251
+ return unreached;
1252
+ }
1253
+ /**
1254
+ * Lints the workspace through the Node API, with the repository's own config, and maps every message to a violation. A pattern that selects no linted file is itself a violation, since the workspace is then not linted at all. It notes how many files it linted and how many files the config covers that the patterns did not reach.
1255
+ */
1256
+ async function lintViolations(cwd, eslint, patterns, note) {
1220
1257
  const violations = [];
1221
1258
  const results = /* @__PURE__ */ new Map();
1222
1259
  for (const pattern of patterns) try {
@@ -1230,6 +1267,10 @@ async function lintViolations(cwd, eslint, patterns) {
1230
1267
  });
1231
1268
  }
1232
1269
  for (const result of results.values()) for (const message of result.messages) violations.push(messageViolation(cwd, result, message));
1270
+ if (note !== void 0) {
1271
+ const unreached = await unreachedFiles(cwd, eslint, new Set(results.keys()));
1272
+ note(unreached.length === 0 ? `linted ${files(results.size)}` : `linted ${files(results.size)}; ${unreached.length === 1 ? "1 other file has" : `${String(unreached.length)} other files have`} an ESLint configuration and ${unreached.length === 1 ? "was" : "were"} not reached by lintPatterns (${patterns.join(", ")})`);
1273
+ }
1233
1274
  return violations.sort(byPosition);
1234
1275
  }
1235
1276
  /**
@@ -1239,7 +1280,9 @@ async function lintViolations(cwd, eslint, patterns) {
1239
1280
  *
1240
1281
  * The config is a black box: the check sees which rules are active on a file, not why. It does not embed a config, so a repository without one is reported and nothing is substituted.
1241
1282
  */
1242
- const eslint = async ({ cwd, options }) => {
1283
+ const eslint = async (context) => {
1284
+ const { options } = context;
1285
+ const cwd = (0, node_path.resolve)(context.cwd);
1243
1286
  const instance = new (loadEslint(cwd))({
1244
1287
  cwd,
1245
1288
  ...options.configFile === void 0 ? {} : { overrideConfigFile: (0, node_path.resolve)(cwd, options.configFile) }
@@ -1247,7 +1290,7 @@ const eslint = async ({ cwd, options }) => {
1247
1290
  const shared = options.rules ?? {};
1248
1291
  const violations = [];
1249
1292
  for (const sample of options.samples.map(sampleOf)) violations.push(...await sampleViolations(instance, sample, shared));
1250
- if (options.lint === true) violations.push(...await lintViolations(cwd, instance, options.lintPatterns ?? DEFAULT_LINT_PATTERNS));
1293
+ if (options.lint === true) violations.push(...await lintViolations(cwd, instance, options.lintPatterns ?? DEFAULT_LINT_PATTERNS, context.note));
1251
1294
  return violations;
1252
1295
  };
1253
1296
  //#endregion
@@ -1257,10 +1300,10 @@ function escapeRegExp(text) {
1257
1300
  return text.replace(REGEXP_SPECIAL, "\\$&");
1258
1301
  }
1259
1302
  /**
1260
- * A regular expression, as source text, matching every file inside any of the packages. The trailing `/` keeps `packages/a` from matching `packages/ab`.
1303
+ * A regular expression, as source text, matching every file inside any of the packages: the path starts with one's {@link packagePathPrefix}.
1261
1304
  */
1262
1305
  function packagesPattern(packages) {
1263
- return `^(${packages.map((member) => `${escapeRegExp(member.dir)}/`).join("|")})`;
1306
+ return `^(${packages.map((member) => escapeRegExp(packagePathPrefix(member.dir))).join("|")})`;
1264
1307
  }
1265
1308
  /**
1266
1309
  * A regular expression, as source text, matching the name an import uses for any of the named packages: the name itself or a subpath of it. It matches nothing but those names, and is `undefined` when no package has a name.
@@ -1419,7 +1462,7 @@ const DEFAULT_DO_NOT_FOLLOW = ["(^|/)dist/"];
1419
1462
  * The package a path in the graph belongs to: the one whose directory holds the file, and only when no directory does, the one whose name the path is (an import that resolved to no file is known only by the name it was written with, or a subpath of it). The directory wins because an unscoped package name can equal the leading directory of another package's files. It throws `ConformanceError` for a path the graph should not contain.
1420
1463
  */
1421
1464
  function packageOfPath(packages, path) {
1422
- const found = packages.find((member) => path.startsWith(`${member.dir}/`)) ?? packages.find((member) => member.name !== void 0 && (path === member.name || path.startsWith(`${member.name}/`)));
1465
+ const found = packages.find((member) => isInPackage(member.dir, path)) ?? packages.find((member) => member.name !== void 0 && (path === member.name || path.startsWith(`${member.name}/`)));
1423
1466
  if (found === void 0) throw new ConformanceError(`${path} is in no workspace package, although the graph is limited to the packages' files and names`);
1424
1467
  return found;
1425
1468
  }
@@ -1575,7 +1618,7 @@ function describePackage(member) {
1575
1618
  */
1576
1619
  function describeTarget(edge) {
1577
1620
  const { to, toFile } = edge;
1578
- return !toFile.startsWith(`${to.dir}/`) && to.name !== void 0 && (toFile === to.name || toFile.startsWith(`${to.name}/`)) ? toFile : `${toFile} in ${describePackage(to)}`;
1621
+ return !isInPackage(to.dir, toFile) && to.name !== void 0 && (toFile === to.name || toFile.startsWith(`${to.name}/`)) ? toFile : `${toFile} in ${describePackage(to)}`;
1579
1622
  }
1580
1623
  //#endregion
1581
1624
  //#region src/checks/import-cross-slice.ts
@@ -1850,7 +1893,7 @@ async function leadsTo(cwd, file, entry, destination) {
1850
1893
  }
1851
1894
  }
1852
1895
  /**
1853
- * Each agent instruction file is tracked by git as a symbolic link (mode 120000) whose target is the README beside it, so the two cannot diverge. The link may be written in any form that resolves there (`./README.md`), and may lead through other tracked links, as in `CLAUDE.md` to `AGENTS.md` to `README.md`. It reads the index, not the working tree, so a checkout with `core.symlinks` false, where a link is written out as a plain file, is judged by what is committed.
1896
+ * Each agent instruction file is tracked by git as a symbolic link (mode 120000) whose target is the README beside it, so the two cannot diverge. A directory without a tracked README has nothing to link to, so a missing instruction file there is not reported; one that exists is still judged. The link may be written in any form that resolves there (`./README.md`), and may lead through other tracked links, as in `CLAUDE.md` to `AGENTS.md` to `README.md`. It reads the index, not the working tree, so a checkout with `core.symlinks` false, where a link is written out as a plain file, is judged by what is committed.
1854
1897
  */
1855
1898
  const instructionSymlinks = async ({ cwd, options }) => {
1856
1899
  const files = options.files ?? DEFAULT_INSTRUCTION_FILES;
@@ -1858,15 +1901,17 @@ const instructionSymlinks = async ({ cwd, options }) => {
1858
1901
  const violations = [];
1859
1902
  for (const directory of await directoriesOf(cwd, options.directories ?? ["."])) {
1860
1903
  const readme = node_path.posix.join(directory, target);
1904
+ const readmeTracked = await indexEntry(cwd, readme) !== void 0;
1861
1905
  for (const name of files) {
1862
1906
  const file = node_path.posix.join(directory, name);
1863
1907
  const entry = await indexEntry(cwd, file);
1864
- if (entry === void 0) violations.push({
1865
- code: "instruction-symlinks/missing",
1866
- message: `${file} is not tracked; commit it as a symbolic link to ${target}`,
1867
- file
1868
- });
1869
- else if (entry.mode !== "120000") violations.push({
1908
+ if (entry === void 0) {
1909
+ if (readmeTracked) violations.push({
1910
+ code: "instruction-symlinks/missing",
1911
+ message: `${file} is not tracked; commit it as a symbolic link to ${target}`,
1912
+ file
1913
+ });
1914
+ } else if (entry.mode !== "120000") violations.push({
1870
1915
  code: "instruction-symlinks/not-a-symlink",
1871
1916
  message: `${file} is committed as a regular file (mode ${entry.mode}); commit it as a symbolic link to ${target}`,
1872
1917
  file
@@ -1878,7 +1923,7 @@ const instructionSymlinks = async ({ cwd, options }) => {
1878
1923
  message: `${file} links to ${linked}; it should link to ${target}`,
1879
1924
  file
1880
1925
  });
1881
- } else if (await indexEntry(cwd, readme) === void 0) violations.push({
1926
+ } else if (!readmeTracked) violations.push({
1882
1927
  code: "instruction-symlinks/dangling",
1883
1928
  message: `${file} links to ${target}, which is not tracked`,
1884
1929
  file
@@ -2090,6 +2135,116 @@ const migrationsDirectory = async ({ cwd, options, configFiles }) => {
2090
2135
  return violations;
2091
2136
  };
2092
2137
  //#endregion
2138
+ //#region src/github.ts
2139
+ /**
2140
+ * GitHub's refusal to read a branch's rules because the repository's plan does not offer rulesets, as for a private repository on a plan without them: no ruleset applies to its branches, so a check that requires one cannot be met. The message is GitHub's own. A {@link GitHubClient} rejects `branchRules` with it, and with any other error for every other refusal.
2141
+ */
2142
+ var RulesetsUnavailableError = class extends ConformanceError {
2143
+ constructor(message, options) {
2144
+ super(message, options);
2145
+ this.name = "RulesetsUnavailableError";
2146
+ }
2147
+ };
2148
+ const repositorySchema = zod.z.object({
2149
+ default_branch: zod.z.string(),
2150
+ allow_rebase_merge: zod.z.optional(zod.z.boolean()),
2151
+ allow_squash_merge: zod.z.optional(zod.z.boolean()),
2152
+ allow_merge_commit: zod.z.optional(zod.z.boolean())
2153
+ });
2154
+ const rulesSchema = zod.z.array(zod.z.looseObject({
2155
+ type: zod.z.string(),
2156
+ parameters: zod.z.optional(zod.z.looseObject({
2157
+ required_status_checks: zod.z.optional(zod.z.array(zod.z.object({ context: zod.z.string() }))),
2158
+ strict_required_status_checks_policy: zod.z.optional(zod.z.boolean()),
2159
+ required_review_thread_resolution: zod.z.optional(zod.z.boolean()),
2160
+ allowed_merge_methods: zod.z.optional(zod.z.array(zod.z.string()))
2161
+ }))
2162
+ }));
2163
+ /**
2164
+ * The body of an error response of the REST API: `message` explains the refusal.
2165
+ */
2166
+ const errorBodySchema = zod.z.looseObject({ message: zod.z.string() });
2167
+ /**
2168
+ * The 403 status of a request that GitHub refuses, for a missing permission, a rate limit or a feature the plan lacks alike.
2169
+ */
2170
+ const FORBIDDEN = 403;
2171
+ /**
2172
+ * How GitHub's message starts when it refuses a request for a feature the repository's plan does not include ('Upgrade to GitHub Pro or make this repository public to enable this feature.'). The REST documentation of the branch rules endpoint lists no error status, and a 403 also means a missing permission ('Resource not accessible by personal access token') or a rate limit, so the message is the only thing that tells a plan limit apart.
2173
+ */
2174
+ const PLAN_LIMIT_PREFIX = "Upgrade to GitHub ";
2175
+ /**
2176
+ * The plan-limit refusal of a branch's rules as {@link RulesetsUnavailableError}.
2177
+ */
2178
+ const rulesRefusal = (status, githubMessage) => status === FORBIDDEN && githubMessage?.startsWith(PLAN_LIMIT_PREFIX) === true ? new RulesetsUnavailableError(githubMessage) : void 0;
2179
+ async function githubMessageOf(response) {
2180
+ const text = await response.text();
2181
+ let body;
2182
+ try {
2183
+ body = JSON.parse(text);
2184
+ } catch {
2185
+ return;
2186
+ }
2187
+ const parsed = errorBodySchema.safeParse(body);
2188
+ return parsed.success ? parsed.data.message : void 0;
2189
+ }
2190
+ const API_ROOT = "https://api.github.com";
2191
+ const API_VERSION = "2022-11-28";
2192
+ /**
2193
+ * How many rules the API returns in one page, its maximum.
2194
+ */
2195
+ const PAGE_SIZE = 100;
2196
+ /**
2197
+ * A {@link GitHubClient} over the REST API.
2198
+ */
2199
+ function createGitHubClient(options) {
2200
+ const request = options.fetch ?? fetch;
2201
+ const root = options.apiUrl ?? API_ROOT;
2202
+ async function get(path, refusal) {
2203
+ const response = await request(`${root}${path}`, { headers: {
2204
+ Accept: "application/vnd.github+json",
2205
+ Authorization: `Bearer ${options.token}`,
2206
+ "X-GitHub-Api-Version": API_VERSION
2207
+ } });
2208
+ if (!response.ok) {
2209
+ const githubMessage = await githubMessageOf(response);
2210
+ throw refusal?.(response.status, githubMessage) ?? new ConformanceError(`GET ${path} failed: ${String(response.status)} ${response.statusText}${githubMessage === void 0 ? "" : `: ${githubMessage}`}`);
2211
+ }
2212
+ return response.json();
2213
+ }
2214
+ return {
2215
+ repository: async (slug) => {
2216
+ const parsed = repositorySchema.parse(await get(`/repos/${slug.owner}/${slug.name}`));
2217
+ const { allow_rebase_merge: allowRebaseMerge, allow_squash_merge: allowSquashMerge, allow_merge_commit: allowMergeCommit } = parsed;
2218
+ if (allowRebaseMerge === void 0 || allowSquashMerge === void 0 || allowMergeCommit === void 0) throw new ConformanceError(`GET /repos/${slug.owner}/${slug.name} did not report the merge-method settings, which GitHub shows only to a token with write access to the repository`);
2219
+ return {
2220
+ defaultBranch: parsed.default_branch,
2221
+ allowRebaseMerge,
2222
+ allowSquashMerge,
2223
+ allowMergeCommit
2224
+ };
2225
+ },
2226
+ branchRules: async (slug, branch) => {
2227
+ const rules = [];
2228
+ for (let page = 1, more = true; more; page += 1) {
2229
+ const body = await get(`/repos/${slug.owner}/${slug.name}/rules/branches/${encodeURIComponent(branch)}?per_page=${String(PAGE_SIZE)}&page=${String(page)}`, rulesRefusal);
2230
+ const found = rulesSchema.parse(body);
2231
+ rules.push(...found);
2232
+ more = found.length === PAGE_SIZE;
2233
+ }
2234
+ return {
2235
+ requiredStatusChecks: rules.filter((rule) => rule.type === "required_status_checks").map((rule) => ({
2236
+ contexts: (rule.parameters?.required_status_checks ?? []).map((check) => check.context),
2237
+ strict: rule.parameters?.strict_required_status_checks_policy === true
2238
+ })),
2239
+ pullRequests: rules.filter((rule) => rule.type === "pull_request").map((rule) => ({
2240
+ requiredReviewThreadResolution: rule.parameters?.required_review_thread_resolution === true,
2241
+ allowedMergeMethods: rule.parameters?.allowed_merge_methods
2242
+ }))
2243
+ };
2244
+ }
2245
+ };
2246
+ }
2247
+ //#endregion
2093
2248
  //#region src/settings/repository.ts
2094
2249
  const SLUG = /^([^/\s]+)\/([^/\s]+)$/u;
2095
2250
  const REMOTE = /github\.com[:/]([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/u;
@@ -2126,6 +2281,24 @@ async function branchOf(github, slug, options) {
2126
2281
  function repositoryFile(slug) {
2127
2282
  return `${slug.owner}/${slug.name}`;
2128
2283
  }
2284
+ /**
2285
+ * The rules of the branch, or GitHub's message when the repository's plan has no rulesets, in which case no rule applies to the branch and none can be added. Any other refusal rejects.
2286
+ */
2287
+ async function branchRulesOf(github, slug, options) {
2288
+ const branch = await branchOf(github, slug, options);
2289
+ try {
2290
+ return { rules: await github.branchRules(slug, branch) };
2291
+ } catch (error) {
2292
+ if (error instanceof RulesetsUnavailableError) return { unavailable: error.message };
2293
+ throw error;
2294
+ }
2295
+ }
2296
+ /**
2297
+ * The message of a `rules-unavailable` violation: the branch rules of the repository cannot be read or enforced on its plan, so `what` cannot be required.
2298
+ */
2299
+ function rulesUnavailableMessage(file, what, githubMessage) {
2300
+ return `the branch rules of ${file} cannot be read or enforced on its plan, so ${what} cannot be required (GitHub: ${githubMessage})`;
2301
+ }
2129
2302
  //#endregion
2130
2303
  //#region src/checks/settings-merge-methods.ts
2131
2304
  const METHODS = [
@@ -2136,20 +2309,23 @@ const METHODS = [
2136
2309
  /**
2137
2310
  * Only one merge method is available for a pull request into the branch, `rebase` unless the options say otherwise. A method is available when the repository allows it and no `pull_request` rule that applies to the branch leaves it out of its allowed methods.
2138
2311
  *
2312
+ * When the repository's plan has no rulesets, no rule applies to the branch and the repository settings alone decide.
2313
+ *
2139
2314
  * Limits: it reads the repository settings and the rules of rulesets that apply to the branch; classic branch protection and a merge queue's own merge method are not read, so a repository whose queue owns the strategy is judged by what it still allows for a direct merge.
2140
2315
  */
2141
2316
  const settingsMergeMethods = async ({ cwd, options, github }) => {
2142
2317
  const slug = await repositoryOf(cwd, options);
2143
2318
  const file = repositoryFile(slug);
2144
2319
  const settings = await github.repository(slug);
2145
- const rules = await github.branchRules(slug, await branchOf(github, slug, options));
2320
+ const read = await branchRulesOf(github, slug, options);
2321
+ const pullRequests = "unavailable" in read ? [] : read.rules.pullRequests;
2146
2322
  const wanted = options.allowed ?? "rebase";
2147
2323
  const repositoryAllows = {
2148
2324
  rebase: settings.allowRebaseMerge,
2149
2325
  squash: settings.allowSquashMerge,
2150
2326
  merge: settings.allowMergeCommit
2151
2327
  };
2152
- const available = METHODS.filter((method) => repositoryAllows[method] && rules.pullRequests.every((rule) => rule.allowedMergeMethods === void 0 || rule.allowedMergeMethods.includes(method)));
2328
+ const available = METHODS.filter((method) => repositoryAllows[method] && pullRequests.every((rule) => rule.allowedMergeMethods === void 0 || rule.allowedMergeMethods.includes(method)));
2153
2329
  const violations = available.filter((method) => method !== wanted).map((method) => ({
2154
2330
  code: "settings-merge-methods/method-enabled",
2155
2331
  message: `${file} allows ${method} merges; only ${wanted} should be allowed`,
@@ -2548,14 +2724,20 @@ const REGISTRY_TOKEN_VARIABLES = ["NODE_AUTH_TOKEN", "NPM_TOKEN"];
2548
2724
  /**
2549
2725
  * The branch requires the check of every junction job of the workflows, and requires branches to be up to date before merging (strict mode). The check name of a job is its `name`, else its id.
2550
2726
  *
2551
- * 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) and `no-junction-job` (no workflow has a job with an id from `junctionJobs`).
2727
+ * The junction job is the one that reports for the whole workflow, so requiring it requires everything it needs, including jobs a path filter may skip. Codes: `no-required-checks` (no `required_status_checks` rule applies), `required-check-missing` (a junction job's check is not among the required ones), `not-strict` (no rule sets the strict policy), `no-junction-job` (no workflow has a job with an id from `junctionJobs`) and `rules-unavailable` (the repository's plan has no rulesets, so no check can be required; reported alone, with GitHub's message).
2552
2728
  *
2553
2729
  * Limits: it reads the rules of rulesets that apply to the branch; classic branch protection is not read, so a repository that uses it is reported as having no required checks. A junction job in a reusable workflow, or a name that is an expression, is not resolved.
2554
2730
  */
2555
2731
  const settingsRequiredChecks = async ({ cwd, options, github }) => {
2556
2732
  const slug = await repositoryOf(cwd, options);
2557
2733
  const file = repositoryFile(slug);
2558
- const rules = await github.branchRules(slug, await branchOf(github, slug, options));
2734
+ const read = await branchRulesOf(github, slug, options);
2735
+ if ("unavailable" in read) return [{
2736
+ code: "settings-required-checks/rules-unavailable",
2737
+ message: rulesUnavailableMessage(file, "a status check", read.unavailable),
2738
+ file
2739
+ }];
2740
+ const { rules } = read;
2559
2741
  const junctions = (await loadWorkflows(cwd, options)).flatMap((workflow) => jobsNamed(workflow, options.junctionJobs ?? DEFAULT_JUNCTION_JOBS).map((job) => ({
2560
2742
  workflow,
2561
2743
  name: job.name ?? job.id
@@ -2592,13 +2774,20 @@ const settingsRequiredChecks = async ({ cwd, options, github }) => {
2592
2774
  /**
2593
2775
  * A pull request cannot merge while a review conversation is unresolved: a `pull_request` rule that applies to the branch sets `required_review_thread_resolution`.
2594
2776
  *
2777
+ * When the repository's plan has no rulesets it reports `rules-unavailable` with GitHub's message, since nothing can require it.
2778
+ *
2595
2779
  * Limits: it reads the rules of rulesets; classic branch protection is not read.
2596
2780
  */
2597
2781
  const settingsReviewThreadResolution = async ({ cwd, options, github }) => {
2598
2782
  const slug = await repositoryOf(cwd, options);
2599
- const rules = await github.branchRules(slug, await branchOf(github, slug, options));
2783
+ const read = await branchRulesOf(github, slug, options);
2600
2784
  const file = repositoryFile(slug);
2601
- return rules.pullRequests.some((rule) => rule.requiredReviewThreadResolution) ? [] : [{
2785
+ if ("unavailable" in read) return [{
2786
+ code: "settings-review-thread-resolution/rules-unavailable",
2787
+ message: rulesUnavailableMessage(file, "resolving review conversations", read.unavailable),
2788
+ file
2789
+ }];
2790
+ return read.rules.pullRequests.some((rule) => rule.requiredReviewThreadResolution) ? [] : [{
2602
2791
  code: "settings-review-thread-resolution/not-required",
2603
2792
  message: `no rule requires review conversations to be resolved before merging on the branch of ${file}`,
2604
2793
  file
@@ -3578,76 +3767,6 @@ const conformanceSchema = zod.z.strictObject({ checks });
3578
3767
  */
3579
3768
  const conformanceSection = (0, _exadev_config.defineSection)("conformance", conformanceSchema);
3580
3769
  //#endregion
3581
- //#region src/github.ts
3582
- const repositorySchema = zod.z.object({
3583
- default_branch: zod.z.string(),
3584
- allow_rebase_merge: zod.z.optional(zod.z.boolean()),
3585
- allow_squash_merge: zod.z.optional(zod.z.boolean()),
3586
- allow_merge_commit: zod.z.optional(zod.z.boolean())
3587
- });
3588
- const rulesSchema = zod.z.array(zod.z.looseObject({
3589
- type: zod.z.string(),
3590
- parameters: zod.z.optional(zod.z.looseObject({
3591
- required_status_checks: zod.z.optional(zod.z.array(zod.z.object({ context: zod.z.string() }))),
3592
- strict_required_status_checks_policy: zod.z.optional(zod.z.boolean()),
3593
- required_review_thread_resolution: zod.z.optional(zod.z.boolean()),
3594
- allowed_merge_methods: zod.z.optional(zod.z.array(zod.z.string()))
3595
- }))
3596
- }));
3597
- const API_ROOT = "https://api.github.com";
3598
- const API_VERSION = "2022-11-28";
3599
- /**
3600
- * How many rules the API returns in one page, its maximum.
3601
- */
3602
- const PAGE_SIZE = 100;
3603
- /**
3604
- * A {@link GitHubClient} over the REST API.
3605
- */
3606
- function createGitHubClient(options) {
3607
- const request = options.fetch ?? fetch;
3608
- const root = options.apiUrl ?? API_ROOT;
3609
- async function get(path) {
3610
- const response = await request(`${root}${path}`, { headers: {
3611
- Accept: "application/vnd.github+json",
3612
- Authorization: `Bearer ${options.token}`,
3613
- "X-GitHub-Api-Version": API_VERSION
3614
- } });
3615
- if (!response.ok) throw new ConformanceError(`GET ${path} failed: ${String(response.status)} ${response.statusText}`);
3616
- return response.json();
3617
- }
3618
- return {
3619
- repository: async (slug) => {
3620
- const parsed = repositorySchema.parse(await get(`/repos/${slug.owner}/${slug.name}`));
3621
- const { allow_rebase_merge: allowRebaseMerge, allow_squash_merge: allowSquashMerge, allow_merge_commit: allowMergeCommit } = parsed;
3622
- if (allowRebaseMerge === void 0 || allowSquashMerge === void 0 || allowMergeCommit === void 0) throw new ConformanceError(`GET /repos/${slug.owner}/${slug.name} did not report the merge-method settings, which GitHub shows only to a token with write access to the repository`);
3623
- return {
3624
- defaultBranch: parsed.default_branch,
3625
- allowRebaseMerge,
3626
- allowSquashMerge,
3627
- allowMergeCommit
3628
- };
3629
- },
3630
- branchRules: async (slug, branch) => {
3631
- const rules = [];
3632
- for (let page = 1, more = true; more; page += 1) {
3633
- const found = rulesSchema.parse(await get(`/repos/${slug.owner}/${slug.name}/rules/branches/${encodeURIComponent(branch)}?per_page=${String(PAGE_SIZE)}&page=${String(page)}`));
3634
- rules.push(...found);
3635
- more = found.length === PAGE_SIZE;
3636
- }
3637
- return {
3638
- requiredStatusChecks: rules.filter((rule) => rule.type === "required_status_checks").map((rule) => ({
3639
- contexts: (rule.parameters?.required_status_checks ?? []).map((check) => check.context),
3640
- strict: rule.parameters?.strict_required_status_checks_policy === true
3641
- })),
3642
- pullRequests: rules.filter((rule) => rule.type === "pull_request").map((rule) => ({
3643
- requiredReviewThreadResolution: rule.parameters?.required_review_thread_resolution === true,
3644
- allowedMergeMethods: rule.parameters?.allowed_merge_methods
3645
- }))
3646
- };
3647
- }
3648
- };
3649
- }
3650
- //#endregion
3651
3770
  //#region src/registry.ts
3652
3771
  /**
3653
3772
  * The options of an enabled check, or `undefined` when the setting is absent or `false`.
@@ -3662,12 +3781,13 @@ function plain(spec) {
3662
3781
  requiresLayout: false,
3663
3782
  requiresGitHub: false,
3664
3783
  isEnabled: (checks) => spec.select(checks) !== void 0,
3665
- run: async ({ cwd, checks, configFiles }) => {
3784
+ run: async ({ cwd, checks, configFiles, note }) => {
3666
3785
  const options = spec.select(checks);
3667
3786
  if (options === void 0) throw new ConformanceError(`the check '${spec.name}' is not enabled`);
3668
3787
  return spec.check({
3669
3788
  cwd,
3670
3789
  options,
3790
+ note,
3671
3791
  ...configFiles === void 0 ? {} : { configFiles }
3672
3792
  });
3673
3793
  }
@@ -3680,7 +3800,7 @@ function online(spec) {
3680
3800
  requiresLayout: false,
3681
3801
  requiresGitHub: true,
3682
3802
  isEnabled: (checks) => spec.select(checks) !== void 0,
3683
- run: async ({ cwd, checks, configFiles, github }) => {
3803
+ run: async ({ cwd, checks, configFiles, github, note }) => {
3684
3804
  const options = spec.select(checks);
3685
3805
  if (options === void 0) throw new ConformanceError(`the check '${spec.name}' is not enabled`);
3686
3806
  if (github === void 0) throw new ConformanceError(`the check '${spec.name}' reads the repository's settings and needs a GitHub client`);
@@ -3688,6 +3808,7 @@ function online(spec) {
3688
3808
  cwd,
3689
3809
  options,
3690
3810
  github,
3811
+ note,
3691
3812
  ...configFiles === void 0 ? {} : { configFiles }
3692
3813
  });
3693
3814
  }
@@ -3912,7 +4033,7 @@ async function conformanceOf(options) {
3912
4033
  cwd: options.cwd
3913
4034
  });
3914
4035
  if (loaded === void 0) throw new ConformanceError(`no 'conformance' section in ${options.cwd}; define it in exadev.config.ts or in exadev.conformance.config.ts`);
3915
- return loaded;
4036
+ return loaded.value;
3916
4037
  }
3917
4038
  async function layoutOf(options, needed) {
3918
4039
  if (options.layout !== void 0) return (0, cosmiconfig_extends.validateStandard)(_exadev_config.layoutSection.schema, options.layout, `'${_exadev_config.layoutSection.name}' section passed to runChecks`);
@@ -3922,7 +4043,7 @@ async function layoutOf(options, needed) {
3922
4043
  cwd: options.cwd
3923
4044
  });
3924
4045
  if (loaded === void 0) throw new ConformanceError(`${needed.join(", ")} read the workspace layout, and there is no 'layout' section in ${options.cwd}; define it in exadev.config.ts or in exadev.layout.config.ts`);
3925
- return loaded;
4046
+ return loaded.value;
3926
4047
  }
3927
4048
  function selected(config, requested, online) {
3928
4049
  const enabled = checkNames.filter((name) => registry[name].isEnabled(config.checks));
@@ -3972,7 +4093,11 @@ function readImported(imported, name) {
3972
4093
  *
3973
4094
  * Violations do not throw. It throws `ConformanceError` when the checks cannot run (nothing enabled, a requested check disabled, a section missing) and `ConfigValidationError` when a section fails its schema, whether it was loaded from `cwd` or passed in.
3974
4095
  */
3975
- async function runChecks(options) {
4096
+ async function runChecks(given) {
4097
+ const options = {
4098
+ ...given,
4099
+ cwd: (0, node_path.resolve)(given.cwd)
4100
+ };
3976
4101
  const config = await conformanceOf(options);
3977
4102
  const names = selected(config, options.checks, options.github !== void 0);
3978
4103
  const layout = await layoutOf(options, names.filter((name) => registry[name].requiresLayout));
@@ -3980,16 +4105,22 @@ async function runChecks(options) {
3980
4105
  const results = [];
3981
4106
  for (const name of names) {
3982
4107
  const check = registry[name];
4108
+ const notes = [];
4109
+ const note = (text) => {
4110
+ notes.push(text);
4111
+ };
3983
4112
  const violations = "run" in check ? await check.run({
3984
4113
  cwd: options.cwd,
3985
4114
  checks: config.checks,
3986
4115
  layout,
3987
4116
  configFiles: options.configFiles,
3988
- github: options.github
4117
+ github: options.github,
4118
+ note
3989
4119
  }) : readImported(imported, name);
3990
4120
  results.push({
3991
4121
  check: name,
3992
- violations
4122
+ violations,
4123
+ notes
3993
4124
  });
3994
4125
  }
3995
4126
  const violations = results.flatMap((result) => result.violations);
@@ -4100,6 +4231,7 @@ exports.DEFAULT_WORKFLOWS = DEFAULT_WORKFLOWS;
4100
4231
  exports.EXIT_CODES = EXIT_CODES;
4101
4232
  exports.ExhaustivenessError = ExhaustivenessError;
4102
4233
  exports.PRESET_COMMIT_TYPES = PRESET_COMMIT_TYPES;
4234
+ exports.RulesetsUnavailableError = RulesetsUnavailableError;
4103
4235
  exports.accessOf = accessOf;
4104
4236
  exports.aggregateMappers = aggregateMappers;
4105
4237
  exports.checkNames = checkNames;