From 59d494b7e98bdbcef16d44f260bcec1fcaddc3d2 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Mon, 22 Jun 2026 11:58:07 -0500 Subject: [PATCH] make the existing general import predicates value specific and introduce type specific equivalents --- docs/README.md | 2 +- docs/guides/exploring-codebases.md | 2 +- docs/reference/predicates.md | 45 ++++- .../konsistent-reverse.json | 9 +- .../konsistent.json | 15 +- .../import-source-groups-broken/shared.ts | 2 + .../src/current.ts | 4 +- .../src/external.ts | 5 +- .../import-source-groups-broken/src/helper.ts | 4 + .../import-source-groups-broken/src/parent.ts | 4 +- .../konsistent-reverse.json | 9 +- .../import-source-groups/konsistent.json | 15 +- e2e/fixtures/import-source-groups/shared.ts | 2 + .../import-source-groups/src/current.ts | 4 +- .../import-source-groups/src/external.ts | 4 +- .../import-source-groups/src/helper.ts | 4 + .../import-source-groups/src/parent.ts | 4 +- e2e/must-not.test.ts | 3 + e2e/new-predicates.test.ts | 12 ++ .../reusable-convention-package.schema.json | 36 ++++ packages/convention/src/index.test.ts | 3 + packages/convention/src/schemas.ts | 3 + packages/konsistent/konsistent.schema.json | 162 ++++++++++++++++++ packages/konsistent/src/config/schema.test.ts | 3 + .../src/core/convention-name.test.ts | 22 +++ .../konsistent/src/core/convention-name.ts | 12 ++ packages/konsistent/src/core/runner.ts | 75 ++++++++ .../konsistent/src/typescript/parser.test.ts | 44 +++++ packages/konsistent/src/typescript/parser.ts | 54 +++++- .../predicates/import-source.test.ts | 93 ++++++++++ .../typescript/predicates/import-source.ts | 21 ++- 31 files changed, 634 insertions(+), 43 deletions(-) diff --git a/docs/README.md b/docs/README.md index 4c7dc7d..ffc7d99 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,7 +22,7 @@ - [CLI](./reference/cli.md) — commands, flags, output formats, exit codes. - [konsistent.json configuration](./reference/configuration.md) — top-level schema and convention shape. - [Path patterns](./reference/path-patterns.md) — globs, placeholders, case transformations, negation. -- [Predicates](./reference/predicates.md) — every `must` predicate (`haveType`, `haveFiles`, `export`, `exportTypes`, `exportConstants`, `exportFunctions`, `exportInterfaces`, `exportClasses`, `import`, `importTypes`). +- [Predicates](./reference/predicates.md) — every `must` predicate (`haveType`, `haveFiles`, `export`, `exportTypes`, `exportConstants`, `exportFunctions`, `exportInterfaces`, `exportClasses`, `import`, `importTypes`, import source predicates). - [Constraints](./reference/constraints.md) — `matches`, `segments` for filtering placeholders. - [Conditional rules](./reference/conditional-rules.md) — `if`, `for`, `excludeFiles` blocks inside `must` arrays. - [Case maps](./reference/case-maps.md) — `kebabToPascalMap`, `kebabToCamelMap` for acronyms and special casing. diff --git a/docs/guides/exploring-codebases.md b/docs/guides/exploring-codebases.md index 9a8518f..e07759a 100644 --- a/docs/guides/exploring-codebases.md +++ b/docs/guides/exploring-codebases.md @@ -148,7 +148,7 @@ For every pattern you identify: - Required files → `haveFiles`. - Local declarations → `declareTypes`, `declareConstants`, `declareFunctions`, `declareInterfaces`, `declareClasses`. - Exports → `export`, `exportTypes`, `exportConstants`, `exportFunctions`, `exportInterfaces`, `exportClasses`. -- Imports → `import`, `importTypes`, `importFromCurrentDir`, `importFromParents`, `importFromExternals`. +- Imports → `import`, `importTypes`, `importFromCurrentDir`, `importFromParents`, `importFromExternals`, `importTypesFromCurrentDir`, `importTypesFromParents`, `importTypesFromExternals`. - Optional file conditions → `if.hasFile` blocks. - Subset-only rules → `if.placeholderSatisfies` with a `matches` or `segments` constraint. diff --git a/docs/reference/predicates.md b/docs/reference/predicates.md index 883e373..a63946f 100644 --- a/docs/reference/predicates.md +++ b/docs/reference/predicates.md @@ -28,6 +28,9 @@ The full machine-readable schema lives at `node_modules/konsistent/konsistent.sc - [`importFromCurrentDir`](#importfromcurrentdir) - [`importFromParents`](#importfromparents) - [`importFromExternals`](#importfromexternals) + - [`importTypesFromCurrentDir`](#importtypesfromcurrentdir) + - [`importTypesFromParents`](#importtypesfromparents) + - [`importTypesFromExternals`](#importtypesfromexternals) - [Structural predicates](#structural-predicates) - [`useDeclarationOrder`](#usedeclarationorder) - [`areBarrelFiles`](#arebarrelfiles) @@ -338,33 +341,63 @@ Same shape as `import`. Useful for enforcing dependency direction — every adap ### `importFromCurrentDir` -Assert whether the file imports from the current directory via `./...`. +Assert whether the file has value imports from the current directory via `./...`. ```json "must": { "importFromCurrentDir": true } ``` -`true` requires at least one import whose module specifier is `"."` or starts with `"./"`. `false` forbids those imports. Both value and type imports count, as do side-effect imports such as `import "./setup"`. +`true` requires at least one non-type import whose module specifier is `"."` or starts with `"./"`. `false` forbids those imports. Type-only imports are ignored. Side-effect imports such as `import "./setup"` count as value imports. ### `importFromParents` -Assert whether the file imports from parent directories via `../...`. +Assert whether the file has value imports from parent directories via `../...`. ```json "must": { "importFromParents": false } ``` -`true` requires at least one import whose module specifier is `".."` or starts with `"../"`. `false` forbids those imports. +`true` requires at least one non-type import whose module specifier is `".."` or starts with `"../"`. `false` forbids those imports. Type-only imports are ignored. ### `importFromExternals` -Assert whether the file imports from external module specifiers. +Assert whether the file has value imports from external module specifiers. ```json "must": { "importFromExternals": true } ``` -`true` requires at least one import that is not `./...` or `../...`. `false` forbids those imports. Scoped packages, `node:` modules, and unresolved path aliases such as `@/utils` count as external because `konsistent` does not resolve module aliases. +`true` requires at least one non-type import that is not `./...` or `../...`. `false` forbids those imports. Scoped packages, `node:` modules, and unresolved path aliases such as `@/utils` count as external because `konsistent` does not resolve module aliases. Type-only imports are ignored. + +### `importTypesFromCurrentDir` + +Assert whether the file has type-only imports from the current directory via `./...`. + +```json +"must": { "importTypesFromCurrentDir": true } +``` + +`true` requires at least one type import whose module specifier is `"."` or starts with `"./"`. `false` forbids those imports. Value imports are ignored. + +### `importTypesFromParents` + +Assert whether the file has type-only imports from parent directories via `../...`. + +```json +"must": { "importTypesFromParents": false } +``` + +`true` requires at least one type import whose module specifier is `".."` or starts with `"../"`. `false` forbids those imports. Value imports are ignored. + +### `importTypesFromExternals` + +Assert whether the file has type-only imports from external module specifiers. + +```json +"must": { "importTypesFromExternals": true } +``` + +`true` requires at least one type import that is not `./...` or `../...`. `false` forbids those imports. Scoped packages, `node:` modules, and unresolved path aliases such as `@/utils` count as external because `konsistent` does not resolve module aliases. Value imports are ignored. --- diff --git a/e2e/fixtures/import-source-groups-broken/konsistent-reverse.json b/e2e/fixtures/import-source-groups-broken/konsistent-reverse.json index 68348e2..724fc2f 100644 --- a/e2e/fixtures/import-source-groups-broken/konsistent-reverse.json +++ b/e2e/fixtures/import-source-groups-broken/konsistent-reverse.json @@ -5,21 +5,24 @@ "name": "no-current-dir-imports", "paths": "src/current.ts", "mustNot": { - "importFromCurrentDir": true + "importFromCurrentDir": true, + "importTypesFromCurrentDir": true } }, { "name": "no-parent-imports", "paths": "src/parent.ts", "mustNot": { - "importFromParents": true + "importFromParents": true, + "importTypesFromParents": true } }, { "name": "no-external-imports", "paths": "src/external.ts", "mustNot": { - "importFromExternals": true + "importFromExternals": true, + "importTypesFromExternals": true } } ] diff --git a/e2e/fixtures/import-source-groups-broken/konsistent.json b/e2e/fixtures/import-source-groups-broken/konsistent.json index 81e2b60..f3721f9 100644 --- a/e2e/fixtures/import-source-groups-broken/konsistent.json +++ b/e2e/fixtures/import-source-groups-broken/konsistent.json @@ -7,7 +7,10 @@ "must": { "importFromCurrentDir": true, "importFromParents": false, - "importFromExternals": false + "importFromExternals": false, + "importTypesFromCurrentDir": true, + "importTypesFromParents": false, + "importTypesFromExternals": false } }, { @@ -16,7 +19,10 @@ "must": { "importFromCurrentDir": false, "importFromParents": true, - "importFromExternals": false + "importFromExternals": false, + "importTypesFromCurrentDir": false, + "importTypesFromParents": true, + "importTypesFromExternals": false } }, { @@ -25,7 +31,10 @@ "must": { "importFromCurrentDir": false, "importFromParents": false, - "importFromExternals": true + "importFromExternals": true, + "importTypesFromCurrentDir": false, + "importTypesFromParents": false, + "importTypesFromExternals": true } } ] diff --git a/e2e/fixtures/import-source-groups-broken/shared.ts b/e2e/fixtures/import-source-groups-broken/shared.ts index ac3fd38..3ae32a4 100644 --- a/e2e/fixtures/import-source-groups-broken/shared.ts +++ b/e2e/fixtures/import-source-groups-broken/shared.ts @@ -1,3 +1,5 @@ export interface Shared { value: string; } + +export const sharedValue = "shared"; diff --git a/e2e/fixtures/import-source-groups-broken/src/current.ts b/e2e/fixtures/import-source-groups-broken/src/current.ts index 0899b35..5dd3eee 100644 --- a/e2e/fixtures/import-source-groups-broken/src/current.ts +++ b/e2e/fixtures/import-source-groups-broken/src/current.ts @@ -1,3 +1,3 @@ -import type { Shared } from "../shared"; +import { sharedValue, type Shared } from "../shared"; -export type Current = Shared; +export const current = (shared: Shared) => shared.value || sharedValue; diff --git a/e2e/fixtures/import-source-groups-broken/src/external.ts b/e2e/fixtures/import-source-groups-broken/src/external.ts index c21a6b7..c680243 100644 --- a/e2e/fixtures/import-source-groups-broken/src/external.ts +++ b/e2e/fixtures/import-source-groups-broken/src/external.ts @@ -1,3 +1,4 @@ -import { helper } from "./helper"; +import { helper, type HelperOptions } from "./helper"; -export const external = helper; +export const external = (options: HelperOptions) => + `${helper}:${options.value}`; diff --git a/e2e/fixtures/import-source-groups-broken/src/helper.ts b/e2e/fixtures/import-source-groups-broken/src/helper.ts index 6119c1e..d68ec80 100644 --- a/e2e/fixtures/import-source-groups-broken/src/helper.ts +++ b/e2e/fixtures/import-source-groups-broken/src/helper.ts @@ -1 +1,5 @@ +export interface HelperOptions { + value: string; +} + export const helper = "helper"; diff --git a/e2e/fixtures/import-source-groups-broken/src/parent.ts b/e2e/fixtures/import-source-groups-broken/src/parent.ts index ca03d0d..628ffbb 100644 --- a/e2e/fixtures/import-source-groups-broken/src/parent.ts +++ b/e2e/fixtures/import-source-groups-broken/src/parent.ts @@ -1,3 +1,3 @@ -import { z } from "zod"; +import { z, type ZodType } from "zod"; -export const parent = z.string(); +export const parent: ZodType = z.string(); diff --git a/e2e/fixtures/import-source-groups/konsistent-reverse.json b/e2e/fixtures/import-source-groups/konsistent-reverse.json index 68348e2..724fc2f 100644 --- a/e2e/fixtures/import-source-groups/konsistent-reverse.json +++ b/e2e/fixtures/import-source-groups/konsistent-reverse.json @@ -5,21 +5,24 @@ "name": "no-current-dir-imports", "paths": "src/current.ts", "mustNot": { - "importFromCurrentDir": true + "importFromCurrentDir": true, + "importTypesFromCurrentDir": true } }, { "name": "no-parent-imports", "paths": "src/parent.ts", "mustNot": { - "importFromParents": true + "importFromParents": true, + "importTypesFromParents": true } }, { "name": "no-external-imports", "paths": "src/external.ts", "mustNot": { - "importFromExternals": true + "importFromExternals": true, + "importTypesFromExternals": true } } ] diff --git a/e2e/fixtures/import-source-groups/konsistent.json b/e2e/fixtures/import-source-groups/konsistent.json index 81e2b60..f3721f9 100644 --- a/e2e/fixtures/import-source-groups/konsistent.json +++ b/e2e/fixtures/import-source-groups/konsistent.json @@ -7,7 +7,10 @@ "must": { "importFromCurrentDir": true, "importFromParents": false, - "importFromExternals": false + "importFromExternals": false, + "importTypesFromCurrentDir": true, + "importTypesFromParents": false, + "importTypesFromExternals": false } }, { @@ -16,7 +19,10 @@ "must": { "importFromCurrentDir": false, "importFromParents": true, - "importFromExternals": false + "importFromExternals": false, + "importTypesFromCurrentDir": false, + "importTypesFromParents": true, + "importTypesFromExternals": false } }, { @@ -25,7 +31,10 @@ "must": { "importFromCurrentDir": false, "importFromParents": false, - "importFromExternals": true + "importFromExternals": true, + "importTypesFromCurrentDir": false, + "importTypesFromParents": false, + "importTypesFromExternals": true } } ] diff --git a/e2e/fixtures/import-source-groups/shared.ts b/e2e/fixtures/import-source-groups/shared.ts index ac3fd38..3ae32a4 100644 --- a/e2e/fixtures/import-source-groups/shared.ts +++ b/e2e/fixtures/import-source-groups/shared.ts @@ -1,3 +1,5 @@ export interface Shared { value: string; } + +export const sharedValue = "shared"; diff --git a/e2e/fixtures/import-source-groups/src/current.ts b/e2e/fixtures/import-source-groups/src/current.ts index c452f24..4a7cf64 100644 --- a/e2e/fixtures/import-source-groups/src/current.ts +++ b/e2e/fixtures/import-source-groups/src/current.ts @@ -1,3 +1,3 @@ -import { helper } from "./helper"; +import { helper, type HelperOptions } from "./helper"; -export const current = helper; +export const current = (options: HelperOptions) => `${helper}:${options.value}`; diff --git a/e2e/fixtures/import-source-groups/src/external.ts b/e2e/fixtures/import-source-groups/src/external.ts index cb0995b..6913c1d 100644 --- a/e2e/fixtures/import-source-groups/src/external.ts +++ b/e2e/fixtures/import-source-groups/src/external.ts @@ -1,3 +1,3 @@ -import { z } from "zod"; +import { z, type ZodType } from "zod"; -export const external = z.string(); +export const external: ZodType = z.string(); diff --git a/e2e/fixtures/import-source-groups/src/helper.ts b/e2e/fixtures/import-source-groups/src/helper.ts index 6119c1e..d68ec80 100644 --- a/e2e/fixtures/import-source-groups/src/helper.ts +++ b/e2e/fixtures/import-source-groups/src/helper.ts @@ -1 +1,5 @@ +export interface HelperOptions { + value: string; +} + export const helper = "helper"; diff --git a/e2e/fixtures/import-source-groups/src/parent.ts b/e2e/fixtures/import-source-groups/src/parent.ts index e054db1..4df9a8b 100644 --- a/e2e/fixtures/import-source-groups/src/parent.ts +++ b/e2e/fixtures/import-source-groups/src/parent.ts @@ -1,3 +1,3 @@ -import type { Shared } from "../shared"; +import { sharedValue, type Shared } from "../shared"; -export type Parent = Shared; +export const parent = (shared: Shared) => shared.value || sharedValue; diff --git a/e2e/must-not.test.ts b/e2e/must-not.test.ts index a36d6ac..edd822c 100644 --- a/e2e/must-not.test.ts +++ b/e2e/must-not.test.ts @@ -104,6 +104,9 @@ describe("mustNot reverse configs", () => { "Forbidden import from current directory", "Forbidden import from parent directories", "Forbidden import from external packages", + "Forbidden type import from current directory", + "Forbidden type import from parent directories", + "Forbidden type import from external packages", ], }); await expect( diff --git a/e2e/new-predicates.test.ts b/e2e/new-predicates.test.ts index 0fc2c3d..f7019df 100644 --- a/e2e/new-predicates.test.ts +++ b/e2e/new-predicates.test.ts @@ -100,6 +100,18 @@ describe("import-source-groups-broken fixture", () => { expect(error.stdout).toContain( "Import from current directory is not allowed" ); + expect(error.stdout).toContain( + "Missing type import from current directory" + ); + expect(error.stdout).toContain( + "Type import from parent directories is not allowed" + ); + expect(error.stdout).toContain( + "Missing type import from external packages" + ); + expect(error.stdout).toContain( + "Type import from current directory is not allowed" + ); } }); }); diff --git a/packages/convention/reusable-convention-package.schema.json b/packages/convention/reusable-convention-package.schema.json index a73fac7..460e9bf 100644 --- a/packages/convention/reusable-convention-package.schema.json +++ b/packages/convention/reusable-convention-package.schema.json @@ -534,6 +534,15 @@ "importFromExternals": { "type": "boolean" }, + "importTypesFromCurrentDir": { + "type": "boolean" + }, + "importTypesFromParents": { + "type": "boolean" + }, + "importTypesFromExternals": { + "type": "boolean" + }, "useDeclarationOrder": { "type": "array", "items": { @@ -993,6 +1002,15 @@ "importFromExternals": { "type": "boolean" }, + "importTypesFromCurrentDir": { + "type": "boolean" + }, + "importTypesFromParents": { + "type": "boolean" + }, + "importTypesFromExternals": { + "type": "boolean" + }, "useDeclarationOrder": { "type": "array", "items": { @@ -1533,6 +1551,15 @@ "importFromExternals": { "type": "boolean" }, + "importTypesFromCurrentDir": { + "type": "boolean" + }, + "importTypesFromParents": { + "type": "boolean" + }, + "importTypesFromExternals": { + "type": "boolean" + }, "useDeclarationOrder": { "type": "array", "items": { @@ -1992,6 +2019,15 @@ "importFromExternals": { "type": "boolean" }, + "importTypesFromCurrentDir": { + "type": "boolean" + }, + "importTypesFromParents": { + "type": "boolean" + }, + "importTypesFromExternals": { + "type": "boolean" + }, "useDeclarationOrder": { "type": "array", "items": { diff --git a/packages/convention/src/index.test.ts b/packages/convention/src/index.test.ts index d21bb4e..0135c62 100644 --- a/packages/convention/src/index.test.ts +++ b/packages/convention/src/index.test.ts @@ -67,6 +67,9 @@ describe("ReusableConventionV1Schema", () => { importFromCurrentDir: true, importFromParents: false, importFromExternals: true, + importTypesFromCurrentDir: true, + importTypesFromParents: false, + importTypesFromExternals: true, }, }); expect(result.success).toBe(true); diff --git a/packages/convention/src/schemas.ts b/packages/convention/src/schemas.ts index f704669..4699b5e 100644 --- a/packages/convention/src/schemas.ts +++ b/packages/convention/src/schemas.ts @@ -84,6 +84,9 @@ export const MustPredicatesV1Schema = z.strictObject({ importFromCurrentDir: z.boolean().optional(), importFromParents: z.boolean().optional(), importFromExternals: z.boolean().optional(), + importTypesFromCurrentDir: z.boolean().optional(), + importTypesFromParents: z.boolean().optional(), + importTypesFromExternals: z.boolean().optional(), useDeclarationOrder: z.array(z.string()).optional(), areBarrelFiles: z.boolean().optional(), }); diff --git a/packages/konsistent/konsistent.schema.json b/packages/konsistent/konsistent.schema.json index b7bc10c..b99fe8d 100644 --- a/packages/konsistent/konsistent.schema.json +++ b/packages/konsistent/konsistent.schema.json @@ -577,6 +577,15 @@ "importFromExternals": { "type": "boolean" }, + "importTypesFromCurrentDir": { + "type": "boolean" + }, + "importTypesFromParents": { + "type": "boolean" + }, + "importTypesFromExternals": { + "type": "boolean" + }, "useDeclarationOrder": { "type": "array", "items": { @@ -1036,6 +1045,15 @@ "importFromExternals": { "type": "boolean" }, + "importTypesFromCurrentDir": { + "type": "boolean" + }, + "importTypesFromParents": { + "type": "boolean" + }, + "importTypesFromExternals": { + "type": "boolean" + }, "useDeclarationOrder": { "type": "array", "items": { @@ -1548,6 +1566,15 @@ "importFromExternals": { "type": "boolean" }, + "importTypesFromCurrentDir": { + "type": "boolean" + }, + "importTypesFromParents": { + "type": "boolean" + }, + "importTypesFromExternals": { + "type": "boolean" + }, "useDeclarationOrder": { "type": "array", "items": { @@ -2077,6 +2104,15 @@ "importFromExternals": { "type": "boolean" }, + "importTypesFromCurrentDir": { + "type": "boolean" + }, + "importTypesFromParents": { + "type": "boolean" + }, + "importTypesFromExternals": { + "type": "boolean" + }, "useDeclarationOrder": { "type": "array", "items": { @@ -2536,6 +2572,15 @@ "importFromExternals": { "type": "boolean" }, + "importTypesFromCurrentDir": { + "type": "boolean" + }, + "importTypesFromParents": { + "type": "boolean" + }, + "importTypesFromExternals": { + "type": "boolean" + }, "useDeclarationOrder": { "type": "array", "items": { @@ -3059,6 +3104,15 @@ "importFromExternals": { "type": "boolean" }, + "importTypesFromCurrentDir": { + "type": "boolean" + }, + "importTypesFromParents": { + "type": "boolean" + }, + "importTypesFromExternals": { + "type": "boolean" + }, "useDeclarationOrder": { "type": "array", "items": { @@ -3518,6 +3572,15 @@ "importFromExternals": { "type": "boolean" }, + "importTypesFromCurrentDir": { + "type": "boolean" + }, + "importTypesFromParents": { + "type": "boolean" + }, + "importTypesFromExternals": { + "type": "boolean" + }, "useDeclarationOrder": { "type": "array", "items": { @@ -4047,6 +4110,15 @@ "importFromExternals": { "type": "boolean" }, + "importTypesFromCurrentDir": { + "type": "boolean" + }, + "importTypesFromParents": { + "type": "boolean" + }, + "importTypesFromExternals": { + "type": "boolean" + }, "useDeclarationOrder": { "type": "array", "items": { @@ -4506,6 +4578,15 @@ "importFromExternals": { "type": "boolean" }, + "importTypesFromCurrentDir": { + "type": "boolean" + }, + "importTypesFromParents": { + "type": "boolean" + }, + "importTypesFromExternals": { + "type": "boolean" + }, "useDeclarationOrder": { "type": "array", "items": { @@ -4974,6 +5055,15 @@ "importFromExternals": { "type": "boolean" }, + "importTypesFromCurrentDir": { + "type": "boolean" + }, + "importTypesFromParents": { + "type": "boolean" + }, + "importTypesFromExternals": { + "type": "boolean" + }, "useDeclarationOrder": { "type": "array", "items": { @@ -5484,6 +5574,15 @@ "importFromExternals": { "type": "boolean" }, + "importTypesFromCurrentDir": { + "type": "boolean" + }, + "importTypesFromParents": { + "type": "boolean" + }, + "importTypesFromExternals": { + "type": "boolean" + }, "useDeclarationOrder": { "type": "array", "items": { @@ -6013,6 +6112,15 @@ "importFromExternals": { "type": "boolean" }, + "importTypesFromCurrentDir": { + "type": "boolean" + }, + "importTypesFromParents": { + "type": "boolean" + }, + "importTypesFromExternals": { + "type": "boolean" + }, "useDeclarationOrder": { "type": "array", "items": { @@ -6472,6 +6580,15 @@ "importFromExternals": { "type": "boolean" }, + "importTypesFromCurrentDir": { + "type": "boolean" + }, + "importTypesFromParents": { + "type": "boolean" + }, + "importTypesFromExternals": { + "type": "boolean" + }, "useDeclarationOrder": { "type": "array", "items": { @@ -6995,6 +7112,15 @@ "importFromExternals": { "type": "boolean" }, + "importTypesFromCurrentDir": { + "type": "boolean" + }, + "importTypesFromParents": { + "type": "boolean" + }, + "importTypesFromExternals": { + "type": "boolean" + }, "useDeclarationOrder": { "type": "array", "items": { @@ -7454,6 +7580,15 @@ "importFromExternals": { "type": "boolean" }, + "importTypesFromCurrentDir": { + "type": "boolean" + }, + "importTypesFromParents": { + "type": "boolean" + }, + "importTypesFromExternals": { + "type": "boolean" + }, "useDeclarationOrder": { "type": "array", "items": { @@ -7983,6 +8118,15 @@ "importFromExternals": { "type": "boolean" }, + "importTypesFromCurrentDir": { + "type": "boolean" + }, + "importTypesFromParents": { + "type": "boolean" + }, + "importTypesFromExternals": { + "type": "boolean" + }, "useDeclarationOrder": { "type": "array", "items": { @@ -8442,6 +8586,15 @@ "importFromExternals": { "type": "boolean" }, + "importTypesFromCurrentDir": { + "type": "boolean" + }, + "importTypesFromParents": { + "type": "boolean" + }, + "importTypesFromExternals": { + "type": "boolean" + }, "useDeclarationOrder": { "type": "array", "items": { @@ -8910,6 +9063,15 @@ "importFromExternals": { "type": "boolean" }, + "importTypesFromCurrentDir": { + "type": "boolean" + }, + "importTypesFromParents": { + "type": "boolean" + }, + "importTypesFromExternals": { + "type": "boolean" + }, "useDeclarationOrder": { "type": "array", "items": { diff --git a/packages/konsistent/src/config/schema.test.ts b/packages/konsistent/src/config/schema.test.ts index da01edf..6ef5cff 100644 --- a/packages/konsistent/src/config/schema.test.ts +++ b/packages/konsistent/src/config/schema.test.ts @@ -131,6 +131,9 @@ describe("ConfigV1Schema", () => { importFromCurrentDir: true, importFromParents: false, importFromExternals: true, + importTypesFromCurrentDir: true, + importTypesFromParents: false, + importTypesFromExternals: true, }, }, ], diff --git a/packages/konsistent/src/core/convention-name.test.ts b/packages/konsistent/src/core/convention-name.test.ts index 99d0960..3e24129 100644 --- a/packages/konsistent/src/core/convention-name.test.ts +++ b/packages/konsistent/src/core/convention-name.test.ts @@ -254,6 +254,18 @@ describe("generateConventionName", () => { generateConventionName({ must: { importFromExternals: false } }) ).toBe("must-not-import-from-externals"); }); + + it("generates type import source names", () => { + expect( + generateConventionName({ must: { importTypesFromCurrentDir: true } }) + ).toBe("must-import-type-from-current-dir"); + expect( + generateConventionName({ must: { importTypesFromParents: false } }) + ).toBe("must-not-import-type-from-parents"); + expect( + generateConventionName({ must: { importTypesFromExternals: true } }) + ).toBe("must-import-type-from-externals"); + }); }); describe("mustNot", () => { @@ -276,6 +288,16 @@ describe("generateConventionName", () => { mustNot: { importFromCurrentDir: false }, }) ).toBe("must-import-from-current-dir"); + expect( + generateConventionName({ + mustNot: { importTypesFromCurrentDir: true }, + }) + ).toBe("must-not-import-type-from-current-dir"); + expect( + generateConventionName({ + mustNot: { importTypesFromCurrentDir: false }, + }) + ).toBe("must-import-type-from-current-dir"); }); }); diff --git a/packages/konsistent/src/core/convention-name.ts b/packages/konsistent/src/core/convention-name.ts index 462f8ff..d855c45 100644 --- a/packages/konsistent/src/core/convention-name.ts +++ b/packages/konsistent/src/core/convention-name.ts @@ -136,6 +136,18 @@ const PREDICATE_RULES: Record< (items[0] === false) === negated ? "must-import-from-externals" : "must-not-import-from-externals", + importTypesFromCurrentDir: ({ items, negated }) => + (items[0] === false) === negated + ? "must-import-type-from-current-dir" + : "must-not-import-type-from-current-dir", + importTypesFromParents: ({ items, negated }) => + (items[0] === false) === negated + ? "must-import-type-from-parents" + : "must-not-import-type-from-parents", + importTypesFromExternals: ({ items, negated }) => + (items[0] === false) === negated + ? "must-import-type-from-externals" + : "must-not-import-type-from-externals", useDeclarationOrder: ({ items, negated }) => { const kebab = deriveKebabFromName(items[0] as string); const prefix = negated ? "must-not-use" : "must-use"; diff --git a/packages/konsistent/src/core/runner.ts b/packages/konsistent/src/core/runner.ts index 4606d8c..7951ad6 100644 --- a/packages/konsistent/src/core/runner.ts +++ b/packages/konsistent/src/core/runner.ts @@ -80,6 +80,9 @@ export const TS_PREDICATES = new Set([ "importFromCurrentDir", "importFromParents", "importFromExternals", + "importTypesFromCurrentDir", + "importTypesFromParents", + "importTypesFromExternals", "useDeclarationOrder", "areBarrelFiles", ]); @@ -434,6 +437,7 @@ const TS_PREDICATE_HANDLERS: Record< expected: must.importFromCurrentDir, predicateName: "importFromCurrentDir", group: "currentDir", + importKind: "value", context, fileStructure, conventionName, @@ -452,6 +456,7 @@ const TS_PREDICATE_HANDLERS: Record< expected: must.importFromParents, predicateName: "importFromParents", group: "parents", + importKind: "value", context, fileStructure, conventionName, @@ -470,6 +475,64 @@ const TS_PREDICATE_HANDLERS: Record< expected: must.importFromExternals, predicateName: "importFromExternals", group: "externals", + importKind: "value", + context, + fileStructure, + conventionName, + severity, + }), + importTypesFromCurrentDir: ({ + must, + context, + fileStructure, + conventionName, + severity, + }) => + must.importTypesFromCurrentDir === undefined + ? [] + : checkImportSource({ + expected: must.importTypesFromCurrentDir, + predicateName: "importTypesFromCurrentDir", + group: "currentDir", + importKind: "type", + context, + fileStructure, + conventionName, + severity, + }), + importTypesFromParents: ({ + must, + context, + fileStructure, + conventionName, + severity, + }) => + must.importTypesFromParents === undefined + ? [] + : checkImportSource({ + expected: must.importTypesFromParents, + predicateName: "importTypesFromParents", + group: "parents", + importKind: "type", + context, + fileStructure, + conventionName, + severity, + }), + importTypesFromExternals: ({ + must, + context, + fileStructure, + conventionName, + severity, + }) => + must.importTypesFromExternals === undefined + ? [] + : checkImportSource({ + expected: must.importTypesFromExternals, + predicateName: "importTypesFromExternals", + group: "externals", + importKind: "type", context, fileStructure, conventionName, @@ -627,6 +690,18 @@ function formatForbiddenMessage(opts: { return value === false ? "Missing import from external packages is not allowed" : "Forbidden import from external packages"; + case "importTypesFromCurrentDir": + return value === false + ? "Missing type import from current directory is not allowed" + : "Forbidden type import from current directory"; + case "importTypesFromParents": + return value === false + ? "Missing type import from parent directories is not allowed" + : "Forbidden type import from parent directories"; + case "importTypesFromExternals": + return value === false + ? "Missing type import from external packages is not allowed" + : "Forbidden type import from external packages"; case "useDeclarationOrder": return `Forbidden declaration order "${(value as string[]) .map((entry) => context.resolveTemplate(entry)) diff --git a/packages/konsistent/src/typescript/parser.test.ts b/packages/konsistent/src/typescript/parser.test.ts index 57b6771..448e115 100644 --- a/packages/konsistent/src/typescript/parser.test.ts +++ b/packages/konsistent/src/typescript/parser.test.ts @@ -243,6 +243,50 @@ describe("parseFileStructure", () => { }, ]); }); + + it("marks import type clauses as type import sources", () => { + const result = parseFileStructure({ + source: "import type { Foo } from './types';", + }); + expect(result.importSources).toEqual([ + { + from: "./types", + isType: true, + pos: { line: 1, column: 1 }, + }, + ]); + }); + + it("marks individual type specifiers as type import sources", () => { + const result = parseFileStructure({ + source: "import { type Foo } from './types';", + }); + expect(result.importSources).toEqual([ + { + from: "./types", + isType: true, + pos: { line: 1, column: 1 }, + }, + ]); + }); + + it("marks mixed imports as value and type import sources", () => { + const result = parseFileStructure({ + source: "import { type Foo, bar } from './module';", + }); + expect(result.importSources).toEqual([ + { + from: "./module", + isType: false, + pos: { line: 1, column: 1 }, + }, + { + from: "./module", + isType: true, + pos: { line: 1, column: 1 }, + }, + ]); + }); }); describe("declaration symbols", () => { diff --git a/packages/konsistent/src/typescript/parser.ts b/packages/konsistent/src/typescript/parser.ts index e0f392e..e49dced 100644 --- a/packages/konsistent/src/typescript/parser.ts +++ b/packages/konsistent/src/typescript/parser.ts @@ -233,6 +233,40 @@ interface ParseCollector { typeAliases: TypeAliasInfo[]; } +function getImportSourceKinds(opts: { node: ts.ImportDeclaration }): { + hasType: boolean; + hasValue: boolean; +} { + const { node } = opts; + const { importClause } = node; + if (!importClause) { + return { hasType: false, hasValue: true }; + } + if (importClause.isTypeOnly) { + return { hasType: true, hasValue: false }; + } + + let hasType = false; + let hasValue = Boolean(importClause.name); + const { namedBindings } = importClause; + + if (namedBindings) { + if (ts.isNamespaceImport(namedBindings)) { + hasValue = true; + } else { + for (const element of namedBindings.elements) { + if (element.isTypeOnly) { + hasType = true; + } else { + hasValue = true; + } + } + } + } + + return { hasType, hasValue: hasValue || !hasType }; +} + function processImportDeclaration(opts: { node: ts.ImportDeclaration; sourceFile: ts.SourceFile; @@ -242,12 +276,22 @@ function processImportDeclaration(opts: { const pos = getPosition({ sourceFile, node }); const moduleSpecifier = (node.moduleSpecifier as ts.StringLiteral).text; const isTypeOnly = node.importClause?.isTypeOnly ?? false; + const sourceKinds = getImportSourceKinds({ node }); - collector.importSources.push({ - from: moduleSpecifier, - isType: isTypeOnly, - pos, - }); + if (sourceKinds.hasValue) { + collector.importSources.push({ + from: moduleSpecifier, + isType: false, + pos, + }); + } + if (sourceKinds.hasType) { + collector.importSources.push({ + from: moduleSpecifier, + isType: true, + pos, + }); + } if (node.importClause?.namedBindings) { if (ts.isNamedImports(node.importClause.namedBindings)) { diff --git a/packages/konsistent/src/typescript/predicates/import-source.test.ts b/packages/konsistent/src/typescript/predicates/import-source.test.ts index 4523fd8..aebcd92 100644 --- a/packages/konsistent/src/typescript/predicates/import-source.test.ts +++ b/packages/konsistent/src/typescript/predicates/import-source.test.ts @@ -23,6 +23,7 @@ describe("checkImportSource", () => { expected: true, predicateName: "importFromCurrentDir", group: "currentDir", + importKind: "value", context: createMockContext({ path: "src/index.ts" }), fileStructure: parseSource({ source: "import { helper } from './helper';", @@ -36,6 +37,7 @@ describe("checkImportSource", () => { expected: false, predicateName: "importFromCurrentDir", group: "currentDir", + importKind: "value", context: createMockContext({ path: "src/index.ts" }), fileStructure: parseSource({ source: "import './setup';" }), }); @@ -51,6 +53,36 @@ describe("checkImportSource", () => { expected: true, predicateName: "importFromParents", group: "parents", + importKind: "value", + context: createMockContext({ path: "src/index.ts" }), + fileStructure: parseSource({ + source: "import { parent } from '../parent';", + }), + }); + expect(result).toEqual([]); + }); + + it("ignores type imports for value import source predicates", () => { + const result = checkImportSource({ + expected: true, + predicateName: "importFromParents", + group: "parents", + importKind: "value", + context: createMockContext({ path: "src/index.ts" }), + fileStructure: parseSource({ + source: "import type { Parent } from '../parent';", + }), + }); + expect(result).toHaveLength(1); + expect(result[0].message).toBe("Missing import from parent directories"); + }); + + it("matches type imports from parents", () => { + const result = checkImportSource({ + expected: true, + predicateName: "importTypesFromParents", + group: "parents", + importKind: "type", context: createMockContext({ path: "src/index.ts" }), fileStructure: parseSource({ source: "import type { Parent } from '../parent';", @@ -59,11 +91,70 @@ describe("checkImportSource", () => { expect(result).toEqual([]); }); + it("ignores value imports for type import source predicates", () => { + const result = checkImportSource({ + expected: true, + predicateName: "importTypesFromCurrentDir", + group: "currentDir", + importKind: "type", + context: createMockContext({ path: "src/index.ts" }), + fileStructure: parseSource({ + source: "import { helper } from './helper';", + }), + }); + expect(result).toHaveLength(1); + expect(result[0].message).toBe( + "Missing type import from current directory" + ); + }); + + it("rejects forbidden type imports from externals", () => { + const result = checkImportSource({ + expected: false, + predicateName: "importTypesFromExternals", + group: "externals", + importKind: "type", + context: createMockContext({ path: "src/index.ts" }), + fileStructure: parseSource({ + source: "import type { ZodType } from 'zod';", + }), + }); + expect(result).toHaveLength(1); + expect(result[0].message).toBe( + "Type import from external packages is not allowed" + ); + }); + + it("matches mixed imports for both value and type predicates", () => { + const fileStructure = parseSource({ + source: "import { type HelperOptions, helper } from './helper';", + }); + const valueResult = checkImportSource({ + expected: true, + predicateName: "importFromCurrentDir", + group: "currentDir", + importKind: "value", + context: createMockContext({ path: "src/index.ts" }), + fileStructure, + }); + const typeResult = checkImportSource({ + expected: true, + predicateName: "importTypesFromCurrentDir", + group: "currentDir", + importKind: "type", + context: createMockContext({ path: "src/index.ts" }), + fileStructure, + }); + expect(valueResult).toEqual([]); + expect(typeResult).toEqual([]); + }); + it("reports missing parent imports", () => { const result = checkImportSource({ expected: true, predicateName: "importFromParents", group: "parents", + importKind: "value", context: createMockContext({ path: "src/index.ts" }), fileStructure: parseSource({ source: "import { helper } from './helper';", @@ -86,6 +177,7 @@ describe("checkImportSource", () => { expected: true, predicateName: "importFromExternals", group: "externals", + importKind: "value", context: createMockContext({ path: "src/index.ts" }), fileStructure, }); @@ -97,6 +189,7 @@ describe("checkImportSource", () => { expected: false, predicateName: "importFromExternals", group: "externals", + importKind: "value", context: createMockContext({ path: "src/index.ts" }), fileStructure: parseSource({ source: "import react from 'react';" }), }); diff --git a/packages/konsistent/src/typescript/predicates/import-source.ts b/packages/konsistent/src/typescript/predicates/import-source.ts index e07d021..d6cc487 100644 --- a/packages/konsistent/src/typescript/predicates/import-source.ts +++ b/packages/konsistent/src/typescript/predicates/import-source.ts @@ -4,6 +4,7 @@ import { createDiagnostic } from "../../core/diagnostics.js"; import type { FileStructure, ImportSourceInfo } from "../types.js"; type ImportSourceGroup = "currentDir" | "parents" | "externals"; +type ImportSourceKind = "type" | "value"; function isImportSourceInGroup(opts: { from: string; @@ -35,10 +36,14 @@ function getGroupLabel(group: ImportSourceGroup): string { function findImportSource(opts: { fileStructure: FileStructure; group: ImportSourceGroup; + importKind: ImportSourceKind; }): ImportSourceInfo | undefined { - const { fileStructure, group } = opts; - return fileStructure.importSources.find((source) => - isImportSourceInGroup({ from: source.from, group }) + const { fileStructure, group, importKind } = opts; + const isType = importKind === "type"; + return fileStructure.importSources.find( + (source) => + isImportSourceInGroup({ from: source.from, group }) && + source.isType === isType ); } @@ -46,6 +51,7 @@ export function checkImportSource(opts: { expected: boolean; predicateName: string; group: ImportSourceGroup; + importKind: ImportSourceKind; context: PredicateContext; fileStructure: FileStructure; conventionName?: string; @@ -55,20 +61,23 @@ export function checkImportSource(opts: { expected, predicateName, group, + importKind, context, fileStructure, conventionName, severity, } = opts; - const found = findImportSource({ fileStructure, group }); + const found = findImportSource({ fileStructure, group, importKind }); const label = getGroupLabel(group); + const noun = importKind === "type" ? "type import" : "import"; + const capitalizedNoun = importKind === "type" ? "Type import" : "Import"; if (expected && !found) { return [ createDiagnostic({ filePath: context.path, predicateName, - message: `Missing import from ${label}`, + message: `Missing ${noun} from ${label}`, conventionName, severity, }), @@ -80,7 +89,7 @@ export function checkImportSource(opts: { createDiagnostic({ filePath: context.path, predicateName, - message: `Import from ${label} is not allowed`, + message: `${capitalizedNoun} from ${label} is not allowed`, conventionName, line: found.pos.line, column: found.pos.column,