Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/knip-config-support.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions packages/core/src/check-dead-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -569,8 +569,8 @@ export const checkDeadCode = async (options: CheckDeadCodeOptions): Promise<Diag
const rootDirectory = toCanonicalPath(options.rootDirectory);
if (!fs.existsSync(path.join(rootDirectory, "package.json"))) return [];

const entryPatterns = collectDeadCodeEntryPatterns(rootDirectory);
const ignorePatterns = collectDeadCodeIgnorePatterns(rootDirectory);
const entryPatterns = await collectDeadCodeEntryPatterns(rootDirectory);
const ignorePatterns = await collectDeadCodeIgnorePatterns(rootDirectory);
const tsConfigPath = resolveTsConfigPath(rootDirectory);
const deslopJsModuleSpecifier =
options.deslopJsModuleSpecifier ?? import.meta.resolve("deslop-js");
Expand Down
43 changes: 35 additions & 8 deletions packages/core/src/dead-code/collect-dead-code-patterns.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import * as fs from "node:fs";
import path from "node:path";
import { createJiti } from "jiti";
import { collectIgnorePatterns } from "../collect-ignore-patterns.js";
import { readIgnoreFile } from "../read-ignore-file.js";
import { failOpenReadJson } from "../utils/fail-open-read-json.js";
Expand All @@ -15,9 +17,30 @@
readonly workspaces?: unknown;
}

const KNIP_CONFIG_BASENAME = "knip.config";
const KNIP_CONFIG_EXTENSIONS = ["ts", "mts", "cts", "js", "mjs", "cjs"] as const;
const KNIP_JSON_FILENAME = "knip.json";

const readKnipConfig = (rootDirectory: string): KnipConfig | null => {
const jiti = createJiti(import.meta.url);

const loadKnipModuleConfig = async (filePath: string): Promise<unknown> => {
try {
const imported = await jiti.import<{ default?: unknown }>(filePath);
return imported?.default ?? imported;
} catch {
return null;
}
};

const readKnipConfig = async (rootDirectory: string): Promise<KnipConfig | null> => {
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<unknown | null>(
path.join(rootDirectory, KNIP_JSON_FILENAME),
null,
Expand Down Expand Up @@ -68,11 +91,11 @@
return patterns;
};

const collectKnipPatterns = (
const collectKnipPatterns = async (
rootDirectory: string,
settingName: keyof Pick<KnipConfig, "entry" | "ignore">,
): string[] => {
const config = readKnipConfig(rootDirectory);
): Promise<string[]> => {
const config = await readKnipConfig(rootDirectory);
if (!config) return [];
return [
...normalizePatternList(config[settingName]),
Expand All @@ -83,18 +106,22 @@
// `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<string[]> => {
const seen = new Set<string>();
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);
}
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<string[]> =>
[...new Set(await collectKnipPatterns(rootDirectory, "entry"))].filter(
(pattern) => pattern.length > 0,
);
87 changes: 87 additions & 0 deletions packages/core/tests/check-dead-code.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
ignorePatterns: ReadonlyArray<string>;
} | 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",
Expand Down
Loading