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
664 lines
24 KiB
TypeScript
664 lines
24 KiB
TypeScript
import * as fs from "node:fs";
|
|
import os from "node:os";
|
|
import * as path from "node:path";
|
|
import { afterAll, describe, expect, it, vi } from "vite-plus/test";
|
|
import { inspect } from "../src/inspect.js";
|
|
import { clearConfigCache } from "@react-doctor/core";
|
|
import {
|
|
commitAll,
|
|
initGitRepo,
|
|
setupReactProject,
|
|
writeFile,
|
|
writeJson,
|
|
} from "./regressions/_helpers.js";
|
|
|
|
const FIXTURES_DIRECTORY = path.resolve(
|
|
import.meta.dirname,
|
|
"..",
|
|
"..",
|
|
"core",
|
|
"tests",
|
|
"fixtures",
|
|
);
|
|
|
|
const buildDuplicateCard = (
|
|
componentName: string,
|
|
title: string,
|
|
valueName: string,
|
|
rootElementName = "section",
|
|
): string => `
|
|
export const ${componentName} = () => (
|
|
<${rootElementName} className="card">
|
|
<header><h2>${title}</h2></header>
|
|
<main><Value value={${valueName}} /></main>
|
|
<footer><Button /></footer>
|
|
</${rootElementName}>
|
|
);
|
|
`;
|
|
|
|
vi.mock("ora", () => ({
|
|
default: () => ({
|
|
text: "",
|
|
start: function () {
|
|
return this;
|
|
},
|
|
stop: function () {
|
|
return this;
|
|
},
|
|
succeed: () => {},
|
|
fail: () => {},
|
|
}),
|
|
}));
|
|
|
|
const noReactTempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-test-"));
|
|
fs.writeFileSync(
|
|
path.join(noReactTempDirectory, "package.json"),
|
|
JSON.stringify({ name: "no-react", dependencies: {} }),
|
|
);
|
|
|
|
afterAll(() => {
|
|
fs.rmSync(noReactTempDirectory, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("inspect", () => {
|
|
it("completes without throwing on a valid React project", async () => {
|
|
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
try {
|
|
await inspect(path.join(FIXTURES_DIRECTORY, "basic-react"), {
|
|
lint: true,
|
|
deadCode: false,
|
|
});
|
|
} finally {
|
|
consoleSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("lints every supported source extension in an explicit-path scan", async () => {
|
|
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-explicit-sources-"));
|
|
try {
|
|
const projectDirectory = setupReactProject(projectRoot, "app");
|
|
const extensions = ["ts", "tsx", "js", "jsx", "mts", "mjs"];
|
|
const includePaths = extensions.map((extension) => `src/conditional-hook.${extension}`);
|
|
for (const relativePath of includePaths) {
|
|
writeFile(
|
|
path.join(projectDirectory, relativePath),
|
|
'import { useState } from "react";\nexport const useConditional = (enabled) => { if (enabled) useState(0); };\n',
|
|
);
|
|
}
|
|
|
|
const result = await inspect(projectDirectory, {
|
|
lint: true,
|
|
deadCode: false,
|
|
noScore: true,
|
|
silent: true,
|
|
includePaths,
|
|
});
|
|
|
|
expect(result.scannedFileCount).toBe(includePaths.length);
|
|
expect(result.analyzedFiles?.toSorted()).toEqual(includePaths.toSorted());
|
|
expect(
|
|
result.diagnostics
|
|
.filter((diagnostic) => diagnostic.rule === "rules-of-hooks")
|
|
.map((diagnostic) => path.extname(diagnostic.filePath))
|
|
.toSorted(),
|
|
).toEqual(extensions.map((extension) => `.${extension}`).toSorted());
|
|
} finally {
|
|
consoleSpy.mockRestore();
|
|
fs.rmSync(projectRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("throws when React dependency is missing", async () => {
|
|
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
try {
|
|
await expect(inspect(noReactTempDirectory, { lint: true })).rejects.toThrow(
|
|
"No React dependency found",
|
|
);
|
|
} finally {
|
|
consoleSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
// Regression (#552): a Preact project has no `react` package, so the run
|
|
// gate must let it through instead of aborting with "No React dependency".
|
|
it("does NOT throw for a Preact project without a react dependency", async () => {
|
|
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
const preactDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-preact-"));
|
|
try {
|
|
fs.writeFileSync(
|
|
path.join(preactDirectory, "package.json"),
|
|
JSON.stringify({ name: "preact-app", dependencies: { preact: "^10.22.0" } }),
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(preactDirectory, "App.tsx"),
|
|
"export function App() { return <div>hi</div>; }\n",
|
|
);
|
|
|
|
const result = await inspect(preactDirectory, { lint: true });
|
|
expect(result.project.preactVersion).toBe("^10.22.0");
|
|
expect(result.project.reactVersion).toBe(null);
|
|
} finally {
|
|
consoleSpy.mockRestore();
|
|
fs.rmSync(preactDirectory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("skips lint when option is disabled", async () => {
|
|
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
try {
|
|
await inspect(path.join(FIXTURES_DIRECTORY, "basic-react"), {
|
|
lint: false,
|
|
deadCode: false,
|
|
});
|
|
} finally {
|
|
consoleSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("completes lint within the timeout budget", async () => {
|
|
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
try {
|
|
const startTime = performance.now();
|
|
await inspect(path.join(FIXTURES_DIRECTORY, "basic-react"), {
|
|
lint: true,
|
|
deadCode: false,
|
|
});
|
|
const elapsedMilliseconds = performance.now() - startTime;
|
|
|
|
expect(elapsedMilliseconds).toBeLessThan(30_000);
|
|
} finally {
|
|
consoleSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
// Regression: when the CLI passes `configOverride`, inspect() must trust
|
|
// the directory it was given and skip the rootDir redirect — otherwise
|
|
// an ancestor config with `rootDir: "apps/web"` would re-route every
|
|
// workspace-package scan back to apps/web. (Bugbot review #200.)
|
|
it("does NOT re-apply rootDir redirect when configOverride is supplied", async () => {
|
|
clearConfigCache();
|
|
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-rootdir-override-"));
|
|
try {
|
|
const adminProjectDirectory = setupReactProject(tempDirectory, "admin");
|
|
setupReactProject(tempDirectory, "web");
|
|
fs.writeFileSync(
|
|
path.join(tempDirectory, "doctor.config.json"),
|
|
JSON.stringify({ rootDir: "web" }),
|
|
);
|
|
|
|
const result = await inspect(adminProjectDirectory, {
|
|
lint: false,
|
|
deadCode: false,
|
|
configOverride: null,
|
|
});
|
|
|
|
expect(result.project.rootDirectory).toBe(adminProjectDirectory);
|
|
} finally {
|
|
consoleSpy.mockRestore();
|
|
fs.rmSync(tempDirectory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// Counterpart: when no configOverride is supplied (direct programmatic
|
|
// inspect() call), rootDir redirection IS honored — same contract as
|
|
// diagnose().
|
|
it("DOES apply rootDir redirect when called without configOverride", async () => {
|
|
clearConfigCache();
|
|
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-rootdir-honor-"));
|
|
try {
|
|
const webProjectDirectory = setupReactProject(tempDirectory, "web");
|
|
setupReactProject(tempDirectory, "admin");
|
|
fs.writeFileSync(
|
|
path.join(tempDirectory, "doctor.config.json"),
|
|
JSON.stringify({ rootDir: "web" }),
|
|
);
|
|
|
|
const result = await inspect(tempDirectory, {
|
|
lint: false,
|
|
deadCode: false,
|
|
});
|
|
|
|
expect(result.project.rootDirectory).toBe(webProjectDirectory);
|
|
} finally {
|
|
consoleSpy.mockRestore();
|
|
fs.rmSync(tempDirectory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// Regression: `resolveCliInspectOptions` leaves `noScore` undefined unless a
|
|
// flag opted out, so a `doctor.config` opt-out must be honored by inspect()'s
|
|
// merge layer (`inputOptions.noScore ?? userConfig?.noScore`). Reverting that
|
|
// merge to ignore config would silently re-enable scoring for opted-out users.
|
|
it("honors config-file noScore (score skipped) when no flag is passed", async () => {
|
|
clearConfigCache();
|
|
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-noscore-config-"));
|
|
try {
|
|
const projectDirectory = setupReactProject(tempDirectory, "app");
|
|
const scanOptions = { lint: false, deadCode: false, silent: true } as const;
|
|
|
|
// Control: no config → scoring on (the merge layer's `?? false` default).
|
|
const withScore = await inspect(projectDirectory, scanOptions);
|
|
expect(withScore.score).not.toBeNull();
|
|
|
|
// Config opt-out with NO flag → inherited by the merge layer → score skipped.
|
|
clearConfigCache();
|
|
writeJson(path.join(projectDirectory, "doctor.config.json"), { noScore: true });
|
|
const withoutScore = await inspect(projectDirectory, scanOptions);
|
|
expect(withoutScore.score).toBeNull();
|
|
} finally {
|
|
consoleSpy.mockRestore();
|
|
fs.rmSync(tempDirectory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// Regression: the baseline base scan materializes only the changed source +
|
|
// config into a temp tree (no node_modules / plugin files), so it MUST resolve
|
|
// a relative `config.plugins` entry from the REAL config dir. When that
|
|
// threading was hardcoded to null, the base side dropped the plugin, its
|
|
// finding vanished from `baseTotalCount`, and head's finding was mislabeled as
|
|
// newly introduced (gating CI on a pre-existing issue).
|
|
it("resolves config.plugins from the real config dir for the baseline base scan", async () => {
|
|
clearConfigCache();
|
|
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-baseline-plugin-"));
|
|
const forbiddenWordPlugin = `
|
|
module.exports = {
|
|
meta: { name: "team-conventions" },
|
|
rules: {
|
|
"no-forbidden-word": {
|
|
create: (context) => ({
|
|
JSXText(node) {
|
|
if (typeof node.value === "string" && node.value.includes("FORBIDDEN")) {
|
|
context.report({ node, message: "'FORBIDDEN' is not allowed" });
|
|
}
|
|
},
|
|
}),
|
|
},
|
|
},
|
|
};
|
|
`;
|
|
try {
|
|
const projectDir = setupReactProject(projectRoot, "app", {
|
|
files: {
|
|
"src/App.tsx": "export const App = () => <div>FORBIDDEN base</div>;\n",
|
|
"lint/team-conventions.cjs": forbiddenWordPlugin,
|
|
},
|
|
});
|
|
writeJson(path.join(projectDir, "doctor.config.json"), {
|
|
plugins: ["./lint/team-conventions.cjs"],
|
|
rules: { "team-conventions/no-forbidden-word": "error" },
|
|
});
|
|
initGitRepo(projectDir);
|
|
const baseRef = commitAll(projectDir, "base carries the plugin finding");
|
|
// Head keeps the finding but edits the file so it's in the scanned diff.
|
|
writeFile(
|
|
path.join(projectDir, "src/App.tsx"),
|
|
"export const App = () => <div>FORBIDDEN head</div>;\n",
|
|
);
|
|
|
|
const result = await inspect(projectDir, {
|
|
lint: true,
|
|
deadCode: false,
|
|
silent: true,
|
|
includePaths: ["src/App.tsx"],
|
|
baseline: { ref: baseRef },
|
|
});
|
|
|
|
// baseTotalCount > 0 proves the base scan loaded the relative plugin from
|
|
// the real config dir; the pre-fix null would leave it at 0.
|
|
expect(result.baselineDelta?.baseTotalCount).toBeGreaterThan(0);
|
|
} finally {
|
|
consoleSpy.mockRestore();
|
|
fs.rmSync(projectRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("reuses head project metadata for baseline scans of leaf-only pnpm catalog monorepos", async () => {
|
|
clearConfigCache();
|
|
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
const monorepoDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-catalog-"));
|
|
try {
|
|
writeFile(
|
|
path.join(monorepoDirectory, "pnpm-workspace.yaml"),
|
|
'packages:\n - "apps/*"\n\ncatalog:\n react: ^19.0.0\n',
|
|
);
|
|
writeJson(path.join(monorepoDirectory, "package.json"), {
|
|
name: "monorepo-root",
|
|
private: true,
|
|
});
|
|
writeJson(path.join(monorepoDirectory, "apps", "web", "package.json"), {
|
|
name: "web",
|
|
dependencies: { react: "catalog:" },
|
|
});
|
|
writeFile(
|
|
path.join(monorepoDirectory, "apps", "web", "src", "App.tsx"),
|
|
"export const App = () => <main>Base</main>;\n",
|
|
);
|
|
initGitRepo(monorepoDirectory);
|
|
const baseRef = commitAll(monorepoDirectory, "initial catalog workspace");
|
|
|
|
writeFile(
|
|
path.join(monorepoDirectory, "apps", "web", "src", "App.tsx"),
|
|
"export const App = () => <main>Head</main>;\n",
|
|
);
|
|
|
|
const result = await inspect(monorepoDirectory, {
|
|
lint: true,
|
|
deadCode: false,
|
|
noScore: true,
|
|
silent: true,
|
|
includePaths: ["apps/web/src/App.tsx"],
|
|
baseline: { ref: baseRef },
|
|
});
|
|
|
|
expect(result.project.reactVersion).toBe("^19.0.0");
|
|
expect(result.skippedChecks).not.toContain("lint");
|
|
expect(result.baselineDelta?.baseTotalCount).toBe(0);
|
|
} finally {
|
|
consoleSpy.mockRestore();
|
|
fs.rmSync(monorepoDirectory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("compares focused JSX duplication against the complete baseline source tree", async () => {
|
|
clearConfigCache();
|
|
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-baseline-jsx-"));
|
|
try {
|
|
const projectDirectory = setupReactProject(projectRoot, "app", {
|
|
files: {
|
|
"src/AccountCard.tsx": buildDuplicateCard("AccountCard", "Account", "account"),
|
|
"src/UserCard.tsx": buildDuplicateCard("UserCard", "User", "user"),
|
|
},
|
|
});
|
|
initGitRepo(projectDirectory);
|
|
const baseRef = commitAll(projectDirectory, "base includes duplicated cards");
|
|
writeFile(
|
|
path.join(projectDirectory, "src", "AccountCard.tsx"),
|
|
buildDuplicateCard("AccountCard", "Account", "customer"),
|
|
);
|
|
|
|
const result = await inspect(projectDirectory, {
|
|
lint: true,
|
|
deadCode: true,
|
|
noScore: true,
|
|
silent: true,
|
|
includePaths: ["src/AccountCard.tsx"],
|
|
baseline: { ref: baseRef },
|
|
});
|
|
|
|
expect(result.baselineDelta?.baseTotalCount).toBeGreaterThan(0);
|
|
expect(
|
|
result.diagnostics.filter((diagnostic) => diagnostic.rule === "duplicate-jsx-subtree"),
|
|
).toEqual([]);
|
|
} finally {
|
|
consoleSpy.mockRestore();
|
|
fs.rmSync(projectRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("surfaces a structurally different duplicate family with the same summary", async () => {
|
|
clearConfigCache();
|
|
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-baseline-jsx-change-"));
|
|
try {
|
|
const projectDirectory = setupReactProject(projectRoot, "app", {
|
|
files: {
|
|
"src/AccountCard.tsx": buildDuplicateCard("AccountCard", "Account", "account"),
|
|
"src/UserCard.tsx": buildDuplicateCard("UserCard", "User", "user"),
|
|
},
|
|
});
|
|
initGitRepo(projectDirectory);
|
|
const baseRef = commitAll(projectDirectory, "base includes duplicated sections");
|
|
writeFile(
|
|
path.join(projectDirectory, "src", "AccountCard.tsx"),
|
|
buildDuplicateCard("AccountCard", "Account", "account", "article"),
|
|
);
|
|
writeFile(
|
|
path.join(projectDirectory, "src", "UserCard.tsx"),
|
|
buildDuplicateCard("UserCard", "User", "user", "article"),
|
|
);
|
|
|
|
const result = await inspect(projectDirectory, {
|
|
lint: true,
|
|
deadCode: true,
|
|
noScore: true,
|
|
silent: true,
|
|
includePaths: ["src/AccountCard.tsx", "src/UserCard.tsx"],
|
|
baseline: { ref: baseRef },
|
|
});
|
|
|
|
expect(
|
|
result.diagnostics.filter((diagnostic) => diagnostic.rule === "duplicate-jsx-subtree"),
|
|
).toHaveLength(1);
|
|
} finally {
|
|
consoleSpy.mockRestore();
|
|
fs.rmSync(projectRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("surfaces a new occurrence added to an existing duplicate family", async () => {
|
|
clearConfigCache();
|
|
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-baseline-jsx-growth-"));
|
|
try {
|
|
const projectDirectory = setupReactProject(projectRoot, "app", {
|
|
files: {
|
|
"src/AccountCard.tsx": buildDuplicateCard("AccountCard", "Account", "account"),
|
|
"src/UserCard.tsx": buildDuplicateCard("UserCard", "User", "user"),
|
|
},
|
|
});
|
|
initGitRepo(projectDirectory);
|
|
const baseRef = commitAll(projectDirectory, "base includes two duplicated cards");
|
|
writeFile(
|
|
path.join(projectDirectory, "src", "TeamCard.tsx"),
|
|
buildDuplicateCard("TeamCard", "Team", "team"),
|
|
);
|
|
|
|
const result = await inspect(projectDirectory, {
|
|
lint: true,
|
|
deadCode: true,
|
|
noScore: true,
|
|
silent: true,
|
|
includePaths: ["src/TeamCard.tsx"],
|
|
baseline: { ref: baseRef },
|
|
});
|
|
|
|
expect(
|
|
result.diagnostics.filter((diagnostic) => diagnostic.rule === "duplicate-jsx-subtree"),
|
|
).toHaveLength(1);
|
|
} finally {
|
|
consoleSpy.mockRestore();
|
|
fs.rmSync(projectRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("keeps a pure file addition in baseline diff mode", async () => {
|
|
clearConfigCache();
|
|
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-baseline-add-"));
|
|
try {
|
|
const projectDirectory = setupReactProject(projectRoot, "app", {
|
|
files: {
|
|
"src/AccountCard.tsx": buildDuplicateCard("AccountCard", "Account", "account"),
|
|
"src/UserCard.tsx": buildDuplicateCard("UserCard", "User", "user"),
|
|
},
|
|
});
|
|
initGitRepo(projectDirectory);
|
|
const baseRef = commitAll(projectDirectory, "base includes unrelated duplication");
|
|
writeFile(
|
|
path.join(projectDirectory, "src", "Added.tsx"),
|
|
"export const Added = () => <main><p>New page</p></main>;\n",
|
|
);
|
|
|
|
const result = await inspect(projectDirectory, {
|
|
lint: true,
|
|
deadCode: true,
|
|
noScore: true,
|
|
silent: true,
|
|
includePaths: ["src/Added.tsx"],
|
|
baseline: { ref: baseRef },
|
|
});
|
|
|
|
expect(result.baselineDelta?.baseTotalCount).toBe(0);
|
|
expect(
|
|
result.diagnostics.filter((diagnostic) => diagnostic.rule === "duplicate-jsx-subtree"),
|
|
).toEqual([]);
|
|
} finally {
|
|
consoleSpy.mockRestore();
|
|
fs.rmSync(projectRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("keeps workspace-owned maintainability baselines across excluded projects", async () => {
|
|
clearConfigCache();
|
|
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
const projectDirectory = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), "react-doctor-baseline-workspace-jsx-"),
|
|
);
|
|
const nestedProjectDirectory = path.join(projectDirectory, "packages", "web");
|
|
try {
|
|
writeJson(path.join(projectDirectory, "package.json"), {
|
|
name: "workspace-root",
|
|
dependencies: { react: "^19.0.0", "react-dom": "^19.0.0" },
|
|
});
|
|
writeFile(
|
|
path.join(nestedProjectDirectory, "src", "AccountCard.tsx"),
|
|
buildDuplicateCard("AccountCard", "Account", "account"),
|
|
);
|
|
writeFile(
|
|
path.join(nestedProjectDirectory, "src", "UserCard.tsx"),
|
|
buildDuplicateCard("UserCard", "User", "user"),
|
|
);
|
|
initGitRepo(projectDirectory);
|
|
const baseRef = commitAll(projectDirectory, "base includes nested duplication");
|
|
const changedPath = "packages/web/src/AccountCard.tsx";
|
|
writeFile(
|
|
path.join(projectDirectory, changedPath),
|
|
buildDuplicateCard("AccountCard", "Account", "customer"),
|
|
);
|
|
|
|
const result = await inspect(projectDirectory, {
|
|
lint: true,
|
|
deadCode: true,
|
|
noScore: true,
|
|
silent: true,
|
|
includePaths: [changedPath],
|
|
baseline: { ref: baseRef },
|
|
excludedProjectDirectories: [nestedProjectDirectory],
|
|
retainExcludedProjectDeadCodeDiagnostics: true,
|
|
});
|
|
|
|
expect(result.baselineDelta?.baseTotalCount).toBeGreaterThan(0);
|
|
expect(
|
|
result.diagnostics.filter((diagnostic) => diagnostic.rule === "duplicate-jsx-subtree"),
|
|
).toEqual([]);
|
|
} finally {
|
|
consoleSpy.mockRestore();
|
|
fs.rmSync(projectDirectory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("keeps baseline comparisons complete when descendant projects are excluded", async () => {
|
|
clearConfigCache();
|
|
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
const projectDirectory = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), "react-doctor-baseline-exclude-"),
|
|
);
|
|
const nestedProjectDirectory = path.join(projectDirectory, "packages", "web");
|
|
try {
|
|
writeJson(path.join(projectDirectory, "package.json"), {
|
|
name: "baseline-exclusion-root",
|
|
dependencies: { react: "^19.0.0", "react-dom": "^19.0.0" },
|
|
});
|
|
writeFile(
|
|
path.join(projectDirectory, "src", "App.tsx"),
|
|
"export const App = () => <main>Base root</main>;\n",
|
|
);
|
|
writeFile(
|
|
path.join(nestedProjectDirectory, "src", "App.tsx"),
|
|
"export const App = () => <main>Base nested</main>;\n",
|
|
);
|
|
initGitRepo(projectDirectory);
|
|
const baseRef = commitAll(projectDirectory, "initial workspace");
|
|
|
|
writeFile(
|
|
path.join(projectDirectory, "src", "App.tsx"),
|
|
"export const App = () => <main>Head root</main>;\n",
|
|
);
|
|
writeFile(
|
|
path.join(nestedProjectDirectory, "src", "App.tsx"),
|
|
"export const App = () => <main>Head nested</main>;\n",
|
|
);
|
|
|
|
const changedFiles = ["src/App.tsx", "packages/web/src/App.tsx"];
|
|
const result = await inspect(projectDirectory, {
|
|
lint: true,
|
|
deadCode: false,
|
|
noScore: true,
|
|
silent: true,
|
|
includePaths: changedFiles,
|
|
baseline: { ref: baseRef, baseFiles: changedFiles, headFiles: changedFiles },
|
|
excludedProjectDirectories: [nestedProjectDirectory],
|
|
});
|
|
|
|
expect(result.analyzedFiles).toEqual(["src/App.tsx"]);
|
|
expect(result.baselineDelta).toBeDefined();
|
|
} finally {
|
|
consoleSpy.mockRestore();
|
|
fs.rmSync(projectDirectory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("degrades baseline to a plain diff when the head lint only partially ran", async () => {
|
|
clearConfigCache();
|
|
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
const projectDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-partial-"));
|
|
try {
|
|
writeJson(path.join(projectDirectory, "package.json"), {
|
|
name: "partial-head",
|
|
dependencies: { react: "^19.0.0", "react-dom": "^19.0.0" },
|
|
});
|
|
writeFile(
|
|
path.join(projectDirectory, "src", "App.tsx"),
|
|
"export const App = () => <main>Base</main>;\n",
|
|
);
|
|
initGitRepo(projectDirectory);
|
|
const baseRef = commitAll(projectDirectory, "initial");
|
|
|
|
writeFile(
|
|
path.join(projectDirectory, "src", "App.tsx"),
|
|
"export const App = () => <main>Head</main>;\n",
|
|
);
|
|
|
|
// A 1ms budget is spent before any lint batch spawns, so every file is
|
|
// deadline-skipped: the head lint is incomplete but `didLintFail` stays
|
|
// false. Baseline must not diff an incomplete head against a full base —
|
|
// it degrades to a plain diff (no delta) instead.
|
|
const result = await inspect(projectDirectory, {
|
|
lint: true,
|
|
deadCode: false,
|
|
noScore: true,
|
|
silent: true,
|
|
includePaths: ["src/App.tsx"],
|
|
baseline: { ref: baseRef },
|
|
maxDurationMs: 1,
|
|
});
|
|
|
|
// A "lint:partial" reason (not a "lint" skip) proves the head lint ran
|
|
// but was truncated, and the baseline degraded to a plain diff rather
|
|
// than diffing an incomplete head against a full base.
|
|
expect(result.skippedCheckReasons?.["lint:partial"]).toContain("max scan duration reached");
|
|
expect(result.skippedChecks).not.toContain("lint");
|
|
expect(result.baselineDelta).toBeUndefined();
|
|
} finally {
|
|
consoleSpy.mockRestore();
|
|
fs.rmSync(projectDirectory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|