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/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");
@@ -212,6 +213,32 @@ const aggregateMappers = async ({ cwd, options }) => {
212
213
  return violations.sort((a, b) => a.file.localeCompare(b.file) || (a.location?.line ?? 0) - (b.location?.line ?? 0) || a.message.localeCompare(b.message));
213
214
  };
214
215
  //#endregion
216
+ //#region src/checks/type-references.ts
217
+ /**
218
+ * The identifier a reference ends in: `infer` in `z.infer`, the name itself when it is not qualified.
219
+ */
220
+ function lastIdentifier(reference) {
221
+ if (ts_morph.Node.isQualifiedName(reference)) return reference.getRight();
222
+ if (ts_morph.Node.isPropertyAccessExpression(reference)) return reference.getNameNode();
223
+ return reference;
224
+ }
225
+ /**
226
+ * The declarations the identifier refers to, through any imports and re-exports.
227
+ */
228
+ function declarationsOf(identifier) {
229
+ const symbol = identifier.getSymbol();
230
+ return (symbol?.getAliasedSymbol() ?? symbol)?.getDeclarations() ?? [];
231
+ }
232
+ /**
233
+ * 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.
234
+ */
235
+ function referenceNames(reference) {
236
+ const identifier = lastIdentifier(reference);
237
+ const symbol = identifier.getSymbol();
238
+ const declared = (symbol?.getAliasedSymbol() ?? symbol)?.getName();
239
+ return declared === void 0 ? [identifier.getText()] : [.../* @__PURE__ */ new Set([identifier.getText(), declared])];
240
+ }
241
+ //#endregion
215
242
  //#region src/checks/command-types.ts
216
243
  /**
217
244
  * 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`.
@@ -229,20 +256,10 @@ const DEFAULT_INFERENCES = [
229
256
  */
230
257
  const STANDARD_SCHEMA_MEMBER = "~standard";
231
258
  /**
232
- * 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.
233
- */
234
- function namesOf(typeName) {
235
- const identifier = ts_morph.Node.isQualifiedName(typeName) ? typeName.getRight() : typeName;
236
- const symbol = identifier.getSymbol();
237
- const declared = (symbol?.getAliasedSymbol() ?? symbol)?.getName();
238
- return declared === void 0 ? [identifier.getText()] : [.../* @__PURE__ */ new Set([identifier.getText(), declared])];
239
- }
240
- /**
241
259
  * 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.
242
260
  */
243
261
  function aliasNamed(typeName) {
244
- const symbol = typeName.getSymbol();
245
- const declaration = (symbol?.getAliasedSymbol() ?? symbol)?.getDeclarations().find(ts_morph.Node.isTypeAliasDeclaration);
262
+ const declaration = declarationsOf(lastIdentifier(typeName)).find(ts_morph.Node.isTypeAliasDeclaration);
246
263
  return declaration?.getTypeParameters().length === 0 ? declaration : void 0;
247
264
  }
248
265
  /**
@@ -255,7 +272,7 @@ function problem$1(declaration, name, inferences, followed = /* @__PURE__ */ new
255
272
  };
256
273
  const written = declaration.getTypeNode();
