mirror of
https://github.com/millionco/react-doctor.git
synced 2026-09-14 20:00:24 +08:00
8c2f03aea9
* feat: make React cleanup first-class * refactor: remove editor integrations * fix: harden React cleanup analysis * fix: detect default export duplication roots * fix: unwrap typed duplication roots * feat: add opt-in project analysis rules * fix: canonicalize project analysis paths * fix: harden project analysis precision * fix: recognize cross-platform project entries * fix: eliminate project analysis false positives * fix: harden project analysis reachability * fix: canonicalize project analysis inputs * fix: resolve project analysis review findings * fix: eliminate residual project analysis false positives * fix: ignore commented registry previews * fix: eliminate project analysis false positives * fix: normalize project analysis paths across platforms * fix: normalize Nextra theme path identity * test: canonicalize convention fixture paths * fix: preserve project analysis provenance * fix: harden project analysis precision * fix: honor project analysis boundaries * fix: recognize conditional config plugins * fix: recognize executable project references * fix: recognize Stencil tool contracts * fix: recognize nested tool references * fix: recognize project setup contracts * fix: recognize generated and local package consumers * fix: recognize static template package references * fix: recognize nested package runtime contracts * fix: close project analysis parser gaps * fix: parse project conventions structurally * refactor: replace structural scanners with parsers * fix: recognize functional Next CSS config * fix: close remaining project analysis gaps * fix: apply tag filters to project analysis * fix: preserve embedded source positions * fix: validate static config helper bindings * fix: bound runtime directory discovery * fix: close final dependency analysis gaps * fix: preserve declaration dependency references * chore: refresh generated rule metadata * fix: make project analysis portable and bounded * test: stabilize cleanup scaling guard * refactor: parse project syntax with oxc * fix: normalize native filesystem paths * fix: separate path identity from report paths * fix: match project files by filesystem identity * fix: match build glob files by package identity * fix: use native path keys for file identity * fix: canonicalize Windows file identities * fix: canonicalize package ownership paths * test: inspect Windows path identities * test: trace Windows package ownership * fix: keep Windows path identities consistent * fix: classify test contracts by normalized path * fix: scope test contracts by canonical package path * fix: keep test package graphs conservative * test: keep React complexity advisory
438 lines
15 KiB
TypeScript
438 lines
15 KiB
TypeScript
import * as fs from "node:fs";
|
|
import os from "node:os";
|
|
import * as path from "node:path";
|
|
import { afterAll, describe, expect, it } from "vite-plus/test";
|
|
import {
|
|
diagnose,
|
|
NoReactDependencyError,
|
|
NotADirectoryError,
|
|
ProjectNotFoundError,
|
|
} from "../src/index.js";
|
|
|
|
const FIXTURES_DIRECTORY = path.resolve(
|
|
import.meta.dirname,
|
|
"..",
|
|
"..",
|
|
"core",
|
|
"tests",
|
|
"fixtures",
|
|
);
|
|
const LINT_OPT_OUT_TEST_TIMEOUT_MS = 60_000;
|
|
|
|
const noReactTempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "rdc-api-test-"));
|
|
fs.writeFileSync(
|
|
path.join(noReactTempDirectory, "package.json"),
|
|
JSON.stringify({ name: "no-react", dependencies: {} }),
|
|
);
|
|
|
|
afterAll(() => {
|
|
fs.rmSync(noReactTempDirectory, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("diagnose", () => {
|
|
it("returns a DiagnoseResult with the expected shape on basic-react", async () => {
|
|
const result = await diagnose(path.join(FIXTURES_DIRECTORY, "basic-react"), {
|
|
deadCode: false,
|
|
lint: false,
|
|
});
|
|
expect(result).toHaveProperty("diagnostics");
|
|
expect(result).toHaveProperty("score");
|
|
expect(result).toHaveProperty("project");
|
|
expect(result).toHaveProperty("skippedChecks");
|
|
expect(result).toHaveProperty("elapsedMilliseconds");
|
|
expect(result.project.reactMajorVersion).toBe(19);
|
|
expect(Array.isArray(result.diagnostics)).toBe(true);
|
|
});
|
|
|
|
it("throws NoReactDependencyError when the directory has package.json without react", async () => {
|
|
await expect(diagnose(noReactTempDirectory, { lint: false })).rejects.toThrow(
|
|
NoReactDependencyError,
|
|
);
|
|
});
|
|
|
|
it("throws ProjectNotFoundError when the directory has no package.json and no React subprojects", async () => {
|
|
const emptyDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "rdc-empty-"));
|
|
try {
|
|
await expect(diagnose(emptyDirectory, { lint: false })).rejects.toThrow(ProjectNotFoundError);
|
|
} finally {
|
|
fs.rmSync(emptyDirectory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("throws NotADirectoryError when the path is a file instead of a directory", async () => {
|
|
const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "rdc-file-"));
|
|
const filePath = path.join(tempDirectory, "not-a-directory.txt");
|
|
fs.writeFileSync(filePath, "");
|
|
try {
|
|
await expect(diagnose(filePath, { lint: false })).rejects.toThrow(NotADirectoryError);
|
|
} finally {
|
|
fs.rmSync(tempDirectory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("sets reactDetected true on a React project and false on a non-React one", async () => {
|
|
const reactResult = await diagnose(path.join(FIXTURES_DIRECTORY, "basic-react"), {
|
|
deadCode: false,
|
|
lint: false,
|
|
});
|
|
expect(reactResult.reactDetected).toBe(true);
|
|
|
|
const nonReactDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "rdc-nonreact-"));
|
|
fs.writeFileSync(
|
|
path.join(nonReactDirectory, "package.json"),
|
|
JSON.stringify({ name: "non-react-tool", dependencies: { lodash: "^4.0.0" } }),
|
|
);
|
|
fs.mkdirSync(path.join(nonReactDirectory, "src"));
|
|
fs.writeFileSync(
|
|
path.join(nonReactDirectory, "src", "index.ts"),
|
|
"export const add = (firstNumber: number, secondNumber: number): number => firstNumber + secondNumber;\n",
|
|
);
|
|
try {
|
|
const nonReactResult = await diagnose(nonReactDirectory, { deadCode: false, lint: false });
|
|
expect(nonReactResult.reactDetected).toBe(false);
|
|
expect(nonReactResult.project.reactVersion).toBeNull();
|
|
} finally {
|
|
fs.rmSync(nonReactDirectory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("scans a Three.js project without reporting React", async () => {
|
|
const threeDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "rdc-three-"));
|
|
fs.writeFileSync(
|
|
path.join(threeDirectory, "package.json"),
|
|
JSON.stringify({ name: "three-tool", dependencies: { three: "0.185.1" } }),
|
|
);
|
|
fs.mkdirSync(path.join(threeDirectory, "src"));
|
|
fs.writeFileSync(
|
|
path.join(threeDirectory, "src", "main.ts"),
|
|
'import * as THREE from "three";\nexport const scene = new THREE.Scene();\n',
|
|
);
|
|
try {
|
|
const threeResult = await diagnose(threeDirectory, { deadCode: false, lint: false });
|
|
expect(threeResult.reactDetected).toBe(false);
|
|
expect(threeResult.project.reactVersion).toBeNull();
|
|
expect(threeResult.project.hasThree).toBe(true);
|
|
} finally {
|
|
fs.rmSync(threeDirectory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("elapsedMilliseconds is non-negative", async () => {
|
|
const result = await diagnose(path.join(FIXTURES_DIRECTORY, "basic-react"), {
|
|
deadCode: false,
|
|
lint: false,
|
|
});
|
|
expect(result.elapsedMilliseconds).toBeGreaterThanOrEqual(0);
|
|
});
|
|
|
|
it(
|
|
"respects lint: false by providing a no-op linter layer",
|
|
{ timeout: LINT_OPT_OUT_TEST_TIMEOUT_MS },
|
|
async () => {
|
|
const directory = path.join(FIXTURES_DIRECTORY, "basic-react");
|
|
const lintEnabledResult = await diagnose(directory, { deadCode: false, lint: true });
|
|
const lintDisabledResult = await diagnose(directory, { deadCode: false, lint: false });
|
|
const lintEnabledSourceDiagnostics = lintEnabledResult.diagnostics.filter(
|
|
(diagnostic) => diagnostic.filePath !== "package.json",
|
|
);
|
|
const lintDisabledSourceDiagnostics = lintDisabledResult.diagnostics.filter(
|
|
(diagnostic) => diagnostic.filePath !== "package.json",
|
|
);
|
|
|
|
expect(lintEnabledSourceDiagnostics.length).toBeGreaterThan(0);
|
|
expect(lintDisabledSourceDiagnostics).toHaveLength(0);
|
|
},
|
|
);
|
|
});
|
|
|
|
describe("diagnose({ projects })", () => {
|
|
it("returns per-project results for multiple directories", async () => {
|
|
const projectDirectories = [
|
|
path.join(FIXTURES_DIRECTORY, "basic-react"),
|
|
path.join(FIXTURES_DIRECTORY, "nextjs-app"),
|
|
];
|
|
const result = await diagnose({
|
|
projects: projectDirectories.map((directory) => ({ directory })),
|
|
deadCode: false,
|
|
lint: false,
|
|
});
|
|
|
|
expect(result.projects).toHaveLength(2);
|
|
expect(result.projects.map((projectResult) => projectResult.directory)).toEqual(
|
|
projectDirectories,
|
|
);
|
|
expect(result).toHaveProperty("diagnostics");
|
|
expect(result).toHaveProperty("score");
|
|
expect(result).toHaveProperty("elapsedMilliseconds");
|
|
expect(Array.isArray(result.diagnostics)).toBe(true);
|
|
|
|
for (const projectResult of result.projects) {
|
|
expect(projectResult.ok).toBe(true);
|
|
if (!projectResult.ok) continue;
|
|
expect(projectResult).toHaveProperty("directory");
|
|
expect(projectResult).toHaveProperty("diagnostics");
|
|
expect(projectResult).toHaveProperty("score");
|
|
expect(projectResult).toHaveProperty("project");
|
|
expect(projectResult).toHaveProperty("skippedChecks");
|
|
expect(projectResult).toHaveProperty("elapsedMilliseconds");
|
|
}
|
|
});
|
|
|
|
it("preserves lint diagnostics when sibling projects reuse one source inventory", async () => {
|
|
const projectDirectories = [
|
|
path.join(FIXTURES_DIRECTORY, "basic-react"),
|
|
path.join(FIXTURES_DIRECTORY, "nextjs-app"),
|
|
];
|
|
const sharedOptions = { deadCode: false };
|
|
const previousNoCache = process.env.REACT_DOCTOR_NO_CACHE;
|
|
process.env.REACT_DOCTOR_NO_CACHE = "1";
|
|
try {
|
|
const batchResult = await diagnose({
|
|
projects: projectDirectories.map((directory) => ({ directory })),
|
|
...sharedOptions,
|
|
});
|
|
const directResults = await Promise.all(
|
|
projectDirectories.map((directory) => diagnose(directory, sharedOptions)),
|
|
);
|
|
|
|
for (let projectIndex = 0; projectIndex < projectDirectories.length; projectIndex += 1) {
|
|
const batchProject = batchResult.projects[projectIndex];
|
|
expect(batchProject.ok).toBe(true);
|
|
if (!batchProject.ok) continue;
|
|
expect(batchProject.diagnostics).toEqual(directResults[projectIndex].diagnostics);
|
|
expect(batchProject.scannedFileCount).toBe(directResults[projectIndex].scannedFileCount);
|
|
}
|
|
} finally {
|
|
if (previousNoCache === undefined) {
|
|
delete process.env.REACT_DOCTOR_NO_CACHE;
|
|
} else {
|
|
process.env.REACT_DOCTOR_NO_CACHE = previousNoCache;
|
|
}
|
|
}
|
|
});
|
|
|
|
it("flattens diagnostics across all succeeded projects", async () => {
|
|
const result = await diagnose({
|
|
projects: [
|
|
{ directory: path.join(FIXTURES_DIRECTORY, "basic-react") },
|
|
{ directory: path.join(FIXTURES_DIRECTORY, "nextjs-app") },
|
|
],
|
|
deadCode: false,
|
|
lint: false,
|
|
});
|
|
|
|
const expectedTotal = result.projects.reduce(
|
|
(sum, projectResult) => sum + (projectResult.ok ? projectResult.diagnostics.length : 0),
|
|
0,
|
|
);
|
|
expect(result.diagnostics).toHaveLength(expectedTotal);
|
|
});
|
|
|
|
it("supports per-project scan option overrides", async () => {
|
|
const result = await diagnose({
|
|
projects: [
|
|
{ directory: path.join(FIXTURES_DIRECTORY, "basic-react"), deadCode: false },
|
|
{ directory: path.join(FIXTURES_DIRECTORY, "nextjs-app"), deadCode: false },
|
|
],
|
|
lint: false,
|
|
});
|
|
|
|
expect(result.projects).toHaveLength(2);
|
|
for (const projectResult of result.projects) {
|
|
expect(projectResult.ok).toBe(true);
|
|
if (!projectResult.ok) continue;
|
|
expect(projectResult.skippedChecks).not.toContain("dead-code");
|
|
}
|
|
});
|
|
|
|
it("respects concurrency: 1 for sequential execution", async () => {
|
|
const result = await diagnose({
|
|
projects: [
|
|
{ directory: path.join(FIXTURES_DIRECTORY, "basic-react") },
|
|
{ directory: path.join(FIXTURES_DIRECTORY, "nextjs-app") },
|
|
],
|
|
deadCode: false,
|
|
lint: false,
|
|
concurrency: 1,
|
|
});
|
|
|
|
expect(result.projects).toHaveLength(2);
|
|
expect(result.elapsedMilliseconds).toBeGreaterThanOrEqual(0);
|
|
});
|
|
|
|
it("handles a single project identically to diagnose()", async () => {
|
|
const multiResult = await diagnose({
|
|
projects: [{ directory: path.join(FIXTURES_DIRECTORY, "basic-react") }],
|
|
deadCode: false,
|
|
lint: false,
|
|
});
|
|
const directResult = await diagnose(path.join(FIXTURES_DIRECTORY, "basic-react"), {
|
|
deadCode: false,
|
|
lint: false,
|
|
});
|
|
|
|
expect(multiResult.projects).toHaveLength(1);
|
|
const firstProject = multiResult.projects[0];
|
|
expect(firstProject.ok).toBe(true);
|
|
if (!firstProject.ok) return;
|
|
expect(firstProject.project.reactMajorVersion).toBe(directResult.project.reactMajorVersion);
|
|
expect(firstProject.project.projectName).toBe(directResult.project.projectName);
|
|
});
|
|
|
|
it("collects failing projects with ok: false without aborting the batch", async () => {
|
|
const result = await diagnose({
|
|
projects: [
|
|
{ directory: path.join(FIXTURES_DIRECTORY, "basic-react") },
|
|
{ directory: noReactTempDirectory },
|
|
],
|
|
deadCode: false,
|
|
lint: false,
|
|
});
|
|
|
|
const succeeded = result.projects.filter((projectResult) => projectResult.ok);
|
|
const failed = result.projects.filter((projectResult) => !projectResult.ok);
|
|
|
|
expect(succeeded).toHaveLength(1);
|
|
expect(succeeded[0].ok && succeeded[0].project.projectName).toBe("test-basic-react");
|
|
expect(failed).toHaveLength(1);
|
|
expect(failed[0].directory).toBe(noReactTempDirectory);
|
|
expect(!failed[0].ok && failed[0].error).toBeInstanceOf(Error);
|
|
});
|
|
|
|
it("returns empty results for an empty projects array", async () => {
|
|
const result = await diagnose({ projects: [], deadCode: false, lint: false });
|
|
|
|
expect(result.projects).toHaveLength(0);
|
|
expect(result.diagnostics).toHaveLength(0);
|
|
expect(result.score).toBeNull();
|
|
expect(result.reactDetected).toBeUndefined();
|
|
expect(result.elapsedMilliseconds).toBeGreaterThanOrEqual(0);
|
|
});
|
|
|
|
it("aggregates reactDetected across succeeded projects", async () => {
|
|
const result = await diagnose({
|
|
projects: [
|
|
{ directory: path.join(FIXTURES_DIRECTORY, "basic-react") },
|
|
{ directory: noReactTempDirectory },
|
|
],
|
|
deadCode: false,
|
|
lint: false,
|
|
});
|
|
|
|
expect(result.reactDetected).toBe(true);
|
|
const succeeded = result.projects.find((projectResult) => projectResult.ok);
|
|
expect(succeeded?.ok && succeeded.reactDetected).toBe(true);
|
|
});
|
|
|
|
it("clamps concurrency: 0 to 1 without hanging", async () => {
|
|
const result = await diagnose({
|
|
projects: [{ directory: path.join(FIXTURES_DIRECTORY, "basic-react") }],
|
|
deadCode: false,
|
|
lint: false,
|
|
concurrency: 0,
|
|
});
|
|
|
|
expect(result.projects).toHaveLength(1);
|
|
});
|
|
|
|
it("accepts per-project ReactDoctorConfig override", async () => {
|
|
const result = await diagnose({
|
|
projects: [
|
|
{
|
|
directory: path.join(FIXTURES_DIRECTORY, "basic-react"),
|
|
deadCode: false,
|
|
config: { ignore: { tags: ["design"] } },
|
|
},
|
|
],
|
|
lint: false,
|
|
});
|
|
|
|
expect(result.projects).toHaveLength(1);
|
|
expect(result.projects[0].ok).toBe(true);
|
|
});
|
|
|
|
it("runs explicitly enabled project rules through the public API", async () => {
|
|
const projectDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "rdc-project-analysis-"));
|
|
const sourceDirectory = path.join(projectDirectory, "src");
|
|
fs.mkdirSync(sourceDirectory);
|
|
fs.writeFileSync(
|
|
path.join(projectDirectory, "package.json"),
|
|
JSON.stringify({
|
|
name: "project-analysis-api-test",
|
|
main: "src/index.ts",
|
|
dependencies: { react: "19.2.5" },
|
|
}),
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(sourceDirectory, "index.ts"),
|
|
'import { usedValue } from "./library.js";\nconsole.log(usedValue);\n',
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(sourceDirectory, "library.ts"),
|
|
"export const usedValue = 1;\nexport const unusedValue = 2;\n",
|
|
);
|
|
try {
|
|
const result = await diagnose({
|
|
projects: [
|
|
{
|
|
directory: projectDirectory,
|
|
config: { rules: { "react-doctor/unused-export": "warn" } },
|
|
},
|
|
],
|
|
deadCode: false,
|
|
lint: false,
|
|
});
|
|
|
|
const projectResult = result.projects[0];
|
|
expect(projectResult.ok).toBe(true);
|
|
if (!projectResult.ok) return;
|
|
expect(projectResult.diagnostics).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
plugin: "react-doctor",
|
|
rule: "unused-export",
|
|
message: expect.stringContaining("unusedValue"),
|
|
}),
|
|
]),
|
|
);
|
|
} finally {
|
|
fs.rmSync(projectDirectory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("respects batch config lint: false", async () => {
|
|
const result = await diagnose({
|
|
projects: [{ directory: path.join(FIXTURES_DIRECTORY, "basic-react") }],
|
|
config: { lint: false },
|
|
deadCode: false,
|
|
});
|
|
|
|
const projectResult = result.projects[0];
|
|
expect(projectResult.ok).toBe(true);
|
|
if (!projectResult.ok) return;
|
|
expect(
|
|
projectResult.diagnostics.filter((diagnostic) => diagnostic.filePath !== "package.json"),
|
|
).toHaveLength(0);
|
|
});
|
|
|
|
it("layers per-project configs on top of a batch-level config", async () => {
|
|
const result = await diagnose({
|
|
projects: [
|
|
{ directory: path.join(FIXTURES_DIRECTORY, "basic-react") },
|
|
{
|
|
directory: path.join(FIXTURES_DIRECTORY, "nextjs-app"),
|
|
config: { rules: { "react-doctor/no-prop-drilling": "off" } },
|
|
},
|
|
],
|
|
config: { ignore: { tags: ["design"] } },
|
|
deadCode: false,
|
|
lint: false,
|
|
});
|
|
|
|
expect(result.projects).toHaveLength(2);
|
|
for (const projectResult of result.projects) {
|
|
expect(projectResult.ok).toBe(true);
|
|
}
|
|
});
|
|
});
|