workspace-conformance 1.2.2 → 1.4.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
 
@@ -128,6 +128,17 @@ Codes: `aggregate-mappers/missing-mapper` (reported at the aggregate's declarati
128
128
 
129
129
  Codes: `command-types/hand-written-interface`, `command-types/hand-written-alias` (an alias that applies none of the generics, including a union or a plain object type) and `command-types/not-a-schema` (an inference applied to something whose type has no `~standard` member). Limits: a union of commands is a hand-written alias unless excluded; a type exported from several listed files is judged once; only interfaces and type aliases are judged, including those inside an exported namespace or a module re-exported as a namespace (under their qualified names), so a class, enum or value is not a command here; when the schema library is not installed the schema's type does not resolve and the alias reports `not-a-schema`.
130
130
 
131
+ **`derived-types`** verifies that the types a file exports are derived from the schemas they belong with, for each configured pair of schema files and type files. It covers what `command-types` does not: a type written by hand next to the schema it should be inferred from, a table row type that should come from the table definition, an error type that should come from its error schema. An exported type is derived when somewhere in its definition a generic named in `inferences` is applied to `typeof schema` (`z.infer<typeof order>`, `InferSelectModel<typeof users>`), or a member named there is read from it (`typeof users.$inferSelect`), where `schema` is declared in one of the pair's schema files, through any imports and re-exports. The definition is followed through aliases and interfaces without type parameters, in any file, so an alias chain across files counts when it ends in an inference; through the type arguments of other generics (`Partial<Order>`); through an intersection with at least one derived member, a union whose members are all derived (`null` and `undefined` aside), arrays, type operators, indexed access and the `extends` clauses of an interface. A type literal is hand-written whatever its members are, and so is a schema passed to a generic that is not an inference (`Wrapper<typeof order>`) or an inference of a schema declared outside the pair's schema files.
132
+
133
+ | Option | Meaning |
134
+ |---|---|
135
+ | `pairs` | A list of `{ schemas, types }`, each a list of globs: every exported interface and type alias in the `types` files must be derived from a schema declared in the `schemas` files. A type file listed by several pairs is judged against each. |
136
+ | `exclude` | Names of exported types that are deliberately written by hand. |
137
+ | `inferences` | Replaces the default list of last names that count as inference: those of `command-types` plus Drizzle's `InferSelectModel`, `InferInsertModel`, `$inferSelect` and `$inferInsert` (`DEFAULT_DERIVED_TYPE_INFERENCES`). |
138
+ | `tsConfig` | See above. |
139
+
140
+ Code: `derived-types/not-derived`, reported at the type's declaration. Limits: a schema is recognised by where it is declared, not by its type, so anything in a schema file passed to an inference counts; a tuple type is not followed and is hand-written; a type exported from several type files of one pair is judged once; types inside an exported namespace or a module re-exported as a namespace are judged under their qualified names, which `exclude` also matches; a glob that matches no file and a missing tsconfig are configuration errors naming the option (`checks.derived-types.pairs.0.schemas`).
141
+
131
142
  ### File tree and cross-config
132
143
 
133
144
  **`instruction-symlinks`** verifies that agent instruction files are symbolic links to the README beside them (written in any form that resolves there, such as `./README.md`, or through other tracked links, as `CLAUDE.md` to `AGENTS.md` to `README.md`), as git records them: mode `120000` in the index, not the working tree, so a checkout with `core.symlinks` false, which writes links out as plain files, is judged by what is committed. Options: `files` (`AGENTS.md` and `CLAUDE.md`), `directories` (globs, `.`), `target` (`README.md`). Codes: `instruction-symlinks/missing` (not tracked), `not-a-symlink`, `wrong-target` (the link does not resolve to the README, including a loop or an absolute target), `dangling` (the target is not tracked). It needs a git working tree and reads the index, so a link that is staged counts before it is committed.
@@ -161,6 +172,33 @@ False positives and blind spots:
161
172
  - 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
173
  - 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
174
 
175
+ ### ESLint
176
+
177
+ **`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.
178
+
179
+ 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.
180
+
181
+ | Option | Meaning |
182
+ |---|---|
183
+ | `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. |
184
+ | `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. |
185
+ | `configFile` | A config file to judge instead of the one ESLint finds from the working directory. |
186
+ | `lint` | Also lint the workspace and report every message it produces. Off when omitted. |
187
+ | `lintPatterns` | What `lint` lints, as file and directory patterns. `.` when omitted. |
188
+
189
+ 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.
190
+
191
+ 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.
192
+
193
+ Limits:
194
+
195
+ - 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.
196
+ - 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.
197
+ - An ignored file and a file that no configuration block matches both resolve to nothing and are reported the same way.
198
+ - Only rule severities are compared, not rule options.
199
+ - Flat config only: the ESLint 9 and later `ESLint` class. Legacy `.eslintrc` configuration is not read.
200
+ - A config that is a TypeScript file needs whatever ESLint itself needs to load it (`jiti`), installed where ESLint is.
201
+
164
202
  ### Workflows
165
203
 
166
204
  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 +294,7 @@ it('conforms to the workspace layout', async () => {
256
294
  });
257
295
  ```
258
296
 
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? }`.
297
+ Every check is also exported as a function taking `{ cwd, options }` (and `layout` for the import checks): `aggregateMappers`, `commandTypes`, `derivedTypes`, `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
298
 
