diff --git a/.changeset/knip-config-support.md b/.changeset/knip-config-support.md new file mode 100644 index 000000000..f084b9f47 --- /dev/null +++ b/.changeset/knip-config-support.md @@ -0,0 +1,6 @@ +--- +"@react-doctor/core": patch +"react-doctor": patch +--- + +Support knip.config.{ts,js,mjs,cjs} in dead-code analysis. React Doctor now loads TypeScript and JavaScript Knip config files in addition to knip.json and package.json#knip, eliminating the need to duplicate ignore patterns. Config files are resolved in this order: knip.config.{ts,mts,cts,js,mjs,cjs} → knip.json → package.json#knip. diff --git a/packages/core/src/check-dead-code.ts b/packages/core/src/check-dead-code.ts index 332373ee7..928910efe 100644 --- a/packages/core/src/check-dead-code.ts +++ b/packages/core/src/check-dead-code.ts @@ -569,8 +569,8 @@ export const checkDeadCode = async (options: CheckDeadCodeOptions): Promise { +const jiti = createJiti(import.meta.url); + +const loadKnipModuleConfig = async (filePath: string): Promise => { + try { + const imported = await jiti.import<{ default?: unknown }>(filePath); + return imported?.default ?? imported; + } catch { + return null; + } +}; + +const readKnipConfig = async (rootDirectory: string): Promise => { + for (const extension of KNIP_CONFIG_EXTENSIONS) { + const configPath = path.join(rootDirectory, `${KNIP_CONFIG_BASENAME}.${extension}`); + if (fs.existsSync(configPath)) { + const loaded = await loadKnipModuleConfig(configPath); + if (isRecord(loaded)) return loaded; + } + } + const knipJson = failOpenReadJson( path.join(rootDirectory, KNIP_JSON_FILENAME), null, @@ -68,11 +91,11 @@ const collectKnipWorkspacePatterns = ( return patterns; }; -const collectKnipPatterns = ( +const collectKnipPatterns = async ( rootDirectory: string, settingName: keyof Pick, -): string[] => { - const config = readKnipConfig(rootDirectory); +): Promise => { + const config = await readKnipConfig(rootDirectory); if (!config) return []; return [ ...normalizePatternList(config[settingName]), @@ -83,12 +106,12 @@ const collectKnipPatterns = ( // `ignore.files` is intentionally excluded: it suppresses *reporting* (via the // diagnostic pipeline), so those files must stay in deslop's graph or a file // imported only by an ignored file is falsely flagged unused (react-doctor#830). -export const collectDeadCodeIgnorePatterns = (rootDirectory: string): string[] => { +export const collectDeadCodeIgnorePatterns = async (rootDirectory: string): Promise => { const seen = new Set(); const sources = [ readIgnoreFile(path.join(rootDirectory, ".gitignore")), collectIgnorePatterns(rootDirectory), - collectKnipPatterns(rootDirectory, "ignore"), + await collectKnipPatterns(rootDirectory, "ignore"), ]; for (const source of sources) { for (const pattern of source) seen.add(pattern); @@ -96,5 +119,9 @@ export const collectDeadCodeIgnorePatterns = (rootDirectory: string): string[] = return [...seen].filter((pattern) => pattern.length > 0); }; -export const collectDeadCodeEntryPatterns = (rootDirectory: string): string[] => - [...new Set(collectKnipPatterns(rootDirectory, "entry"))].filter((pattern) => pattern.length > 0); +export const collectDeadCodeEntryPatterns = async ( + rootDirectory: string, +): Promise => + [...new Set(await collectKnipPatterns(rootDirectory, "entry"))].filter( + (pattern) => pattern.length > 0, + ); diff --git a/packages/core/tests/check-dead-code.test.ts b/packages/core/tests/check-dead-code.test.ts index 943ee6852..ae7c659a0 100644 --- a/packages/core/tests/check-dead-code.test.ts +++ b/packages/core/tests/check-dead-code.test.ts @@ -187,6 +187,93 @@ describe("checkDeadCode", () => { expect(capturedInput?.ignorePatterns).toContain("packages/*/src/fixtures.ts"); }); + it("honors unused-file ignore patterns from knip.config.ts", async () => { + const directory = setupProject("knip-config-ts-ignore", { + "src/index.ts": "export const used = 1;\n", + "src/knipignored.ts": "export const ignored = 1;\n", + "knip.config.ts": 'export default { ignore: ["src/knipignored.ts"] };\n', + }); + + const flagged = await flaggedUnusedFiles(directory); + expect(flagged.some((entry) => entry.endsWith("knipignored.ts"))).toBe(false); + }); + + it("honors unused-file ignore patterns from knip.config.js", async () => { + const directory = setupProject("knip-config-js-ignore", { + "src/index.ts": "export const used = 1;\n", + "src/knipignored.ts": "export const ignored = 1;\n", + "knip.config.js": 'export default { ignore: ["src/knipignored.ts"] };\n', + }); + + const flagged = await flaggedUnusedFiles(directory); + expect(flagged.some((entry) => entry.endsWith("knipignored.ts"))).toBe(false); + }); + + it("honors unused-file ignore patterns from knip.config.mjs", async () => { + const directory = setupProject("knip-config-mjs-ignore", { + "src/index.ts": "export const used = 1;\n", + "src/knipignored.ts": "export const ignored = 1;\n", + "knip.config.mjs": 'export default { ignore: ["src/knipignored.ts"] };\n', + }); + + const flagged = await flaggedUnusedFiles(directory); + expect(flagged.some((entry) => entry.endsWith("knipignored.ts"))).toBe(false); + }); + + it("prefers knip.config.ts over knip.json", async () => { + const directory = setupProject("knip-config-ts-precedence", { + "src/index.ts": "export const used = 1;\n", + "src/config-ts-ignored.ts": "export const ignored = 1;\n", + "src/json-ignored.ts": "export const ignored = 1;\n", + "knip.config.ts": 'export default { ignore: ["src/config-ts-ignored.ts"] };\n', + "knip.json": JSON.stringify({ ignore: ["src/json-ignored.ts"] }), + }); + + const flagged = await flaggedUnusedFiles(directory); + expect(flagged.some((entry) => entry.endsWith("config-ts-ignored.ts"))).toBe(false); + expect(flagged.some((entry) => entry.endsWith("json-ignored.ts"))).toBe(true); + }); + + it("forwards knip.config.ts entry and ignore patterns including workspaces", async () => { + const directory = setupProject("knip-config-ts-workspaces", { + "src/index.ts": "export const used = 1;\n", + "knip.config.ts": `export default { + entry: ["src/custom-entry.ts"], + ignore: ["src/generated.ts"], + workspaces: { + "packages/*": { + entry: ["src/main.ts"], + ignore: ["src/fixtures.ts"], + }, + }, + };\n`, + }); + let capturedInput: { + entryPatterns: ReadonlyArray; + ignorePatterns: ReadonlyArray; + } | null = null; + + await checkDeadCode({ + rootDirectory: directory, + createWorker: (input) => { + capturedInput = input; + return { + result: Promise.resolve({ + unusedFiles: [], + unusedExports: [], + unusedDependencies: [], + circularDependencies: [], + }), + }; + }, + }); + + expect(capturedInput?.entryPatterns).toContain("src/custom-entry.ts"); + expect(capturedInput?.entryPatterns).toContain("packages/*/src/main.ts"); + expect(capturedInput?.ignorePatterns).toContain("src/generated.ts"); + expect(capturedInput?.ignorePatterns).toContain("packages/*/src/fixtures.ts"); + }); + it("maps unused exports, dependencies, and cycles from worker results", async () => { const directory = setupProject("worker-result-shapes", { "src/index.ts": "export const used = 1;\n",