workspace-conformance 1.2.2 → 1.3.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 CHANGED
@@ -10,7 +10,7 @@ It complements the workspace architecture rules of [`@exadev/eslint-config`](htt
10
10
  pnpm add -D workspace-conformance @exadev/config cosmiconfig 'typescript@^6'
11
11
  ```
12
12
 
13
- `cosmiconfig` (`^9.0.0 || ^10.0.0`) is a peer dependency because `@exadev/config` loads config files through it. `typescript` (`^5.9.0 || ^6.0.0`) is a peer dependency because dependency-cruiser needs it to parse TypeScript and supports versions below 7 (the range is in the install command because an unpinned `typescript` installs a newer major, which pnpm only warns about); the import checks fail with a configuration error when dependency-cruiser cannot load it. ts-morph bundles its own compiler and does not use yours. The package supports the Node lines dependency-cruiser and its own dependencies support: 22.13 and later 22, 24, and 26 and later.
13
+ `cosmiconfig` (`^9.0.0 || ^10.0.0`) is a peer dependency because `@exadev/config` loads config files through it. `typescript` (`^5.9.0 || ^6.0.0`) is a peer dependency because dependency-cruiser needs it to parse TypeScript and supports versions below 7 (the range is in the install command because an unpinned `typescript` installs a newer major, which pnpm only warns about); the import checks fail with a configuration error when dependency-cruiser cannot load it. ts-morph bundles its own compiler and does not use yours. `eslint` (`^9.0.0 || ^10.0.0`) is an optional peer dependency, needed only by the `eslint` check, which uses the copy installed in the repository it checks. The package supports the Node lines dependency-cruiser and its own dependencies support: 22.13 and later 22, 24, and 26 and later.
14
14
 
15
15
  Configure the checks in `exadev.config.ts`:
16
16
 
@@ -161,6 +161,33 @@ False positives and blind spots:
161
161
  - Config variants: only the directory searched is looked at, whereas wrangler also finds a config in a parent directory and follows a redirected config; a repository with both `wrangler.json` and `wrangler.toml` is judged by the first, and a `migrations_pattern` that narrows which files are applied is not read. A generator config that computes `out` from the environment is judged by its value in the process running the check.
162
162
  - Scripts: a script is matched by the text of the command, so `drizzle-kit migrate` run against a local database for tests is reported, and a command assembled in a shell variable or run from a file the script calls is not seen. Narrow `packageJsons` to exempt a package.
163
163
 
164
+ ### ESLint
165
+
166
+ **`eslint`** proves that the repository's own ESLint is applied. Every other lint rule runs inside ESLint, so none of them can notice a config that dropped a shared preset, switched its rules off or narrowed `ignores` until nothing is linted; the repository then looks configured and enforces nothing. This check asks ESLint itself, through its Node API, and carries no ESLint config of its own: a flat config holds plugin objects, a parser and functions, and editors and CI expect `eslint.config.*` where it already is.
167
+
168
+ ESLint (`^9.0.0 || ^10.0.0`) is an optional peer dependency, needed only by this check. It is resolved from the directory the checks run in, the way a module there would find it, and not from this package, so the answer is the one the repository's own lint script gets. When `eslint` does not resolve from there the run fails with a configuration error. When ESLint finds no config file for a sample the check reports `eslint/no-config` and does not substitute one.
169
+
170
+ | Option | Meaning |
171
+ |---|---|
172
+ | `samples` | Required. One file per kind of source the repository lints (`src/index.ts`, `package.json`, `eslint.config.ts`), as a path or `{ path, rules }`. A path need not exist: ESLint resolves a file's configuration from its path alone. |
173
+ | `rules` | Rules required for every sample, each with the severity it must have at least: `warn` is met by `warn` or `error`, `error` only by `error`. Rule entries in the form `[severity, ...options]` and numeric severities are read as their severity. A sample's own `rules` are added to these and win. |
174
+ | `configFile` | A config file to judge instead of the one ESLint finds from the working directory. |
175
+ | `lint` | Also lint the workspace and report every message it produces. Off when omitted. |
176
+ | `lintPatterns` | What `lint` lints, as file and directory patterns. `.` when omitted. |
177
+
178
+ The default level lints nothing. For each sample it calls `calculateConfigForFile`, the same call the CLI's `--print-config` makes, which applies `files` and `ignores`. A file that is ignored, or that no configuration block matches, resolves to no configuration (`isPathIgnored` is that call returning nothing), and that is `eslint/not-linted`. Otherwise each required rule must be configured at the required severity: `eslint/rule-missing`, `eslint/rule-off` or `eslint/rule-too-weak`, each naming the rule and the file.
179
+
180
+ With `lint` the check also lints the workspace through `lintFiles` and maps every message to a violation at its position. The code names the rule, `eslint/lint/<rule id>`, so `eslint/lint/no-console` and `eslint/lint/@scope/plugin/rule` are stable; a parsing error is `eslint/fatal` and a message with no rule, such as an unused disable directive, is `eslint/lint-message`. A pattern that matches no file, or only ignored files, is `eslint/nothing-linted`. Warnings are reported as well as errors, since a repository that fails its lint script on warnings (`--max-warnings 0`) is judged by what that script reports.
181
+
182
+ Limits:
183
+
184
+ - An external config is a black box. The check sees which rules are active on a file, not why, so it cannot tell a rule enabled by a shared preset from one written locally. Require the rules that matter and let the repository decide how it gets them.
185
+ - It reads the config ESLint resolves for each sample, and a sample stands for the files like it. A rule can be off for one directory the samples do not cover, so choose a sample for each kind of source and each directory with its own override.
186
+ - An ignored file and a file that no configuration block matches both resolve to nothing and are reported the same way.
187
+ - Only rule severities are compared, not rule options.
188
+ - Flat config only: the ESLint 9 and later `ESLint` class. Legacy `.eslintrc` configuration is not read.
189
+ - A config that is a TypeScript file needs whatever ESLint itself needs to load it (`jiti`), installed where ESLint is.
190
+
164
191
  ### Workflows
165
192
 
166
193
  The `workflow-*` checks read GitHub Actions workflow files and run offline. Each file under `.github/workflows` is parsed into a model of its events, jobs, the transitive `needs` graph, `if` conditions, effective permissions (a job's own declaration replaces the workflow's, it is not merged with it) and effective environment (workflow, then job, then step), and the checks read that model. A file that is not YAML, or is not shaped like a workflow, fails the run with a configuration error instead of being skipped. Expressions (`${{ ... }}`) are never evaluated: a value that is an expression is treated as unknown, and a check that needs a literal ignores it.
@@ -256,7 +283,7 @@ it('conforms to the workspace layout', async () => {
256
283
  });
257
284
  ```
258
285
 
259
- Every check is also exported as a function taking `{ cwd, options }` (and `layout` for the import checks): `aggregateMappers`, `commandTypes`, `importUphill`, `importRankSkip`, `importCrossSlice`, `importIsolatedGroups`, `importCycles`, `instructionSymlinks`, `singleStorybook`, `commitTypes`, `dockerfilePackageManager`, `migrationsDirectory`, the workflow checks (`workflowJobOrdering`, `workflowSkippableJobs`, `workflowRunnerResolution`, `workflowVersionSingleSource`, `workflowCredentials`, `workflowRepositoryDispatch`, `workflowUpdateBotCooldown`, `workflowMergeGroup` and `workflowActionPinning`) and, taking `{ cwd, options, github }`, the settings checks (`settingsMergeMethods`, `settingsRequiredChecks` and `settingsReviewThreadResolution`). These take options and a layout as already validated and do not check them again; go through `runChecks` for validation. `parseWorkflow` and `loadWorkflows` expose the workflow model the workflow checks read. A check is a function that returns `Promise<readonly Violation[]>`, where a `Violation` is `{ code, message, file, location? }`.
286
+ Every check is also exported as a function taking `{ cwd, options }` (and `layout` for the import checks): `aggregateMappers`, `commandTypes`, `importUphill`, `importRankSkip`, `importCrossSlice`, `importIsolatedGroups`, `importCycles`, `instructionSymlinks`, `singleStorybook`, `commitTypes`, `dockerfilePackageManager`, `migrationsDirectory`, `eslint`, the workflow checks (`workflowJobOrdering`, `workflowSkippableJobs`, `workflowRunnerResolution`, `workflowVersionSingleSource`, `workflowCredentials`, `workflowRepositoryDispatch`, `workflowUpdateBotCooldown`, `workflowMergeGroup` and `workflowActionPinning`) and, taking `{ cwd, options, github }`, the settings checks (`settingsMergeMethods`, `settingsRequiredChecks` and `settingsReviewThreadResolution`). These take options and a layout as already validated and do not check them again; go through `runChecks` for validation. `parseWorkflow` and `loadWorkflows` expose the workflow model the workflow checks read. A check is a function that returns `Promise<readonly Violation[]>`, where a `Violation` is `{ code, message, file, location? }`.
260
287
 
261
288
  ### Proving a handler map is exhaustive
262
289
 
package/dist/cli.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
2
3
  import { dirname, extname, join, posix, relative, resolve, sep } from "node:path";
3
4
  import { parseArgs } from "node:util";
4
5
  import { z } from "zod";
@@ -2498,6 +2499,179 @@ const importUphillSpec = {
2498
2499
  })