261
299
  ### Proving a handler map is exhaustive
262
300
 
package/dist/cli.js CHANGED
@@ -1,10 +1,11 @@
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";
5
6
  import { existsSync } from "node:fs";
6
7
  import { escapePath, glob, isDynamicPattern } from "tinyglobby";
7
- import { Node, Project } from "ts-morph";
8
+ import { Node, Project, SyntaxKind } from "ts-morph";
8
9
  import { createJitiLoader, validateStandard } from "cosmiconfig-extends";
9
10
  import { readFile, realpath } from "node:fs/promises";
10
11
  import { execFile } from "node:child_process";
@@ -283,6 +284,32 @@ const aggregateMappers = async ({ cwd, options }) => {
283
284
  return violations.sort((a, b) => a.file.localeCompare(b.file) || (a.location?.line ?? 0) - (b.location?.line ?? 0) || a.message.localeCompare(b.message));
284
285
  };
285
286
  //#endregion
287
+ //#region src/checks/type-references.ts
288
+ /**
289
+ * The identifier a reference ends in: `infer` in `z.infer`, the name itself when it is not qualified.
290
+ */
291
+ function lastIdentifier(reference) {
292
+ if (Node.isQualifiedName(reference)) return reference.getRight();
293
+ if (Node.isPropertyAccessExpression(reference)) return reference.getNameNode();
294
+ return reference;
295
+ }
296
+ /**
297
+ * The declarations the identifier refers to, through any imports and re-exports.
298
+ */
299
+ function declarationsOf(identifier) {
300
+ const symbol = identifier.getSymbol();
301
+ return (symbol?.getAliasedSymbol() ?? symbol)?.getDeclarations() ?? [];
302
+ }
303
+ /**
304
+ * The names a reference goes by: the last name as written, and the name it was declared with when that differs, as for `Infer` in `import type { infer as Infer } from 'zod'`, which is also `infer`. A library may itself re-export a generic under another name, so both count.
305
+ */
306
+ function referenceNames(reference) {
307
+ const identifier = lastIdentifier(reference);
308
+ const symbol = identifier.getSymbol();
309
+ const declared = (symbol?.getAliasedSymbol() ?? symbol)?.getName();
310
+ return declared === void 0 ? [identifier.getText()] : [.../* @__PURE__ */ new Set([identifier.getText(), declared])];
311
+ }
312
+ //#endregion
286
313
  //#region src/checks/command-types.ts
287
314
  /**
288
315
  * The last names of the generics that turn a schema into its type, used when the options name none: `z.infer`, `z.input`, `z.output`, `z.TypeOf` and Valibot's `InferInput` and `InferOutput`.
@@ -300,20 +327,10 @@ const DEFAULT_INFERENCES = [
300
327
  */
301
328
  const STANDARD_SCHEMA_MEMBER = "~standard";
302
329
  /**
303
- * The names a type reference goes by: the last name as written, and the name it was declared with when that differs, as for `Infer` in `import type { infer as Infer } from 'zod'`, which is also `infer`. A library may itself re-export a generic under another name, so both count.
304
- */
305
- function namesOf(typeName) {
306
- const identifier = Node.isQualifiedName(typeName) ? typeName.getRight() : typeName;
307
- const symbol = identifier.getSymbol();
308
- const declared = (symbol?.getAliasedSymbol() ?? symbol)?.getName();
309
- return declared === void 0 ? [identifier.getText()] : [.../* @__PURE__ */ new Set([identifier.getText(), declared])];
310
- }
311
- /**
312
330
  * The local alias without type parameters that a type reference names, or `undefined` when it names anything else. Such an alias is a name for the type it is written as, so it is judged by that.
313
331
  */
