workspace-conformance 1.3.0 → 1.5.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 +24 -1
- package/dist/cli.js +286 -13
- package/dist/index.cjs +288 -12
- package/dist/index.d.cts +82 -1
- package/dist/index.d.ts +82 -1
- package/dist/index.js +287 -14
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -117,6 +117,18 @@ The type-graph checks build a ts-morph program from the files their options sele
|
|
|
117
117
|
|
|
118
118
|
Codes: `aggregate-mappers/missing-mapper` (reported at the aggregate's declaration), `aggregate-mappers/no-adapters`. Limits: it checks that a file exists, not what is in it; a type re-exported by the contract counts as an aggregate, and a type re-exported under another name (`export type { Created as CreatedAlias }`) counts once for each name it is exported as, each needing a mapper, so helper types and aliases need `exclude`; a type in an exported namespace, or in a module re-exported as a namespace (`export * as ns`), is an aggregate under its qualified name (`ns.Type`, which is also what `{name}` and `exclude` use); a placeholder with no value, a glob that matches no contract and a missing tsconfig are configuration errors, which name the option (`checks.aggregate-mappers.contracts`) and not the value written in it.
|
|
119
119
|
|
|
120
|
+
**`codec-pairs`** verifies that every encoder a codec file exports has the decoder its name implies, exported by the same file, and every decoder the encoder. A name is an encoder or a decoder when it fits that option's template: with `encoder: 'encode{name}'` and `decoder: 'decode{name}'`, `encodeInvoice` needs `decodeInvoice` and `decodeReceipt` needs `encodeReceipt`. It checks only that both halves exist; that each pair round-trips is a property test (decode after encode gives back the value) and stays the repository's own, since a type graph cannot run code.
|
|
121
|
+
|
|
122
|
+
| Option | Meaning |
|
|
123
|
+
|---|---|
|
|
124
|
+
| `codecs` | Globs of codec files. Each file is judged on its own exports, re-exports included. |
|
|
125
|
+
| `encoder` | Name template of an encoder, such as `encode{name}` or `{name}Encoder`. `{name}` appears exactly once, beside other text, and is the only placeholder, or the section fails to load. |
|
|
126
|
+
| `decoder` | Name template of a decoder, in the same form and different from `encoder`. |
|
|
127
|
+
| `exclude` | Names of exported encoders and decoders that need no counterpart, such as an encoder for a format that is only ever written. |
|
|
128
|
+
| `tsConfig` | See above. |
|
|
129
|
+
|
|
130
|
+
Codes: `codec-pairs/missing-decoder` and `codec-pairs/missing-encoder`, each reported at the declaration of the half that exists. An export whose name fits neither template is not a codec and is ignored, not reported, so a codec file may also export its helpers, schemas and types without listing them in `exclude`; `exclude` is for a half that deliberately has no counterpart. Limits: only values count (functions, constants, destructured constants, classes and enums), so an interface or type alias whose name fits a template is ignored; the counterpart is looked up by name among the same file's exports, not by signature, and a value re-exported under another name counts under each name it is exported as; a value in an exported namespace, or in a module re-exported as a namespace (`export * as ns`), is judged under its qualified name (`ns.encodeInvoice`, which is also what `exclude` uses) and pairs only with a counterpart in the same namespace; a glob that matches no codec file and a missing tsconfig are configuration errors.
|
|
131
|
+
|
|
120
132
|
**`command-types`** verifies that every command type is derived from a schema and not written by hand. An exported alias must apply a generic named in `inferences` (`infer`, `input`, `output`, `TypeOf`, `InferInput` and `InferOutput` by default, the last name of the reference, so `z.infer` and Valibot's `v.InferOutput` both count) to `typeof schema`, where the type of `schema` has the `~standard` member every Standard Schema has. The schema is recognised by type, so it may be imported or renamed and need not come from Zod. The generic may be imported under another name (`import type { infer as Infer }`), and an alias may stand between the export and the inference (`type Base = z.infer<typeof schema>; export type Created = Base;`): a local alias without type parameters is judged by the type it is written as. An alias with type parameters is not followed and is hand-written.
|
|
121
133
|
|
|
122
134
|
| Option | Meaning |
|
|
@@ -128,6 +140,17 @@ Codes: `aggregate-mappers/missing-mapper` (reported at the aggregate's declarati
|
|
|
128
140
|
|
|
129
141
|
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
142
|
|
|
143
|
+
**`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.
|
|
144
|
+
|
|
145
|
+
| Option | Meaning |
|
|
146
|
+
|---|---|
|
|
147
|
+
| `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. |
|
|
148
|
+
| `exclude` | Names of exported types that are deliberately written by hand. |
|
|
149
|
+
| `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`). |
|
|
150
|
+
| `tsConfig` | See above. |
|
|
151
|
+
|
|
152
|
+
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`).
|
|
153
|
+
|
|
131
154
|
### File tree and cross-config
|
|
132
155
|
|
|
133
156
|
**`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 +306,7 @@ it('conforms to the workspace layout', async () => {
|
|
|
283
306
|
});
|
|
284
307
|
```
|
|
285
308
|
|
|
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? }`.
|
|
309
|
+
Every check is also exported as a function taking `{ cwd, options }` (and `layout` for the import checks): `aggregateMappers`, `codecPairs`, `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
310
|
|
|
288
311
|
### Proving a handler map is exhaustive
|
|
289
312
|
|
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";
|
|
@@ -171,6 +171,23 @@ function expandTemplate(template, values) {
|
|
|
171
171
|
return value;
|
|
172
172
|
});
|
|
173
173
|
}
|
|
174
|
+
const NAME_PLACEHOLDER = "{name}";
|
|
175
|
+
/**
|
|
176
|
+
* What a name template must be, as a configuration error says it. It does not quote the template, as no configuration error quotes a value.
|
|
177
|
+
*/
|
|
178
|
+
const NAME_TEMPLATE_REQUIREMENT = "needs {name} exactly once, other text beside it and no other placeholder";
|
|
179
|
+
const ANY_PLACEHOLDER = /\{[a-z]+\}/u;
|
|
180
|
+
/**
|
|
181
|
+
* `template` split at `{name}`, or `undefined` unless it holds `{name}` exactly once, other text beside it and no other placeholder. Without other text every name would match, and with `{name}` twice a name could be split more than one way.
|
|
182
|
+
*/
|
|
183
|
+
function parseNameTemplate(template) {
|
|
184
|
+
const [before, after, ...rest] = template.split(NAME_PLACEHOLDER);
|
|
185
|
+
if (before === void 0 || after === void 0 || rest.length > 0 || before + after === "" || ANY_PLACEHOLDER.test(before + after)) return;
|
|
186
|
+
return {
|
|
187
|
+
before,
|
|
188
|
+
after
|
|
189
|
+
};
|
|
190
|
+
}
|
|
174
191
|
//#endregion
|
|
175
192
|
//#region src/ts-project.ts
|
|
176
193
|
/**
|
|
@@ -284,6 +301,123 @@ const aggregateMappers = async ({ cwd, options }) => {
|
|
|
284
301
|
return violations.sort((a, b) => a.file.localeCompare(b.file) || (a.location?.line ?? 0) - (b.location?.line ?? 0) || a.message.localeCompare(b.message));
|
|
285
302
|
};
|
|
286
303
|
//#endregion
|
|
304
|
+
//#region src/checks/codec-pairs.ts
|
|
305
|
+
function collect(container, prefix, found, entered) {
|
|
306
|
+
for (const [name, declarations] of container.getExportedDeclarations()) {
|
|
307
|
+
const value = declarations.find((declaration) => !Node.isInterfaceDeclaration(declaration) && !Node.isTypeAliasDeclaration(declaration) && !Node.isModuleDeclaration(declaration) && !Node.isSourceFile(declaration));
|
|
308
|
+
if (value !== void 0) found.set(`${prefix}${name}`, value);
|
|
309
|
+
for (const member of declarations) if (Node.isModuleDeclaration(member)) collect(member, `${prefix}${name}.`, found, entered);
|
|
310
|
+
else if (Node.isSourceFile(member) && !entered.has(member)) collect(member, `${prefix}${name}.`, found, /* @__PURE__ */ new Set([...entered, member]));
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* The values `file` exports, by name, wherever they are declared: a re-export counts. Those inside an exported namespace, or in a module re-exported as a namespace (`export * as ns from './x'`), are included under their qualified name (`Namespace.value`). Interfaces and type aliases are types, not codecs, and a namespace is where codecs live rather than one itself, so all three are left out.
|
|
315
|
+
*/
|
|
316
|
+
function exportedValues(file) {
|
|
317
|
+
const found = /* @__PURE__ */ new Map();
|
|
318
|
+
collect(file, "", found, /* @__PURE__ */ new Set([file]));
|
|
319
|
+
return found;
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Where the declaration's name is written, or where the declaration starts when it has no name node.
|
|
323
|
+
*/
|
|
324
|
+
function valueLocation(declaration) {
|
|
325
|
+
const nameNode = Node.hasName(declaration) ? declaration.getNameNode() : void 0;
|
|
326
|
+
const { line, column } = declaration.getSourceFile().getLineAndColumnAtPos((nameNode ?? declaration).getStart());
|
|
327
|
+
return {
|
|
328
|
+
line,
|
|
329
|
+
column
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* The template of an option, which the configuration has already validated; a template that does not parse is a configuration error naming the option.
|
|
334
|
+
*/
|
|
335
|
+
function templateOf(options, option) {
|
|
336
|
+
const template = parseNameTemplate(options[option]);
|
|
337
|
+
if (template === void 0) throw new ConformanceError(`checks.codec-pairs.${option}: ${NAME_TEMPLATE_REQUIREMENT}`);
|
|
338
|
+
return template;
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* The shared name a qualified export name gives under `template`, or `undefined` when its last segment does not fit the template. The namespace part is kept apart so a counterpart is looked for in the same namespace.
|
|
342
|
+
*/
|
|
343
|
+
function sharedName(qualified, template) {
|
|
344
|
+
const namespace = qualified.slice(0, qualified.lastIndexOf(".") + 1);
|
|
345
|
+
const local = qualified.slice(namespace.length);
|
|
346
|
+
if (local.length <= template.before.length + template.after.length || !local.startsWith(template.before) || !local.endsWith(template.after)) return;
|
|
347
|
+
return {
|
|
348
|
+
namespace,
|
|
349
|
+
name: local.slice(template.before.length, local.length - template.after.length)
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Every encoder a codec file exports has the decoder its name implies, exported by the same file, and every decoder the encoder. A name is an encoder or a decoder when it fits that template; other exports are not codecs and are not judged. A half re-exported from another file counts, and is reported where it is declared.
|
|
354
|
+
*
|
|
355
|
+
* It checks that the counterpart is exported, not that the two round-trip: that is a property test of the repository's own.
|
|
356
|
+
*/
|
|
357
|
+
const codecPairs = async ({ cwd, options }) => {
|
|
358
|
+
const { project, files } = await createProject(cwd, options.tsConfig, options.codecs, {
|
|
359
|
+
check: "codec-pairs",
|
|
360
|
+
files: "codecs"
|
|
361
|
+
});
|
|
362
|
+
const excluded = new Set(options.exclude);
|
|
363
|
+
const encoder = templateOf(options, "encoder");
|
|
364
|
+
const decoder = templateOf(options, "decoder");
|
|
365
|
+
const halves = [{
|
|
366
|
+
template: encoder,
|
|
367
|
+
counterpart: decoder,
|
|
368
|
+
missing: "decoder"
|
|
369
|
+
}, {
|
|
370
|
+
template: decoder,
|
|
371
|
+
counterpart: encoder,
|
|
372
|
+
missing: "encoder"
|
|
373
|
+
}];
|
|
374
|
+
const violations = [];
|
|
375
|
+
for (const file of files) {
|
|
376
|
+
const values = exportedValues(project.getSourceFileOrThrow(resolve(cwd, file)));
|
|
377
|
+
for (const [qualified, declaration] of values) {
|
|
378
|
+
if (excluded.has(qualified)) continue;
|
|
379
|
+
for (const { template, counterpart, missing } of halves) {
|
|
380
|
+
const shared = sharedName(qualified, template);
|
|
381
|
+
if (shared === void 0) continue;
|
|
382
|
+
const expected = `${shared.namespace}${counterpart.before}${shared.name}${counterpart.after}`;
|
|
383
|
+
if (!values.has(expected)) violations.push({
|
|
384
|
+
code: `codec-pairs/missing-${missing}`,
|
|
385
|
+
message: `${qualified} has no ${missing}: ${file} does not export ${expected}`,
|
|
386
|
+
file: relativePosix(cwd, declaration.getSourceFile().getFilePath()),
|
|
387
|
+
location: valueLocation(declaration)
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return violations.sort((a, b) => a.file.localeCompare(b.file) || (a.location?.line ?? 0) - (b.location?.line ?? 0) || a.message.localeCompare(b.message));
|
|
393
|
+
};
|
|
394
|
+
//#endregion
|
|
395
|
+
//#region src/checks/type-references.ts
|
|
396
|
+
/**
|
|
397
|
+
* The identifier a reference ends in: `infer` in `z.infer`, the name itself when it is not qualified.
|
|
398
|
+
*/
|
|
399
|
+
function lastIdentifier(reference) {
|
|
400
|
+
if (Node.isQualifiedName(reference)) return reference.getRight();
|
|
401
|
+
if (Node.isPropertyAccessExpression(reference)) return reference.getNameNode();
|
|
402
|
+
return reference;
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* The declarations the identifier refers to, through any imports and re-exports.
|
|
406
|
+
*/
|
|
407
|
+
function declarationsOf(identifier) {
|
|
408
|
+
const symbol = identifier.getSymbol();
|
|
409
|
+
return (symbol?.getAliasedSymbol() ?? symbol)?.getDeclarations() ?? [];
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* 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.
|
|
413
|
+
*/
|
|
414
|
+
function referenceNames(reference) {
|
|
415
|
+
const identifier = lastIdentifier(reference);
|
|
416
|
+
const symbol = identifier.getSymbol();
|
|
417
|
+
const declared = (symbol?.getAliasedSymbol() ?? symbol)?.getName();
|
|
418
|
+
return declared === void 0 ? [identifier.getText()] : [.../* @__PURE__ */ new Set([identifier.getText(), declared])];
|
|
419
|
+
}
|
|
420
|
+
//#endregion
|
|
287
421
|
//#region src/checks/command-types.ts
|
|
288
422
|
/**
|
|
289
423
|
* 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 +435,10 @@ const DEFAULT_INFERENCES = [
|
|
|
301
435
|
*/
|
|
302
436
|
const STANDARD_SCHEMA_MEMBER = "~standard";
|
|
303
437
|
/**
|
|
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
438
|
* 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
439
|
*/
|
|
315
440
|
function aliasNamed(typeName) {
|
|
316
|
-
const
|
|
317
|
-
const declaration = (symbol?.getAliasedSymbol() ?? symbol)?.getDeclarations().find(Node.isTypeAliasDeclaration);
|
|
441
|
+
const declaration = declarationsOf(lastIdentifier(typeName)).find(Node.isTypeAliasDeclaration);
|
|
318
442
|
return declaration?.getTypeParameters().length === 0 ? declaration : void 0;
|
|
319
443
|
}
|
|
320
444
|
/**
|
|
@@ -327,7 +451,7 @@ function problem$1(declaration, name, inferences, followed = /* @__PURE__ */ new
|
|
|
327
451
|
};
|
|
328
452
|
const written = declaration.getTypeNode();
|
|
329
453
|
if (Node.isTypeReference(written)) {
|
|
330
|
-
const inference =
|
|
454
|
+
const inference = referenceNames(written.getTypeName()).find((candidate) => inferences.includes(candidate));
|
|
331
455
|
if (inference !== void 0) {
|
|
332
456
|
const [argument] = written.getTypeArguments();
|
|
333
457
|
if (argument !== void 0 && Node.isTypeQuery(argument) && argument.getExprName().getType().getProperty(STANDARD_SCHEMA_MEMBER) !== void 0) return;
|
|
@@ -523,6 +647,120 @@ const commitTypes = async ({ cwd, options, configFiles }) => {
|
|
|
523
647
|
return violations;
|
|
524
648
|
};
|
|
525
649
|
//#endregion
|
|
650
|
+
//#region src/checks/derived-types.ts
|
|
651
|
+
/**
|
|
652
|
+
* 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`.
|
|
653
|
+
*/
|
|
654
|
+
const DEFAULT_DERIVED_TYPE_INFERENCES = [
|
|
655
|
+
...DEFAULT_INFERENCES,
|
|
656
|
+
"InferSelectModel",
|
|
657
|
+
"InferInsertModel",
|
|
658
|
+
"$inferSelect",
|
|
659
|
+
"$inferInsert"
|
|
660
|
+
];
|
|
661
|
+
/**
|
|
662
|
+
* Whether the node names something declared in one of the schema files.
|
|
663
|
+
*/
|
|
664
|
+
function declaredInSchema(name, derivation) {
|
|
665
|
+
const identifier = Node.isIdentifier(name) ? name : Node.isQualifiedName(name) ? name.getRight() : void 0;
|
|
666
|
+
return identifier !== void 0 && declarationsOf(identifier).some((declaration) => derivation.schemaFiles.has(relativePosix(derivation.cwd, declaration.getSourceFile().getFilePath())));
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* 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.
|
|
670
|
+
*/
|
|
671
|
+
function followable(reference, derivation) {
|
|
672
|
+
const name = Node.isTypeReference(reference) ? reference.getTypeName() : reference.getExpression();
|
|
673
|
+
if (!Node.isIdentifier(name) && !Node.isQualifiedName(name) && !Node.isPropertyAccessExpression(name)) return;
|
|
674
|
+
const declaration = declarationsOf(lastIdentifier(name)).find((found) => Node.isTypeAliasDeclaration(found) || Node.isInterfaceDeclaration(found));
|
|
675
|
+
return declaration?.getTypeParameters().length === 0 && !derivation.followed.has(declaration) ? declaration : void 0;
|
|
676
|
+
}
|
|
677
|
+
function isNullish(node) {
|
|
678
|
+
return node.getKind() === SyntaxKind.UndefinedKeyword || Node.isLiteralTypeNode(node) && node.getLiteral().getKind() === SyntaxKind.NullKeyword;
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* 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.
|
|
682
|
+
*/
|
|
683
|
+
function derivedReference(reference, derivation) {
|
|
684
|
+
const name = Node.isTypeReference(reference) ? reference.getTypeName() : reference.getExpression();
|
|
685
|
+
if ((Node.isIdentifier(name) || Node.isQualifiedName(name) || Node.isPropertyAccessExpression(name)) && referenceNames(name).some((candidate) => derivation.inferences.has(candidate))) {
|
|
686
|
+
const [argument] = reference.getTypeArguments();
|
|
687
|
+
return argument !== void 0 && Node.isTypeQuery(argument) && declaredInSchema(argument.getExprName(), derivation);
|
|
688
|
+
}
|
|
689
|
+
const declaration = followable(reference, derivation);
|
|
690
|
+
if (declaration !== void 0) return derivedDeclaration(declaration, {
|
|
691
|
+
...derivation,
|
|
692
|
+
followed: /* @__PURE__ */ new Set([...derivation.followed, declaration])
|
|
693
|
+
});
|
|
694
|
+
return reference.getTypeArguments().some((argument) => derivedType(argument, derivation));
|
|
695
|
+
}
|
|
696
|
+
/**
|
|
697
|
+
* Whether a type, as written, is derived from a schema. A type literal is written by hand whatever its members are.
|
|
698
|
+
*/
|
|
699
|
+
function derivedType(node, derivation) {
|
|
700
|
+
if (Node.isTypeReference(node) || Node.isExpressionWithTypeArguments(node)) return derivedReference(node, derivation);
|
|
701
|
+
if (Node.isTypeQuery(node)) {
|
|
702
|
+
const name = node.getExprName();
|
|
703
|
+
return Node.isQualifiedName(name) && referenceNames(name).some((candidate) => derivation.inferences.has(candidate)) && declaredInSchema(name.getLeft(), derivation);
|
|
704
|
+
}
|
|
705
|
+
if (Node.isParenthesizedTypeNode(node) || Node.isTypeOperatorTypeNode(node)) return derivedType(node.getTypeNode(), derivation);
|
|
706
|
+
if (Node.isArrayTypeNode(node)) return derivedType(node.getElementTypeNode(), derivation);
|
|
707
|
+
if (Node.isIndexedAccessTypeNode(node)) return derivedType(node.getObjectTypeNode(), derivation);
|
|
708
|
+
if (Node.isIntersectionTypeNode(node)) return node.getTypeNodes().some((member) => derivedType(member, derivation));
|
|
709
|
+
if (Node.isUnionTypeNode(node)) {
|
|
710
|
+
const members = node.getTypeNodes().filter((member) => !isNullish(member));
|
|
711
|
+
return members.length > 0 && members.every((member) => derivedType(member, derivation));
|
|
712
|
+
}
|
|
713
|
+
return false;
|
|
714
|
+
}
|
|
715
|
+
/**
|
|
716
|
+
* 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.
|
|
717
|
+
*/
|
|
718
|
+
function derivedDeclaration(declaration, derivation) {
|
|
719
|
+
if (Node.isInterfaceDeclaration(declaration)) return declaration.getExtends().some((heritage) => derivedReference(heritage, derivation));
|
|
720
|
+
return derivedType(declaration.getTypeNodeOrThrow(), derivation);
|
|
721
|
+
}
|
|
722
|
+
async function pairViolations(cwd, options, pair, index) {
|
|
723
|
+
const { project, files } = await createProject(cwd, options.tsConfig, pair.types, {
|
|
724
|
+
check: "derived-types",
|
|
725
|
+
files: `pairs.${String(index)}.types`
|
|
726
|
+
});
|
|
727
|
+
const schemaFiles = await findFiles(cwd, pair.schemas);
|
|
728
|
+
if (schemaFiles.length === 0) throw new ConformanceError(`checks.derived-types.pairs.${String(index)}.schemas: no file matches`);
|
|
729
|
+
const excluded = new Set(options.exclude);
|
|
730
|
+
const derivation = {
|
|
731
|
+
inferences: new Set(options.inferences ?? DEFAULT_DERIVED_TYPE_INFERENCES),
|
|
732
|
+
schemaFiles: new Set(schemaFiles),
|
|
733
|
+
cwd,
|
|
734
|
+
followed: /* @__PURE__ */ new Set()
|
|
735
|
+
};
|
|
736
|
+
const seen = /* @__PURE__ */ new Set();
|
|
737
|
+
const violations = [];
|
|
738
|
+
for (const file of files) for (const [name, declaration] of exportedTypes(project.getSourceFileOrThrow(resolve(cwd, file)))) {
|
|
739
|
+
if (excluded.has(name) || seen.has(declaration)) continue;
|
|
740
|
+
seen.add(declaration);
|
|
741
|
+
if (!derivedDeclaration(declaration, {
|
|
742
|
+
...derivation,
|
|
743
|
+
followed: /* @__PURE__ */ new Set([declaration])
|
|
744
|
+
})) violations.push({
|
|
745
|
+
code: "derived-types/not-derived",
|
|
746
|
+
message: `${name} is not derived from a schema in ${pair.schemas.join(", ")}: nothing in its definition applies an inference generic to one`,
|
|
747
|
+
file: relativePosix(cwd, declaration.getSourceFile().getFilePath()),
|
|
748
|
+
location: declarationLocation(declaration)
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
return violations;
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* 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`).
|
|
755
|
+
*
|
|
756
|
+
* 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.
|
|
757
|
+
*/
|
|
758
|
+
const derivedTypes = async ({ cwd, options }) => {
|
|
759
|
+
const violations = [];
|
|
760
|
+
for (const [index, pair] of options.pairs.entries()) violations.push(...await pairViolations(cwd, options, pair, index));
|
|
761
|
+
return violations.sort((a, b) => a.file.localeCompare(b.file) || (a.location?.line ?? 0) - (b.location?.line ?? 0) || a.message.localeCompare(b.message));
|
|
762
|
+
};
|
|
763
|
+
//#endregion
|
|
526
764
|
//#region src/checks/dockerfile-package-manager.ts
|
|
527
765
|
const DEFAULT_DOCKERFILES = [
|
|
528
766
|
"**/Dockerfile",
|
|
@@ -3028,12 +3266,24 @@ const registry = {
|
|
|
3028
3266
|
select: (checks) => enabledOptions(checks["aggregate-mappers"]),
|
|
3029
3267
|
check: aggregateMappers
|
|
3030
3268
|
}),
|
|
3269
|
+
"codec-pairs": plain({
|
|
3270
|
+
name: "codec-pairs",
|
|
3271
|
+
description: "Every exported encoder in a codec file has a decoder under the naming templates, and every decoder an encoder",
|
|
3272
|
+
select: (checks) => enabledOptions(checks["codec-pairs"]),
|
|
3273
|
+
check: codecPairs
|
|
3274
|
+
}),
|
|
3031
3275
|
"command-types": plain({
|
|
3032
3276
|
name: "command-types",
|
|
3033
3277
|
description: "Every exported command type is derived from a schema, not hand-written",
|
|
3034
3278
|
select: (checks) => enabledOptions(checks["command-types"]),
|
|
3035
3279
|
check: commandTypes
|
|
3036
3280
|
}),
|
|
3281
|
+
"derived-types": plain({
|
|
3282
|
+
name: "derived-types",
|
|
3283
|
+
description: "Every exported type in a file paired with schema files is derived from one of those schemas, not hand-written",
|
|
3284
|
+
select: (checks) => enabledOptions(checks["derived-types"]),
|
|
3285
|
+
check: derivedTypes
|
|
3286
|
+
}),
|
|
3037
3287
|
"import-uphill": importCheck({
|
|
3038
3288
|
name: "import-uphill",
|
|
3039
3289
|
description: "No package imports a package of a higher rank",
|
|
@@ -3201,6 +3451,10 @@ function compiles(source) {
|
|
|
3201
3451
|
*/
|
|
3202
3452
|
const pattern = z.string().min(1).refine(compiles, "not a valid regular expression");
|
|
3203
3453
|
const patterns = z.array(pattern);
|
|
3454
|
+
/**
|
|
3455
|
+
* A name template of `codec-pairs`.
|
|
3456
|
+
*/
|
|
3457
|
+
const nameTemplate = z.string().refine((template) => parseNameTemplate(template) !== void 0, NAME_TEMPLATE_REQUIREMENT);
|
|
3204
3458
|
const severity = z.enum(["warn", "error"]);
|
|
3205
3459
|
const requiredRules = z.record(z.string().min(1), severity);
|
|
3206
3460
|
const selection = {
|
|
@@ -3233,12 +3487,31 @@ const checks = z.strictObject({
|
|
|
3233
3487
|
exclude: z.exactOptional(names),
|
|
3234
3488
|
tsConfig: z.exactOptional(path)
|
|
3235
3489
|
}))),
|
|
3490
|
+
"codec-pairs": z.exactOptional(enabled(z.strictObject({
|
|
3491
|
+
codecs: paths,
|
|
3492
|
+
encoder: nameTemplate,
|
|
3493
|
+
decoder: nameTemplate,
|
|
3494
|
+
exclude: z.exactOptional(names),
|
|
3495
|
+
tsConfig: z.exactOptional(path)
|
|
3496
|
+
}).refine((options) => options.encoder !== options.decoder, {
|
|
3497
|
+
path: ["decoder"],
|
|
3498
|
+
message: "the decoder template is the encoder template, so every name would be its own pair"
|
|
3499
|
+
}))),
|
|
3236
3500
|
"command-types": z.exactOptional(enabled(z.strictObject({
|
|
3237
3501
|
commands: paths,
|
|
3238
3502
|
exclude: z.exactOptional(names),
|
|
3239
3503
|
inferences: z.exactOptional(paths),
|
|
3240
3504
|
tsConfig: z.exactOptional(path)
|
|
3241
3505
|
}))),
|
|
3506
|
+
"derived-types": z.exactOptional(enabled(z.strictObject({
|
|
3507
|
+
pairs: z.array(z.strictObject({
|
|
3508
|
+
schemas: paths,
|
|
3509
|
+
types: paths
|
|
3510
|
+
})).min(1),
|
|
3511
|
+
exclude: z.exactOptional(names),
|
|
3512
|
+
inferences: z.exactOptional(paths),
|
|
3513
|
+
tsConfig: z.exactOptional(path)
|
|
3514
|
+
}))),
|
|
3242
3515
|
"import-uphill": z.exactOptional(enabled(importGraph)),
|
|
3243
3516
|
"import-rank-skip": z.exactOptional(enabled(importGraph)),
|
|
3244
3517
|
"import-cross-slice": z.exactOptional(enabled(importGraph)),
|