2499
2500
  };
2500
2501
  //#endregion
2502
+ //#region src/checks/eslint.ts
2503
+ /**
2504
+ * The patterns the full level lints when the options name none.
2505
+ */
2506
+ const DEFAULT_LINT_PATTERNS = ["."];
2507
+ const SEVERITY_RANK = {
2508
+ off: 0,
2509
+ warn: 1,
2510
+ error: 2
2511
+ };
2512
+ function hasEslintClass(value) {
2513
+ return isRecord(value) && typeof value["ESLint"] === "function";
2514
+ }
2515
+ /**
2516
+ * The `ESLint` class of the ESLint installed where the checks run, found the way a module in `cwd` finds it. It is not this package's own copy, so the results agree with the repository's own lint script.
2517
+ */
2518
+ function loadEslint(cwd) {
2519
+ const requireFromCwd = createRequire(join(cwd, "package.json"));
2520
+ try {
2521
+ requireFromCwd.resolve("eslint");
2522
+ } catch (error) {
2523
+ throw new ConformanceError(`the eslint check uses the repository's own ESLint, and 'eslint' does not resolve from ${cwd}; install it there`, { cause: error });
2524
+ }
2525
+ const loaded = requireFromCwd("eslint");
2526
+ if (!hasEslintClass(loaded)) throw new ConformanceError(`the 'eslint' that resolves from ${cwd} does not export the ESLint class of the Node API`);
2527
+ return loaded.ESLint;
2528
+ }
2529
+ /**
2530
+ * The severity a rule entry resolves to. An entry is a severity or an array whose first element is one, and a severity is `0`, `1`, `2`, `'off'`, `'warn'` or `'error'`; `calculateConfigForFile` returns whichever form the config was written in.
2531
+ */
2532
+ function normaliseSeverity(entry) {
2533
+ const severity = Array.isArray(entry) ? entry[0] : entry;
2534
+ if (severity === 0 || severity === "off") return "off";
2535
+ if (severity === 1 || severity === "warn") return "warn";
2536
+ if (severity === 2 || severity === "error") return "error";
2537
+ throw new ConformanceError(`ESLint resolved ${JSON.stringify(entry)} as a rule entry, which is not a severity or an array that starts with one`);
2538
+ }
2539
+ /**
2540
+ * The severity of each rule in a resolved configuration.
2541
+ */
2542
+ function resolvedSeverities(config) {
2543
+ if (!isRecord(config)) throw new ConformanceError("ESLint resolved a configuration that is not an object");
2544
+ const { rules } = config;
2545
+ if (rules !== void 0 && !isRecord(rules)) throw new ConformanceError("ESLint resolved a configuration whose 'rules' is not an object");
2546
+ return new Map(Object.entries(rules ?? {}).map(([rule, entry]) => [rule, normaliseSeverity(entry)]));
2547
+ }
2548
+ /**
2549
+ * Whether `error` is the one `calculateConfigForFile` throws when ESLint finds no config file for the path.
2550
+ */
2551
+ function isMissingConfig(error) {
2552
+ return isRecord(error) && error["messageTemplate"] === "config-file-missing";
2553
+ }
2554
+ function sampleOf(sample) {
2555
+ return typeof sample === "string" ? { path: sample } : sample;
2556
+ }
2557
+ function ruleViolations(file, required, actual) {
2558
+ return Object.entries(required).flatMap(([rule, severity]) => {
2559
+ const found = actual.get(rule);
2560
+ if (found === void 0) return [{
2561
+ code: "eslint/rule-missing",
2562
+ message: `the rule '${rule}' is not configured for ${file} (required: ${severity})`,
2563
+ file
2564
+ }];
2565
+ if (found === "off") return [{
2566
+ code: "eslint/rule-off",
2567
+ message: `the rule '${rule}' is off for ${file} (required: ${severity})`,
2568
+ file
2569
+ }];
2570
+ if (SEVERITY_RANK[found] < SEVERITY_RANK[severity]) return [{
2571
+ code: "eslint/rule-too-weak",
2572
+ message: `the rule '${rule}' is '${found}' for ${file} (required: ${severity})`,
2573
+ file
2574
+ }];
2575
+ return [];
2576
+ });
2577
+ }
2578
+ async function sampleViolations(eslint, sample, shared) {
2579
+ const file = sample.path;
2580
+ let config;
2581
+ try {
2582
+ config = await eslint.calculateConfigForFile(file);
2583
+ } catch (error) {
2584
+ if (isMissingConfig(error)) return [{
2585
+ code: "eslint/no-config",
2586
+ message: `ESLint finds no config file for ${file}; the check does not fall back to a config of its own`,
2587
+ file
2588
+ }];
2589
+ throw new ConformanceError(`ESLint could not resolve its configuration for ${file}`, { cause: error });
2590
+ }
2591
+ if (config === void 0) return [{
2592
+ code: "eslint/not-linted",
2593
+ message: `${file} is not linted: it is ignored, or no configuration block matches it`,
2594
+ file
2595
+ }];
2596
+ return ruleViolations(file, {
2597
+ ...shared,
2598
+ ...sample.rules
2599
+ }, resolvedSeverities(config));
2600
+ }
2601
+ /**
2602
+ * Whether `error` is ESLint refusing a pattern because it selects no file that is linted.
2603
+ */
2604
+ function isNothingLinted(error) {
2605
+ return error instanceof Error && isRecord(error) && (error["messageTemplate"] === "file-not-found" || error["messageTemplate"] === "all-matched-files-ignored");
2606
+ }
2607
+ function messageViolation(cwd, result, message) {
2608
+ const file = relativePosix(cwd, result.filePath);
2609
+ const location = {
2610
+ line: message.line,
2611
+ column: message.column
2612
+ };
2613
+ if (message.ruleId !== null) {
2614
+ const severity = message.severity === 1 ? "warn" : "error";
2615
+ return {
2616
+ code: `eslint/lint/${message.ruleId}`,
2617
+ message: `${message.ruleId} (${severity}): ${message.message}`,
2618
+ file,
2619
+ location
2620
+ };
2621
+ }
2622
+ return message.fatal === true ? {
2623
+ code: "eslint/fatal",
2624
+ message: message.message,
2625
+ file,
2626
+ location
2627
+ } : {
2628
+ code: "eslint/lint-message",
2629
+ message: message.message,
2630
+ file,
2631
+ location
2632
+ };
2633
+ }
2634
+ function byPosition(left, right) {
2635
+ 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);
2636
+ }
2637
+ /**
2638
+ * 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.
2639
+ */
2640
+ async function lintViolations(cwd, eslint, patterns) {
2641
+ const violations = [];
2642
+ const results = /* @__PURE__ */ new Map();
2643
+ for (const pattern of patterns) try {
2644
+ for (const result of await eslint.lintFiles([pattern])) results.set(result.filePath, result);
2645
+ } catch (error) {
2646
+ if (!isNothingLinted(error)) throw new ConformanceError(`ESLint could not lint '${pattern}'`, { cause: error });
2647
+ violations.push({
2648
+ code: "eslint/nothing-linted",
2649
+ message: error.message,
2650
+ file: pattern
2651
+ });
2652
+ }
2653
+ for (const result of results.values()) for (const message of result.messages) violations.push(messageViolation(cwd, result, message));
2654
+ return violations.sort(byPosition);
2655
+ }
2656
+ /**
2657
+ * Proves the repository's own ESLint is applied, which no check that runs inside ESLint can notice: a config that dropped a shared preset, switched its rules off or narrowed `ignores` until nothing is linted still passes its own lint.
2658
+ *
2659
+ * It loads the ESLint that resolves from the directory the checks run in and resolves the repository's own config through the Node API, so the answer is the one the repository's lint script gets. By default it lints nothing: for each sample it reads the resolved configuration (the object `--print-config` prints) and reports a sample that is not linted, and each required rule that is missing, off or weaker than required. With `lint` it also lints the workspace and reports every message.
2660
+ *
2661
+ * 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.
2662
+ */
2663
+ const eslint = async ({ cwd, options }) => {
2664
+ const instance = new (loadEslint(cwd))({
2665
+ cwd,
2666
+ ...options.configFile === void 0 ? {} : { overrideConfigFile: resolve(cwd, options.configFile) }
2667
+ });
2668
+ const shared = options.rules ?? {};
2669
+ const violations = [];
2670
+ for (const sample of options.samples.map(sampleOf)) violations.push(...await sampleViolations(instance, sample, shared));
2671
+ if (options.lint === true) violations.push(...await lintViolations(cwd, instance, options.lintPatterns ?? DEFAULT_LINT_PATTERNS));
2672
+ return violations;
2673
+ };
2674
+ //#endregion
2501
2675
  //#region src/checks/instruction-symlinks.ts
