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 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.0",
4
+ "version": "1.5.0",
5
5
  "bin": {
6
6
  "workspace-conformance": "./dist/cli.js"
7
7
  },