Files
Aiden Bai 8c2f03aea9 feat: make React cleanup first-class (#1624)
* 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
2026-08-13 16:26:08 -07:00

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);
}
});
});