2502
2676
  /**
2503
2677
  * The instruction file names checked when the options name none.
@@ -2920,6 +3094,12 @@ const registry = {
2920
3094
  select: (checks) => enabledOptions(checks["migrations-directory"]),
2921
3095
  check: migrationsDirectory
2922
3096
  }),
3097
+ eslint: plain({
3098
+ name: "eslint",
3099
+ description: "The repository's own ESLint lints the sample files with the required rules enabled at the required severity",
3100
+ select: (checks) => enabledOptions(checks.eslint),
3101
+ check: eslint
3102
+ }),
2923
3103
  "workflow-job-ordering": plain({
2924
3104
  name: "workflow-job-ordering",
2925
3105
  description: "Release, deploy and junction jobs are ordered so a failure stops what follows",
@@ -3021,6 +3201,8 @@ function compiles(source) {
3021
3201
  */
3022
3202
  const pattern = z.string().min(1).refine(compiles, "not a valid regular expression");
3023
3203
  const patterns = z.array(pattern);
3204
+ const severity = z.enum(["warn", "error"]);
3205
+ const requiredRules = z.record(z.string().min(1), severity);
3024
3206
  const selection = {
3025
3207
  workflows: z.exactOptional(paths),
3026
3208
  exclude: z.exactOptional(paths)
@@ -3080,6 +3262,16 @@ const checks = z.strictObject({
3080
3262
  exclude: z.exactOptional(paths),
3081
3263
  packageJson: z.exactOptional(path)
3082
3264
  }))),
