workspace-conformance 1.2.2 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -397,6 +397,40 @@ interface CommandTypesOptions {
397
397
  */
398
398
  readonly tsConfig?: string;
399
399
  }
400
+ /**
401
+ * A set of schema files and the files whose exported types must be derived from those schemas.
402
+ */
403
+ interface DerivedTypesPair {
404
+ /**
405
+ * Globs of the files that declare the schemas, relative to the directory the checks run in.
406
+ */
407
+ readonly schemas: readonly string[];
408
+ /**
409
+ * Globs of the files whose exported interfaces and type aliases must be derived from a schema declared in `schemas`.
410
+ */
411
+ readonly types: readonly string[];
412
+ }
413
+ /**
414
+ * Options of the `derived-types` check: every type a file exports is derived from a schema of its pair.
415
+ */
416
+ interface DerivedTypesOptions {
417
+ /**
418
+ * The schema files and the type files that must derive from them. Each type file is judged against the schemas of every pair that lists it.
419
+ */
420
+ readonly pairs: readonly DerivedTypesPair[];
421
+ /**
422
+ * Names of exported types that are deliberately written by hand.
423
+ */
424
+ readonly exclude?: readonly string[];
425
+ /**
426
+ * The last names of the generics that turn a schema into its type, and of the members that hold it, such as `infer` in `z.infer<typeof schema>` and `$inferSelect` in `typeof table.$inferSelect`. `infer`, `input`, `output`, `TypeOf`, `InferInput`, `InferOutput`, `InferSelectModel`, `InferInsertModel`, `$inferSelect` and `$inferInsert` when omitted.
427
+ */
428
+ readonly inferences?: readonly string[];
429
+ /**
430
+ * The tsconfig that supplies compiler options and module resolution; `tsconfig.json` when omitted.
431
+ */
432
+ readonly tsConfig?: string;
433
+ }
400
434
  /**
401
435
  * Options shared by the checks that cruise the workspace's import graph.
402
436
  */
@@ -474,6 +508,48 @@ interface DockerfilePackageManagerOptions {
474
508
  */
475
509
  readonly packageJson?: string;
476
510
  }
511
+ /**
512
+ * The severity a rule must have at least: `warn` is met by `warn` or `error`, `error` only by `error`.
513
+ */
514
+ type EslintRequiredSeverity = "warn" | "error";
515
+ /**
516
+ * A file whose resolved ESLint configuration is checked.
517
+ */
518
+ interface EslintSample {
519
+ /**
520
+ * The file ESLint would lint, relative to the directory the checks run in. It need not exist: ESLint resolves a file's configuration from its path alone.
521
+ */
522
+ readonly path: string;
523
+ /**
524
+ * Rules required for this file on top of the check's `rules`, which this file's entries override.
525
+ */
526
+ readonly rules?: Readonly<Record<string, EslintRequiredSeverity>>;
527
+ }
528
+ /**
529
+ * Options of the `eslint` check: the repository's own ESLint, resolved from the directory the checks run in, lints the sample files with the required rules.
530
+ */
531
+ interface EslintOptions {
532
+ /**
533
+ * One file per kind of source the repository lints (`src/index.ts`, `package.json`, `eslint.config.ts`). A bare string is `{ path }`. Each must be linted: not ignored, and matched by a configuration block.
534
+ */
535
+ readonly samples: readonly (string | EslintSample)[];
536
+ /**
537
+ * Rules required for every sample, with the severity each must have at least.
538
+ */
539
+ readonly rules?: Readonly<Record<string, EslintRequiredSeverity>>;
540
+ /**
541
+ * The config file under test, relative to the directory the checks run in, instead of the one ESLint finds from there.
542
+ */
543
+ readonly configFile?: string;
544
+ /**
545
+ * Also lint the workspace and report every message ESLint produces. Off when omitted: the default level lints nothing and only reads resolved configuration.
546
+ */
547
+ readonly lint?: boolean;
548
+ /**
549
+ * What the full level lints, as file and directory patterns relative to the directory the checks run in. `.` when omitted. Used only with `lint`.
550
+ */
551
+ readonly lintPatterns?: readonly string[];
552
+ }
477
553
  /**
478
554
  * Options of the `migrations-directory` check: the directory a schema generator writes migrations to is the one the deploy tool applies them from, and no script applies them with the generator.
479
555
  */