314
332
  function aliasNamed(typeName) {
315
- const symbol = typeName.getSymbol();
316
- const declaration = (symbol?.getAliasedSymbol() ?? symbol)?.getDeclarations().find(Node.isTypeAliasDeclaration);
333
+ const declaration = declarationsOf(lastIdentifier(typeName)).find(Node.isTypeAliasDeclaration);
317
334
  return declaration?.getTypeParameters().length === 0 ? declaration : void 0;
318
335
  }
319
336
  /**
@@ -326,7 +343,7 @@ function problem$1(declaration, name, inferences, followed = /* @__PURE__ */ new
326
343
  };
327
344
  const written = declaration.getTypeNode();
328
345
  if (Node.isTypeReference(written)) {
329
- const inference = namesOf(written.getTypeName()).find((candidate) => inferences.includes(candidate));
346
+ const inference = referenceNames(written.getTypeName()).find((candidate) => inferences.includes(candidate));
330
347
  if (inference !== void 0) {
331
348
  const [argument] = written.getTypeArguments();
332
349
  if (argument !== void 0 && Node.isTypeQuery(argument) && argument.getExprName().getType().getProperty(STANDARD_SCHEMA_MEMBER) !== void 0) return;
@@ -522,6 +539,120 @@ const commitTypes = async ({ cwd, options, configFiles }) => {
522
539
  return violations;
523
540
  };
524
541
  //#endregion
542
+ //#region src/checks/derived-types.ts
543
+ /**
544
+ * The last names that count as inference when the options name none: those of `command-types`, Drizzle's `InferSelectModel` and `InferInsertModel` generics, and the `$inferSelect` and `$inferInsert` members of a Drizzle table, read as `typeof table.$inferSelect`.
545
+ */
546
+ const DEFAULT_DERIVED_TYPE_INFERENCES = [
547
+ ...DEFAULT_INFERENCES,
548
+ "InferSelectModel",
549
+ "InferInsertModel",
550
+ "$inferSelect",
551
+ "$inferInsert"
552
+ ];
553
+ /**
554
+ * Whether the node names something declared in one of the schema files.
555
+ */
556
+ function declaredInSchema(name, derivation) {
557
+ const identifier = Node.isIdentifier(name) ? name : Node.isQualifiedName(name) ? name.getRight() : void 0;
558
+ return identifier !== void 0 && declarationsOf(identifier).some((declaration) => derivation.schemaFiles.has(relativePosix(derivation.cwd, declaration.getSourceFile().getFilePath())));
559
+ }
560
+ /**
561
+ * The interface or type alias without type parameters a reference names, when it has not been followed yet. Such a declaration is a name for what it is written as, so it is judged by that.
562
+ */
563
+ function followable(reference, derivation) {
564
+ const name = Node.isTypeReference(reference) ? reference.getTypeName() : reference.getExpression();
565
+ if (!Node.isIdentifier(name) && !Node.isQualifiedName(name) && !Node.isPropertyAccessExpression(name)) return;
566
+ const declaration = declarationsOf(lastIdentifier(name)).find((found) => Node.isTypeAliasDeclaration(found) || Node.isInterfaceDeclaration(found));
567
+ return declaration?.getTypeParameters().length === 0 && !derivation.followed.has(declaration) ? declaration : void 0;
568
+ }
569
+ function isNullish(node) {
570
+ return node.getKind() === SyntaxKind.UndefinedKeyword || Node.isLiteralTypeNode(node) && node.getLiteral().getKind() === SyntaxKind.NullKeyword;
571
+ }
572
+ /**
573
+ * Whether a reference is derived: an inference applied to `typeof schema`, an alias or interface that is itself derived, or another generic given a derived type, as `Partial<Order>` is.
574
+ */
575
+ function derivedReference(reference, derivation) {
576
+ const name = Node.isTypeReference(reference) ? reference.getTypeName() : reference.getExpression();
577
+ if ((Node.isIdentifier(name) || Node.isQualifiedName(name) || Node.isPropertyAccessExpression(name)) && referenceNames(name).some((candidate) => derivation.inferences.has(candidate))) {
578
+ const [argument] = reference.getTypeArguments();
579
+ return argument !== void 0 && Node.isTypeQuery(argument) && declaredInSchema(argument.getExprName(), derivation);
580
+ }
581
+ const declaration = followable(reference, derivation);
582
+ if (declaration !== void 0) return derivedDeclaration(declaration, {
583
+ ...derivation,
584
+ followed: /* @__PURE__ */ new Set([...derivation.followed, declaration])
585
+ });
586
+ return reference.getTypeArguments().some((argument) => derivedType(argument, derivation));
587
+ }
588
+ /**
589
+ * Whether a type, as written, is derived from a schema. A type literal is written by hand whatever its members are.
590
+ */
591
+ function derivedType(node, derivation) {
592
+ if (Node.isTypeReference(node) || Node.isExpressionWithTypeArguments(node)) return derivedReference(node, derivation);
593
+ if (Node.isTypeQuery(node)) {
594
+ const name = node.getExprName();
595
+ return Node.isQualifiedName(name) && referenceNames(name).some((candidate) => derivation.inferences.has(candidate)) && declaredInSchema(name.getLeft(), derivation);
596
+ }
597
+ if (Node.isParenthesizedTypeNode(node) || Node.isTypeOperatorTypeNode(node)) return derivedType(node.getTypeNode(), derivation);
598
+ if (Node.isArrayTypeNode(node)) return derivedType(node.getElementTypeNode(), derivation);
599
+ if (Node.isIndexedAccessTypeNode(node)) return derivedType(node.getObjectTypeNode(), derivation);
600
+ if (Node.isIntersectionTypeNode(node)) return node.getTypeNodes().some((member) => derivedType(member, derivation));
601
+ if (Node.isUnionTypeNode(node)) {
602
+ const members = node.getTypeNodes().filter((member) => !isNullish(member));
603
+ return members.length > 0 && members.every((member) => derivedType(member, derivation));
604
+ }
605
+ return false;
606
+ }
607
+ /**
608
+ * Whether an interface or type alias is derived: an alias by the type it is written as, an interface by whether it extends a derived type.
609
+ */
610
+ function derivedDeclaration(declaration, derivation) {
611
+ if (Node.isInterfaceDeclaration(declaration)) return declaration.getExtends().some((heritage) => derivedReference(heritage, derivation));
612
+ return derivedType(declaration.getTypeNodeOrThrow(), derivation);
613
+ }
614
+ async function pairViolations(cwd, options, pair, index) {
615
+ const { project, files } = await createProject(cwd, options.tsConfig, pair.types, {
616
+ check: "derived-types",
617
+ files: `pairs.${String(index)}.types`
618
+ });
619
+ const schemaFiles = await findFiles(cwd, pair.schemas);
620
+ if (schemaFiles.length === 0) throw new ConformanceError(`checks.derived-types.pairs.${String(index)}.schemas: no file matches`);
621
+ const excluded = new Set(options.exclude);
622
+ const derivation = {
623
+ inferences: new Set(options.inferences ?? DEFAULT_DERIVED_TYPE_INFERENCES),
624
+ schemaFiles: new Set(schemaFiles),
625
+ cwd,
626
+ followed: /* @__PURE__ */ new Set()
627
+ };
628
+ const seen = /* @__PURE__ */ new Set();
629
+ const violations = [];
630
+ for (const file of files) for (const [name, declaration] of exportedTypes(project.getSourceFileOrThrow(resolve(cwd, file)))) {
631
+ if (excluded.has(name) || seen.has(declaration)) continue;
632
+ seen.add(declaration);
633
+ if (!derivedDeclaration(declaration, {
634
+ ...derivation,
635
+ followed: /* @__PURE__ */ new Set([declaration])
636
+ })) violations.push({
637
+ code: "derived-types/not-derived",
638
+ message: `${name} is not derived from a schema in ${pair.schemas.join(", ")}: nothing in its definition applies an inference generic to one`,
639
+ file: relativePosix(cwd, declaration.getSourceFile().getFilePath()),
640
+ location: declarationLocation(declaration)
641
+ });
642
+ }
643
+ return violations;
644
+ }
645
+ /**
646
+ * For each pair, every exported interface and type alias of its type files is derived from a schema declared in its schema files: somewhere in its definition an inference (a generic in `inferences`, such as `z.infer` or `InferSelectModel`) is applied to `typeof schema`, or an inference member is read from it (`typeof table.$inferSelect`).
647
+ *
648
+ * The definition is followed through aliases and interfaces without type parameters, in any file, and through the arguments of other generics, intersections, unions whose members are all derived (`null` and `undefined` aside), arrays, type operators and indexed access. A type literal, and a schema passed to a generic that is not an inference, are written by hand.
649
+ */
650
+ const derivedTypes = async ({ cwd, options }) => {
651
+ const violations = [];
652
+ for (const [index, pair] of options.pairs.entries()) violations.push(...await pairViolations(cwd, options, pair, index));
653
+ return violations.sort((a, b) => a.file.localeCompare(b.file) || (a.location?.line ?? 0) - (b.location?.line ?? 0) || a.message.localeCompare(b.message));
654
+ };
655
+ //#endregion
525
656
  //#region src/checks/dockerfile-package-manager.ts
526
657
  const DEFAULT_DOCKERFILES = [
527
658
  "**/Dockerfile",
@@ -2498,6 +2629,179 @@ const importUphillSpec = {
2498
2629
  })
2499
2630
  };
2500
2631
  //#endregion
2632
+ //#region src/checks/eslint.ts
2633
+ /**
2634
+ * The patterns the full level lints when the options name none.
2635
+ */
2636
+ const DEFAULT_LINT_PATTERNS = ["."];
2637
+ const SEVERITY_RANK = {
2638
+ off: 0,
2639
+ warn: 1,
2640
+ error: 2
2641
+ };
2642
+ function hasEslintClass(value) {
2643
+ return isRecord(value) && typeof value["ESLint"] === "function";
2644
+ }
2645
+ /**
2646
+ * 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.
2647
+ */
2648
+ function loadEslint(cwd) {
2649
+ const requireFromCwd = createRequire(join(cwd, "package.json"));
2650
+ try {
2651
+ requireFromCwd.resolve("eslint");
2652
+ } catch (error) {
2653
+ throw new ConformanceError(`the eslint check uses the repository's own ESLint, and 'eslint' does not resolve from ${cwd}; install it there`, { cause: error });
2654
+ }
2655
+ const loaded = requireFromCwd("eslint");
2656
+ if (!hasEslintClass(loaded)) throw new ConformanceError(`the 'eslint' that resolves from ${cwd} does not export the ESLint class of the Node API`);
2657
+ return loaded.ESLint;
2658
+ }
2659
+ /**
2660
+ * 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.
2661
+ */
2662
+ function normaliseSeverity(entry) {
2663
+ const severity = Array.isArray(entry) ? entry[0] : entry;
2664
+ if (severity === 0 || severity === "off") return "off";
2665
+ if (severity === 1 || severity === "warn") return "warn";
2666
+ if (severity === 2 || severity === "error") return "error";
2667
+ throw new ConformanceError(`ESLint resolved ${JSON.stringify(entry)} as a rule entry, which is not a severity or an array that starts with one`);
2668
+ }
2669
+ /**
2670
+ * The severity of each rule in a resolved configuration.
2671
+ */
2672
+ function resolvedSeverities(config) {
2673
+ if (!isRecord(config)) throw new ConformanceError("ESLint resolved a configuration that is not an object");
2674
+ const { rules } = config;
2675
+ if (rules !== void 0 && !isRecord(rules)) throw new ConformanceError("ESLint resolved a configuration whose 'rules' is not an object");
2676
+ return new Map(Object.entries(rules ?? {}).map(([rule, entry]) => [rule, normaliseSeverity(entry)]));
2677
+ }
2678
+ /**
2679
+ * Whether `error` is the one `calculateConfigForFile` throws when ESLint finds no config file for the path.
2680
+ */
2681
+ function isMissingConfig(error) {
2682
+ return isRecord(error) && error["messageTemplate"] === "config-file-missing";
2683
+ }
2684
+ function sampleOf(sample) {
2685
+ return typeof sample === "string" ? { path: sample } : sample;
2686
+ }
2687
+ function ruleViolations(file, required, actual) {
2688
+ return Object.entries(required).flatMap(([rule, severity]) => {
2689
+ const found = actual.get(rule);
2690
+ if (found === void 0) return [{
2691
+ code: "eslint/rule-missing",
2692
+ message: `the rule '${rule}' is not configured for ${file} (required: ${severity})`,
2693
+ file
2694
+ }];
2695
+ if (found === "off") return [{
2696
+ code: "eslint/rule-off",
2697
+ message: `the rule '${rule}' is off for ${file} (required: ${severity})`,
2698
+ file
2699
+ }];
2700
+ if (SEVERITY_RANK[found] < SEVERITY_RANK[severity]) return [{
2701
+ code: "eslint/rule-too-weak",
2702
+ message: `the rule '${rule}' is '${found}' for ${file} (required: ${severity})`,
2703
+ file
2704
+ }];
2705
+ return [];
2706
+ });
2707
+ }
2708
+ async function sampleViolations(eslint, sample, shared) {
2709
+ const file = sample.path;
2710
+ let config;
2711
+ try {
2712
+ config = await eslint.calculateConfigForFile(file);
2713
+ } catch (error) {
2714
+ if (isMissingConfig(error)) return [{
2715
+ code: "eslint/no-config",
2716
+ message: `ESLint finds no config file for ${file}; the check does not fall back to a config of its own`,
2717
+ file
2718
+ }];
2719
+ throw new ConformanceError(`ESLint could not resolve its configuration for ${file}`, { cause: error });
2720
+ }
2721
+ if (config === void 0) return [{
2722
+ code: "eslint/not-linted",
2723
+ message: `${file} is not linted: it is ignored, or no configuration block matches it`,
2724
+ file
2725
+ }];
2726
+ return ruleViolations(file, {
2727
+ ...shared,
2728
+ ...sample.rules
2729
+ }, resolvedSeverities(config));
2730
+ }
2731
+ /**
2732
+ * Whether `error` is ESLint refusing a pattern because it selects no file that is linted.
2733
+ */
2734
+ function isNothingLinted(error) {
2735
+ return error instanceof Error && isRecord(error) && (error["messageTemplate"] === "file-not-found" || error["messageTemplate"] === "all-matched-files-ignored");
2736
+ }
2737
+ function messageViolation(cwd, result, message) {
2738
+ const file = relativePosix(cwd, result.filePath);
2739
+ const location = {
2740
+ line: message.line,
2741
+ column: message.column
2742
+ };
2743
+ if (message.ruleId !== null) {
2744
+ const severity = message.severity === 1 ? "warn" : "error";
2745
+ return {
2746
+ code: `eslint/lint/${message.ruleId}`,
2747
+ message: `${message.ruleId} (${severity}): ${message.message}`,
2748
+ file,
2749
+ location
2750
+ };
2751
+ }
2752
+ return message.fatal === true ? {
2753
+ code: "eslint/fatal",
2754
+ message: message.message,
2755
+ file,
2756
+ location
2757
+ } : {
2758
+ code: "eslint/lint-message",
2759
+ message: message.message,
2760
+ file,
2761
+ location
2762
+ };
2763
+ }
2764
+ function byPosition(left, right) {
2765
+ 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);
2766
+ }
2767
+ /**
2768
+ * 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.
2769
+ */
2770
+ async function lintViolations(cwd, eslint, patterns) {
2771
+ const violations = [];
2772
+ const results = /* @__PURE__ */ new Map();
2773
+ for (const pattern of patterns) try {
2774
+ for (const result of await eslint.lintFiles([pattern])) results.set(result.filePath, result);
2775
+ } catch (error) {
2776
+ if (!isNothingLinted(error)) throw new ConformanceError(`ESLint could not lint '${pattern}'`, { cause: error });
2777
+ violations.push({
2778
+ code: "eslint/nothing-linted",
2779
+ message: error.message,
2780
+ file: pattern
2781
+ });
2782
+ }
2783
+ for (const result of results.values()) for (const message of result.messages) violations.push(messageViolation(cwd, result, message));
2784
+ return violations.sort(byPosition);
2785
+ }
2786
+ /**
2787
+ * 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.
2788
+ *
2789
+ * 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.
2790
+ *
2791
+ * 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.
2792
+ */
2793
+ const eslint = async ({ cwd, options }) => {
2794
+ const instance = new (loadEslint(cwd))({
2795
+ cwd,
2796
+ ...options.configFile === void 0 ? {} : { overrideConfigFile: resolve(cwd, options.configFile) }
2797
+ });
2798
+ const shared = options.rules ?? {};
2799
+ const violations = [];
2800
+ for (const sample of options.samples.map(sampleOf)) violations.push(...await sampleViolations(instance, sample, shared));
2801
+ if (options.lint === true) violations.push(...await lintViolations(cwd, instance, options.lintPatterns ?? DEFAULT_LINT_PATTERNS));
2802
+ return violations;
2803
+ };
2804
+ //#endregion
2501
2805
  //#region src/checks/instruction-symlinks.ts