3265
+ eslint: z.exactOptional(enabled(z.strictObject({
3266
+ samples: z.array(z.union([path, z.strictObject({
3267
+ path,
3268
+ rules: z.exactOptional(requiredRules)
3269
+ })])).min(1),
3270
+ rules: z.exactOptional(requiredRules),
3271
+ configFile: z.exactOptional(path),
3272
+ lint: z.exactOptional(z.boolean()),
3273
+ lintPatterns: z.exactOptional(paths)
3274
+ }))),
3083
3275
  "migrations-directory": z.exactOptional(enabled(z.strictObject({
3084
3276
  generator: z.enum(generatorNames),
3085
3277
  deployTool: z.enum(deployToolNames),
package/dist/index.cjs CHANGED
@@ -5,6 +5,7 @@ let tinyglobby = require("tinyglobby");
5
5
  let ts_morph = require("ts-morph");
6
6
  let cosmiconfig_extends = require("cosmiconfig-extends");
7
7
  let node_fs_promises = require("node:fs/promises");
8
+ let node_module = require("node:module");
8
9
  let yaml = require("yaml");
9
10
  let node_child_process = require("node:child_process");
10
11
  let jsonc_parser = require("jsonc-parser");
@@ -543,6 +544,179 @@ const dockerfilePackageManager = async ({ cwd, options }) => {
543
544
  return violations;
544
545
  };
545
546
  //#endregion
547
+ //#region src/checks/eslint.ts
548
+ /**
549
+ * The patterns the full level lints when the options name none.
550
+ */
551
+ const DEFAULT_LINT_PATTERNS = ["."];
552
+ const SEVERITY_RANK = {
553
+ off: 0,
554
+ warn: 1,
555
+ error: 2
556
+ };
557
+ function hasEslintClass(value) {
558
+ return isRecord(value) && typeof value["ESLint"] === "function";
559
+ }
560
+ /**
561
+ * The `ESLint` class of the ESLint installed where the checks run, found the way a module in `cwd` finds it. It is not this package's own copy, so the results agree with the repository's own lint script.
562
+ */
563
+ function loadEslint(cwd) {
564
+ const requireFromCwd = (0, node_module.createRequire)((0, node_path.join)(cwd, "package.json"));
565
+ try {
566
+ requireFromCwd.resolve("eslint");
567
+ } catch (error) {
568
+ throw new ConformanceError(`the eslint check uses the repository's own ESLint, and 'eslint' does not resolve from ${cwd}; install it there`, { cause: error });
569
+ }
570
+ const loaded = requireFromCwd("eslint");
571
+ if (!hasEslintClass(loaded)) throw new ConformanceError(`the 'eslint' that resolves from ${cwd} does not export the ESLint class of the Node API`);
572
+ return loaded.ESLint;
573
+ }
574
+ /**
575
+ * The severity a rule entry resolves to. An entry is a severity or an array whose first element is one, and a severity is `0`, `1`, `2`, `'off'`, `'warn'` or `'error'`; `calculateConfigForFile` returns whichever form the config was written in.
576
+ */
577
+ function normaliseSeverity(entry) {
578
+ const severity = Array.isArray(entry) ? entry[0] : entry;
579
+ if (severity === 0 || severity === "off") return "off";
580
+ if (severity === 1 || severity === "warn") return "warn";
581
+ if (severity === 2 || severity === "error") return "error";
582
+ throw new ConformanceError(`ESLint resolved ${JSON.stringify(entry)} as a rule entry, which is not a severity or an array that starts with one`);
583
+ }
584
+ /**
585
+ * The severity of each rule in a resolved configuration.
586
+ */
587
+ function resolvedSeverities(config) {
588
+ if (!isRecord(config)) throw new ConformanceError("ESLint resolved a configuration that is not an object");
589
+ const { rules } = config;
590
+ if (rules !== void 0 && !isRecord(rules)) throw new ConformanceError("ESLint resolved a configuration whose 'rules' is not an object");
591
+ return new Map(Object.entries(rules ?? {}).map(([rule, entry]) => [rule, normaliseSeverity(entry)]));
592
+ }
593
+ /**
594
+ * Whether `error` is the one `calculateConfigForFile` throws when ESLint finds no config file for the path.
595
+ */
596
+ function isMissingConfig(error) {
597
+ return isRecord(error) && error["messageTemplate"] === "config-file-missing";
598
+ }
599
+ function sampleOf(sample) {
600
+ return typeof sample === "string" ? { path: sample } : sample;
601
+ }
602
+ function ruleViolations(file, required, actual) {
603
+ return Object.entries(required).flatMap(([rule, severity]) => {
604
+ const found = actual.get(rule);
605
+ if (found === void 0) return [{
606
+ code: "eslint/rule-missing",
607
+ message: `the rule '${rule}' is not configured for ${file} (required: ${severity})`,
608
+ file
609
+ }];
610
+ if (found === "off") return [{
611
+ code: "eslint/rule-off",
612
+ message: `the rule '${rule}' is off for ${file} (required: ${severity})`,
613
+ file
614
+ }];
615
+ if (SEVERITY_RANK[found] < SEVERITY_RANK[severity]) return [{
616
+ code: "eslint/rule-too-weak",
617
+ message: `the rule '${rule}' is '${found}' for ${file} (required: ${severity})`,
618
+ file
619
+ }];
620
+ return [];
621
+ });
622
+ }
623
+ async function sampleViolations(eslint, sample, shared) {
624
+ const file = sample.path;
625
+ let config;
626
+ try {
627
+ config = await eslint.calculateConfigForFile(file);
628
+ } catch (error) {
629
+ if (isMissingConfig(error)) return [{
630
+ code: "eslint/no-config",
631
+ message: `ESLint finds no config file for ${file}; the check does not fall back to a config of its own`,
632
+ file
633
+ }];
634
+ throw new ConformanceError(`ESLint could not resolve its configuration for ${file}`, { cause: error });
635
+ }
636
+ if (config === void 0) return [{
637
+ code: "eslint/not-linted",
638
+ message: `${file} is not linted: it is ignored, or no configuration block matches it`,
639
+ file
640
+ }];
641
+ return ruleViolations(file, {
642
+ ...shared,
643
+ ...sample.rules
644
+ }, resolvedSeverities(config));
645
+ }
646
+ /**
647
+ * Whether `error` is ESLint refusing a pattern because it selects no file that is linted.
648
+ */
649
+ function isNothingLinted(error) {
650
+ return error instanceof Error && isRecord(error) && (error["messageTemplate"] === "file-not-found" || error["messageTemplate"] === "all-matched-files-ignored");
651
+ }
652
+ function messageViolation(cwd, result, message) {
653
+ const file = relativePosix(cwd, result.filePath);
654
+ const location = {
655
+ line: message.line,
656
+ column: message.column
657
+ };
658
+ if (message.ruleId !== null) {
659
+ const severity = message.severity === 1 ? "warn" : "error";
660
+ return {
661
+ code: `eslint/lint/${message.ruleId}`,
662
+ message: `${message.ruleId} (${severity}): ${message.message}`,
663
+ file,
664
+ location
665
+ };
666
+ }
667
+ return message.fatal === true ? {
668
+ code: "eslint/fatal",
669
+ message: message.message,
670
+ file,
671
+ location
672
+ } : {
673
+ code: "eslint/lint-message",
674
+ message: message.message,
675
+ file,
676
+ location
677
+ };
678
+ }
679
+ function byPosition(left, right) {
680
+ 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);
681
+ }
682
+ /**
683
+ * 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.
684
+ */
685
+ async function lintViolations(cwd, eslint, patterns) {
686
+ const violations = [];
687
+ const results = /* @__PURE__ */ new Map();
688
+ for (const pattern of patterns) try {
689
+ for (const result of await eslint.lintFiles([pattern])) results.set(result.filePath, result);
690
+ } catch (error) {
691
+ if (!isNothingLinted(error)) throw new ConformanceError(`ESLint could not lint '${pattern}'`, { cause: error });
692
+ violations.push({
693
+ code: "eslint/nothing-linted",
694
+ message: error.message,
695
+ file: pattern
696
+ });
697
+ }
698
+ for (const result of results.values()) for (const message of result.messages) violations.push(messageViolation(cwd, result, message));
699
+ return violations.sort(byPosition);
700
+ }
701
+ /**
702
+ * Proves the repository's own ESLint is applied, which no check that runs inside ESLint can notice: a config that dropped a shared preset, switched its rules off or narrowed `ignores` until nothing is linted still passes its own lint.
703
+ *
704
+ * It loads the ESLint that resolves from the directory the checks run in and resolves the repository's own config through the Node API, so the answer is the one the repository's lint script gets. By default it lints nothing: for each sample it reads the resolved configuration (the object `--print-config` prints) and reports a sample that is not linted, and each required rule that is missing, off or weaker than required. With `lint` it also lints the workspace and reports every message.
705
+ *
706
+ * 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.
707
+ */
708
+ const eslint = async ({ cwd, options }) => {
709
+ const instance = new (loadEslint(cwd))({
710
+ cwd,
711
+ ...options.configFile === void 0 ? {} : { overrideConfigFile: (0, node_path.resolve)(cwd, options.configFile) }
712
+ });
713
+ const shared = options.rules ?? {};
714
+ const violations = [];
715
+ for (const sample of options.samples.map(sampleOf)) violations.push(...await sampleViolations(instance, sample, shared));
716
+ if (options.lint === true) violations.push(...await lintViolations(cwd, instance, options.lintPatterns ?? DEFAULT_LINT_PATTERNS));
717
+ return violations;
718
+ };
719
+ //#endregion
546
720
  //#region src/workspace/rules.ts
547
721
  const REGEXP_SPECIAL = /[.*+?^${}()|[\]\\]/gu;
548
722
  function escapeRegExp(text) {
@@ -2767,6 +2941,8 @@ function compiles(source) {
2767
2941
  */
2768
2942
  const pattern = zod.z.string().min(1).refine(compiles, "not a valid regular expression");
2769
2943
  const patterns = zod.z.array(pattern);
2944
+ const severity = zod.z.enum(["warn", "error"]);
2945
+ const requiredRules = zod.z.record(zod.z.string().min(1), severity);
2770
2946
  const selection = {
2771
2947
  workflows: zod.z.exactOptional(paths),
2772
2948
  exclude: zod.z.exactOptional(paths)
@@ -2826,6 +3002,16 @@ const checks = zod.z.strictObject({
2826
3002
  exclude: zod.z.exactOptional(paths),
2827
3003
  packageJson: zod.z.exactOptional(path)
2828
3004
  }))),
3005
+ eslint: zod.z.exactOptional(enabled(zod.z.strictObject({
3006
+ samples: zod.z.array(zod.z.union([path, zod.z.strictObject({
3007
+ path,
3008
+ rules: zod.z.exactOptional(requiredRules)
3009
+ })])).min(1),
3010
+ rules: zod.z.exactOptional(requiredRules),
3011
+ configFile: zod.z.exactOptional(path),
3012
+ lint: zod.z.exactOptional(zod.z.boolean()),
3013
+ lintPatterns: zod.z.exactOptional(paths)
3014
+ }))),
2829
3015
  "migrations-directory": zod.z.exactOptional(enabled(zod.z.strictObject({
2830
3016
  generator: zod.z.enum(generatorNames),
2831
3017
  deployTool: zod.z.enum(deployToolNames),
@@ -3100,6 +3286,12 @@ const registry = {
3100
3286
  select: (checks) => enabledOptions(checks["migrations-directory"]),
3101
3287
  check: migrationsDirectory
3102
3288
  }),
3289
+ eslint: plain({
3290
+ name: "eslint",
3291
+ description: "The repository's own ESLint lints the sample files with the required rules enabled at the required severity",
3292
+ select: (checks) => enabledOptions(checks.eslint),
3293
+ check: eslint
3294
+ }),
3103
3295
  "workflow-job-ordering": plain({
3104
3296
  name: "workflow-job-ordering",
3105
3297
  description: "Release, deploy and junction jobs are ordered so a failure stops what follows",
@@ -3402,6 +3594,7 @@ exports.deployTools = deployTools;
3402
3594
  exports.dockerfilePackageManager = dockerfilePackageManager;
3403
3595
  exports.effectiveEnv = effectiveEnv;
3404
3596
  exports.effectivePermissions = effectivePermissions;
3597
+ exports.eslint = eslint;
3405
3598
  exports.generators = generators;
3406
3599
  exports.handlerCases = handlerCases;
3407
3600
  exports.importCrossSlice = importCrossSlice;
package/dist/index.d.cts CHANGED
@@ -474,6 +474,48 @@ interface DockerfilePackageManagerOptions {
474
474
  */
475
475
  readonly packageJson?: string;
476
476
  }
477
+ /**
478
+ * The severity a rule must have at least: `warn` is met by `warn` or `error`, `error` only by `error`.
479
+ */
480
+ type EslintRequiredSeverity = "warn" | "error";
481
+ /**
482
+ * A file whose resolved ESLint configuration is checked.
483
+ */
484
+ interface EslintSample {
485
+ /**
486
+ * The file ESLint would lint, relative to the directory the checks run in. It need not exist: ESLint resolves a file's configuration from its path alone.
487
+ */
488
+ readonly path: string;
489
+ /**
490
+ * Rules required for this file on top of the check's `rules`, which this file's entries override.
491
+ */
492
+ readonly rules?: Readonly<Record<string, EslintRequiredSeverity>>;
493
+ }
494
+ /**
495
+ * Options of the `eslint` check: the repository's own ESLint, resolved from the directory the checks run in, lints the sample files with the required rules.
496
+ */
497
+ interface EslintOptions {
498
+ /**
499
+ * One file per kind of source the repository lints (`src/index.ts`, `package.json`, `eslint.config.ts`). A bare string is `{ path }`. Each must be linted: not ignored, and matched by a configuration block.
500
+ */
501
+ readonly samples: readonly (string | EslintSample)[];
502
+ /**
503
+ * Rules required for every sample, with the severity each must have at least.
504
+ */
505
+ readonly rules?: Readonly<Record<string, EslintRequiredSeverity>>;
506
+ /**
507
+ * The config file under test, relative to the directory the checks run in, instead of the one ESLint finds from there.
508
+ */
509
+ readonly configFile?: string;
510
+ /**
511
+ * Also lint the workspace and report every message ESLint produces. Off when omitted: the default level lints nothing and only reads resolved configuration.
512
+ */
513
+ readonly lint?: boolean;
514
+ /**
515
+ * What the full level lints, as file and directory patterns relative to the directory the checks run in. `.` when omitted. Used only with `lint`.
516
+ */
517
+ readonly lintPatterns?: readonly string[];
518
+ }
477
519
  /**
478
520
  * Options of the `migrations-directory` check: the directory a schema generator writes migrations to is the one the deploy tool applies them from, and no script applies them with the generator.
479
521
  */
@@ -672,6 +714,7 @@ interface CheckOptionsByName {
672
714
  readonly "commit-types": CommitTypesOptions;
673
715
  readonly "dockerfile-package-manager": DockerfilePackageManagerOptions;
674
716
  readonly "migrations-directory": MigrationsDirectoryOptions;
717
+ readonly eslint: EslintOptions;
675
718
  readonly "workflow-job-ordering": WorkflowJobOrderingOptions;
676
719
  readonly "workflow-skippable-jobs": WorkflowSkippableJobsOptions;
677
720
  readonly "workflow-runner-resolution": WorkflowRunnerResolutionOptions;
@@ -730,6 +773,16 @@ export declare const commitTypes: CheckFunction<CommitTypesOptions>;
730
773
  */
731
774
  export declare const dockerfilePackageManager: CheckFunction<DockerfilePackageManagerOptions>;
732
775
  //#endregion
776
+ //#region src/checks/eslint.d.ts
777
+ /**
778
+ * Proves the repository's own ESLint is applied, which no check that runs inside ESLint can notice: a config that dropped a shared preset, switched its rules off or narrowed `ignores` until nothing is linted still passes its own lint.
779
+ *
780
+ * It loads the ESLint that resolves from the directory the checks run in and resolves the repository's own config through the Node API, so the answer is the one the repository's lint script gets. By default it lints nothing: for each sample it reads the resolved configuration (the object `--print-config` prints) and reports a sample that is not linted, and each required rule that is missing, off or weaker than required. With `lint` it also lints the workspace and reports every message.
781
+ *
782
+ * 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.
783
+ */
784
+ export declare const eslint: CheckFunction<EslintOptions>;
785
+ //#endregion
733
786
  //#region src/workspace/packages.d.ts
734
787
  /**
735
788
  * A package directory found under the workspace root, before the layout classifies it.
@@ -1222,4 +1275,4 @@ export declare function proveExhaustive(input: {
1222
1275
  readonly compilerOptions?: CompilerOptions;
1223
1276
  }): ExhaustivenessProof;
1224
1277
  //#endregion
1225
- export type { AggregateMappersOptions, BranchRules, CaseRemoval, CheckContext, CheckFunction, CheckName, CheckOptionsByName, CheckResult, ChecksConfig, CommandTypesOptions, CommitTypesOptions, ConformanceConfig, DeployDirectory, DeploySelector, DeployToolAdapter, DeployToolName, DiscoveredPackage, DockerfilePackageManagerOptions, ExhaustivenessProof, GeneratorAdapter, GeneratorName, GitHubCheckContext, GitHubCheckFunction, GitHubClient, GitHubClientOptions, ImportGraphOptions, InstructionSymlinksOptions, Job, LayoutCheckContext, LayoutCheckFunction, MigrationsDirectoryOptions, PermissionAccess, Permissions, PinningLevel, RegisteredCheck, RepositorySettings, RepositorySlug, RunChecksOptions, RunResult, RunsOn, SettingsMergeMethodsOptions, SettingsOptions, SettingsRequiredChecksOptions, SettingsReviewThreadResolutionOptions, SingleStorybookOptions, SourceLocation, Step, TypeErrorReport, UsesReference, Violation, Workflow, WorkflowActionPinningOptions, WorkflowCredentialsOptions, WorkflowJobOrderingOptions, WorkflowMergeGroupOptions, WorkflowRepositoryDispatchOptions, WorkflowRunnerResolutionOptions, WorkflowSelection, WorkflowSkippableJobsOptions, WorkflowUpdateBotCooldownOptions, WorkflowVersionSingleSourceOptions, WorkspacePackage };
1278
+ export type { AggregateMappersOptions, BranchRules, CaseRemoval, CheckContext, CheckFunction, CheckName, CheckOptionsByName, CheckResult, ChecksConfig, CommandTypesOptions, CommitTypesOptions, ConformanceConfig, DeployDirectory, DeploySelector, DeployToolAdapter, DeployToolName, DiscoveredPackage, DockerfilePackageManagerOptions, EslintOptions, EslintRequiredSeverity, EslintSample, ExhaustivenessProof, GeneratorAdapter, GeneratorName, GitHubCheckContext, GitHubCheckFunction, GitHubClient, GitHubClientOptions, ImportGraphOptions, InstructionSymlinksOptions, Job, LayoutCheckContext, LayoutCheckFunction, MigrationsDirectoryOptions, PermissionAccess, Permissions, PinningLevel, RegisteredCheck, RepositorySettings, RepositorySlug, RunChecksOptions, RunResult, RunsOn, SettingsMergeMethodsOptions, SettingsOptions, SettingsRequiredChecksOptions, SettingsReviewThreadResolutionOptions, SingleStorybookOptions, SourceLocation, Step, TypeErrorReport, UsesReference, Violation, Workflow, WorkflowActionPinningOptions, WorkflowCredentialsOptions, WorkflowJobOrderingOptions, WorkflowMergeGroupOptions, WorkflowRepositoryDispatchOptions, WorkflowRunnerResolutionOptions, WorkflowSelection, WorkflowSkippableJobsOptions, WorkflowUpdateBotCooldownOptions, WorkflowVersionSingleSourceOptions, WorkspacePackage };
package/dist/index.d.ts CHANGED
@@ -474,6 +474,48 @@ interface DockerfilePackageManagerOptions {
474
474
  */
475
475
  readonly packageJson?: string;
476
476
  }
477
+ /**
478
+ * The severity a rule must have at least: `warn` is met by `warn` or `error`, `error` only by `error`.
479
+ */
480
+ type EslintRequiredSeverity = "warn" | "error";
481
+ /**
482
+ * A file whose resolved ESLint configuration is checked.
483
+ */
484
+ interface EslintSample {
485
+ /**
486
+ * The file ESLint would lint, relative to the directory the checks run in. It need not exist: ESLint resolves a file's configuration from its path alone.
487
+ */
488
+ readonly path: string;
489
+ /**
490
+ * Rules required for this file on top of the check's `rules`, which this file's entries override.
491
+ */
492
+ readonly rules?: Readonly<Record<string, EslintRequiredSeverity>>;
493
+ }
494
+ /**
495
+ * Options of the `eslint` check: the repository's own ESLint, resolved from the directory the checks run in, lints the sample files with the required rules.
496
+ */
497
+ interface EslintOptions {
498
+ /**
499
+ * One file per kind of source the repository lints (`src/index.ts`, `package.json`, `eslint.config.ts`). A bare string is `{ path }`. Each must be linted: not ignored, and matched by a configuration block.
500
+ */
501
+ readonly samples: readonly (string | EslintSample)[];
502
+ /**
503
+ * Rules required for every sample, with the severity each must have at least.
504
+ */
505
+ readonly rules?: Readonly<Record<string, EslintRequiredSeverity>>;
506
+ /**
507
+ * The config file under test, relative to the directory the checks run in, instead of the one ESLint finds from there.
508
+ */
509
+ readonly configFile?: string;
510
+ /**
511
+ * Also lint the workspace and report every message ESLint produces. Off when omitted: the default level lints nothing and only reads resolved configuration.
512
+ */
513
+ readonly lint?: boolean;
514
+ /**
515
+ * What the full level lints, as file and directory patterns relative to the directory the checks run in. `.` when omitted. Used only with `lint`.
516
+ */
517
+ readonly lintPatterns?: readonly string[];
518
+ }
477
519
  /**
478
520
  * Options of the `migrations-directory` check: the directory a schema generator writes migrations to is the one the deploy tool applies them from, and no script applies them with the generator.
479
521
  */
@@ -672,6 +714,7 @@ interface CheckOptionsByName {
672
714
  readonly "commit-types": CommitTypesOptions;
673
715
  readonly "dockerfile-package-manager": DockerfilePackageManagerOptions;
674
716
  readonly "migrations-directory": MigrationsDirectoryOptions;
717
+ readonly eslint: EslintOptions;
675
718
  readonly "workflow-job-ordering": WorkflowJobOrderingOptions;
676
719
  readonly "workflow-skippable-jobs": WorkflowSkippableJobsOptions;
677
720
  readonly "workflow-runner-resolution": WorkflowRunnerResolutionOptions;
@@ -730,6 +773,16 @@ export declare const commitTypes: CheckFunction<CommitTypesOptions>;
730
773
  */
731
774
  export declare const dockerfilePackageManager: CheckFunction<DockerfilePackageManagerOptions>;
732
775
  //#endregion
776
+ //#region src/checks/eslint.d.ts
777
+ /**
778
+ * Proves the repository's own ESLint is applied, which no check that runs inside ESLint can notice: a config that dropped a shared preset, switched its rules off or narrowed `ignores` until nothing is linted still passes its own lint.
779
+ *
780
+ * It loads the ESLint that resolves from the directory the checks run in and resolves the repository's own config through the Node API, so the answer is the one the repository's lint script gets. By default it lints nothing: for each sample it reads the resolved configuration (the object `--print-config` prints) and reports a sample that is not linted, and each required rule that is missing, off or weaker than required. With `lint` it also lints the workspace and reports every message.
781
+ *
782
+ * 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.
783
+ */
784
+ export declare const eslint: CheckFunction<EslintOptions>;
785
+ //#endregion
733
786
  //#region src/workspace/packages.d.ts
734
787
  /**
735
788
  * A package directory found under the workspace root, before the layout classifies it.
@@ -1222,4 +1275,4 @@ export declare function proveExhaustive(input: {
1222
1275
  readonly compilerOptions?: CompilerOptions;
1223
1276
  }): ExhaustivenessProof;
1224
1277
  //#endregion
1225
- export type { AggregateMappersOptions, BranchRules, CaseRemoval, CheckContext, CheckFunction, CheckName, CheckOptionsByName, CheckResult, ChecksConfig, CommandTypesOptions, CommitTypesOptions, ConformanceConfig, DeployDirectory, DeploySelector, DeployToolAdapter, DeployToolName, DiscoveredPackage, DockerfilePackageManagerOptions, ExhaustivenessProof, GeneratorAdapter, GeneratorName, GitHubCheckContext, GitHubCheckFunction, GitHubClient, GitHubClientOptions, ImportGraphOptions, InstructionSymlinksOptions, Job, LayoutCheckContext, LayoutCheckFunction, MigrationsDirectoryOptions, PermissionAccess, Permissions, PinningLevel, RegisteredCheck, RepositorySettings, RepositorySlug, RunChecksOptions, RunResult, RunsOn, SettingsMergeMethodsOptions, SettingsOptions, SettingsRequiredChecksOptions, SettingsReviewThreadResolutionOptions, SingleStorybookOptions, SourceLocation, Step, TypeErrorReport, UsesReference, Violation, Workflow, WorkflowActionPinningOptions, WorkflowCredentialsOptions, WorkflowJobOrderingOptions, WorkflowMergeGroupOptions, WorkflowRepositoryDispatchOptions, WorkflowRunnerResolutionOptions, WorkflowSelection, WorkflowSkippableJobsOptions, WorkflowUpdateBotCooldownOptions, WorkflowVersionSingleSourceOptions, WorkspacePackage };
1278
+ export type { AggregateMappersOptions, BranchRules, CaseRemoval, CheckContext, CheckFunction, CheckName, CheckOptionsByName, CheckResult, ChecksConfig, CommandTypesOptions, CommitTypesOptions, ConformanceConfig, DeployDirectory, DeploySelector, DeployToolAdapter, DeployToolName, DiscoveredPackage, DockerfilePackageManagerOptions, EslintOptions, EslintRequiredSeverity, EslintSample, ExhaustivenessProof, GeneratorAdapter, GeneratorName, GitHubCheckContext, GitHubCheckFunction, GitHubClient, GitHubClientOptions, ImportGraphOptions, InstructionSymlinksOptions, Job, LayoutCheckContext, LayoutCheckFunction, MigrationsDirectoryOptions, PermissionAccess, Permissions, PinningLevel, RegisteredCheck, RepositorySettings, RepositorySlug, RunChecksOptions, RunResult, RunsOn, SettingsMergeMethodsOptions, SettingsOptions, SettingsRequiredChecksOptions, SettingsReviewThreadResolutionOptions, SingleStorybookOptions, SourceLocation, Step, TypeErrorReport, UsesReference, Violation, Workflow, WorkflowActionPinningOptions, WorkflowCredentialsOptions, WorkflowJobOrderingOptions, WorkflowMergeGroupOptions, WorkflowRepositoryDispatchOptions, WorkflowRunnerResolutionOptions, WorkflowSelection, WorkflowSkippableJobsOptions, WorkflowUpdateBotCooldownOptions, WorkflowVersionSingleSourceOptions, WorkspacePackage };
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { createRequire } from "node:module";
1
2
  import { existsSync } from "node:fs";
2
3
  import { dirname, extname, join, posix, relative, resolve, sep } from "node:path";
3
4
  import { escapePath, glob, isDynamicPattern } from "tinyglobby";
@@ -542,6 +543,179 @@ const dockerfilePackageManager = async ({ cwd, options }) => {
542
543
  return violations;
543
544
  };
544
545
  //#endregion
546
+ //#region src/checks/eslint.ts
547
+ /**
548
+ * The patterns the full level lints when the options name none.
549
+ */
550
+ const DEFAULT_LINT_PATTERNS = ["."];
551
+ const SEVERITY_RANK = {
552
+ off: 0,
553
+ warn: 1,
554
+ error: 2
555
+ };
556
+ function hasEslintClass(value) {
557
+ return isRecord(value) && typeof value["ESLint"] === "function";
558
+ }
559
+ /**
560
+ * The `ESLint` class of the ESLint installed where the checks run, found the way a module in `cwd` finds it. It is not this package's own copy, so the results agree with the repository's own lint script.
561
+ */
562
+ function loadEslint(cwd) {
563
+ const requireFromCwd = createRequire(join(cwd, "package.json"));
564
+ try {
565
+ requireFromCwd.resolve("eslint");
566
+ } catch (error) {
567
+ throw new ConformanceError(`the eslint check uses the repository's own ESLint, and 'eslint' does not resolve from ${cwd}; install it there`, { cause: error });
568
+ }
569
+ const loaded = requireFromCwd("eslint");
570
+ if (!hasEslintClass(loaded)) throw new ConformanceError(`the 'eslint' that resolves from ${cwd} does not export the ESLint class of the Node API`);
571
+ return loaded.ESLint;
572
+ }
573
+ /**
574
+ * The severity a rule entry resolves to. An entry is a severity or an array whose first element is one, and a severity is `0`, `1`, `2`, `'off'`, `'warn'` or `'error'`; `calculateConfigForFile` returns whichever form the config was written in.
575
+ */
576
+ function normaliseSeverity(entry) {
577
+ const severity = Array.isArray(entry) ? entry[0] : entry;
578
+ if (severity === 0 || severity === "off") return "off";
579
+ if (severity === 1 || severity === "warn") return "warn";
580
+ if (severity === 2 || severity === "error") return "error";
581
+ throw new ConformanceError(`ESLint resolved ${JSON.stringify(entry)} as a rule entry, which is not a severity or an array that starts with one`);
582
+ }
583
+ /**
584
+ * The severity of each rule in a resolved configuration.
585
+ */
586
+ function resolvedSeverities(config) {
587
+ if (!isRecord(config)) throw new ConformanceError("ESLint resolved a configuration that is not an object");
588
+ const { rules } = config;
589
+ if (rules !== void 0 && !isRecord(rules)) throw new ConformanceError("ESLint resolved a configuration whose 'rules' is not an object");
590
+ return new Map(Object.entries(rules ?? {}).map(([rule, entry]) => [rule, normaliseSeverity(entry)]));
591
+ }
592
+ /**
593
+ * Whether `error` is the one `calculateConfigForFile` throws when ESLint finds no config file for the path.
594
+ */
595
+ function isMissingConfig(error) {
596
+ return isRecord(error) && error["messageTemplate"] === "config-file-missing";
597
+ }
598
+ function sampleOf(sample) {
599
+ return typeof sample === "string" ? { path: sample } : sample;
600
+ }
601
+ function ruleViolations(file, required, actual) {
602
+ return Object.entries(required).flatMap(([rule, severity]) => {
603
+ const found = actual.get(rule);
604
+ if (found === void 0) return [{
605
+ code: "eslint/rule-missing",
606
+ message: `the rule '${rule}' is not configured for ${file} (required: ${severity})`,
607
+ file
608
+ }];
609
+ if (found === "off") return [{
610
+ code: "eslint/rule-off",
611
+ message: `the rule '${rule}' is off for ${file} (required: ${severity})`,
612
+ file
613
+ }];
614
+ if (SEVERITY_RANK[found] < SEVERITY_RANK[severity]) return [{
615
+ code: "eslint/rule-too-weak",
616
+ message: `the rule '${rule}' is '${found}' for ${file} (required: ${severity})`,
617
+ file
618
+ }];
619
+ return [];
620
+ });
621
+ }
622
+ async function sampleViolations(eslint, sample, shared) {
623
+ const file = sample.path;
624
+ let config;
625
+ try {
626
+ config = await eslint.calculateConfigForFile(file);
627
+ } catch (error) {
628
+ if (isMissingConfig(error)) return [{
629
+ code: "eslint/no-config",
630
+ message: `ESLint finds no config file for ${file}; the check does not fall back to a config of its own`,
631
+ file
632
+ }];
633
+ throw new ConformanceError(`ESLint could not resolve its configuration for ${file}`, { cause: error });
634
+ }
635
+ if (config === void 0) return [{
636
+ code: "eslint/not-linted",
637
+ message: `${file} is not linted: it is ignored, or no configuration block matches it`,
638
+ file
639
+ }];
640
+ return ruleViolations(file, {
641
+ ...shared,
642
+ ...sample.rules
643
+ }, resolvedSeverities(config));
644
+ }
645
+ /**
646
+ * Whether `error` is ESLint refusing a pattern because it selects no file that is linted.
647
+ */
648
+ function isNothingLinted(error) {
649
+ return error instanceof Error && isRecord(error) && (error["messageTemplate"] === "file-not-found" || error["messageTemplate"] === "all-matched-files-ignored");
650
+ }
651
+ function messageViolation(cwd, result, message) {
652
+ const file = relativePosix(cwd, result.filePath);
653
+ const location = {
654
+ line: message.line,
655
+ column: message.column
656
+ };
657
+ if (message.ruleId !== null) {
658
+ const severity = message.severity === 1 ? "warn" : "error";
659
+ return {
660
+ code: `eslint/lint/${message.ruleId}`,
661
+ message: `${message.ruleId} (${severity}): ${message.message}`,
662
+ file,
663
+ location
664
+ };
665
+ }
666
+ return message.fatal === true ? {
667
+ code: "eslint/fatal",
668
+ message: message.message,
669
+ file,
670
+ location
671
+ } : {
672
+ code: "eslint/lint-message",
673
+ message: message.message,
674
+ file,
675
+ location
676
+ };
677
+ }
678
+ function byPosition(left, right) {
679
+ 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);
680
+ }
681
+ /**
682
+ * 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.
683
+ */
684
+ async function lintViolations(cwd, eslint, patterns) {
685
+ const violations = [];
686
+ const results = /* @__PURE__ */ new Map();
687
+ for (const pattern of patterns) try {
688
+ for (const result of await eslint.lintFiles([pattern])) results.set(result.filePath, result);
689
+ } catch (error) {
690
+ if (!isNothingLinted(error)) throw new ConformanceError(`ESLint could not lint '${pattern}'`, { cause: error });
691
+ violations.push({
692
+ code: "eslint/nothing-linted",
693
+ message: error.message,
694
+ file: pattern
695
+ });
696
+ }
697
+ for (const result of results.values()) for (const message of result.messages) violations.push(messageViolation(cwd, result, message));
698
+ return violations.sort(byPosition);
699
+ }
700
+ /**
701
+ * Proves the repository's own ESLint is applied, which no check that runs inside ESLint can notice: a config that dropped a shared preset, switched its rules off or narrowed `ignores` until nothing is linted still passes its own lint.
702
+ *
703
+ * It loads the ESLint that resolves from the directory the checks run in and resolves the repository's own config through the Node API, so the answer is the one the repository's lint script gets. By default it lints nothing: for each sample it reads the resolved configuration (the object `--print-config` prints) and reports a sample that is not linted, and each required rule that is missing, off or weaker than required. With `lint` it also lints the workspace and reports every message.
704
+ *
705
+ * 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.
706
+ */
707
+ const eslint = async ({ cwd, options }) => {
708
+ const instance = new (loadEslint(cwd))({
709
+ cwd,
710
+ ...options.configFile === void 0 ? {} : { overrideConfigFile: resolve(cwd, options.configFile) }
711
+ });
712
+ const shared = options.rules ?? {};
713
+ const violations = [];
714
+ for (const sample of options.samples.map(sampleOf)) violations.push(...await sampleViolations(instance, sample, shared));
715
+ if (options.lint === true) violations.push(...await lintViolations(cwd, instance, options.lintPatterns ?? DEFAULT_LINT_PATTERNS));
716
+ return violations;
717
+ };
718
+ //#endregion
545
719
  //#region src/workspace/rules.ts
546
720
  const REGEXP_SPECIAL = /[.*+?^${}()|[\]\\]/gu;
547
721
  function escapeRegExp(text) {
@@ -2766,6 +2940,8 @@ function compiles(source) {
2766
2940
  */
2767
2941
  const pattern = z.string().min(1).refine(compiles, "not a valid regular expression");
2768
2942
  const patterns = z.array(pattern);
2943
+ const severity = z.enum(["warn", "error"]);
2944
+ const requiredRules = z.record(z.string().min(1), severity);
2769
2945
  const selection = {
2770
2946
  workflows: z.exactOptional(paths),
2771
2947
  exclude: z.exactOptional(paths)
@@ -2825,6 +3001,16 @@ const checks = z.strictObject({
2825
3001
  exclude: z.exactOptional(paths),
2826
3002
  packageJson: z.exactOptional(path)
2827
3003
  }))),
3004
+ eslint: z.exactOptional(enabled(z.strictObject({
3005
+ samples: z.array(z.union([path, z.strictObject({
3006
+ path,
3007
+ rules: z.exactOptional(requiredRules)
3008
+ })])).min(1),
3009
+ rules: z.exactOptional(requiredRules),
3010
+ configFile: z.exactOptional(path),
3011
+ lint: z.exactOptional(z.boolean()),
3012
+ lintPatterns: z.exactOptional(paths)
3013
+ }))),
2828
3014
  "migrations-directory": z.exactOptional(enabled(z.strictObject({
2829
3015
  generator: z.enum(generatorNames),
2830
3016
  deployTool: z.enum(deployToolNames),
@@ -3099,6 +3285,12 @@ const registry = {
3099
3285
  select: (checks) => enabledOptions(checks["migrations-directory"]),
3100
3286
  check: migrationsDirectory
3101
3287
  }),
3288
+ eslint: plain({
3289
+ name: "eslint",
3290
+ description: "The repository's own ESLint lints the sample files with the required rules enabled at the required severity",
3291
+ select: (checks) => enabledOptions(checks.eslint),
3292
+ check: eslint
3293
+ }),
3102
3294
  "workflow-job-ordering": plain({
3103
3295
  name: "workflow-job-ordering",
3104
3296
  description: "Release, deploy and junction jobs are ordered so a failure stops what follows",
@@ -3380,4 +3572,4 @@ function proveExhaustive(input) {
3380
3572
  }) };
3381
3573
  }
3382
3574
  //#endregion
3383
- export { ConformanceError, DEFAULT_INFERENCES, DEFAULT_WORKFLOWS, EXIT_CODES, ExhaustivenessError, PRESET_COMMIT_TYPES, accessOf, aggregateMappers, checkNames, classifyPackages, commandTypes, commitTypes, conformanceSchema, conformanceSection, createGitHubClient, crossSliceRules, cycleRule, deployTools, dockerfilePackageManager, effectiveEnv, effectivePermissions, generators, handlerCases, importCrossSlice, importCycles, importIsolatedGroups, importRankSkip, importUphill, instructionSymlinks, isCheckName, isolatedGroupRules, loadWorkflows, migrationsDirectory, packagesPattern, parseUses, parseWorkflow, proveExhaustive, rankSkipRules, registry, removeCase, runChecks, settingsMergeMethods, settingsRequiredChecks, settingsReviewThreadResolution, singleStorybook, transitiveNeeds, typeErrors, uphillRules, workflowActionPinning, workflowCredentials, workflowJobOrdering, workflowMergeGroup, workflowRepositoryDispatch, workflowRunnerResolution, workflowSkippableJobs, workflowUpdateBotCooldown, workflowVersionSingleSource };
3575
+ export { ConformanceError, DEFAULT_INFERENCES, DEFAULT_WORKFLOWS, EXIT_CODES, ExhaustivenessError, PRESET_COMMIT_TYPES, accessOf, aggregateMappers, checkNames, classifyPackages, commandTypes, commitTypes, conformanceSchema, conformanceSection, createGitHubClient, crossSliceRules, cycleRule, deployTools, dockerfilePackageManager, effectiveEnv, effectivePermissions, eslint, generators, handlerCases, importCrossSlice, importCycles, importIsolatedGroups, importRankSkip, importUphill, instructionSymlinks, isCheckName, isolatedGroupRules, loadWorkflows, migrationsDirectory, packagesPattern, parseUses, parseWorkflow, proveExhaustive, rankSkipRules, registry, removeCase, runChecks, settingsMergeMethods, settingsRequiredChecks, settingsReviewThreadResolution, singleStorybook, transitiveNeeds, typeErrors, uphillRules, workflowActionPinning, workflowCredentials, workflowJobOrdering, workflowMergeGroup, workflowRepositoryDispatch, workflowRunnerResolution, workflowSkippableJobs, workflowUpdateBotCooldown, workflowVersionSingleSource };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "workspace-conformance",
3
3
  "description": "Conformance checks for a pnpm workspace that ESLint cannot express: type-graph, import-graph and cross-file checks driven by the shared layout section of @exadev/config",
4
- "version": "1.2.2",
4
+ "version": "1.3.0",
5
5
  "bin": {
6
6
  "workspace-conformance": "./dist/cli.js"
7
7
  },
@@ -72,8 +72,14 @@
72
72
  "packageManager": "pnpm@12.4.1+sha512.2e81e399d73fe8390dab25e06aa788ab7a5908248d2f5a370f82b481147a6a7a367bf8048f9a6fdb6460f21a66f0542dedb8b94ca2c8723596741920b1656d4c",
73
73
  "peerDependencies": {
74
74
  "cosmiconfig": "^9.0.0 || ^10.0.0",
75
+ "eslint": "^9.0.0 || ^10.0.0",
75
76
  "typescript": "^5.9.0 || ^6.0.0"
76
77
  },
78
+ "peerDependenciesMeta": {
79
+ "eslint": {
80
+ "optional": true
81
+ }
82
+ },
77
83
  "publishConfig": {
78
84
  "access": "public",
79
85
  "provenance": true