257
274
  if (ts_morph.Node.isTypeReference(written)) {
258
- const inference = namesOf(written.getTypeName()).find((candidate) => inferences.includes(candidate));
275
+ const inference = referenceNames(written.getTypeName()).find((candidate) => inferences.includes(candidate));
259
276
  if (inference !== void 0) {
260
277
  const [argument] = written.getTypeArguments();
261
278
  if (argument !== void 0 && ts_morph.Node.isTypeQuery(argument) && argument.getExprName().getType().getProperty(STANDARD_SCHEMA_MEMBER) !== void 0) return;
@@ -451,6 +468,120 @@ const commitTypes = async ({ cwd, options, configFiles }) => {
451
468
  return violations;
452
469
  };
453
470
  //#endregion
471
+ //#region src/checks/derived-types.ts
472
+ /**
473
+ * 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`.
474
+ */
475
+ const DEFAULT_DERIVED_TYPE_INFERENCES = [
476
+ ...DEFAULT_INFERENCES,
477
+ "InferSelectModel",
478
+ "InferInsertModel",
479
+ "$inferSelect",
480
+ "$inferInsert"
481
+ ];
482
+ /**
483
+ * Whether the node names something declared in one of the schema files.
484
+ */
485
+ function declaredInSchema(name, derivation) {
486
+ const identifier = ts_morph.Node.isIdentifier(name) ? name : ts_morph.Node.isQualifiedName(name) ? name.getRight() : void 0;
487
+ return identifier !== void 0 && declarationsOf(identifier).some((declaration) => derivation.schemaFiles.has(relativePosix(derivation.cwd, declaration.getSourceFile().getFilePath())));
488
+ }
489
+ /**
490
+ * 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.
491
+ */
492
+ function followable(reference, derivation) {
493
+ const name = ts_morph.Node.isTypeReference(reference) ? reference.getTypeName() : reference.getExpression();
494
+ if (!ts_morph.Node.isIdentifier(name) && !ts_morph.Node.isQualifiedName(name) && !ts_morph.Node.isPropertyAccessExpression(name)) return;
495
+ const declaration = declarationsOf(lastIdentifier(name)).find((found) => ts_morph.Node.isTypeAliasDeclaration(found) || ts_morph.Node.isInterfaceDeclaration(found));
496
+ return declaration?.getTypeParameters().length === 0 && !derivation.followed.has(declaration) ? declaration : void 0;
497
+ }
498
+ function isNullish(node) {
499
+ return node.getKind() === ts_morph.SyntaxKind.UndefinedKeyword || ts_morph.Node.isLiteralTypeNode(node) && node.getLiteral().getKind() === ts_morph.SyntaxKind.NullKeyword;
500
+ }
501
+ /**
502
+ * 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.
503
+ */
504
+ function derivedReference(reference, derivation) {
505
+ const name = ts_morph.Node.isTypeReference(reference) ? reference.getTypeName() : reference.getExpression();
506
+ if ((ts_morph.Node.isIdentifier(name) || ts_morph.Node.isQualifiedName(name) || ts_morph.Node.isPropertyAccessExpression(name)) && referenceNames(name).some((candidate) => derivation.inferences.has(candidate))) {
507
+ const [argument] = reference.getTypeArguments();
508
+ return argument !== void 0 && ts_morph.Node.isTypeQuery(argument) && declaredInSchema(argument.getExprName(), derivation);
509
+ }
510
+ const declaration = followable(reference, derivation);
511
+ if (declaration !== void 0) return derivedDeclaration(declaration, {
512
+ ...derivation,
513
+ followed: /* @__PURE__ */ new Set([...derivation.followed, declaration])
514
+ });
515
+ return reference.getTypeArguments().some((argument) => derivedType(argument, derivation));
516
+ }
517
+ /**
518
+ * Whether a type, as written, is derived from a schema. A type literal is written by hand whatever its members are.
519
+ */
520
+ function derivedType(node, derivation) {
521
+ if (ts_morph.Node.isTypeReference(node) || ts_morph.Node.isExpressionWithTypeArguments(node)) return derivedReference(node, derivation);
522
+ if (ts_morph.Node.isTypeQuery(node)) {
523
+ const name = node.getExprName();
524
+ return ts_morph.Node.isQualifiedName(name) && referenceNames(name).some((candidate) => derivation.inferences.has(candidate)) && declaredInSchema(name.getLeft(), derivation);
525
+ }
526
+ if (ts_morph.Node.isParenthesizedTypeNode(node) || ts_morph.Node.isTypeOperatorTypeNode(node)) return derivedType(node.getTypeNode(), derivation);
527
+ if (ts_morph.Node.isArrayTypeNode(node)) return derivedType(node.getElementTypeNode(), derivation);
528
+ if (ts_morph.Node.isIndexedAccessTypeNode(node)) return derivedType(node.getObjectTypeNode(), derivation);
529
+ if (ts_morph.Node.isIntersectionTypeNode(node)) return node.getTypeNodes().some((member) => derivedType(member, derivation));
530
+ if (ts_morph.Node.isUnionTypeNode(node)) {
531
+ const members = node.getTypeNodes().filter((member) => !isNullish(member));
532
+ return members.length > 0 && members.every((member) => derivedType(member, derivation));
533
+ }
534
+ return false;
535
+ }
536
+ /**
537
+ * 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.
538
+ */
539
+ function derivedDeclaration(declaration, derivation) {
540
+ if (ts_morph.Node.isInterfaceDeclaration(declaration)) return declaration.getExtends().some((heritage) => derivedReference(heritage, derivation));
541
+ return derivedType(declaration.getTypeNodeOrThrow(), derivation);
542
+ }
543
+ async function pairViolations(cwd, options, pair, index) {
544
+ const { project, files } = await createProject(cwd, options.tsConfig, pair.types, {
545
+ check: "derived-types",
546
+ files: `pairs.${String(index)}.types`
547
+ });
548
+ const schemaFiles = await findFiles(cwd, pair.schemas);
549
+ if (schemaFiles.length === 0) throw new ConformanceError(`checks.derived-types.pairs.${String(index)}.schemas: no file matches`);
550
+ const excluded = new Set(options.exclude);
551
+ const derivation = {
552
+ inferences: new Set(options.inferences ?? DEFAULT_DERIVED_TYPE_INFERENCES),
553
+ schemaFiles: new Set(schemaFiles),
554
+ cwd,
555
+ followed: /* @__PURE__ */ new Set()
556
+ };
557
+ const seen = /* @__PURE__ */ new Set();
558
+ const violations = [];
559
+ for (const file of files) for (const [name, declaration] of exportedTypes(project.getSourceFileOrThrow((0, node_path.resolve)(cwd, file)))) {
560
+ if (excluded.has(name) || seen.has(declaration)) continue;
561
+ seen.add(declaration);
562
+ if (!derivedDeclaration(declaration, {
563
+ ...derivation,
564
+ followed: /* @__PURE__ */ new Set([declaration])
565
+ })) violations.push({
566
+ code: "derived-types/not-derived",
567
+ message: `${name} is not derived from a schema in ${pair.schemas.join(", ")}: nothing in its definition applies an inference generic to one`,
568
+ file: relativePosix(cwd, declaration.getSourceFile().getFilePath()),
569
+ location: declarationLocation(declaration)
570
+ });
571
+ }
572
+ return violations;
573
+ }
574
+ /**
575
+ * 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`).
576
+ *
577
+ * 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.
578
+ */
579
+ const derivedTypes = async ({ cwd, options }) => {
580
+ const violations = [];
581
+ for (const [index, pair] of options.pairs.entries()) violations.push(...await pairViolations(cwd, options, pair, index));
582
+ return violations.sort((a, b) => a.file.localeCompare(b.file) || (a.location?.line ?? 0) - (b.location?.line ?? 0) || a.message.localeCompare(b.message));
583
+ };
584
+ //#endregion
454
585
  //#region src/checks/dockerfile-package-manager.ts
455
586
  const DEFAULT_DOCKERFILES = [
456
587
  "**/Dockerfile",
@@ -543,6 +674,179 @@ const dockerfilePackageManager = async ({ cwd, options }) => {
543
674
  return violations;
544
675
  };
545
676
  //#endregion
677
+ //#region src/checks/eslint.ts
678
+ /**
679
+ * The patterns the full level lints when the options name none.
680
+ */
681
+ const DEFAULT_LINT_PATTERNS = ["."];
682
+ const SEVERITY_RANK = {
683
+ off: 0,
684
+ warn: 1,
685
+ error: 2
686
+ };
687
+ function hasEslintClass(value) {
688
+ return isRecord(value) && typeof value["ESLint"] === "function";
689
+ }
690
+ /**
691
+ * 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.
692
+ */
693
+ function loadEslint(cwd) {
694
+ const requireFromCwd = (0, node_module.createRequire)((0, node_path.join)(cwd, "package.json"));
695
+ try {
696
+ requireFromCwd.resolve("eslint");
697
+ } catch (error) {
698
+ throw new ConformanceError(`the eslint check uses the repository's own ESLint, and 'eslint' does not resolve from ${cwd}; install it there`, { cause: error });
699
+ }
700
+ const loaded = requireFromCwd("eslint");
701
+ if (!hasEslintClass(loaded)) throw new ConformanceError(`the 'eslint' that resolves from ${cwd} does not export the ESLint class of the Node API`);
702
+ return loaded.ESLint;
703
+ }
704
+ /**
705
+ * 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.
706
+ */
707
+ function normaliseSeverity(entry) {
708
+ const severity = Array.isArray(entry) ? entry[0] : entry;
709
+ if (severity === 0 || severity === "off") return "off";
710
+ if (severity === 1 || severity === "warn") return "warn";
711
+ if (severity === 2 || severity === "error") return "error";
712
+ throw new ConformanceError(`ESLint resolved ${JSON.stringify(entry)} as a rule entry, which is not a severity or an array that starts with one`);
713
+ }
714
+ /**
715
+ * The severity of each rule in a resolved configuration.
716
+ */
717
+ function resolvedSeverities(config) {
718
+ if (!isRecord(config)) throw new ConformanceError("ESLint resolved a configuration that is not an object");
719
+ const { rules } = config;
720
+ if (rules !== void 0 && !isRecord(rules)) throw new ConformanceError("ESLint resolved a configuration whose 'rules' is not an object");
721
+ return new Map(Object.entries(rules ?? {}).map(([rule, entry]) => [rule, normaliseSeverity(entry)]));
722
+ }
723
+ /**
724
+ * Whether `error` is the one `calculateConfigForFile` throws when ESLint finds no config file for the path.
725
+ */
726
+ function isMissingConfig(error) {
727
+ return isRecord(error) && error["messageTemplate"] === "config-file-missing";
728
+ }
729
+ function sampleOf(sample) {
730
+ return typeof sample === "string" ? { path: sample } : sample;
731
+ }
732
+ function ruleViolations(file, required, actual) {
733
+ return Object.entries(required).flatMap(([rule, severity]) => {
734
+ const found = actual.get(rule);
735
+ if (found === void 0) return [{
736
+ code: "eslint/rule-missing",
737
+ message: `the rule '${rule}' is not configured for ${file} (required: ${severity})`,
738
+ file
739
+ }];
740
+ if (found === "off") return [{
741
+ code: "eslint/rule-off",
742
+ message: `the rule '${rule}' is off for ${file} (required: ${severity})`,
743
+ file
744
+ }];
745
+ if (SEVERITY_RANK[found] < SEVERITY_RANK[severity]) return [{
746
+ code: "eslint/rule-too-weak",
747
+ message: `the rule '${rule}' is '${found}' for ${file} (required: ${severity})`,
748
+ file
749
+ }];
750
+ return [];
751
+ });
752
+ }
753
+ async function sampleViolations(eslint, sample, shared) {
754
+ const file = sample.path;
755
+ let config;
756
+ try {
757
+ config = await eslint.calculateConfigForFile(file);
758
+ } catch (error) {
759
+ if (isMissingConfig(error)) return [{
760
+ code: "eslint/no-config",
761
+ message: `ESLint finds no config file for ${file}; the check does not fall back to a config of its own`,
762
+ file
763
+ }];
764
+ throw new ConformanceError(`ESLint could not resolve its configuration for ${file}`, { cause: error });
765
+ }
766
+ if (config === void 0) return [{
767
+ code: "eslint/not-linted",
768
+ message: `${file} is not linted: it is ignored, or no configuration block matches it`,
769
+ file
770
+ }];
771
+ return ruleViolations(file, {
772
+ ...shared,
773
+ ...sample.rules
774
+ }, resolvedSeverities(config));
775
+ }
776
+ /**
777
+ * Whether `error` is ESLint refusing a pattern because it selects no file that is linted.
778
+ */
779
+ function isNothingLinted(error) {
780
+ return error instanceof Error && isRecord(error) && (error["messageTemplate"] === "file-not-found" || error["messageTemplate"] === "all-matched-files-ignored");
781
+ }
782
+ function messageViolation(cwd, result, message) {
783
+ const file = relativePosix(cwd, result.filePath);
784
+ const location = {
785
+ line: message.line,
786
+ column: message.column
787
+ };
788
+ if (message.ruleId !== null) {
789
+ const severity = message.severity === 1 ? "warn" : "error";
790
+ return {
791
+ code: `eslint/lint/${message.ruleId}`,
792
+ message: `${message.ruleId} (${severity}): ${message.message}`,
793
+ file,
794
+ location
795
+ };
796
+ }
797
+ return message.fatal === true ? {
798
+ code: "eslint/fatal",
799
+ message: message.message,
800
+ file,
801
+ location
802
+ } : {
803
+ code: "eslint/lint-message",
804
+ message: message.message,
805
+ file,
806
+ location
807
+ };
808
+ }
809
+ function byPosition(left, right) {
810
+ 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);
811
+ }
812
+ /**
813
+ * 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.
814
+ */
815
+ async function lintViolations(cwd, eslint, patterns) {
816
+ const violations = [];
817
+ const results = /* @__PURE__ */ new Map();
818
+ for (const pattern of patterns) try {
819
+ for (const result of await eslint.lintFiles([pattern])) results.set(result.filePath, result);
820
+ } catch (error) {
821
+ if (!isNothingLinted(error)) throw new ConformanceError(`ESLint could not lint '${pattern}'`, { cause: error });
822
+ violations.push({
823
+ code: "eslint/nothing-linted",
824
+ message: error.message,
825
+ file: pattern
826
+ });
827
+ }
828
+ for (const result of results.values()) for (const message of result.messages) violations.push(messageViolation(cwd, result, message));
829
+ return violations.sort(byPosition);
830
+ }
831
+ /**
832
+ * 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.
833
+ *
834
+ * 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.
835
+ *
836
+ * 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.
837
+ */
838
+ const eslint = async ({ cwd, options }) => {
839
+ const instance = new (loadEslint(cwd))({
840
+ cwd,
841
+ ...options.configFile === void 0 ? {} : { overrideConfigFile: (0, node_path.resolve)(cwd, options.configFile) }
842
+ });
843
+ const shared = options.rules ?? {};
844
+ const violations = [];
845
+ for (const sample of options.samples.map(sampleOf)) violations.push(...await sampleViolations(instance, sample, shared));
846
+ if (options.lint === true) violations.push(...await lintViolations(cwd, instance, options.lintPatterns ?? DEFAULT_LINT_PATTERNS));
847
+ return violations;
848
+ };
849
+ //#endregion
546
850
  //#region src/workspace/rules.ts
547
851
  const REGEXP_SPECIAL = /[.*+?^${}()|[\]\\]/gu;
548
852
  function escapeRegExp(text) {
@@ -2767,6 +3071,8 @@ function compiles(source) {
2767
3071
  */
2768
3072
  const pattern = zod.z.string().min(1).refine(compiles, "not a valid regular expression");
2769
3073
  const patterns = zod.z.array(pattern);
3074
+ const severity = zod.z.enum(["warn", "error"]);
3075
+ const requiredRules = zod.z.record(zod.z.string().min(1), severity);
2770
3076
  const selection = {
2771
3077
  workflows: zod.z.exactOptional(paths),
2772
3078
  exclude: zod.z.exactOptional(paths)
@@ -2803,6 +3109,15 @@ const checks = zod.z.strictObject({
2803
3109
  inferences: zod.z.exactOptional(paths),
2804
3110
  tsConfig: zod.z.exactOptional(path)
2805
3111
  }))),
3112
+ "derived-types": zod.z.exactOptional(enabled(zod.z.strictObject({
3113
+ pairs: zod.z.array(zod.z.strictObject({
3114
+ schemas: paths,
3115
+ types: paths
3116
+ })).min(1),
3117
+ exclude: zod.z.exactOptional(names),
3118
+ inferences: zod.z.exactOptional(paths),
3119
+ tsConfig: zod.z.exactOptional(path)
3120
+ }))),
2806
3121
  "import-uphill": zod.z.exactOptional(enabled(importGraph)),
2807
3122
  "import-rank-skip": zod.z.exactOptional(enabled(importGraph)),
2808
3123
  "import-cross-slice": zod.z.exactOptional(enabled(importGraph)),
@@ -2826,6 +3141,16 @@ const checks = zod.z.strictObject({
2826
3141
  exclude: zod.z.exactOptional(paths),
2827
3142
  packageJson: zod.z.exactOptional(path)
2828
3143
  }))),
3144
+ eslint: zod.z.exactOptional(enabled(zod.z.strictObject({
3145
+ samples: zod.z.array(zod.z.union([path, zod.z.strictObject({
3146
+ path,
3147
+ rules: zod.z.exactOptional(requiredRules)
3148
+ })])).min(1),
3149
+ rules: zod.z.exactOptional(requiredRules),
3150
+ configFile: zod.z.exactOptional(path),
3151
+ lint: zod.z.exactOptional(zod.z.boolean()),
3152
+ lintPatterns: zod.z.exactOptional(paths)
3153
+ }))),
2829
3154
  "migrations-directory": zod.z.exactOptional(enabled(zod.z.strictObject({
2830
3155
  generator: zod.z.enum(generatorNames),
2831
3156
  deployTool: zod.z.enum(deployToolNames),
@@ -3040,6 +3365,12 @@ const registry = {
3040
3365
  select: (checks) => enabledOptions(checks["command-types"]),
3041
3366
  check: commandTypes
3042
3367
  }),
3368
+ "derived-types": plain({
3369
+ name: "derived-types",
3370
+ description: "Every exported type in a file paired with schema files is derived from one of those schemas, not hand-written",
3371
+ select: (checks) => enabledOptions(checks["derived-types"]),
3372
+ check: derivedTypes
3373
+ }),
3043
3374
  "import-uphill": importCheck({
3044
3375
  name: "import-uphill",
3045
3376
  description: "No package imports a package of a higher rank",
@@ -3100,6 +3431,12 @@ const registry = {
3100
3431
  select: (checks) => enabledOptions(checks["migrations-directory"]),
3101
3432
  check: migrationsDirectory
3102
3433
  }),
3434
+ eslint: plain({
3435
+ name: "eslint",
3436
+ description: "The repository's own ESLint lints the sample files with the required rules enabled at the required severity",
3437
+ select: (checks) => enabledOptions(checks.eslint),
3438
+ check: eslint
3439
+ }),
3103
3440
  "workflow-job-ordering": plain({
3104
3441
  name: "workflow-job-ordering",
3105
3442
  description: "Release, deploy and junction jobs are ordered so a failure stops what follows",
@@ -3382,6 +3719,7 @@ function proveExhaustive(input) {
3382
3719
  }
3383
3720
  //#endregion
3384
3721
  exports.ConformanceError = ConformanceError;
3722
+ exports.DEFAULT_DERIVED_TYPE_INFERENCES = DEFAULT_DERIVED_TYPE_INFERENCES;
3385
3723
  exports.DEFAULT_INFERENCES = DEFAULT_INFERENCES;
3386
3724
  exports.DEFAULT_WORKFLOWS = DEFAULT_WORKFLOWS;
3387
3725
  exports.EXIT_CODES = EXIT_CODES;
@@ -3399,9 +3737,11 @@ exports.createGitHubClient = createGitHubClient;
3399
3737
  exports.crossSliceRules = crossSliceRules;
3400
3738
  exports.cycleRule = cycleRule;
3401
3739
  exports.deployTools = deployTools;
3740
+ exports.derivedTypes = derivedTypes;
3402
3741
  exports.dockerfilePackageManager = dockerfilePackageManager;
3403
3742
  exports.effectiveEnv = effectiveEnv;
3404
3743
  exports.effectivePermissions = effectivePermissions;
3744
+ exports.eslint = eslint;
3405
3745
  exports.generators = generators;
3406
3746
  exports.handlerCases = handlerCases;
3407
3747
  exports.importCrossSlice = importCrossSlice;
package/dist/index.d.cts CHANGED
@@ -397,6 +397,40 @@ interface CommandTypesOptions {
397
397
  */
398
398
  readonly tsConfig?: string;
399
399
  }
400
+ /**
401
+ * A set of schema files and the files whose exported types must be derived from those schemas.
402
+ */
403
+ interface DerivedTypesPair {
404
+ /**
405
+ * Globs of the files that declare the schemas, relative to the directory the checks run in.
406
+ */
407
+ readonly schemas: readonly string[];
408
+ /**
409
+ * Globs of the files whose exported interfaces and type aliases must be derived from a schema declared in `schemas`.
410
+ */
411
+ readonly types: readonly string[];
412
+ }
413
+ /**
414
+ * Options of the `derived-types` check: every type a file exports is derived from a schema of its pair.
415
+ */
416
+ interface DerivedTypesOptions {
417
+ /**
418
+ * The schema files and the type files that must derive from them. Each type file is judged against the schemas of every pair that lists it.
419
+ */
420
+ readonly pairs: readonly DerivedTypesPair[];
421
+ /**
422
+ * Names of exported types that are deliberately written by hand.
423
+ */
424
+ readonly exclude?: readonly string[];
425
+ /**
426
+ * The last names of the generics that turn a schema into its type, and of the members that hold it, such as `infer` in `z.infer<typeof schema>` and `$inferSelect` in `typeof table.$inferSelect`. `infer`, `input`, `output`, `TypeOf`, `InferInput`, `InferOutput`, `InferSelectModel`, `InferInsertModel`, `$inferSelect` and `$inferInsert` when omitted.
427
+ */
428
+ readonly inferences?: readonly string[];
429
+ /**
430
+ * The tsconfig that supplies compiler options and module resolution; `tsconfig.json` when omitted.
431
+ */
432
+ readonly tsConfig?: string;
433
+ }
400
434
  /**
401
435
  * Options shared by the checks that cruise the workspace's import graph.
402
436
  */
@@ -474,6 +508,48 @@ interface DockerfilePackageManagerOptions {
474
508
  */
475
509
  readonly packageJson?: string;
476
510
  }
511
+ /**
512
+ * The severity a rule must have at least: `warn` is met by `warn` or `error`, `error` only by `error`.
513
+ */
514
+ type EslintRequiredSeverity = "warn" | "error";
515
+ /**
516
+ * A file whose resolved ESLint configuration is checked.
517
+ */
518
+ interface EslintSample {
519
+ /**
520
+ * 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.
521
+ */
522
+ readonly path: string;
523
+ /**
524
+ * Rules required for this file on top of the check's `rules`, which this file's entries override.
525
+ */
526
+ readonly rules?: Readonly<Record<string, EslintRequiredSeverity>>;
527
+ }
528
+ /**
529
+ * 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.
530
+ */
531
+ interface EslintOptions {
532
+ /**
533
+ * 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.
534
+ */
535
+ readonly samples: readonly (string | EslintSample)[];
536
+ /**
537
+ * Rules required for every sample, with the severity each must have at least.
538
+ */
539
+ readonly rules?: Readonly<Record<string, EslintRequiredSeverity>>;
540
+ /**
541
+ * The config file under test, relative to the directory the checks run in, instead of the one ESLint finds from there.
542
+ */
543
+ readonly configFile?: string;
544
+ /**
545
+ * Also lint the workspace and report every message ESLint produces. Off when omitted: the default level lints nothing and only reads resolved configuration.
546
+ */
547
+ readonly lint?: boolean;
548
+ /**
549
+ * What the full level lints, as file and directory patterns relative to the directory the checks run in. `.` when omitted. Used only with `lint`.
550
+ */
551
+ readonly lintPatterns?: readonly string[];
552
+ }
477
553
  /**
478
554
  * 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
555
  */
@@ -662,6 +738,7 @@ type SettingsReviewThreadResolutionOptions = SettingsOptions;
662
738
  interface CheckOptionsByName {
663
739
  readonly "aggregate-mappers": AggregateMappersOptions;
664
740
  readonly "command-types": CommandTypesOptions;
741
+ readonly "derived-types": DerivedTypesOptions;
665
742
  readonly "import-uphill": ImportGraphOptions;
666
743
  readonly "import-rank-skip": ImportGraphOptions;
667
744
  readonly "import-cross-slice": ImportGraphOptions;
@@ -672,6 +749,7 @@ interface CheckOptionsByName {
672
749
  readonly "commit-types": CommitTypesOptions;
673
750
  readonly "dockerfile-package-manager": DockerfilePackageManagerOptions;
674
751
  readonly "migrations-directory": MigrationsDirectoryOptions;
752
+ readonly eslint: EslintOptions;
675
753
  readonly "workflow-job-ordering": WorkflowJobOrderingOptions;
676
754
  readonly "workflow-skippable-jobs": WorkflowSkippableJobsOptions;
677
755
  readonly "workflow-runner-resolution": WorkflowRunnerResolutionOptions;
@@ -722,6 +800,18 @@ export declare const PRESET_COMMIT_TYPES: readonly string[];
722
800
  */
723
801
  export declare const commitTypes: CheckFunction<CommitTypesOptions>;
724
802
  //#endregion
803
+ //#region src/checks/derived-types.d.ts
804
+ /**
805
+ * 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`.
806
+ */
807
+ export declare const DEFAULT_DERIVED_TYPE_INFERENCES: readonly string[];
808
+ /**
809
+ * 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`).
810
+ *
811
+ * 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.
812
+ */
813
+ export declare const derivedTypes: CheckFunction<DerivedTypesOptions>;
814
+ //#endregion
725
815
  //#region src/checks/dockerfile-package-manager.d.ts
726
816
  /**
727
817
  * Every pin of the package manager that `packageManager` names, in each Dockerfile, is the version `packageManager` names, with any `+sha` integrity suffix ignored on both sides. Pins of other package managers are not the workspace's package manager and are left alone.
@@ -730,6 +820,16 @@ export declare const commitTypes: CheckFunction<CommitTypesOptions>;
730
820
  */
731
821
  export declare const dockerfilePackageManager: CheckFunction<DockerfilePackageManagerOptions>;
732
822
  //#endregion
823
+ //#region src/checks/eslint.d.ts
824
+ /**
825
+ * 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.
826
+ *
827
+ * 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.
828
+ *
829
+ * 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.
830
+ */
831
+ export declare const eslint: CheckFunction<EslintOptions>;
832
+ //#endregion
733
833
  //#region src/workspace/packages.d.ts
734
834
  /**
735
835
  * A package directory found under the workspace root, before the layout classifies it.
@@ -1222,4 +1322,4 @@ export declare function proveExhaustive(input: {
1222
1322
  readonly compilerOptions?: CompilerOptions;
1223
1323
  }): ExhaustivenessProof;
1224
1324
  //#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 };
1325
+ export type { AggregateMappersOptions, BranchRules, CaseRemoval, CheckContext, CheckFunction, CheckName, CheckOptionsByName, CheckResult, ChecksConfig, CommandTypesOptions, CommitTypesOptions, ConformanceConfig, DeployDirectory, DeploySelector, DeployToolAdapter, DeployToolName, DerivedTypesOptions, DerivedTypesPair, 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 };