2502
2806
  /**
2503
2807
  * The instruction file names checked when the options name none.
@@ -2860,6 +3164,12 @@ const registry = {
2860
3164
  select: (checks) => enabledOptions(checks["command-types"]),
2861
3165
  check: commandTypes
2862
3166
  }),
3167
+ "derived-types": plain({
3168
+ name: "derived-types",
3169
+ description: "Every exported type in a file paired with schema files is derived from one of those schemas, not hand-written",
3170
+ select: (checks) => enabledOptions(checks["derived-types"]),
3171
+ check: derivedTypes
3172
+ }),
2863
3173
  "import-uphill": importCheck({
2864
3174
  name: "import-uphill",
2865
3175
  description: "No package imports a package of a higher rank",
@@ -2920,6 +3230,12 @@ const registry = {
2920
3230
  select: (checks) => enabledOptions(checks["migrations-directory"]),
2921
3231
  check: migrationsDirectory
2922
3232
  }),
3233
+ eslint: plain({
3234
+ name: "eslint",
3235
+ description: "The repository's own ESLint lints the sample files with the required rules enabled at the required severity",
3236
+ select: (checks) => enabledOptions(checks.eslint),
3237
+ check: eslint
3238
+ }),
2923
3239
  "workflow-job-ordering": plain({
2924
3240
  name: "workflow-job-ordering",
2925
3241
  description: "Release, deploy and junction jobs are ordered so a failure stops what follows",
@@ -3021,6 +3337,8 @@ function compiles(source) {
3021
3337
  */
