From 8da69d0db61e83d7a2b863d5aadc8da5a943249b Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Mon, 6 Jul 2026 10:17:54 -0500 Subject: [PATCH 1/2] fix importFrom predicate to distinguish between exact package matches and subpackages --- docs/guides/fixing-violations.md | 6 +- docs/reference/predicates.md | 10 +- .../import-from-broken/konsistent.json | 7 +- .../src/no-package-subpath.ts | 3 + e2e/fixtures/import-from/konsistent.json | 7 +- .../import-from/src/no-package-root.ts | 3 + e2e/fixtures/import-from/src/package.ts | 2 + e2e/new-predicates.test.ts | 4 +- .../reusable-convention-package.schema.json | 48 +++- packages/convention/src/index.test.ts | 2 +- packages/convention/src/schemas.ts | 2 +- packages/konsistent/konsistent.schema.json | 216 ++++++++++++++++-- .../src/config/placeholder-validator.test.ts | 2 +- .../src/config/placeholder-validator.ts | 20 +- packages/konsistent/src/config/schema.test.ts | 19 +- packages/konsistent/src/core/runner.test.ts | 59 ++++- packages/konsistent/src/core/runner.ts | 1 + .../typescript/predicates/import-from.test.ts | 76 +++++- .../src/typescript/predicates/import-from.ts | 70 +++--- 19 files changed, 453 insertions(+), 104 deletions(-) create mode 100644 e2e/fixtures/import-from-broken/src/no-package-subpath.ts create mode 100644 e2e/fixtures/import-from/src/no-package-root.ts diff --git a/docs/guides/fixing-violations.md b/docs/guides/fixing-violations.md index a8b895f..bd9394a 100644 --- a/docs/guides/fixing-violations.md +++ b/docs/guides/fixing-violations.md @@ -212,14 +212,14 @@ Search first: grep for `X` and case/suffix variants. Also look for object factor #### `import` / `importTypes` / `importFrom` -Message: `Missing import "X" from ""`, `Missing import type "X" from ""`, or `Missing import from ""`. +Message: `Missing import "X" from ""`, `Missing import type "X" from ""`, or `Missing import from ""`. -Search first: confirm the export `X` exists at `` for named-import rules, or that the target module/package is already used by sibling files for `importFrom`. If not, find where `X` (or its variants) is currently imported from. +Search first: confirm the export `X` exists at `` for named-import rules, or that the target module specifier is already used by sibling files for `importFrom`. If not, find where `X` (or its variants) is currently imported from. - **Trivial**: - Export exists at the specified module path → add the import statement. - Symbol is currently imported from a different path → change the import path. - - For `importFrom`, a sibling file imports from the same package/path for the same role → add the analogous import. + - For `importFrom`, a sibling file imports from the same exact specifier, or from a subpath covered by a configured `/*` wildcard, for the same role → add the analogous import. - **Non-trivial**: - Export does not exist at the target path *and* the symbol does not exist anywhere. Usually cascades from a separate violation — fix that first; this one may resolve automatically. - For `importFrom`, no sibling establishes why the dependency should exist. Adding a new dependency may be a design decision. diff --git a/docs/reference/predicates.md b/docs/reference/predicates.md index 5c94eb7..6e60b66 100644 --- a/docs/reference/predicates.md +++ b/docs/reference/predicates.md @@ -328,19 +328,21 @@ Bare-string form (`"import": ["useState"]`) checks the binding regardless of sou ### `importFrom` -Assert that the file has at least one import statement from a specific module path or package. +Assert that the file has at least one import statement from a specific module specifier. ```json "must": { "importFrom": "react" } ``` ```json -"must": { "importFrom": "./setup" } +"must": { "importFrom": ["./setup", "package/*"] } ``` -The value is a string. Relative and absolute path-like specifiers are matched exactly, so `"./setup"` only matches `import "./setup"`. +The value is a string or an array of strings. When an array is used in `must`, every entry must be satisfied. -Package roots match the package itself and its subpaths. For example, `"react"` matches imports from `"react"` and `"react/jsx-runtime"`, and `"@scope/pkg"` matches `"@scope/pkg"` and `"@scope/pkg/subpath"`. Similarly named packages such as `"react-dom"` or `"@scope/pkg-extra"` do not match. +By default, entries match exactly. For example, `"package"` only matches `import ... from "package"` and does not match `import ... from "package/v4"`. + +Use a trailing `/*` to match subpaths under a prefix. For example, `"package/*"` matches `"package/v4"`, and `"@scope/pkg/*"` matches `"@scope/pkg/subpath"`. The wildcard does not match the root itself, so `"package/*"` does not match `"package"`. Both value imports and type-only imports count because this predicate checks import statements by source. Side-effect imports such as `import "./setup"` also count. diff --git a/e2e/fixtures/import-from-broken/konsistent.json b/e2e/fixtures/import-from-broken/konsistent.json index 645dfb3..5b1bfad 100644 --- a/e2e/fixtures/import-from-broken/konsistent.json +++ b/e2e/fixtures/import-from-broken/konsistent.json @@ -9,12 +9,17 @@ { "name": "package-imports", "paths": "src/package.ts", - "must": { "importFrom": "@scope/pkg" } + "must": { "importFrom": ["@scope/pkg/*", "react"] } }, { "name": "no-react-imports", "paths": "src/no-react.ts", "mustNot": { "importFrom": "react" } + }, + { + "name": "no-package-subpath-imports", + "paths": "src/no-package-subpath.ts", + "mustNot": { "importFrom": "package/*" } } ] } diff --git a/e2e/fixtures/import-from-broken/src/no-package-subpath.ts b/e2e/fixtures/import-from-broken/src/no-package-subpath.ts new file mode 100644 index 0000000..e1a4cde --- /dev/null +++ b/e2e/fixtures/import-from-broken/src/no-package-subpath.ts @@ -0,0 +1,3 @@ +import { value } from "package/v4"; + +export const packageSubpath = value; diff --git a/e2e/fixtures/import-from/konsistent.json b/e2e/fixtures/import-from/konsistent.json index 645dfb3..af2225f 100644 --- a/e2e/fixtures/import-from/konsistent.json +++ b/e2e/fixtures/import-from/konsistent.json @@ -9,12 +9,17 @@ { "name": "package-imports", "paths": "src/package.ts", - "must": { "importFrom": "@scope/pkg" } + "must": { "importFrom": ["@scope/pkg/*", "react"] } }, { "name": "no-react-imports", "paths": "src/no-react.ts", "mustNot": { "importFrom": "react" } + }, + { + "name": "no-package-root-imports", + "paths": "src/no-package-root.ts", + "mustNot": { "importFrom": "package" } } ] } diff --git a/e2e/fixtures/import-from/src/no-package-root.ts b/e2e/fixtures/import-from/src/no-package-root.ts new file mode 100644 index 0000000..e1a4cde --- /dev/null +++ b/e2e/fixtures/import-from/src/no-package-root.ts @@ -0,0 +1,3 @@ +import { value } from "package/v4"; + +export const packageSubpath = value; diff --git a/e2e/fixtures/import-from/src/package.ts b/e2e/fixtures/import-from/src/package.ts index ba3c08c..3ec2f37 100644 --- a/e2e/fixtures/import-from/src/package.ts +++ b/e2e/fixtures/import-from/src/package.ts @@ -1,3 +1,5 @@ import type { Tool } from "@scope/pkg/tools"; +import React from "react"; export type PackageTool = Tool; +export const packageReact = React; diff --git a/e2e/new-predicates.test.ts b/e2e/new-predicates.test.ts index 5829ce3..4d0c057 100644 --- a/e2e/new-predicates.test.ts +++ b/e2e/new-predicates.test.ts @@ -135,8 +135,10 @@ describe("import-from-broken fixture", () => { const error = err as { stdout: string; code: number; status: number }; expect(error.code ?? error.status).toBe(1); expect(error.stdout).toContain('Missing import from "./helper"'); - expect(error.stdout).toContain('Missing import from "@scope/pkg"'); + expect(error.stdout).toContain('Missing import from "@scope/pkg/*"'); + expect(error.stdout).toContain('Missing import from "react"'); expect(error.stdout).toContain('Forbidden import from "react"'); + expect(error.stdout).toContain('Forbidden import from "package/*"'); } }); }); diff --git a/packages/convention/reusable-convention-package.schema.json b/packages/convention/reusable-convention-package.schema.json index 9f288a1..db6a929 100644 --- a/packages/convention/reusable-convention-package.schema.json +++ b/packages/convention/reusable-convention-package.schema.json @@ -503,7 +503,17 @@ } }, "importFrom": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "importTypes": { "type": "array", @@ -974,7 +984,17 @@ } }, "importFrom": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "importTypes": { "type": "array", @@ -1526,7 +1546,17 @@ } }, "importFrom": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "importTypes": { "type": "array", @@ -1997,7 +2027,17 @@ } }, "importFrom": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "importTypes": { "type": "array", diff --git a/packages/convention/src/index.test.ts b/packages/convention/src/index.test.ts index a5f91df..99ed3ff 100644 --- a/packages/convention/src/index.test.ts +++ b/packages/convention/src/index.test.ts @@ -64,7 +64,7 @@ describe("ReusableConventionV1Schema", () => { }, ], useDeclarationOrder: ["localValue", "createLocal"], - importFrom: "react", + importFrom: ["react", "zod/*"], importFromCurrentDir: true, importFromParents: false, importFromExternals: true, diff --git a/packages/convention/src/schemas.ts b/packages/convention/src/schemas.ts index e80ce19..d1e0c27 100644 --- a/packages/convention/src/schemas.ts +++ b/packages/convention/src/schemas.ts @@ -78,7 +78,7 @@ export const MustPredicatesV1Schema = z.strictObject({ .array(z.union([z.string(), ClassDefinitionV1Schema])) .optional(), import: z.array(z.union([z.string(), ImportDefinitionV1Schema])).optional(), - importFrom: z.string().optional(), + importFrom: z.union([z.string(), z.array(z.string())]).optional(), importTypes: z .array(z.union([z.string(), ImportDefinitionV1Schema])) .optional(), diff --git a/packages/konsistent/konsistent.schema.json b/packages/konsistent/konsistent.schema.json index 60c94b7..5faac8d 100644 --- a/packages/konsistent/konsistent.schema.json +++ b/packages/konsistent/konsistent.schema.json @@ -546,7 +546,17 @@ } }, "importFrom": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "importTypes": { "type": "array", @@ -1017,7 +1027,17 @@ } }, "importFrom": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "importTypes": { "type": "array", @@ -1541,7 +1561,17 @@ } }, "importFrom": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "importTypes": { "type": "array", @@ -2082,7 +2112,17 @@ } }, "importFrom": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "importTypes": { "type": "array", @@ -2553,7 +2593,17 @@ } }, "importFrom": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "importTypes": { "type": "array", @@ -3088,7 +3138,17 @@ } }, "importFrom": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "importTypes": { "type": "array", @@ -3559,7 +3619,17 @@ } }, "importFrom": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "importTypes": { "type": "array", @@ -4100,7 +4170,17 @@ } }, "importFrom": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "importTypes": { "type": "array", @@ -4571,7 +4651,17 @@ } }, "importFrom": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "importTypes": { "type": "array", @@ -5051,7 +5141,17 @@ } }, "importFrom": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "importTypes": { "type": "array", @@ -5573,7 +5673,17 @@ } }, "importFrom": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "importTypes": { "type": "array", @@ -6114,7 +6224,17 @@ } }, "importFrom": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "importTypes": { "type": "array", @@ -6585,7 +6705,17 @@ } }, "importFrom": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "importTypes": { "type": "array", @@ -7120,7 +7250,17 @@ } }, "importFrom": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "importTypes": { "type": "array", @@ -7591,7 +7731,17 @@ } }, "importFrom": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "importTypes": { "type": "array", @@ -8132,7 +8282,17 @@ } }, "importFrom": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "importTypes": { "type": "array", @@ -8603,7 +8763,17 @@ } }, "importFrom": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "importTypes": { "type": "array", @@ -9083,7 +9253,17 @@ } }, "importFrom": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "importTypes": { "type": "array", diff --git a/packages/konsistent/src/config/placeholder-validator.test.ts b/packages/konsistent/src/config/placeholder-validator.test.ts index de5be4b..54860bb 100644 --- a/packages/konsistent/src/config/placeholder-validator.test.ts +++ b/packages/konsistent/src/config/placeholder-validator.test.ts @@ -383,7 +383,7 @@ describe("validatePlaceholders", () => { { name: "imports", paths: ["src/{x}"], - must: { importFrom: "@scope/${missingPackage}" }, + must: { importFrom: ["@scope/pkg", "@scope/${missingPackage}/*"] }, }, ]; diff --git a/packages/konsistent/src/config/placeholder-validator.ts b/packages/konsistent/src/config/placeholder-validator.ts index 2645110..d086f7b 100644 --- a/packages/konsistent/src/config/placeholder-validator.ts +++ b/packages/konsistent/src/config/placeholder-validator.ts @@ -309,13 +309,19 @@ function collectUsagesInPredicates(opts: { declared, usages, }); - if (predicates.importFrom) { - pushStringUsages({ - value: predicates.importFrom, - key: `${prefix}.importFrom`, - declared, - usages, - }); + if (predicates.importFrom !== undefined) { + const importFrom = + typeof predicates.importFrom === "string" + ? [predicates.importFrom] + : predicates.importFrom; + for (const source of importFrom) { + pushStringUsages({ + value: source, + key: `${prefix}.importFrom`, + declared, + usages, + }); + } } collectUsagesInDefinitionList({ list: predicates.importTypes, diff --git a/packages/konsistent/src/config/schema.test.ts b/packages/konsistent/src/config/schema.test.ts index 92ce678..0f40c27 100644 --- a/packages/konsistent/src/config/schema.test.ts +++ b/packages/konsistent/src/config/schema.test.ts @@ -128,7 +128,7 @@ describe("ConfigV1Schema", () => { paths: "src/*.ts", must: { useDeclarationOrder: ["alpha", "beta"], - importFrom: "react", + importFrom: ["react", "zod/*"], importFromCurrentDir: true, importFromParents: false, importFromExternals: true, @@ -142,13 +142,26 @@ describe("ConfigV1Schema", () => { expect(result.success).toBe(true); }); - it("rejects importFrom when it is not a string", () => { + it("rejects importFrom when it is neither a string nor a string array", () => { const result = ConfigV1Schema.safeParse({ version: "v1", conventions: [ { paths: "src/*.ts", - must: { importFrom: ["react"] }, + must: { importFrom: 123 }, + }, + ], + }); + expect(result.success).toBe(false); + }); + + it("rejects importFrom arrays with non-string items", () => { + const result = ConfigV1Schema.safeParse({ + version: "v1", + conventions: [ + { + paths: "src/*.ts", + must: { importFrom: ["react", 123] }, }, ], }); diff --git a/packages/konsistent/src/core/runner.test.ts b/packages/konsistent/src/core/runner.test.ts index 91625f8..708787a 100644 --- a/packages/konsistent/src/core/runner.test.ts +++ b/packages/konsistent/src/core/runner.test.ts @@ -203,13 +203,13 @@ describe("run", () => { expect(diagnostics[0].message).toBe('Forbidden constant export "debug"'); }); - it("reports diagnostics when mustNot importFrom matches", async () => { + it("returns no diagnostics when mustNot importFrom does not match a subpath", async () => { const config: ConfigV1 = { version: "v1", conventions: [ { paths: "src/module.ts", - mustNot: { importFrom: "react" }, + mustNot: { importFrom: "package" }, }, ], }; @@ -217,13 +217,64 @@ describe("run", () => { globResults: new Map([["src/module.ts", ["src/module.ts"]]]), files: new Set(["src/module.ts"]), fileContents: new Map([ - ["src/module.ts", "import { jsx } from 'react/jsx-runtime';"], + ["src/module.ts", "import { value } from 'package/v4';"], + ]), + }); + const { diagnostics } = await run({ config, fileSystem: fs }); + expect(diagnostics).toEqual([]); + }); + + it("reports diagnostics when mustNot importFrom wildcard matches a subpath", async () => { + const config: ConfigV1 = { + version: "v1", + conventions: [ + { + paths: "src/module.ts", + mustNot: { importFrom: "package/*" }, + }, + ], + }; + const fs = createMockFileSystem({ + globResults: new Map([["src/module.ts", ["src/module.ts"]]]), + files: new Set(["src/module.ts"]), + fileContents: new Map([ + ["src/module.ts", "import { value } from 'package/v4';"], ]), }); const { diagnostics } = await run({ config, fileSystem: fs }); expect(diagnostics).toHaveLength(1); expect(diagnostics[0].predicateName).toBe("mustNot.importFrom"); - expect(diagnostics[0].message).toBe('Forbidden import from "react"'); + expect(diagnostics[0].message).toBe('Forbidden import from "package/*"'); + }); + + it("reports diagnostics per matching mustNot importFrom array item", async () => { + const config: ConfigV1 = { + version: "v1", + conventions: [ + { + paths: "src/module.ts", + mustNot: { importFrom: ["react", "package/*", "never"] }, + }, + ], + }; + const fs = createMockFileSystem({ + globResults: new Map([["src/module.ts", ["src/module.ts"]]]), + files: new Set(["src/module.ts"]), + fileContents: new Map([ + [ + "src/module.ts", + [ + "import React from 'react';", + "import { value } from 'package/v4';", + ].join("\n"), + ], + ]), + }); + const { diagnostics } = await run({ config, fileSystem: fs }); + expect(diagnostics.map((d) => d.message)).toEqual([ + 'Forbidden import from "react"', + 'Forbidden import from "package/*"', + ]); }); it("applies block metadata to mustNot predicates", async () => { diff --git a/packages/konsistent/src/core/runner.ts b/packages/konsistent/src/core/runner.ts index 3909151..54468d0 100644 --- a/packages/konsistent/src/core/runner.ts +++ b/packages/konsistent/src/core/runner.ts @@ -629,6 +629,7 @@ const ITEM_LEVEL_MUST_NOT_PREDICATES = new Set([ "exportInterfaces", "exportClasses", "import", + "importFrom", "importTypes", ]); diff --git a/packages/konsistent/src/typescript/predicates/import-from.test.ts b/packages/konsistent/src/typescript/predicates/import-from.test.ts index d049fdf..0521570 100644 --- a/packages/konsistent/src/typescript/predicates/import-from.test.ts +++ b/packages/konsistent/src/typescript/predicates/import-from.test.ts @@ -48,20 +48,43 @@ describe("checkImportFrom", () => { expect(result[0].filePath).toBe("src/index.ts"); }); - it("matches package subpaths from an unscoped package root", () => { + it("matches exact package import sources", () => { const result = checkImportFrom({ expected: "react", context: createMockContext({ path: "src/index.ts" }), fileStructure: parseSource({ - source: "import { jsx } from 'react/jsx-runtime';", + source: "import React from 'react';", }), }); expect(result).toEqual([]); }); - it("matches package subpaths from a scoped package root", () => { + it("does not match package subpaths without a wildcard", () => { const result = checkImportFrom({ - expected: "@scope/pkg", + expected: "package", + context: createMockContext({ path: "src/index.ts" }), + fileStructure: parseSource({ + source: "import { value } from 'package/v4';", + }), + }); + expect(result).toHaveLength(1); + expect(result[0].message).toBe('Missing import from "package"'); + }); + + it("matches package subpaths with a trailing wildcard", () => { + const result = checkImportFrom({ + expected: "package/*", + context: createMockContext({ path: "src/index.ts" }), + fileStructure: parseSource({ + source: "import { value } from 'package/v4';", + }), + }); + expect(result).toEqual([]); + }); + + it("matches scoped package subpaths with a trailing wildcard", () => { + const result = checkImportFrom({ + expected: "@scope/pkg/*", context: createMockContext({ path: "src/index.ts" }), fileStructure: parseSource({ source: "import { tool } from '@scope/pkg/tools';", @@ -70,27 +93,27 @@ describe("checkImportFrom", () => { expect(result).toEqual([]); }); - it("does not match similarly named packages", () => { + it("does not match package roots with a trailing wildcard", () => { const result = checkImportFrom({ - expected: "react", + expected: "package/*", context: createMockContext({ path: "src/index.ts" }), fileStructure: parseSource({ - source: "import { render } from 'react-dom';", + source: "import { value } from 'package';", }), }); expect(result).toHaveLength(1); + expect(result[0].message).toBe('Missing import from "package/*"'); }); - it("treats package subpaths as exact sources", () => { + it("does not match similarly named packages with a trailing wildcard", () => { const result = checkImportFrom({ - expected: "@scope/pkg/tools", + expected: "react/*", context: createMockContext({ path: "src/index.ts" }), fileStructure: parseSource({ - source: "import { helper } from '@scope/pkg/tools/helper';", + source: "import { render } from 'react-dom/client';", }), }); expect(result).toHaveLength(1); - expect(result[0].message).toBe('Missing import from "@scope/pkg/tools"'); }); it("matches type-only import statements", () => { @@ -106,7 +129,7 @@ describe("checkImportFrom", () => { it("resolves template placeholders in the source", () => { const result = checkImportFrom({ - expected: "@scope/${packageName}", + expected: "@scope/${packageName}/*", context: createMockContext({ path: "src/index.ts", placeholders: { packageName: { toString: () => "pkg" } }, @@ -118,6 +141,35 @@ describe("checkImportFrom", () => { expect(result).toEqual([]); }); + it("requires every import source in an array", () => { + const result = checkImportFrom({ + expected: ["react", "package/*"], + context: createMockContext({ path: "src/index.ts" }), + fileStructure: parseSource({ + source: [ + "import React from 'react';", + "import { value } from 'package/v4';", + ].join("\n"), + }), + }); + expect(result).toEqual([]); + }); + + it("returns diagnostics for missing import sources in an array", () => { + const result = checkImportFrom({ + expected: ["react", "package"], + context: createMockContext({ path: "src/index.ts" }), + fileStructure: parseSource({ + source: "import { value } from 'package/v4';", + }), + }); + expect(result).toHaveLength(2); + expect(result.map((d) => d.message)).toEqual([ + 'Missing import from "react"', + 'Missing import from "package"', + ]); + }); + it("includes conventionName when provided", () => { const result = checkImportFrom({ expected: "react", diff --git a/packages/konsistent/src/typescript/predicates/import-from.ts b/packages/konsistent/src/typescript/predicates/import-from.ts index cafee9d..6b44142 100644 --- a/packages/konsistent/src/typescript/predicates/import-from.ts +++ b/packages/konsistent/src/typescript/predicates/import-from.ts @@ -3,65 +3,49 @@ import type { Diagnostic, DiagnosticSeverity } from "../../core/diagnostics.js"; import { createDiagnostic } from "../../core/diagnostics.js"; import type { FileStructure } from "../types.js"; -function isRelativeOrAbsoluteSpecifier(value: string): boolean { - return ( - value === "." || - value === ".." || - value.startsWith("./") || - value.startsWith("../") || - value.startsWith("/") - ); -} - -function isPackageRootSpecifier(value: string): boolean { - if (isRelativeOrAbsoluteSpecifier(value)) { - return false; - } - if (value.startsWith("@")) { - const parts = value.split("/"); - return parts.length === 2 && parts[0].length > 1 && parts[1] !== ""; - } - return !value.includes("/"); -} - function doesImportSourceMatch(opts: { from: string; expected: string; }): boolean { const { from, expected } = opts; - if (from === expected) { - return true; - } - if (!isPackageRootSpecifier(expected)) { - return false; + if (expected.endsWith("/*")) { + const prefix = expected.slice(0, -2); + return from.startsWith(`${prefix}/`); } - return from.startsWith(`${expected}/`); + return from === expected; } export function checkImportFrom(opts: { - expected: string; + expected: string | string[]; context: PredicateContext; fileStructure: FileStructure; conventionName?: string; severity?: DiagnosticSeverity; }): Diagnostic[] { const { expected, context, fileStructure, conventionName, severity } = opts; - const resolvedFrom = context.resolveTemplate(expected); - const found = fileStructure.importSources.find((source) => - doesImportSourceMatch({ from: source.from, expected: resolvedFrom }) - ); + const diagnostics: Diagnostic[] = []; + const expectedSources = typeof expected === "string" ? [expected] : expected; + + for (const source of expectedSources) { + const resolvedFrom = context.resolveTemplate(source); + const found = fileStructure.importSources.find((importSource) => + doesImportSourceMatch({ from: importSource.from, expected: resolvedFrom }) + ); + + if (found) { + continue; + } - if (found) { - return []; + diagnostics.push( + createDiagnostic({ + filePath: context.path, + predicateName: "importFrom", + message: `Missing import from "${resolvedFrom}"`, + conventionName, + severity, + }) + ); } - return [ - createDiagnostic({ - filePath: context.path, - predicateName: "importFrom", - message: `Missing import from "${resolvedFrom}"`, - conventionName, - severity, - }), - ]; + return diagnostics; } From 397c94cb0f9b326e39c9a946e8bbc343d0a1b24a Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Mon, 6 Jul 2026 10:20:26 -0500 Subject: [PATCH 2/2] changeset --- .changeset/ripe-places-rhyme.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/ripe-places-rhyme.md diff --git a/.changeset/ripe-places-rhyme.md b/.changeset/ripe-places-rhyme.md new file mode 100644 index 0000000..078d460 --- /dev/null +++ b/.changeset/ripe-places-rhyme.md @@ -0,0 +1,6 @@ +--- +"@konsistent/convention": patch +"konsistent": patch +--- + +fix(konsistent): fix `importFrom` predicate to distinguish between exact vs sub match imports