workspace-conformance 1.4.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 +13 -1
- package/dist/cli.js +128 -0
- package/dist/index.cjs +129 -0
- package/dist/index.d.cts +35 -1
- package/dist/index.d.ts +35 -1
- package/dist/index.js +129 -1
- 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 |
|
|
@@ -294,7 +306,7 @@ it('conforms to the workspace layout', async () => {
|
|
|
294
306
|
});
|
|
295
307
|
```
|
|
296
308
|
|
|
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? }`.
|
|
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? }`.
|
|
298
310
|
|
|
299
311
|
### Proving a handler map is exhaustive
|
|
300
312
|
|
package/dist/cli.js
CHANGED
|
@@ -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,97 @@ 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
|
|
287
395
|
//#region src/checks/type-references.ts
|
|
288
396
|
/**
|
|
289
397
|
* The identifier a reference ends in: `infer` in `z.infer`, the name itself when it is not qualified.
|
|
@@ -3158,6 +3266,12 @@ const registry = {
|
|
|
3158
3266
|
select: (checks) => enabledOptions(checks["aggregate-mappers"]),
|
|
3159
3267
|
check: aggregateMappers
|
|
3160
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
|
+
}),
|
|
3161
3275
|
"command-types": plain({
|
|
3162
3276
|
name: "command-types",
|
|
3163
3277
|
description: "Every exported command type is derived from a schema, not hand-written",
|
|
@@ -3337,6 +3451,10 @@ function compiles(source) {
|
|
|
3337
3451
|
*/
|
|
3338
3452
|
const pattern = z.string().min(1).refine(compiles, "not a valid regular expression");
|
|
3339
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);
|
|
3340
3458
|
const severity = z.enum(["warn", "error"]);
|
|
3341
3459
|
const requiredRules = z.record(z.string().min(1), severity);
|
|
3342
3460
|
const selection = {
|
|
@@ -3369,6 +3487,16 @@ const checks = z.strictObject({
|
|
|
3369
3487
|
exclude: z.exactOptional(names),
|
|
3370
3488
|
tsConfig: z.exactOptional(path)
|
|
3371
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
|
+
}))),
|
|
3372
3500
|
"command-types": z.exactOptional(enabled(z.strictObject({
|
|
3373
3501
|
commands: paths,
|
|
3374
3502
|
exclude: z.exactOptional(names),
|
package/dist/index.cjs
CHANGED
|
@@ -100,6 +100,23 @@ function expandTemplate(template, values) {
|
|
|
100
100
|
return value;
|
|
101
101
|
});
|
|
102
102
|
}
|
|
103
|
+
const NAME_PLACEHOLDER = "{name}";
|
|
104
|
+
/**
|
|
105
|
+
* 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.
|
|
106
|
+
*/
|
|
107
|
+
const NAME_TEMPLATE_REQUIREMENT = "needs {name} exactly once, other text beside it and no other placeholder";
|
|
108
|
+
const ANY_PLACEHOLDER = /\{[a-z]+\}/u;
|
|
109
|
+
/**
|
|
110
|
+
* `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.
|
|
111
|
+
*/
|
|
112
|
+
function parseNameTemplate(template) {
|
|
113
|
+
const [before, after, ...rest] = template.split(NAME_PLACEHOLDER);
|
|
114
|
+
if (before === void 0 || after === void 0 || rest.length > 0 || before + after === "" || ANY_PLACEHOLDER.test(before + after)) return;
|
|
115
|
+
return {
|
|
116
|
+
before,
|
|
117
|
+
after
|
|
118
|
+
};
|
|
119
|
+
}
|
|
103
120
|
//#endregion
|
|
104
121
|
//#region src/ts-project.ts
|
|
105
122
|
/**
|
|
@@ -213,6 +230,97 @@ const aggregateMappers = async ({ cwd, options }) => {
|
|
|
213
230
|
return violations.sort((a, b) => a.file.localeCompare(b.file) || (a.location?.line ?? 0) - (b.location?.line ?? 0) || a.message.localeCompare(b.message));
|
|
214
231
|
};
|
|
215
232
|
//#endregion
|
|
233
|
+
//#region src/checks/codec-pairs.ts
|
|
234
|
+
function collect(container, prefix, found, entered) {
|
|
235
|
+
for (const [name, declarations] of container.getExportedDeclarations()) {
|
|
236
|
+
const value = declarations.find((declaration) => !ts_morph.Node.isInterfaceDeclaration(declaration) && !ts_morph.Node.isTypeAliasDeclaration(declaration) && !ts_morph.Node.isModuleDeclaration(declaration) && !ts_morph.Node.isSourceFile(declaration));
|
|
237
|
+
if (value !== void 0) found.set(`${prefix}${name}`, value);
|
|
238
|
+
for (const member of declarations) if (ts_morph.Node.isModuleDeclaration(member)) collect(member, `${prefix}${name}.`, found, entered);
|
|
239
|
+
else if (ts_morph.Node.isSourceFile(member) && !entered.has(member)) collect(member, `${prefix}${name}.`, found, /* @__PURE__ */ new Set([...entered, member]));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* 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.
|
|
244
|
+
*/
|
|
245
|
+
function exportedValues(file) {
|
|
246
|
+
const found = /* @__PURE__ */ new Map();
|
|
247
|
+
collect(file, "", found, /* @__PURE__ */ new Set([file]));
|
|
248
|
+
return found;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Where the declaration's name is written, or where the declaration starts when it has no name node.
|
|
252
|
+
*/
|
|
253
|
+
function valueLocation(declaration) {
|
|
254
|
+
const nameNode = ts_morph.Node.hasName(declaration) ? declaration.getNameNode() : void 0;
|
|
255
|
+
const { line, column } = declaration.getSourceFile().getLineAndColumnAtPos((nameNode ?? declaration).getStart());
|
|
256
|
+
return {
|
|
257
|
+
line,
|
|
258
|
+
column
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* The template of an option, which the configuration has already validated; a template that does not parse is a configuration error naming the option.
|
|
263
|
+
*/
|
|
264
|
+
function templateOf(options, option) {
|
|
265
|
+
const template = parseNameTemplate(options[option]);
|
|
266
|
+
if (template === void 0) throw new ConformanceError(`checks.codec-pairs.${option}: ${NAME_TEMPLATE_REQUIREMENT}`);
|
|
267
|
+
return template;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* 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.
|
|
271
|
+
*/
|
|
272
|
+
function sharedName(qualified, template) {
|
|
273
|
+
const namespace = qualified.slice(0, qualified.lastIndexOf(".") + 1);
|
|
274
|
+
const local = qualified.slice(namespace.length);
|
|
275
|
+
if (local.length <= template.before.length + template.after.length || !local.startsWith(template.before) || !local.endsWith(template.after)) return;
|
|
276
|
+
return {
|
|
277
|
+
namespace,
|
|
278
|
+
name: local.slice(template.before.length, local.length - template.after.length)
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* 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.
|
|
283
|
+
*
|
|
284
|
+
* It checks that the counterpart is exported, not that the two round-trip: that is a property test of the repository's own.
|
|
285
|
+
*/
|
|
286
|
+
const codecPairs = async ({ cwd, options }) => {
|
|
287
|
+
const { project, files } = await createProject(cwd, options.tsConfig, options.codecs, {
|
|
288
|
+
check: "codec-pairs",
|
|
289
|
+
files: "codecs"
|
|
290
|
+
});
|
|
291
|
+
const excluded = new Set(options.exclude);
|
|
292
|
+
const encoder = templateOf(options, "encoder");
|
|
293
|
+
const decoder = templateOf(options, "decoder");
|
|
294
|
+
const halves = [{
|
|
295
|
+
template: encoder,
|
|
296
|
+
counterpart: decoder,
|
|
297
|
+
missing: "decoder"
|
|
298
|
+
}, {
|
|
299
|
+
template: decoder,
|
|
300
|
+
counterpart: encoder,
|
|
301
|
+
missing: "encoder"
|
|
302
|
+
}];
|
|
303
|
+
const violations = [];
|
|
304
|
+
for (const file of files) {
|
|
305
|
+
const values = exportedValues(project.getSourceFileOrThrow((0, node_path.resolve)(cwd, file)));
|
|
306
|
+
for (const [qualified, declaration] of values) {
|
|
307
|
+
if (excluded.has(qualified)) continue;
|
|
308
|
+
for (const { template, counterpart, missing } of halves) {
|
|
309
|
+
const shared = sharedName(qualified, template);
|
|
310
|
+
if (shared === void 0) continue;
|
|
311
|
+
const expected = `${shared.namespace}${counterpart.before}${shared.name}${counterpart.after}`;
|
|
312
|
+
if (!values.has(expected)) violations.push({
|
|
313
|
+
code: `codec-pairs/missing-${missing}`,
|
|
314
|
+
message: `${qualified} has no ${missing}: ${file} does not export ${expected}`,
|
|
315
|
+
file: relativePosix(cwd, declaration.getSourceFile().getFilePath()),
|
|
316
|
+
location: valueLocation(declaration)
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return violations.sort((a, b) => a.file.localeCompare(b.file) || (a.location?.line ?? 0) - (b.location?.line ?? 0) || a.message.localeCompare(b.message));
|
|
322
|
+
};
|
|
323
|
+
//#endregion
|
|
216
324
|
//#region src/checks/type-references.ts
|
|
217
325
|
/**
|
|
218
326
|
* The identifier a reference ends in: `infer` in `z.infer`, the name itself when it is not qualified.
|
|
@@ -3071,6 +3179,10 @@ function compiles(source) {
|
|
|
3071
3179
|
*/
|
|
3072
3180
|
const pattern = zod.z.string().min(1).refine(compiles, "not a valid regular expression");
|
|
3073
3181
|
const patterns = zod.z.array(pattern);
|
|
3182
|
+
/**
|
|
3183
|
+
* A name template of `codec-pairs`.
|
|
3184
|
+
*/
|
|
3185
|
+
const nameTemplate = zod.z.string().refine((template) => parseNameTemplate(template) !== void 0, NAME_TEMPLATE_REQUIREMENT);
|
|
3074
3186
|
const severity = zod.z.enum(["warn", "error"]);
|
|
3075
3187
|
const requiredRules = zod.z.record(zod.z.string().min(1), severity);
|
|
3076
3188
|
const selection = {
|
|
@@ -3103,6 +3215,16 @@ const checks = zod.z.strictObject({
|
|
|
3103
3215
|
exclude: zod.z.exactOptional(names),
|
|
3104
3216
|
tsConfig: zod.z.exactOptional(path)
|
|
3105
3217
|
}))),
|
|
3218
|
+
"codec-pairs": zod.z.exactOptional(enabled(zod.z.strictObject({
|
|
3219
|
+
codecs: paths,
|
|
3220
|
+
encoder: nameTemplate,
|
|
3221
|
+
decoder: nameTemplate,
|
|
3222
|
+
exclude: zod.z.exactOptional(names),
|
|
3223
|
+
tsConfig: zod.z.exactOptional(path)
|
|
3224
|
+
}).refine((options) => options.encoder !== options.decoder, {
|
|
3225
|
+
path: ["decoder"],
|
|
3226
|
+
message: "the decoder template is the encoder template, so every name would be its own pair"
|
|
3227
|
+
}))),
|
|
3106
3228
|
"command-types": zod.z.exactOptional(enabled(zod.z.strictObject({
|
|
3107
3229
|
commands: paths,
|
|
3108
3230
|
exclude: zod.z.exactOptional(names),
|
|
@@ -3359,6 +3481,12 @@ const registry = {
|
|
|
3359
3481
|
select: (checks) => enabledOptions(checks["aggregate-mappers"]),
|
|
3360
3482
|
check: aggregateMappers
|
|
3361
3483
|
}),
|
|
3484
|
+
"codec-pairs": plain({
|
|
3485
|
+
name: "codec-pairs",
|
|
3486
|
+
description: "Every exported encoder in a codec file has a decoder under the naming templates, and every decoder an encoder",
|
|
3487
|
+
select: (checks) => enabledOptions(checks["codec-pairs"]),
|
|
3488
|
+
check: codecPairs
|
|
3489
|
+
}),
|
|
3362
3490
|
"command-types": plain({
|
|
3363
3491
|
name: "command-types",
|
|
3364
3492
|
description: "Every exported command type is derived from a schema, not hand-written",
|
|
@@ -3729,6 +3857,7 @@ exports.accessOf = accessOf;
|
|
|
3729
3857
|
exports.aggregateMappers = aggregateMappers;
|
|
3730
3858
|
exports.checkNames = checkNames;
|
|
3731
3859
|
exports.classifyPackages = classifyPackages;
|
|
3860
|
+
exports.codecPairs = codecPairs;
|
|
3732
3861
|
exports.commandTypes = commandTypes;
|
|
3733
3862
|
exports.commitTypes = commitTypes;
|
|
3734
3863
|
exports.conformanceSchema = conformanceSchema;
|
package/dist/index.d.cts
CHANGED
|
@@ -376,6 +376,31 @@ interface AggregateMappersOptions {
|
|
|
376
376
|
*/
|
|
377
377
|
readonly tsConfig?: string;
|
|
378
378
|
}
|
|
379
|
+
/**
|
|
380
|
+
* Options of the `codec-pairs` check: every encoder a codec file exports has a decoder it also exports, and every decoder an encoder.
|
|
381
|
+
*/
|
|
382
|
+
interface CodecPairsOptions {
|
|
383
|
+
/**
|
|
384
|
+
* Globs of the codec files, relative to the directory the checks run in. Each file is judged on its own exports, re-exports included.
|
|
385
|
+
*/
|
|
386
|
+
readonly codecs: readonly string[];
|
|
387
|
+
/**
|
|
388
|
+
* The name of an encoder, with `{name}` standing for the name it shares with its decoder, such as `encode{name}` or `{name}Encoder`. `{name}` appears exactly once, beside other text, and is the only placeholder.
|
|
389
|
+
*/
|
|
390
|
+
readonly encoder: string;
|
|
391
|
+
/**
|
|
392
|
+
* The name of a decoder, in the same form as `encoder` and different from it.
|
|
393
|
+
*/
|
|
394
|
+
readonly decoder: string;
|
|
395
|
+
/**
|
|
396
|
+
* Names of exported encoders and decoders that need no counterpart, such as an encoder for a format that is only ever written.
|
|
397
|
+
*/
|
|
398
|
+
readonly exclude?: readonly string[];
|
|
399
|
+
/**
|
|
400
|
+
* The tsconfig that supplies compiler options and module resolution; `tsconfig.json` when omitted.
|
|
401
|
+
*/
|
|
402
|
+
readonly tsConfig?: string;
|
|
403
|
+
}
|
|
379
404
|
/**
|
|
380
405
|
* Options of the `command-types` check: every command type a file exports is derived from a schema.
|
|
381
406
|
*/
|
|
@@ -737,6 +762,7 @@ type SettingsReviewThreadResolutionOptions = SettingsOptions;
|
|
|
737
762
|
*/
|
|
738
763
|
interface CheckOptionsByName {
|
|
739
764
|
readonly "aggregate-mappers": AggregateMappersOptions;
|
|
765
|
+
readonly "codec-pairs": CodecPairsOptions;
|
|
740
766
|
readonly "command-types": CommandTypesOptions;
|
|
741
767
|
readonly "derived-types": DerivedTypesOptions;
|
|
742
768
|
readonly "import-uphill": ImportGraphOptions;
|
|
@@ -776,6 +802,14 @@ type CheckName = keyof CheckOptionsByName;
|
|
|
776
802
|
*/
|
|
777
803
|
export declare const aggregateMappers: CheckFunction<AggregateMappersOptions>;
|
|
778
804
|
//#endregion
|
|
805
|
+
//#region src/checks/codec-pairs.d.ts
|
|
806
|
+
/**
|
|
807
|
+
* 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.
|
|
808
|
+
*
|
|
809
|
+
* It checks that the counterpart is exported, not that the two round-trip: that is a property test of the repository's own.
|
|
810
|
+
*/
|
|
811
|
+
export declare const codecPairs: CheckFunction<CodecPairsOptions>;
|
|
812
|
+
//#endregion
|
|
779
813
|
//#region src/checks/command-types.d.ts
|
|
780
814
|
/**
|
|
781
815
|
* 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`.
|
|
@@ -1322,4 +1356,4 @@ export declare function proveExhaustive(input: {
|
|
|
1322
1356
|
readonly compilerOptions?: CompilerOptions;
|
|
1323
1357
|
}): ExhaustivenessProof;
|
|
1324
1358
|
//#endregion
|
|
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 };
|
|
1359
|
+
export type { AggregateMappersOptions, BranchRules, CaseRemoval, CheckContext, CheckFunction, CheckName, CheckOptionsByName, CheckResult, ChecksConfig, CodecPairsOptions, 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
|
@@ -376,6 +376,31 @@ interface AggregateMappersOptions {
|
|
|
376
376
|
*/
|
|
377
377
|
readonly tsConfig?: string;
|
|
378
378
|
}
|
|
379
|
+
/**
|
|
380
|
+
* Options of the `codec-pairs` check: every encoder a codec file exports has a decoder it also exports, and every decoder an encoder.
|
|
381
|
+
*/
|
|
382
|
+
interface CodecPairsOptions {
|
|
383
|
+
/**
|
|
384
|
+
* Globs of the codec files, relative to the directory the checks run in. Each file is judged on its own exports, re-exports included.
|
|
385
|
+
*/
|
|
386
|
+
readonly codecs: readonly string[];
|
|
387
|
+
/**
|
|
388
|
+
* The name of an encoder, with `{name}` standing for the name it shares with its decoder, such as `encode{name}` or `{name}Encoder`. `{name}` appears exactly once, beside other text, and is the only placeholder.
|
|
389
|
+
*/
|
|
390
|
+
readonly encoder: string;
|
|
391
|
+
/**
|
|
392
|
+
* The name of a decoder, in the same form as `encoder` and different from it.
|
|
393
|
+
*/
|
|
394
|
+
readonly decoder: string;
|
|
395
|
+
/**
|
|
396
|
+
* Names of exported encoders and decoders that need no counterpart, such as an encoder for a format that is only ever written.
|
|
397
|
+
*/
|
|
398
|
+
readonly exclude?: readonly string[];
|
|
399
|
+
/**
|
|
400
|
+
* The tsconfig that supplies compiler options and module resolution; `tsconfig.json` when omitted.
|
|
401
|
+
*/
|
|
402
|
+
readonly tsConfig?: string;
|
|
403
|
+
}
|
|
379
404
|
/**
|
|
380
405
|
* Options of the `command-types` check: every command type a file exports is derived from a schema.
|
|
381
406
|
*/
|
|
@@ -737,6 +762,7 @@ type SettingsReviewThreadResolutionOptions = SettingsOptions;
|
|
|
737
762
|
*/
|
|
738
763
|
interface CheckOptionsByName {
|
|
739
764
|
readonly "aggregate-mappers": AggregateMappersOptions;
|
|
765
|
+
readonly "codec-pairs": CodecPairsOptions;
|
|
740
766
|
readonly "command-types": CommandTypesOptions;
|
|
741
767
|
readonly "derived-types": DerivedTypesOptions;
|
|
742
768
|
readonly "import-uphill": ImportGraphOptions;
|
|
@@ -776,6 +802,14 @@ type CheckName = keyof CheckOptionsByName;
|
|
|
776
802
|
*/
|
|
777
803
|
export declare const aggregateMappers: CheckFunction<AggregateMappersOptions>;
|
|
778
804
|
//#endregion
|
|
805
|
+
//#region src/checks/codec-pairs.d.ts
|
|
806
|
+
/**
|
|
807
|
+
* 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.
|
|
808
|
+
*
|
|
809
|
+
* It checks that the counterpart is exported, not that the two round-trip: that is a property test of the repository's own.
|
|
810
|
+
*/
|
|
811
|
+
export declare const codecPairs: CheckFunction<CodecPairsOptions>;
|
|
812
|
+
//#endregion
|
|
779
813
|
//#region src/checks/command-types.d.ts
|
|
780
814
|
/**
|
|
781
815
|
* 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`.
|
|
@@ -1322,4 +1356,4 @@ export declare function proveExhaustive(input: {
|
|
|
1322
1356
|
readonly compilerOptions?: CompilerOptions;
|
|
1323
1357
|
}): ExhaustivenessProof;
|
|
1324
1358
|
//#endregion
|
|
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 };
|
|
1359
|
+
export type { AggregateMappersOptions, BranchRules, CaseRemoval, CheckContext, CheckFunction, CheckName, CheckOptionsByName, CheckResult, ChecksConfig, CodecPairsOptions, 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
|
@@ -99,6 +99,23 @@ function expandTemplate(template, values) {
|
|
|
99
99
|
return value;
|
|
100
100
|
});
|
|
101
101
|
}
|
|
102
|
+
const NAME_PLACEHOLDER = "{name}";
|
|
103
|
+
/**
|
|
104
|
+
* 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.
|
|
105
|
+
*/
|
|
106
|
+
const NAME_TEMPLATE_REQUIREMENT = "needs {name} exactly once, other text beside it and no other placeholder";
|
|
107
|
+
const ANY_PLACEHOLDER = /\{[a-z]+\}/u;
|
|
108
|
+
/**
|
|
109
|
+
* `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.
|
|
110
|
+
*/
|
|
111
|
+
function parseNameTemplate(template) {
|
|
112
|
+
const [before, after, ...rest] = template.split(NAME_PLACEHOLDER);
|
|
113
|
+
if (before === void 0 || after === void 0 || rest.length > 0 || before + after === "" || ANY_PLACEHOLDER.test(before + after)) return;
|
|
114
|
+
return {
|
|
115
|
+
before,
|
|
116
|
+
after
|
|
117
|
+
};
|
|
118
|
+
}
|
|
102
119
|
//#endregion
|
|
103
120
|
//#region src/ts-project.ts
|
|
104
121
|
/**
|
|
@@ -212,6 +229,97 @@ const aggregateMappers = async ({ cwd, options }) => {
|
|
|
212
229
|
return violations.sort((a, b) => a.file.localeCompare(b.file) || (a.location?.line ?? 0) - (b.location?.line ?? 0) || a.message.localeCompare(b.message));
|
|
213
230
|
};
|
|
214
231
|
//#endregion
|
|
232
|
+
//#region src/checks/codec-pairs.ts
|
|
233
|
+
function collect(container, prefix, found, entered) {
|
|
234
|
+
for (const [name, declarations] of container.getExportedDeclarations()) {
|
|
235
|
+
const value = declarations.find((declaration) => !Node.isInterfaceDeclaration(declaration) && !Node.isTypeAliasDeclaration(declaration) && !Node.isModuleDeclaration(declaration) && !Node.isSourceFile(declaration));
|
|
236
|
+
if (value !== void 0) found.set(`${prefix}${name}`, value);
|
|
237
|
+
for (const member of declarations) if (Node.isModuleDeclaration(member)) collect(member, `${prefix}${name}.`, found, entered);
|
|
238
|
+
else if (Node.isSourceFile(member) && !entered.has(member)) collect(member, `${prefix}${name}.`, found, /* @__PURE__ */ new Set([...entered, member]));
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* 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.
|
|
243
|
+
*/
|
|
244
|
+
function exportedValues(file) {
|
|
245
|
+
const found = /* @__PURE__ */ new Map();
|
|
246
|
+
collect(file, "", found, /* @__PURE__ */ new Set([file]));
|
|
247
|
+
return found;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Where the declaration's name is written, or where the declaration starts when it has no name node.
|
|
251
|
+
*/
|
|
252
|
+
function valueLocation(declaration) {
|
|
253
|
+
const nameNode = Node.hasName(declaration) ? declaration.getNameNode() : void 0;
|
|
254
|
+
const { line, column } = declaration.getSourceFile().getLineAndColumnAtPos((nameNode ?? declaration).getStart());
|
|
255
|
+
return {
|
|
256
|
+
line,
|
|
257
|
+
column
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* The template of an option, which the configuration has already validated; a template that does not parse is a configuration error naming the option.
|
|
262
|
+
*/
|
|
263
|
+
function templateOf(options, option) {
|
|
264
|
+
const template = parseNameTemplate(options[option]);
|
|
265
|
+
if (template === void 0) throw new ConformanceError(`checks.codec-pairs.${option}: ${NAME_TEMPLATE_REQUIREMENT}`);
|
|
266
|
+
return template;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* 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.
|
|
270
|
+
*/
|
|
271
|
+
function sharedName(qualified, template) {
|
|
272
|
+
const namespace = qualified.slice(0, qualified.lastIndexOf(".") + 1);
|
|
273
|
+
const local = qualified.slice(namespace.length);
|
|
274
|
+
if (local.length <= template.before.length + template.after.length || !local.startsWith(template.before) || !local.endsWith(template.after)) return;
|
|
275
|
+
return {
|
|
276
|
+
namespace,
|
|
277
|
+
name: local.slice(template.before.length, local.length - template.after.length)
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* 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.
|
|
282
|
+
*
|
|
283
|
+
* It checks that the counterpart is exported, not that the two round-trip: that is a property test of the repository's own.
|
|
284
|
+
*/
|
|
285
|
+
const codecPairs = async ({ cwd, options }) => {
|
|
286
|
+
const { project, files } = await createProject(cwd, options.tsConfig, options.codecs, {
|
|
287
|
+
check: "codec-pairs",
|
|
288
|
+
files: "codecs"
|
|
289
|
+
});
|
|
290
|
+
const excluded = new Set(options.exclude);
|
|
291
|
+
const encoder = templateOf(options, "encoder");
|
|
292
|
+
const decoder = templateOf(options, "decoder");
|
|
293
|
+
const halves = [{
|
|
294
|
+
template: encoder,
|
|
295
|
+
counterpart: decoder,
|
|
296
|
+
missing: "decoder"
|
|
297
|
+
}, {
|
|
298
|
+
template: decoder,
|
|
299
|
+
counterpart: encoder,
|
|
300
|
+
missing: "encoder"
|
|
301
|
+
}];
|
|
302
|
+
const violations = [];
|
|
303
|
+
for (const file of files) {
|
|
304
|
+
const values = exportedValues(project.getSourceFileOrThrow(resolve(cwd, file)));
|
|
305
|
+
for (const [qualified, declaration] of values) {
|
|
306
|
+
if (excluded.has(qualified)) continue;
|
|
307
|
+
for (const { template, counterpart, missing } of halves) {
|
|
308
|
+
const shared = sharedName(qualified, template);
|
|
309
|
+
if (shared === void 0) continue;
|
|
310
|
+
const expected = `${shared.namespace}${counterpart.before}${shared.name}${counterpart.after}`;
|
|
311
|
+
if (!values.has(expected)) violations.push({
|
|
312
|
+
code: `codec-pairs/missing-${missing}`,
|
|
313
|
+
message: `${qualified} has no ${missing}: ${file} does not export ${expected}`,
|
|
314
|
+
file: relativePosix(cwd, declaration.getSourceFile().getFilePath()),
|
|
315
|
+
location: valueLocation(declaration)
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return violations.sort((a, b) => a.file.localeCompare(b.file) || (a.location?.line ?? 0) - (b.location?.line ?? 0) || a.message.localeCompare(b.message));
|
|
321
|
+
};
|
|
322
|
+
//#endregion
|
|
215
323
|
//#region src/checks/type-references.ts
|
|
216
324
|
/**
|
|
217
325
|
* The identifier a reference ends in: `infer` in `z.infer`, the name itself when it is not qualified.
|
|
@@ -3070,6 +3178,10 @@ function compiles(source) {
|
|
|
3070
3178
|
*/
|
|
3071
3179
|
const pattern = z.string().min(1).refine(compiles, "not a valid regular expression");
|
|
3072
3180
|
const patterns = z.array(pattern);
|
|
3181
|
+
/**
|
|
3182
|
+
* A name template of `codec-pairs`.
|
|
3183
|
+
*/
|
|
3184
|
+
const nameTemplate = z.string().refine((template) => parseNameTemplate(template) !== void 0, NAME_TEMPLATE_REQUIREMENT);
|
|
3073
3185
|
const severity = z.enum(["warn", "error"]);
|
|
3074
3186
|
const requiredRules = z.record(z.string().min(1), severity);
|
|
3075
3187
|
const selection = {
|
|
@@ -3102,6 +3214,16 @@ const checks = z.strictObject({
|
|
|
3102
3214
|
exclude: z.exactOptional(names),
|
|
3103
3215
|
tsConfig: z.exactOptional(path)
|
|
3104
3216
|
}))),
|
|
3217
|
+
"codec-pairs": z.exactOptional(enabled(z.strictObject({
|
|
3218
|
+
codecs: paths,
|
|
3219
|
+
encoder: nameTemplate,
|
|
3220
|
+
decoder: nameTemplate,
|
|
3221
|
+
exclude: z.exactOptional(names),
|
|
3222
|
+
tsConfig: z.exactOptional(path)
|
|
3223
|
+
}).refine((options) => options.encoder !== options.decoder, {
|
|
3224
|
+
path: ["decoder"],
|
|
3225
|
+
message: "the decoder template is the encoder template, so every name would be its own pair"
|
|
3226
|
+
}))),
|
|
3105
3227
|
"command-types": z.exactOptional(enabled(z.strictObject({
|
|
3106
3228
|
commands: paths,
|
|
3107
3229
|
exclude: z.exactOptional(names),
|
|
@@ -3358,6 +3480,12 @@ const registry = {
|
|
|
3358
3480
|
select: (checks) => enabledOptions(checks["aggregate-mappers"]),
|
|
3359
3481
|
check: aggregateMappers
|
|
3360
3482
|
}),
|
|
3483
|
+
"codec-pairs": plain({
|
|
3484
|
+
name: "codec-pairs",
|
|
3485
|
+
description: "Every exported encoder in a codec file has a decoder under the naming templates, and every decoder an encoder",
|
|
3486
|
+
select: (checks) => enabledOptions(checks["codec-pairs"]),
|
|
3487
|
+
check: codecPairs
|
|
3488
|
+
}),
|
|
3361
3489
|
"command-types": plain({
|
|
3362
3490
|
name: "command-types",
|
|
3363
3491
|
description: "Every exported command type is derived from a schema, not hand-written",
|
|
@@ -3717,4 +3845,4 @@ function proveExhaustive(input) {
|
|
|
3717
3845
|
}) };
|
|
3718
3846
|
}
|
|
3719
3847
|
//#endregion
|
|
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 };
|
|
3848
|
+
export { ConformanceError, DEFAULT_DERIVED_TYPE_INFERENCES, DEFAULT_INFERENCES, DEFAULT_WORKFLOWS, EXIT_CODES, ExhaustivenessError, PRESET_COMMIT_TYPES, accessOf, aggregateMappers, checkNames, classifyPackages, codecPairs, 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.
|
|
4
|
+
"version": "1.5.0",
|
|
5
5
|
"bin": {
|
|
6
6
|
"workspace-conformance": "./dist/cli.js"
|
|
7
7
|
},
|