3022
3338
  const pattern = z.string().min(1).refine(compiles, "not a valid regular expression");
3023
3339
  const patterns = z.array(pattern);
3340
+ const severity = z.enum(["warn", "error"]);
3341
+ const requiredRules = z.record(z.string().min(1), severity);
3024
3342
  const selection = {
3025
3343
  workflows: z.exactOptional(paths),
3026
3344
  exclude: z.exactOptional(paths)
@@ -3057,6 +3375,15 @@ const checks = z.strictObject({
3057
3375
  inferences: z.exactOptional(paths),
3058
3376
  tsConfig: z.exactOptional(path)
3059
3377
  }))),
3378
+ "derived-types": z.exactOptional(enabled(z.strictObject({
3379
+ pairs: z.array(z.strictObject({
3380
+ schemas: paths,
3381
+ types: paths
3382
+ })).min(1),
3383
+ exclude: z.exactOptional(names),
3384
+ inferences: z.exactOptional(paths),
3385
+ tsConfig: z.exactOptional(path)
3386
+ }))),
3060
3387
  "import-uphill": z.exactOptional(enabled(importGraph)),
3061
3388
  "import-rank-skip": z.exactOptional(enabled(importGraph)),
3062
3389
  "import-cross-slice": z.exactOptional(enabled(importGraph)),
@@ -3080,6 +3407,16 @@ const checks = z.strictObject({
3080
3407
  exclude: z.exactOptional(paths),
3081
3408
  packageJson: z.exactOptional(path)
3082
3409
  }))),
3410
+ eslint: z.exactOptional(enabled(z.strictObject({
3411
+ samples: z.array(z.union([path, z.strictObject({
3412
+ path,
3413
+ rules: z.exactOptional(requiredRules)
3414
+ })])).min(1),
3415
+ rules: z.exactOptional(requiredRules),
3416
+ configFile: z.exactOptional(path),
3417
+ lint: z.exactOptional(z.boolean()),
3418
+ lintPatterns: z.exactOptional(paths)
3419
+ }))),
3083
3420
  "migrations-directory": z.exactOptional(enabled(z.strictObject({
3084
3421
  generator: z.enum(generatorNames),
3085
3422
  deployTool: z.enum(deployToolNames),