workspace-conformance 1.3.0 → 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
@@ -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.
@@ -283,7 +294,7 @@ it('conforms to the workspace layout', async () => {
283
294
  });
284
295
  ```
285
296
 
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? }`.
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? }`.
287
298
 
288
299
  ### Proving a handler map is exhaustive
289
300
 
package/dist/cli.js CHANGED
@@ -5,7 +5,7 @@ import { parseArgs } from "node:util";
5
5
  import { z } from "zod";
6
6
  import { existsSync } from "node:fs";
7
7
  import { escapePath, glob, isDynamicPattern } from "tinyglobby";
8
- import { Node, Project } from "ts-morph";
8
+ import { Node, Project, SyntaxKind } from "ts-morph";
9
9
  import { createJitiLoader, validateStandard } from "cosmiconfig-extends";
10
10
  import { readFile, realpath } from "node:fs/promises";
11
11
  import { execFile } from "node:child_process";
@@ -284,6 +284,32 @@ const aggregateMappers = async ({ cwd, options }) => {
284
284
  return violations.sort((a, b) => a.file.localeCompare(b.file) || (a.location?.line ?? 0) - (b.location?.line ?? 0) || a.message.localeCompare(b.message));
285
285
  };
286
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
287
313
  //#region src/checks/command-types.ts
288
314
  /**
289
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`.
@@ -301,20 +327,10 @@ const DEFAULT_INFERENCES = [
301
327
  */
302
328
  const STANDARD_SCHEMA_MEMBER = "~standard";
303
329
  /**
304
- * 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.
305
- */
306
- function namesOf(typeName) {
307
- const identifier = Node.isQualifiedName(typeName) ? typeName.getRight() : typeName;
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
- /**
313
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.
314
331
  */
315
332
  function aliasNamed(typeName) {
316
- const symbol = typeName.getSymbol();
317
- const declaration = (symbol?.getAliasedSymbol() ?? symbol)?.getDeclarations().find(Node.isTypeAliasDeclaration);
333
+ const declaration = declarationsOf(lastIdentifier(typeName)).find(Node.isTypeAliasDeclaration);
318
334
  return declaration?.getTypeParameters().length === 0 ? declaration : void 0;
319
335
  }
320
336
  /**
@@ -327,7 +343,7 @@ function problem$1(declaration, name, inferences, followed = /* @__PURE__ */ new
327
343
  };
328
344
  const written = declaration.getTypeNode();
329
345
  if (Node.isTypeReference(written)) {
330
- const inference = namesOf(written.getTypeName()).find((candidate) => inferences.includes(candidate));
346
+ const inference = referenceNames(written.getTypeName()).find((candidate) => inferences.includes(candidate));
331
347
  if (inference !== void 0) {
332
348
  const [argument] = written.getTypeArguments();
333
349
  if (argument !== void 0 && Node.isTypeQuery(argument) && argument.getExprName().getType().getProperty(STANDARD_SCHEMA_MEMBER) !== void 0) return;
@@ -523,6 +539,120 @@ const commitTypes = async ({ cwd, options, configFiles }) => {
523
539
  return violations;
524
540
  };
525
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
526
656
  //#region src/checks/dockerfile-package-manager.ts
527
657
  const DEFAULT_DOCKERFILES = [
528
658
  "**/Dockerfile",
@@ -3034,6 +3164,12 @@ const registry = {
3034
3164
  select: (checks) => enabledOptions(checks["command-types"]),
3035
3165
  check: commandTypes
3036
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
+ }),
3037
3173
  "import-uphill": importCheck({
3038
3174
  name: "import-uphill",
3039
3175
  description: "No package imports a package of a higher rank",
@@ -3239,6 +3375,15 @@ const checks = z.strictObject({
3239
3375
  inferences: z.exactOptional(paths),
3240
3376
  tsConfig: z.exactOptional(path)
3241
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
+ }))),
3242
3387
  "import-uphill": z.exactOptional(enabled(importGraph)),
3243
3388
  "import-rank-skip": z.exactOptional(enabled(importGraph)),
3244
3389
  "import-cross-slice": z.exactOptional(enabled(importGraph)),
package/dist/index.cjs CHANGED
@@ -213,6 +213,32 @@ const aggregateMappers = async ({ cwd, options }) => {
213
213
  return violations.sort((a, b) => a.file.localeCompare(b.file) || (a.location?.line ?? 0) - (b.location?.line ?? 0) || a.message.localeCompare(b.message));
214
214
  };
215
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
216
242
  //#region src/checks/command-types.ts
217
243
  /**
218
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`.
@@ -230,20 +256,10 @@ const DEFAULT_INFERENCES = [
230
256
  */
231
257
  const STANDARD_SCHEMA_MEMBER = "~standard";
232
258
  /**
233
- * 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.
234
- */
235
- function namesOf(typeName) {
236
- const identifier = ts_morph.Node.isQualifiedName(typeName) ? typeName.getRight() : typeName;
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
- /**
242
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.
243
260
  */
244
261
  function aliasNamed(typeName) {
245
- const symbol = typeName.getSymbol();
246
- const declaration = (symbol?.getAliasedSymbol() ?? symbol)?.getDeclarations().find(ts_morph.Node.isTypeAliasDeclaration);
262
+ const declaration = declarationsOf(lastIdentifier(typeName)).find(ts_morph.Node.isTypeAliasDeclaration);
247
263
  return declaration?.getTypeParameters().length === 0 ? declaration : void 0;
248
264
  }
249
265
  /**
@@ -256,7 +272,7 @@ function problem$1(declaration, name, inferences, followed = /* @__PURE__ */ new
256
272
  };
257
273
  const written = declaration.getTypeNode();
258
274
  if (ts_morph.Node.isTypeReference(written)) {
259
- const inference = namesOf(written.getTypeName()).find((candidate) => inferences.includes(candidate));
275
+ const inference = referenceNames(written.getTypeName()).find((candidate) => inferences.includes(candidate));
260
276
  if (inference !== void 0) {
261
277
  const [argument] = written.getTypeArguments();
262
278
  if (argument !== void 0 && ts_morph.Node.isTypeQuery(argument) && argument.getExprName().getType().getProperty(STANDARD_SCHEMA_MEMBER) !== void 0) return;
@@ -452,6 +468,120 @@ const commitTypes = async ({ cwd, options, configFiles }) => {
452
468
  return violations;
453
469
  };
454
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
455
585
  //#region src/checks/dockerfile-package-manager.ts
456
586
  const DEFAULT_DOCKERFILES = [
457
587
  "**/Dockerfile",
@@ -2979,6 +3109,15 @@ const checks = zod.z.strictObject({
2979
3109
  inferences: zod.z.exactOptional(paths),
2980
3110
  tsConfig: zod.z.exactOptional(path)
2981
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
+ }))),
2982
3121
  "import-uphill": zod.z.exactOptional(enabled(importGraph)),
2983
3122
  "import-rank-skip": zod.z.exactOptional(enabled(importGraph)),
2984
3123
  "import-cross-slice": zod.z.exactOptional(enabled(importGraph)),
@@ -3226,6 +3365,12 @@ const registry = {
3226
3365
  select: (checks) => enabledOptions(checks["command-types"]),
3227
3366
  check: commandTypes
3228
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
+ }),
3229
3374
  "import-uphill": importCheck({
3230
3375
  name: "import-uphill",
3231
3376
  description: "No package imports a package of a higher rank",
@@ -3574,6 +3719,7 @@ function proveExhaustive(input) {
3574
3719
  }
3575
3720
  //#endregion
3576
3721
  exports.ConformanceError = ConformanceError;
3722
+ exports.DEFAULT_DERIVED_TYPE_INFERENCES = DEFAULT_DERIVED_TYPE_INFERENCES;
3577
3723
  exports.DEFAULT_INFERENCES = DEFAULT_INFERENCES;
3578
3724
  exports.DEFAULT_WORKFLOWS = DEFAULT_WORKFLOWS;
3579
3725
  exports.EXIT_CODES = EXIT_CODES;
@@ -3591,6 +3737,7 @@ exports.createGitHubClient = createGitHubClient;
3591
3737
  exports.crossSliceRules = crossSliceRules;
3592
3738
  exports.cycleRule = cycleRule;
3593
3739
  exports.deployTools = deployTools;
3740
+ exports.derivedTypes = derivedTypes;
3594
3741
  exports.dockerfilePackageManager = dockerfilePackageManager;
3595
3742
  exports.effectiveEnv = effectiveEnv;
3596
3743
  exports.effectivePermissions = effectivePermissions;
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
  */
@@ -704,6 +738,7 @@ type SettingsReviewThreadResolutionOptions = SettingsOptions;
704
738
  interface CheckOptionsByName {
705
739
  readonly "aggregate-mappers": AggregateMappersOptions;
706
740
  readonly "command-types": CommandTypesOptions;
741
+ readonly "derived-types": DerivedTypesOptions;
707
742
  readonly "import-uphill": ImportGraphOptions;
708
743
  readonly "import-rank-skip": ImportGraphOptions;
709
744
  readonly "import-cross-slice": ImportGraphOptions;
@@ -765,6 +800,18 @@ export declare const PRESET_COMMIT_TYPES: readonly string[];
765
800
  */
766
801
  export declare const commitTypes: CheckFunction<CommitTypesOptions>;
767
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
768
815
  //#region src/checks/dockerfile-package-manager.d.ts
769
816
  /**
770
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.
@@ -1275,4 +1322,4 @@ export declare function proveExhaustive(input: {
1275
1322
  readonly compilerOptions?: CompilerOptions;
1276
1323
  }): ExhaustivenessProof;
1277
1324
  //#endregion
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 };
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 };
package/dist/index.d.ts 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
  */
@@ -704,6 +738,7 @@ type SettingsReviewThreadResolutionOptions = SettingsOptions;
704
738
  interface CheckOptionsByName {
705
739
  readonly "aggregate-mappers": AggregateMappersOptions;
706
740
  readonly "command-types": CommandTypesOptions;
741
+ readonly "derived-types": DerivedTypesOptions;
707
742
  readonly "import-uphill": ImportGraphOptions;
708
743
  readonly "import-rank-skip": ImportGraphOptions;
709
744
  readonly "import-cross-slice": ImportGraphOptions;
@@ -765,6 +800,18 @@ export declare const PRESET_COMMIT_TYPES: readonly string[];
765
800
  */
766
801
  export declare const commitTypes: CheckFunction<CommitTypesOptions>;
767
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
768
815
  //#region src/checks/dockerfile-package-manager.d.ts
769
816
  /**
770
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.
@@ -1275,4 +1322,4 @@ export declare function proveExhaustive(input: {
1275
1322
  readonly compilerOptions?: CompilerOptions;
1276
1323
  }): ExhaustivenessProof;
1277
1324
  //#endregion
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 };
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 };
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ import { createRequire } from "node:module";
2
2
  import { existsSync } from "node:fs";
3
3
  import { dirname, extname, join, posix, relative, resolve, sep } from "node:path";
4
4
  import { escapePath, glob, isDynamicPattern } from "tinyglobby";
5
- import { ModuleKind, ModuleResolutionKind, Node, Project, ScriptTarget, ts } from "ts-morph";
5
+ import { ModuleKind, ModuleResolutionKind, Node, Project, ScriptTarget, SyntaxKind, ts } from "ts-morph";
6
6
  import { createJitiLoader, validateStandard } from "cosmiconfig-extends";
7
7
  import { readFile, realpath } from "node:fs/promises";
8
8
  import { LineCounter, isMap, isNode, isScalar, isSeq, parse, parseDocument } from "yaml";
@@ -212,6 +212,32 @@ const aggregateMappers = async ({ cwd, options }) => {
212
212
  return violations.sort((a, b) => a.file.localeCompare(b.file) || (a.location?.line ?? 0) - (b.location?.line ?? 0) || a.message.localeCompare(b.message));
213
213
  };
214
214
  //#endregion
215
+ //#region src/checks/type-references.ts
216
+ /**
217
+ * The identifier a reference ends in: `infer` in `z.infer`, the name itself when it is not qualified.
218
+ */
219
+ function lastIdentifier(reference) {
220
+ if (Node.isQualifiedName(reference)) return reference.getRight();
221
+ if (Node.isPropertyAccessExpression(reference)) return reference.getNameNode();
222
+ return reference;
223
+ }
224
+ /**
225
+ * The declarations the identifier refers to, through any imports and re-exports.
226
+ */
227
+ function declarationsOf(identifier) {
228
+ const symbol = identifier.getSymbol();
229
+ return (symbol?.getAliasedSymbol() ?? symbol)?.getDeclarations() ?? [];
230
+ }
231
+ /**
232
+ * 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.
233
+ */
234
+ function referenceNames(reference) {
235
+ const identifier = lastIdentifier(reference);
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
+ //#endregion
215
241
  //#region src/checks/command-types.ts
216
242
  /**
217
243
  * 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 +255,10 @@ const DEFAULT_INFERENCES = [
229
255
  */
230
256
  const STANDARD_SCHEMA_MEMBER = "~standard";
231
257
  /**
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 = 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
258
  * 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
259
  */
243
260
  function aliasNamed(typeName) {
244
- const symbol = typeName.getSymbol();
245
- const declaration = (symbol?.getAliasedSymbol() ?? symbol)?.getDeclarations().find(Node.isTypeAliasDeclaration);
261
+ const declaration = declarationsOf(lastIdentifier(typeName)).find(Node.isTypeAliasDeclaration);
246
262
  return declaration?.getTypeParameters().length === 0 ? declaration : void 0;
247
263
  }
248
264
  /**
@@ -255,7 +271,7 @@ function problem$1(declaration, name, inferences, followed = /* @__PURE__ */ new
255
271
  };
256
272
  const written = declaration.getTypeNode();
257
273
  if (Node.isTypeReference(written)) {
258
- const inference = namesOf(written.getTypeName()).find((candidate) => inferences.includes(candidate));
274
+ const inference = referenceNames(written.getTypeName()).find((candidate) => inferences.includes(candidate));
259
275
  if (inference !== void 0) {
260
276
  const [argument] = written.getTypeArguments();
261
277
  if (argument !== void 0 && Node.isTypeQuery(argument) && argument.getExprName().getType().getProperty(STANDARD_SCHEMA_MEMBER) !== void 0) return;
@@ -451,6 +467,120 @@ const commitTypes = async ({ cwd, options, configFiles }) => {
451
467
  return violations;
452
468
  };
453
469
  //#endregion
470
+ //#region src/checks/derived-types.ts
471
+ /**
472
+ * 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`.
473
+ */
474
+ const DEFAULT_DERIVED_TYPE_INFERENCES = [
475
+ ...DEFAULT_INFERENCES,
476
+ "InferSelectModel",
477
+ "InferInsertModel",
478
+ "$inferSelect",
479
+ "$inferInsert"
480
+ ];
481
+ /**
482
+ * Whether the node names something declared in one of the schema files.
483
+ */
484
+ function declaredInSchema(name, derivation) {
485
+ const identifier = Node.isIdentifier(name) ? name : Node.isQualifiedName(name) ? name.getRight() : void 0;
486
+ return identifier !== void 0 && declarationsOf(identifier).some((declaration) => derivation.schemaFiles.has(relativePosix(derivation.cwd, declaration.getSourceFile().getFilePath())));
487
+ }
488
+ /**
489
+ * 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.
490
+ */
491
+ function followable(reference, derivation) {
492
+ const name = Node.isTypeReference(reference) ? reference.getTypeName() : reference.getExpression();
493
+ if (!Node.isIdentifier(name) && !Node.isQualifiedName(name) && !Node.isPropertyAccessExpression(name)) return;
494
+ const declaration = declarationsOf(lastIdentifier(name)).find((found) => Node.isTypeAliasDeclaration(found) || Node.isInterfaceDeclaration(found));
495
+ return declaration?.getTypeParameters().length === 0 && !derivation.followed.has(declaration) ? declaration : void 0;
496
+ }
497
+ function isNullish(node) {
498
+ return node.getKind() === SyntaxKind.UndefinedKeyword || Node.isLiteralTypeNode(node) && node.getLiteral().getKind() === SyntaxKind.NullKeyword;
499
+ }
500
+ /**
501
+ * 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.
502
+ */
503
+ function derivedReference(reference, derivation) {
504
+ const name = Node.isTypeReference(reference) ? reference.getTypeName() : reference.getExpression();
505
+ if ((Node.isIdentifier(name) || Node.isQualifiedName(name) || Node.isPropertyAccessExpression(name)) && referenceNames(name).some((candidate) => derivation.inferences.has(candidate))) {
506
+ const [argument] = reference.getTypeArguments();
507
+ return argument !== void 0 && Node.isTypeQuery(argument) && declaredInSchema(argument.getExprName(), derivation);
508
+ }
509
+ const declaration = followable(reference, derivation);
510
+ if (declaration !== void 0) return derivedDeclaration(declaration, {
511
+ ...derivation,
512
+ followed: /* @__PURE__ */ new Set([...derivation.followed, declaration])
513
+ });
514
+ return reference.getTypeArguments().some((argument) => derivedType(argument, derivation));
515
+ }
516
+ /**
517
+ * Whether a type, as written, is derived from a schema. A type literal is written by hand whatever its members are.
518
+ */
519
+ function derivedType(node, derivation) {
520
+ if (Node.isTypeReference(node) || Node.isExpressionWithTypeArguments(node)) return derivedReference(node, derivation);
521
+ if (Node.isTypeQuery(node)) {
522
+ const name = node.getExprName();
523
+ return Node.isQualifiedName(name) && referenceNames(name).some((candidate) => derivation.inferences.has(candidate)) && declaredInSchema(name.getLeft(), derivation);
524
+ }
525
+ if (Node.isParenthesizedTypeNode(node) || Node.isTypeOperatorTypeNode(node)) return derivedType(node.getTypeNode(), derivation);
526
+ if (Node.isArrayTypeNode(node)) return derivedType(node.getElementTypeNode(), derivation);
527
+ if (Node.isIndexedAccessTypeNode(node)) return derivedType(node.getObjectTypeNode(), derivation);
528
+ if (Node.isIntersectionTypeNode(node)) return node.getTypeNodes().some((member) => derivedType(member, derivation));
529
+ if (Node.isUnionTypeNode(node)) {
530
+ const members = node.getTypeNodes().filter((member) => !isNullish(member));
531
+ return members.length > 0 && members.every((member) => derivedType(member, derivation));
532
+ }
533
+ return false;
534
+ }
535
+ /**
536
+ * 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.
537
+ */
538
+ function derivedDeclaration(declaration, derivation) {
539
+ if (Node.isInterfaceDeclaration(declaration)) return declaration.getExtends().some((heritage) => derivedReference(heritage, derivation));
540
+ return derivedType(declaration.getTypeNodeOrThrow(), derivation);
541
+ }
542
+ async function pairViolations(cwd, options, pair, index) {
543
+ const { project, files } = await createProject(cwd, options.tsConfig, pair.types, {
544
+ check: "derived-types",
545
+ files: `pairs.${String(index)}.types`
546
+ });
547
+ const schemaFiles = await findFiles(cwd, pair.schemas);
548
+ if (schemaFiles.length === 0) throw new ConformanceError(`checks.derived-types.pairs.${String(index)}.schemas: no file matches`);
549
+ const excluded = new Set(options.exclude);
550
+ const derivation = {
551
+ inferences: new Set(options.inferences ?? DEFAULT_DERIVED_TYPE_INFERENCES),
552
+ schemaFiles: new Set(schemaFiles),
553
+ cwd,
554
+ followed: /* @__PURE__ */ new Set()
555
+ };
556
+ const seen = /* @__PURE__ */ new Set();
557
+ const violations = [];
558
+ for (const file of files) for (const [name, declaration] of exportedTypes(project.getSourceFileOrThrow(resolve(cwd, file)))) {
559
+ if (excluded.has(name) || seen.has(declaration)) continue;
560
+ seen.add(declaration);
561
+ if (!derivedDeclaration(declaration, {
562
+ ...derivation,
563
+ followed: /* @__PURE__ */ new Set([declaration])
564
+ })) violations.push({
565
+ code: "derived-types/not-derived",
566
+ message: `${name} is not derived from a schema in ${pair.schemas.join(", ")}: nothing in its definition applies an inference generic to one`,
567
+ file: relativePosix(cwd, declaration.getSourceFile().getFilePath()),
568
+ location: declarationLocation(declaration)
569
+ });
570
+ }
571
+ return violations;
572
+ }
573
+ /**
574
+ * 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`).
575
+ *
576
+ * 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.
577
+ */
578
+ const derivedTypes = async ({ cwd, options }) => {
579
+ const violations = [];
580
+ for (const [index, pair] of options.pairs.entries()) violations.push(...await pairViolations(cwd, options, pair, index));
581
+ return violations.sort((a, b) => a.file.localeCompare(b.file) || (a.location?.line ?? 0) - (b.location?.line ?? 0) || a.message.localeCompare(b.message));
582
+ };
583
+ //#endregion
454
584
  //#region src/checks/dockerfile-package-manager.ts
455
585
  const DEFAULT_DOCKERFILES = [
456
586
  "**/Dockerfile",
@@ -2978,6 +3108,15 @@ const checks = z.strictObject({
2978
3108
  inferences: z.exactOptional(paths),
2979
3109
  tsConfig: z.exactOptional(path)
2980
3110
  }))),
3111
+ "derived-types": z.exactOptional(enabled(z.strictObject({
3112
+ pairs: z.array(z.strictObject({
3113
+ schemas: paths,
3114
+ types: paths
3115
+ })).min(1),
3116
+ exclude: z.exactOptional(names),
3117
+ inferences: z.exactOptional(paths),
3118
+ tsConfig: z.exactOptional(path)
3119
+ }))),
2981
3120
  "import-uphill": z.exactOptional(enabled(importGraph)),
2982
3121
  "import-rank-skip": z.exactOptional(enabled(importGraph)),
2983
3122
  "import-cross-slice": z.exactOptional(enabled(importGraph)),
@@ -3225,6 +3364,12 @@ const registry = {
3225
3364
  select: (checks) => enabledOptions(checks["command-types"]),
3226
3365
  check: commandTypes
3227
3366
  }),
3367
+ "derived-types": plain({
3368
+ name: "derived-types",
3369
+ description: "Every exported type in a file paired with schema files is derived from one of those schemas, not hand-written",
3370
+ select: (checks) => enabledOptions(checks["derived-types"]),
3371
+ check: derivedTypes
3372
+ }),
3228
3373
  "import-uphill": importCheck({
3229
3374
  name: "import-uphill",
3230
3375
  description: "No package imports a package of a higher rank",
@@ -3572,4 +3717,4 @@ function proveExhaustive(input) {
3572
3717
  }) };
3573
3718
  }
3574
3719
  //#endregion
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 };
3720
+ export { ConformanceError, DEFAULT_DERIVED_TYPE_INFERENCES, DEFAULT_INFERENCES, DEFAULT_WORKFLOWS, EXIT_CODES, ExhaustivenessError, PRESET_COMMIT_TYPES, accessOf, aggregateMappers, checkNames, classifyPackages, commandTypes, commitTypes, conformanceSchema, conformanceSection, createGitHubClient, crossSliceRules, cycleRule, deployTools, derivedTypes, 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.3.0",
4
+ "version": "1.4.0",
5
5
  "bin": {
6
6
  "workspace-conformance": "./dist/cli.js"
7
7
  },