@@ -662,6 +738,7 @@ type SettingsReviewThreadResolutionOptions = SettingsOptions;
662
738
  interface CheckOptionsByName {
663
739
  readonly "aggregate-mappers": AggregateMappersOptions;
664
740
  readonly "command-types": CommandTypesOptions;
741
+ readonly "derived-types": DerivedTypesOptions;
665
742
  readonly "import-uphill": ImportGraphOptions;
666
743
  readonly "import-rank-skip": ImportGraphOptions;
667
744
  readonly "import-cross-slice": ImportGraphOptions;
@@ -672,6 +749,7 @@ interface CheckOptionsByName {
672
749
  readonly "commit-types": CommitTypesOptions;
673
750
  readonly "dockerfile-package-manager": DockerfilePackageManagerOptions;
674
751
  readonly "migrations-directory": MigrationsDirectoryOptions;
752
+ readonly eslint: EslintOptions;
675
753
  readonly "workflow-job-ordering": WorkflowJobOrderingOptions;
676
754
  readonly "workflow-skippable-jobs": WorkflowSkippableJobsOptions;
677
755
  readonly "workflow-runner-resolution": WorkflowRunnerResolutionOptions;
@@ -722,6 +800,18 @@ export declare const PRESET_COMMIT_TYPES: readonly string[];
722
800
  */
723
801
  export declare const commitTypes: CheckFunction<CommitTypesOptions>;
724
802
  //#endregion
803
+ //#region src/checks/derived-types.d.ts
804
+ /**
805
+ * The last names that count as inference when the options name none: those of `command-types`, Drizzle's `InferSelectModel` and `InferInsertModel` generics, and the `$inferSelect` and `$inferInsert` members of a Drizzle table, read as `typeof table.$inferSelect`.
806
+ */
807
+ export declare const DEFAULT_DERIVED_TYPE_INFERENCES: readonly string[];
808
+ /**
809
+ * For each pair, every exported interface and type alias of its type files is derived from a schema declared in its schema files: somewhere in its definition an inference (a generic in `inferences`, such as `z.infer` or `InferSelectModel`) is applied to `typeof schema`, or an inference member is read from it (`typeof table.$inferSelect`).
810
+ *
811
+ * The definition is followed through aliases and interfaces without type parameters, in any file, and through the arguments of other generics, intersections, unions whose members are all derived (`null` and `undefined` aside), arrays, type operators and indexed access. A type literal, and a schema passed to a generic that is not an inference, are written by hand.
812
+ */
813
+ export declare const derivedTypes: CheckFunction<DerivedTypesOptions>;
814
+ //#endregion
725
815
  //#region src/checks/dockerfile-package-manager.d.ts
726
816
  /**
727
817
  * Every pin of the package manager that `packageManager` names, in each Dockerfile, is the version `packageManager` names, with any `+sha` integrity suffix ignored on both sides. Pins of other package managers are not the workspace's package manager and are left alone.
@@ -730,6 +820,16 @@ export declare const commitTypes: CheckFunction<CommitTypesOptions>;
730
820
  */
731
821
  export declare const dockerfilePackageManager: CheckFunction<DockerfilePackageManagerOptions>;
732
822
  //#endregion
823
+ //#region src/checks/eslint.d.ts
824
+ /**
825
+ * Proves the repository's own ESLint is applied, which no check that runs inside ESLint can notice: a config that dropped a shared preset, switched its rules off or narrowed `ignores` until nothing is linted still passes its own lint.
826
+ *
827
+ * It loads the ESLint that resolves from the directory the checks run in and resolves the repository's own config through the Node API, so the answer is the one the repository's lint script gets. By default it lints nothing: for each sample it reads the resolved configuration (the object `--print-config` prints) and reports a sample that is not linted, and each required rule that is missing, off or weaker than required. With `lint` it also lints the workspace and reports every message.
828
+ *
829
+ * The config is a black box: the check sees which rules are active on a file, not why. It does not embed a config, so a repository without one is reported and nothing is substituted.
830
+ */
831
+ export declare const eslint: CheckFunction<EslintOptions>;
832
+ //#endregion
733
833
  //#region src/workspace/packages.d.ts
734
834
  /**
735
835
  * A package directory found under the workspace root, before the layout classifies it.
@@ -1222,4 +1322,4 @@ export declare function proveExhaustive(input: {
1222
1322
  readonly compilerOptions?: CompilerOptions;
1223
1323
  }): ExhaustivenessProof;
1224
1324
  //#endregion
1225
- export type { AggregateMappersOptions, BranchRules, CaseRemoval, CheckContext, CheckFunction, CheckName, CheckOptionsByName, CheckResult, ChecksConfig, CommandTypesOptions, CommitTypesOptions, ConformanceConfig, DeployDirectory, DeploySelector, DeployToolAdapter, DeployToolName, DiscoveredPackage, DockerfilePackageManagerOptions, ExhaustivenessProof, GeneratorAdapter, GeneratorName, GitHubCheckContext, GitHubCheckFunction, GitHubClient, GitHubClientOptions, ImportGraphOptions, InstructionSymlinksOptions, Job, LayoutCheckContext, LayoutCheckFunction, MigrationsDirectoryOptions, PermissionAccess, Permissions, PinningLevel, RegisteredCheck, RepositorySettings, RepositorySlug, RunChecksOptions, RunResult, RunsOn, SettingsMergeMethodsOptions, SettingsOptions, SettingsRequiredChecksOptions, SettingsReviewThreadResolutionOptions, SingleStorybookOptions, SourceLocation, Step, TypeErrorReport, UsesReference, Violation, Workflow, WorkflowActionPinningOptions, WorkflowCredentialsOptions, WorkflowJobOrderingOptions, WorkflowMergeGroupOptions, WorkflowRepositoryDispatchOptions, WorkflowRunnerResolutionOptions, WorkflowSelection, WorkflowSkippableJobsOptions, WorkflowUpdateBotCooldownOptions, WorkflowVersionSingleSourceOptions, WorkspacePackage };
1325
+ export type { AggregateMappersOptions, BranchRules, CaseRemoval, CheckContext, CheckFunction, CheckName, CheckOptionsByName, CheckResult, ChecksConfig, CommandTypesOptions, CommitTypesOptions, ConformanceConfig, DeployDirectory, DeploySelector, DeployToolAdapter, DeployToolName, DerivedTypesOptions, DerivedTypesPair, DiscoveredPackage, DockerfilePackageManagerOptions, EslintOptions, EslintRequiredSeverity, EslintSample, ExhaustivenessProof, GeneratorAdapter, GeneratorName, GitHubCheckContext, GitHubCheckFunction, GitHubClient, GitHubClientOptions, ImportGraphOptions, InstructionSymlinksOptions, Job, LayoutCheckContext, LayoutCheckFunction, MigrationsDirectoryOptions, PermissionAccess, Permissions, PinningLevel, RegisteredCheck, RepositorySettings, RepositorySlug, RunChecksOptions, RunResult, RunsOn, SettingsMergeMethodsOptions, SettingsOptions, SettingsRequiredChecksOptions, SettingsReviewThreadResolutionOptions, SingleStorybookOptions, SourceLocation, Step, TypeErrorReport, UsesReference, Violation, Workflow, WorkflowActionPinningOptions, WorkflowCredentialsOptions, WorkflowJobOrderingOptions, WorkflowMergeGroupOptions, WorkflowRepositoryDispatchOptions, WorkflowRunnerResolutionOptions, WorkflowSelection, WorkflowSkippableJobsOptions, WorkflowUpdateBotCooldownOptions, WorkflowVersionSingleSourceOptions, WorkspacePackage };
package/dist/index.js CHANGED
@@ -1,7 +1,8 @@
1
+ import { createRequire } from "node:module";
1
2
  import { existsSync } from "node:fs";
2
3
  import { dirname, extname, join, posix, relative, resolve, sep } from "node:path";
3
4
  import { escapePath, glob, isDynamicPattern } from "tinyglobby";
4
- import { ModuleKind, ModuleResolutionKind, Node, Project, ScriptTarget, ts } from "ts-morph";
5
+ import { ModuleKind, ModuleResolutionKind, Node, Project, ScriptTarget, SyntaxKind, ts } from "ts-morph";
5
6
  import { createJitiLoader, validateStandard } from "cosmiconfig-extends";
6
7
  import { readFile, realpath } from "node:fs/promises";
7
8
  import { LineCounter, isMap, isNode, isScalar, isSeq, parse, parseDocument } from "yaml";
@@ -211,6 +212,32 @@ const aggregateMappers = async ({ cwd, options }) => {
211
212
  return violations.sort((a, b) => a.file.localeCompare(b.file) || (a.location?.line ?? 0) - (b.location?.line ?? 0) || a.message.localeCompare(b.message));
212
213
  };
213
214
  //#endregion
215
+ //#region src/checks/type-references.ts
216
+ /**
217
+ * The identifier a reference ends in: `infer` in `z.infer`, the name itself when it is not qualified.
218
+ */
219
+ function lastIdentifier(reference) {
220
+ if (Node.isQualifiedName(reference)) return reference.getRight();
221
+ if (Node.isPropertyAccessExpression(reference)) return reference.getNameNode();
222
+ return reference;
223
+ }
224
+ /**
225
+ * The declarations the identifier refers to, through any imports and re-exports.
226
+ */
227
+ function declarationsOf(identifier) {
228
+ const symbol = identifier.getSymbol();
229
+ return (symbol?.getAliasedSymbol() ?? symbol)?.getDeclarations() ?? [];
230
+ }
231
+ /**
232
+ * The names a reference goes by: the last name as written, and the name it was declared with when that differs, as for `Infer` in `import type { infer as Infer } from 'zod'`, which is also `infer`. A library may itself re-export a generic under another name, so both count.
233
+ */
234
+ function referenceNames(reference) {
235
+ const identifier = lastIdentifier(reference);
236
+ const symbol = identifier.getSymbol();
237
+ const declared = (symbol?.getAliasedSymbol() ?? symbol)?.getName();
238
+ return declared === void 0 ? [identifier.getText()] : [.../* @__PURE__ */ new Set([identifier.getText(), declared])];
239
+ }
240
+ //#endregion
214
241
  //#region src/checks/command-types.ts
215
242
  /**
216
243
  * The last names of the generics that turn a schema into its type, used when the options name none: `z.infer`, `z.input`, `z.output`, `z.TypeOf` and Valibot's `InferInput` and `InferOutput`.
@@ -228,20 +255,10 @@ const DEFAULT_INFERENCES = [
228
255
  */
229
256
  const STANDARD_SCHEMA_MEMBER = "~standard";
230
257
  /**
231
- * The names a type reference goes by: the last name as written, and the name it was declared with when that differs, as for `Infer` in `import type { infer as Infer } from 'zod'`, which is also `infer`. A library may itself re-export a generic under another name, so both count.
232
- */
233
- function namesOf(typeName) {
234
- const identifier = Node.isQualifiedName(typeName) ? typeName.getRight() : typeName;
235
- const symbol = identifier.getSymbol();
236
- const declared = (symbol?.getAliasedSymbol() ?? symbol)?.getName();
237
- return declared === void 0 ? [identifier.getText()] : [.../* @__PURE__ */ new Set([identifier.getText(), declared])];
238
- }
239
- /**
240
258
  * The local alias without type parameters that a type reference names, or `undefined` when it names anything else. Such an alias is a name for the type it is written as, so it is judged by that.
241
259
  */
242
260
  function aliasNamed(typeName) {
243
- const symbol = typeName.getSymbol();
244
- const declaration = (symbol?.getAliasedSymbol() ?? symbol)?.getDeclarations().find(Node.isTypeAliasDeclaration);
261
+ const declaration = declarationsOf(lastIdentifier(typeName)).find(Node.isTypeAliasDeclaration);
245
262
  return declaration?.getTypeParameters().length === 0 ? declaration : void 0;
246
263
  }
247
264
  /**
@@ -254,7 +271,7 @@ function problem$1(declaration, name, inferences, followed = /* @__PURE__ */ new
254
271
  };
255
272
  const written = declaration.getTypeNode();
256
273
  if (Node.isTypeReference(written)) {
257
- const inference = namesOf(written.getTypeName()).find((candidate) => inferences.includes(candidate));
274
+ const inference = referenceNames(written.getTypeName()).find((candidate) => inferences.includes(candidate));
258
275
  if (inference !== void 0) {
259
276
  const [argument] = written.getTypeArguments();
260
277
  if (argument !== void 0 && Node.isTypeQuery(argument) && argument.getExprName().getType().getProperty(STANDARD_SCHEMA_MEMBER) !== void 0) return;
@@ -450,6 +467,120 @@ const commitTypes = async ({ cwd, options, configFiles }) => {
450
467
  return violations;
451
468
  };
452
469
  //#endregion
470
+ //#region src/checks/derived-types.ts
471
+ /**
472
+ * The last names that count as inference when the options name none: those of `command-types`, Drizzle's `InferSelectModel` and `InferInsertModel` generics, and the `$inferSelect` and `$inferInsert` members of a Drizzle table, read as `typeof table.$inferSelect`.
473
+ */
474
+ const DEFAULT_DERIVED_TYPE_INFERENCES = [
475
+ ...DEFAULT_INFERENCES,
476
+ "InferSelectModel",
477
+ "InferInsertModel",
478
+ "$inferSelect",
479
+ "$inferInsert"
480
+ ];
481
+ /**
482
+ * Whether the node names something declared in one of the schema files.
483
+ */
484
+ function declaredInSchema(name, derivation) {
485
+ const identifier = Node.isIdentifier(name) ? name : Node.isQualifiedName(name) ? name.getRight() : void 0;
486
+ return identifier !== void 0 && declarationsOf(identifier).some((declaration) => derivation.schemaFiles.has(relativePosix(derivation.cwd, declaration.getSourceFile().getFilePath())));
487
+ }
488
+ /**
489
+ * The interface or type alias without type parameters a reference names, when it has not been followed yet. Such a declaration is a name for what it is written as, so it is judged by that.
490
+ */
491
+ function followable(reference, derivation) {
492
+ const name = Node.isTypeReference(reference) ? reference.getTypeName() : reference.getExpression();
493
+ if (!Node.isIdentifier(name) && !Node.isQualifiedName(name) && !Node.isPropertyAccessExpression(name)) return;
494
+ const declaration = declarationsOf(lastIdentifier(name)).find((found) => Node.isTypeAliasDeclaration(found) || Node.isInterfaceDeclaration(found));
495
+ return declaration?.getTypeParameters().length === 0 && !derivation.followed.has(declaration) ? declaration : void 0;
496
+ }
497
+ function isNullish(node) {
498
+ return node.getKind() === SyntaxKind.UndefinedKeyword || Node.isLiteralTypeNode(node) && node.getLiteral().getKind() === SyntaxKind.NullKeyword;
499
+ }
500
+ /**
501
+ * Whether a reference is derived: an inference applied to `typeof schema`, an alias or interface that is itself derived, or another generic given a derived type, as `Partial<Order>` is.
502
+ */
503
+ function derivedReference(reference, derivation) {
504
+ const name = Node.isTypeReference(reference) ? reference.getTypeName() : reference.getExpression();
505
+ if ((Node.isIdentifier(name) || Node.isQualifiedName(name) || Node.isPropertyAccessExpression(name)) && referenceNames(name).some((candidate) => derivation.inferences.has(candidate))) {
506
+ const [argument] = reference.getTypeArguments();
507
+ return argument !== void 0 && Node.isTypeQuery(argument) && declaredInSchema(argument.getExprName(), derivation);
508
+ }
509
+ const declaration = followable(reference, derivation);
510
+ if (declaration !== void 0) return derivedDeclaration(declaration, {
511
+ ...derivation,
512
+ followed: /* @__PURE__ */ new Set([...derivation.followed, declaration])
513
+ });
514
+ return reference.getTypeArguments().some((argument) => derivedType(argument, derivation));
515
+ }
516
+ /**
517
+ * Whether a type, as written, is derived from a schema. A type literal is written by hand whatever its members are.
518
+ */
519
+ function derivedType(node, derivation) {
520
+ if (Node.isTypeReference(node) || Node.isExpressionWithTypeArguments(node)) return derivedReference(node, derivation);
521
+ if (Node.isTypeQuery(node)) {
522
+ const name = node.getExprName();
523
+ return Node.isQualifiedName(name) && referenceNames(name).some((candidate) => derivation.inferences.has(candidate)) && declaredInSchema(name.getLeft(), derivation);
524
+ }
525
+ if (Node.isParenthesizedTypeNode(node) || Node.isTypeOperatorTypeNode(node)) return derivedType(node.getTypeNode(), derivation);
526
+ if (Node.isArrayTypeNode(node)) return derivedType(node.getElementTypeNode(), derivation);
527
+ if (Node.isIndexedAccessTypeNode(node)) return derivedType(node.getObjectTypeNode(), derivation);
528
+ if (Node.isIntersectionTypeNode(node)) return node.getTypeNodes().some((member) => derivedType(member, derivation));
529
+ if (Node.isUnionTypeNode(node)) {
530
+ const members = node.getTypeNodes().filter((member) => !isNullish(member));
531
+ return members.length > 0 && members.every((member) => derivedType(member, derivation));
532
+ }
533
+ return false;
534
+ }
535
+ /**
536
+ * Whether an interface or type alias is derived: an alias by the type it is written as, an interface by whether it extends a derived type.
537
+ */
538
+ function derivedDeclaration(declaration, derivation) {
539
+ if (Node.isInterfaceDeclaration(declaration)) return declaration.getExtends().some((heritage) => derivedReference(heritage, derivation));
540
+ return derivedType(declaration.getTypeNodeOrThrow(), derivation);
541
+ }
542
+ async function pairViolations(cwd, options, pair, index) {
543
+ const { project, files } = await createProject(cwd, options.tsConfig, pair.types, {
544
+ check: "derived-types",
545
+ files: `pairs.${String(index)}.types`
546
+ });
547
+ const schemaFiles = await findFiles(cwd, pair.schemas);
548
+ if (schemaFiles.length === 0) throw new ConformanceError(`checks.derived-types.pairs.${String(index)}.schemas: no file matches`);
549
+ const excluded = new Set(options.exclude);
550
+ const derivation = {
551
+ inferences: new Set(options.inferences ?? DEFAULT_DERIVED_TYPE_INFERENCES),
552
+ schemaFiles: new Set(schemaFiles),
553
+ cwd,
554
+ followed: /* @__PURE__ */ new Set()
555
+ };
556
+ const seen = /* @__PURE__ */ new Set();
557
+ const violations = [];
558
+ for (const file of files) for (const [name, declaration] of exportedTypes(project.getSourceFileOrThrow(resolve(cwd, file)))) {
559
+ if (excluded.has(name) || seen.has(declaration)) continue;
560
+ seen.add(declaration);
561
+ if (!derivedDeclaration(declaration, {
562
+ ...derivation,
563
+ followed: /* @__PURE__ */ new Set([declaration])
564
+ })) violations.push({
565
+ code: "derived-types/not-derived",
566
+ message: `${name} is not derived from a schema in ${pair.schemas.join(", ")}: nothing in its definition applies an inference generic to one`,
567
+ file: relativePosix(cwd, declaration.getSourceFile().getFilePath()),
568
+ location: declarationLocation(declaration)
569
+ });
570
+ }
571
+ return violations;
572
+ }
573
+ /**
574
+ * For each pair, every exported interface and type alias of its type files is derived from a schema declared in its schema files: somewhere in its definition an inference (a generic in `inferences`, such as `z.infer` or `InferSelectModel`) is applied to `typeof schema`, or an inference member is read from it (`typeof table.$inferSelect`).
575
+ *
576
+ * The definition is followed through aliases and interfaces without type parameters, in any file, and through the arguments of other generics, intersections, unions whose members are all derived (`null` and `undefined` aside), arrays, type operators and indexed access. A type literal, and a schema passed to a generic that is not an inference, are written by hand.
577
+ */
578
+ const derivedTypes = async ({ cwd, options }) => {
579
+ const violations = [];
580
+ for (const [index, pair] of options.pairs.entries()) violations.push(...await pairViolations(cwd, options, pair, index));
581
+ return violations.sort((a, b) => a.file.localeCompare(b.file) || (a.location?.line ?? 0) - (b.location?.line ?? 0) || a.message.localeCompare(b.message));
582
+ };
583
+ //#endregion
453
584
  //#region src/checks/dockerfile-package-manager.ts
454
585
  const DEFAULT_DOCKERFILES = [
455
586
  "**/Dockerfile",
@@ -542,6 +673,179 @@ const dockerfilePackageManager = async ({ cwd, options }) => {
542
673
  return violations;
543
674
  };
544
675
  //#endregion
676
+ //#region src/checks/eslint.ts
677
+ /**
678
+ * The patterns the full level lints when the options name none.
679
+ */
680
+ const DEFAULT_LINT_PATTERNS = ["."];
681
+ const SEVERITY_RANK = {
682
+ off: 0,
683
+ warn: 1,
684
+ error: 2
685
+ };
686
+ function hasEslintClass(value) {
687
+ return isRecord(value) && typeof value["ESLint"] === "function";
688
+ }
689
+ /**
690
+ * The `ESLint` class of the ESLint installed where the checks run, found the way a module in `cwd` finds it. It is not this package's own copy, so the results agree with the repository's own lint script.
691
+ */
692
+ function loadEslint(cwd) {
693
+ const requireFromCwd = createRequire(join(cwd, "package.json"));
694
+ try {
695
+ requireFromCwd.resolve("eslint");
696
+ } catch (error) {
697
+ throw new ConformanceError(`the eslint check uses the repository's own ESLint, and 'eslint' does not resolve from ${cwd}; install it there`, { cause: error });
698
+ }
699
+ const loaded = requireFromCwd("eslint");
700
+ if (!hasEslintClass(loaded)) throw new ConformanceError(`the 'eslint' that resolves from ${cwd} does not export the ESLint class of the Node API`);
701
+ return loaded.ESLint;
702
+ }
703
+ /**
704
+ * The severity a rule entry resolves to. An entry is a severity or an array whose first element is one, and a severity is `0`, `1`, `2`, `'off'`, `'warn'` or `'error'`; `calculateConfigForFile` returns whichever form the config was written in.
705
+ */
706
+ function normaliseSeverity(entry) {
707
+ const severity = Array.isArray(entry) ? entry[0] : entry;
708
+ if (severity === 0 || severity === "off") return "off";
709
+ if (severity === 1 || severity === "warn") return "warn";
710
+ if (severity === 2 || severity === "error") return "error";
711
+ throw new ConformanceError(`ESLint resolved ${JSON.stringify(entry)} as a rule entry, which is not a severity or an array that starts with one`);
712
+ }
713
+ /**
714
+ * The severity of each rule in a resolved configuration.
715
+ */
716
+ function resolvedSeverities(config) {
717
+ if (!isRecord(config)) throw new ConformanceError("ESLint resolved a configuration that is not an object");
718
+ const { rules } = config;
719
+ if (rules !== void 0 && !isRecord(rules)) throw new ConformanceError("ESLint resolved a configuration whose 'rules' is not an object");
720
+ return new Map(Object.entries(rules ?? {}).map(([rule, entry]) => [rule, normaliseSeverity(entry)]));
721
+ }
722
+ /**
723
+ * Whether `error` is the one `calculateConfigForFile` throws when ESLint finds no config file for the path.
724
+ */
725
+ function isMissingConfig(error) {
726
+ return isRecord(error) && error["messageTemplate"] === "config-file-missing";
727
+ }
728
+ function sampleOf(sample) {
729
+ return typeof sample === "string" ? { path: sample } : sample;
730
+ }
731
+ function ruleViolations(file, required, actual) {
732
+ return Object.entries(required).flatMap(([rule, severity]) => {
733
+ const found = actual.get(rule);
734
+ if (found === void 0) return [{
735
+ code: "eslint/rule-missing",
736
+ message: `the rule '${rule}' is not configured for ${file} (required: ${severity})`,
737
+ file
738
+ }];
739
+ if (found === "off") return [{
740
+ code: "eslint/rule-off",
741
+ message: `the rule '${rule}' is off for ${file} (required: ${severity})`,
742
+ file
743
+ }];
744
+ if (SEVERITY_RANK[found] < SEVERITY_RANK[severity]) return [{
745
+ code: "eslint/rule-too-weak",
746
+ message: `the rule '${rule}' is '${found}' for ${file} (required: ${severity})`,
747
+ file
748
+ }];
749
+ return [];
750
+ });
751
+ }
752
+ async function sampleViolations(eslint, sample, shared) {
753
+ const file = sample.path;
754
+ let config;
755
+ try {
756
+ config = await eslint.calculateConfigForFile(file);
757
+ } catch (error) {
758
+ if (isMissingConfig(error)) return [{
759
+ code: "eslint/no-config",
760
+ message: `ESLint finds no config file for ${file}; the check does not fall back to a config of its own`,
761
+ file
762
+ }];
763
+ throw new ConformanceError(`ESLint could not resolve its configuration for ${file}`, { cause: error });
764
+ }
765
+ if (config === void 0) return [{
766
+ code: "eslint/not-linted",
767
+ message: `${file} is not linted: it is ignored, or no configuration block matches it`,
768
+ file
769
+ }];
770
+ return ruleViolations(file, {
771
+ ...shared,
772
+ ...sample.rules
773
+ }, resolvedSeverities(config));
774
+ }
775
+ /**
776
+ * Whether `error` is ESLint refusing a pattern because it selects no file that is linted.
777
+ */
778
+ function isNothingLinted(error) {
779
+ return error instanceof Error && isRecord(error) && (error["messageTemplate"] === "file-not-found" || error["messageTemplate"] === "all-matched-files-ignored");
780
+ }
781
+ function messageViolation(cwd, result, message) {
782
+ const file = relativePosix(cwd, result.filePath);
783
+ const location = {
784
+ line: message.line,
785
+ column: message.column
786
+ };
787
+ if (message.ruleId !== null) {
788
+ const severity = message.severity === 1 ? "warn" : "error";
789
+ return {
790
+ code: `eslint/lint/${message.ruleId}`,
791
+ message: `${message.ruleId} (${severity}): ${message.message}`,
792
+ file,
793
+ location
794
+ };
795
+ }
796
+ return message.fatal === true ? {
797
+ code: "eslint/fatal",
798
+ message: message.message,
799
+ file,
800
+ location
801
+ } : {
802
+ code: "eslint/lint-message",
803
+ message: message.message,
804
+ file,
805
+ location
806
+ };
807
+ }
808
+ function byPosition(left, right) {
809
+ return left.file.localeCompare(right.file) || (left.location?.line ?? 0) - (right.location?.line ?? 0) || (left.location?.column ?? 0) - (right.location?.column ?? 0) || left.code.localeCompare(right.code);
810
+ }
811
+ /**
812
+ * Lints the workspace through the Node API, with the repository's own config, and maps every message to a violation. A pattern that selects no linted file is itself a violation, since the workspace is then not linted at all.
813
+ */
814
+ async function lintViolations(cwd, eslint, patterns) {
815
+ const violations = [];
816
+ const results = /* @__PURE__ */ new Map();
817
+ for (const pattern of patterns) try {
818
+ for (const result of await eslint.lintFiles([pattern])) results.set(result.filePath, result);
819
+ } catch (error) {
820
+ if (!isNothingLinted(error)) throw new ConformanceError(`ESLint could not lint '${pattern}'`, { cause: error });
821
+ violations.push({
822
+ code: "eslint/nothing-linted",
823
+ message: error.message,
824
+ file: pattern
825
+ });
826
+ }
827
+ for (const result of results.values()) for (const message of result.messages) violations.push(messageViolation(cwd, result, message));
828
+ return violations.sort(byPosition);
829
+ }
830
+ /**
831
+ * Proves the repository's own ESLint is applied, which no check that runs inside ESLint can notice: a config that dropped a shared preset, switched its rules off or narrowed `ignores` until nothing is linted still passes its own lint.
832
+ *
833
+ * It loads the ESLint that resolves from the directory the checks run in and resolves the repository's own config through the Node API, so the answer is the one the repository's lint script gets. By default it lints nothing: for each sample it reads the resolved configuration (the object `--print-config` prints) and reports a sample that is not linted, and each required rule that is missing, off or weaker than required. With `lint` it also lints the workspace and reports every message.
834
+ *
835
+ * The config is a black box: the check sees which rules are active on a file, not why. It does not embed a config, so a repository without one is reported and nothing is substituted.
836
+ */
837
+ const eslint = async ({ cwd, options }) => {
838
+ const instance = new (loadEslint(cwd))({
839
+ cwd,
840
+ ...options.configFile === void 0 ? {} : { overrideConfigFile: resolve(cwd, options.configFile) }
841
+ });
842
+ const shared = options.rules ?? {};
843
+ const violations = [];
844
+ for (const sample of options.samples.map(sampleOf)) violations.push(...await sampleViolations(instance, sample, shared));
845
+ if (options.lint === true) violations.push(...await lintViolations(cwd, instance, options.lintPatterns ?? DEFAULT_LINT_PATTERNS));
846
+ return violations;
847
+ };
848
+ //#endregion
545
849
  //#region src/workspace/rules.ts
546
850
  const REGEXP_SPECIAL = /[.*+?^${}()|[\]\\]/gu;
547
851
  function escapeRegExp(text) {
@@ -2766,6 +3070,8 @@ function compiles(source) {
2766
3070
  */
2767
3071
  const pattern = z.string().min(1).refine(compiles, "not a valid regular expression");
2768
3072
  const patterns = z.array(pattern);
3073
+ const severity = z.enum(["warn", "error"]);
3074
+ const requiredRules = z.record(z.string().min(1), severity);
2769
3075
  const selection = {
2770
3076
  workflows: z.exactOptional(paths),
2771
3077
  exclude: z.exactOptional(paths)
@@ -2802,6 +3108,15 @@ const checks = z.strictObject({
2802
3108
  inferences: z.exactOptional(paths),
2803
3109
  tsConfig: z.exactOptional(path)
2804
3110
  }))),
3111
+ "derived-types": z.exactOptional(enabled(z.strictObject({
3112
+ pairs: z.array(z.strictObject({
3113
+ schemas: paths,
3114
+ types: paths
3115
+ })).min(1),
3116
+ exclude: z.exactOptional(names),
3117
+ inferences: z.exactOptional(paths),
3118
+ tsConfig: z.exactOptional(path)
3119
+ }))),
2805
3120
  "import-uphill": z.exactOptional(enabled(importGraph)),
2806
3121
  "import-rank-skip": z.exactOptional(enabled(importGraph)),
2807
3122
  "import-cross-slice": z.exactOptional(enabled(importGraph)),
@@ -2825,6 +3140,16 @@ const checks = z.strictObject({
2825
3140
  exclude: z.exactOptional(paths),
2826
3141
  packageJson: z.exactOptional(path)
2827
3142
  }))),
3143
+ eslint: z.exactOptional(enabled(z.strictObject({
3144
+ samples: z.array(z.union([path, z.strictObject({
3145
+ path,
3146
+ rules: z.exactOptional(requiredRules)
3147
+ })])).min(1),
3148
+ rules: z.exactOptional(requiredRules),
3149
+ configFile: z.exactOptional(path),
3150
+ lint: z.exactOptional(z.boolean()),
3151
+ lintPatterns: z.exactOptional(paths)
3152
+ }))),
2828
3153
  "migrations-directory": z.exactOptional(enabled(z.strictObject({
2829
3154
  generator: z.enum(generatorNames),
2830
3155
  deployTool: z.enum(deployToolNames),
@@ -3039,6 +3364,12 @@ const registry = {
3039
3364
  select: (checks) => enabledOptions(checks["command-types"]),
3040
3365
  check: commandTypes
3041
3366
  }),
3367
+ "derived-types": plain({
3368
+ name: "derived-types",
3369
+ description: "Every exported type in a file paired with schema files is derived from one of those schemas, not hand-written",
3370
+ select: (checks) => enabledOptions(checks["derived-types"]),
3371
+ check: derivedTypes
3372
+ }),
3042
3373
  "import-uphill": importCheck({
3043
3374
  name: "import-uphill",
3044
3375
  description: "No package imports a package of a higher rank",
@@ -3099,6 +3430,12 @@ const registry = {
3099
3430
  select: (checks) => enabledOptions(checks["migrations-directory"]),
3100
3431
  check: migrationsDirectory
3101
3432
  }),
3433
+ eslint: plain({
3434
+ name: "eslint",
3435
+ description: "The repository's own ESLint lints the sample files with the required rules enabled at the required severity",
3436
+ select: (checks) => enabledOptions(checks.eslint),
3437
+ check: eslint
3438
+ }),
3102
3439
  "workflow-job-ordering": plain({
3103
3440
  name: "workflow-job-ordering",
3104
3441
  description: "Release, deploy and junction jobs are ordered so a failure stops what follows",
@@ -3380,4 +3717,4 @@ function proveExhaustive(input) {
3380
3717
  }) };
3381
3718
  }
3382
3719
  //#endregion
3383
- export { ConformanceError, DEFAULT_INFERENCES, DEFAULT_WORKFLOWS, EXIT_CODES, ExhaustivenessError, PRESET_COMMIT_TYPES, accessOf, aggregateMappers, checkNames, classifyPackages, commandTypes, commitTypes, conformanceSchema, conformanceSection, createGitHubClient, crossSliceRules, cycleRule, deployTools, dockerfilePackageManager, effectiveEnv, effectivePermissions, generators, handlerCases, importCrossSlice, importCycles, importIsolatedGroups, importRankSkip, importUphill, instructionSymlinks, isCheckName, isolatedGroupRules, loadWorkflows, migrationsDirectory, packagesPattern, parseUses, parseWorkflow, proveExhaustive, rankSkipRules, registry, removeCase, runChecks, settingsMergeMethods, settingsRequiredChecks, settingsReviewThreadResolution, singleStorybook, transitiveNeeds, typeErrors, uphillRules, workflowActionPinning, workflowCredentials, workflowJobOrdering, workflowMergeGroup, workflowRepositoryDispatch, workflowRunnerResolution, workflowSkippableJobs, workflowUpdateBotCooldown, workflowVersionSingleSource };
3720
+ export { ConformanceError, DEFAULT_DERIVED_TYPE_INFERENCES, DEFAULT_INFERENCES, DEFAULT_WORKFLOWS, EXIT_CODES, ExhaustivenessError, PRESET_COMMIT_TYPES, accessOf, aggregateMappers, checkNames, classifyPackages, commandTypes, commitTypes, conformanceSchema, conformanceSection, createGitHubClient, crossSliceRules, cycleRule, deployTools, derivedTypes, dockerfilePackageManager, effectiveEnv, effectivePermissions, eslint, generators, handlerCases, importCrossSlice, importCycles, importIsolatedGroups, importRankSkip, importUphill, instructionSymlinks, isCheckName, isolatedGroupRules, loadWorkflows, migrationsDirectory, packagesPattern, parseUses, parseWorkflow, proveExhaustive, rankSkipRules, registry, removeCase, runChecks, settingsMergeMethods, settingsRequiredChecks, settingsReviewThreadResolution, singleStorybook, transitiveNeeds, typeErrors, uphillRules, workflowActionPinning, workflowCredentials, workflowJobOrdering, workflowMergeGroup, workflowRepositoryDispatch, workflowRunnerResolution, workflowSkippableJobs, workflowUpdateBotCooldown, workflowVersionSingleSource };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "workspace-conformance",
3
3
  "description": "Conformance checks for a pnpm workspace that ESLint cannot express: type-graph, import-graph and cross-file checks driven by the shared layout section of @exadev/config",
4
- "version": "1.2.2",
4
+ "version": "1.4.0",
5
5
  "bin": {
6
6
  "workspace-conformance": "./dist/cli.js"
7
7
  },
@@ -72,8 +72,14 @@
72
72
  "packageManager": "pnpm@12.4.1+sha512.2e81e399d73fe8390dab25e06aa788ab7a5908248d2f5a370f82b481147a6a7a367bf8048f9a6fdb6460f21a66f0542dedb8b94ca2c8723596741920b1656d4c",
73
73
  "peerDependencies": {
74
74
  "cosmiconfig": "^9.0.0 || ^10.0.0",
75
+ "eslint": "^9.0.0 || ^10.0.0",
75
76
  "typescript": "^5.9.0 || ^6.0.0"
76
77
  },
78
+ "peerDependenciesMeta": {
79
+ "eslint": {
80
+ "optional": true
81
+ }
82
+ },
77
83
  "publishConfig": {
78
84
  "access": "public",
79
85
  "provenance": true