From 504218038b77d3e2d605367910cb660ba6612e74 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 27 Jun 2026 16:12:37 +0000 Subject: [PATCH 1/3] refactor: simplify repo-wide cleanup findings Co-authored-by: Aiden Bai --- .agents/skills/product-thinking/SKILL.md | 4 +- .../src/project-info/count-source-files.ts | 2 +- packages/core/src/utils/list-source-files.ts | 2 +- .../tests/list-source-files-with-size.test.ts | 18 +++ .../core/tests/services/dead-code.test.ts | 11 +- .../cli/utils/render-multi-project-summary.ts | 4 +- .../cli/utils/resolve-cli-inspect-options.ts | 1 + .../tests/resolve-cli-inspect-options.test.ts | 7 + packages/website/public/llms.txt | 2 +- packages/website/src/components/terminal.tsx | 4 +- scripts/convert-node-imports.mjs | 131 ------------------ skills/react-doctor/references/explain.md | 2 +- 12 files changed, 37 insertions(+), 151 deletions(-) delete mode 100644 scripts/convert-node-imports.mjs diff --git a/.agents/skills/product-thinking/SKILL.md b/.agents/skills/product-thinking/SKILL.md index 8563d6ad8..c452f29c3 100644 --- a/.agents/skills/product-thinking/SKILL.md +++ b/.agents/skills/product-thinking/SKILL.md @@ -22,7 +22,7 @@ Run the pass whenever a diff touches a surface a user — a developer running Re | GitHub Action input/output | `action.yml` | Versioned independently (`vN`); workflows in other repos break when an input or output changes. | | Terminal output / UX | `cli/utils/` renderers | The first impression and the daily experience; noise or confusion here is what makes people stop running it. | -**Not here:** lint rules go through the `rule-research` → `rule-writing` → `rule-validate` pipeline, and rule configuration through `doctor-explain` — this pass is for the surface _around_ the rules, not the rules themselves. Internal-only changes (the engine, private `core` types, tests, tooling) skip the pass entirely; note in one line why the change is internal and move on. +**Not here:** lint rules go through the `rule-research` → `rule-writing` → `rule-validate` pipeline, and rule configuration through `react-doctor/references/explain.md` — this pass is for the surface _around_ the rules, not the rules themselves. Internal-only changes (the engine, private `core` types, tests, tooling) skip the pass entirely; note in one line why the change is internal and move on. ## Steps @@ -87,7 +87,7 @@ A surface nobody can discover is wasted, and a stale doc is a trust bug. Documen - The `--help` / usage text next to the new flag or command, so it's discoverable from the CLI itself. - The website page and the canonical prompt under `react.doctor/prompts/...`, which is what agents fetch at runtime. -- The distributed skills (`skills/react-doctor`, `skills/doctor-explain`) when the change alters the user-facing workflow. +- The distributed skills (`skills/react-doctor`, `skills/react-doctor/references/explain.md`) when the change alters the user-facing workflow. ### 6. Record the kill metric diff --git a/packages/core/src/project-info/count-source-files.ts b/packages/core/src/project-info/count-source-files.ts index e48c51707..5ac895cbd 100644 --- a/packages/core/src/project-info/count-source-files.ts +++ b/packages/core/src/project-info/count-source-files.ts @@ -40,7 +40,7 @@ const countSourceFilesViaGit = (rootDirectory: string): number | null => { // filesystem walk in countSourceFilesViaFilesystem. const result = spawnSync( "git", - ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], + ["ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", "."], { cwd: rootDirectory, encoding: "utf-8", diff --git a/packages/core/src/utils/list-source-files.ts b/packages/core/src/utils/list-source-files.ts index 929bb3771..2c402215b 100644 --- a/packages/core/src/utils/list-source-files.ts +++ b/packages/core/src/utils/list-source-files.ts @@ -35,7 +35,7 @@ const listSourceFilesViaGit = (rootDirectory: string): string[] | null => { // skipping submodule files entirely. const result = spawnSync( "git", - ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], + ["ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", "."], { cwd: rootDirectory, encoding: "utf-8", diff --git a/packages/core/tests/list-source-files-with-size.test.ts b/packages/core/tests/list-source-files-with-size.test.ts index 426b28721..75ba6f77b 100644 --- a/packages/core/tests/list-source-files-with-size.test.ts +++ b/packages/core/tests/list-source-files-with-size.test.ts @@ -1,8 +1,10 @@ +import { spawnSync } from "node:child_process"; import * as fs from "node:fs"; import os from "node:os"; import * as path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; import { MINIFIED_MIN_SIZE_BYTES } from "../src/project-info/constants.js"; +import { countSourceFiles } from "../src/project-info/count-source-files.js"; import { listSourceFiles, listSourceFilesWithSize } from "../src/utils/list-source-files.js"; describe("listSourceFilesWithSize", () => { @@ -56,4 +58,20 @@ describe("listSourceFilesWithSize", () => { listSourceFilesWithSize(temporaryDirectory).map((entry) => entry.path), ); }); + + it("scopes git listings to the requested subdirectory", () => { + const workspaceDirectory = path.join(temporaryDirectory, "workspace"); + const appDirectory = path.join(workspaceDirectory, "apps", "web"); + const packageDirectory = path.join(workspaceDirectory, "packages", "ui"); + fs.mkdirSync(appDirectory, { recursive: true }); + fs.mkdirSync(packageDirectory, { recursive: true }); + fs.writeFileSync(path.join(appDirectory, "App.tsx"), "export const App = () => null;\n"); + fs.writeFileSync(path.join(packageDirectory, "Button.tsx"), "export const Button = () => null;\n"); + + spawnSync("git", ["init"], { cwd: workspaceDirectory }); + spawnSync("git", ["add", "."], { cwd: workspaceDirectory }); + + expect(listSourceFiles(appDirectory)).toEqual(["App.tsx"]); + expect(countSourceFiles(appDirectory)).toBe(1); + }); }); diff --git a/packages/core/tests/services/dead-code.test.ts b/packages/core/tests/services/dead-code.test.ts index 9ee4d7dd6..fcb6005df 100644 --- a/packages/core/tests/services/dead-code.test.ts +++ b/packages/core/tests/services/dead-code.test.ts @@ -2,7 +2,7 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Stream from "effect/Stream"; import { describe, expect, it } from "vite-plus/test"; -import type { Diagnostic, ProjectInfo } from "@react-doctor/core"; +import type { Diagnostic } from "@react-doctor/core"; import { DeadCode } from "../../src/services/dead-code.js"; const sampleDiagnostic: Diagnostic = { @@ -17,14 +17,6 @@ const sampleDiagnostic: Diagnostic = { category: "Maintainability", }; -const sampleInput = { - rootDirectory: "/repo", - userConfig: null, -} satisfies { - rootDirectory: string; - userConfig: ProjectInfo["framework"] extends string ? null : null; -}; - describe("DeadCode.layerOf", () => { it("emits the supplied diagnostics as a stream", async () => { const collected = await Effect.runPromise( @@ -71,4 +63,3 @@ describe("DeadCode.layerNode", () => { }); }); -void sampleInput; diff --git a/packages/react-doctor/src/cli/utils/render-multi-project-summary.ts b/packages/react-doctor/src/cli/utils/render-multi-project-summary.ts index ba6eda091..749411e54 100644 --- a/packages/react-doctor/src/cli/utils/render-multi-project-summary.ts +++ b/packages/react-doctor/src/cli/utils/render-multi-project-summary.ts @@ -23,8 +23,8 @@ interface ProjectScanEntry { const getScoreLabel = (score: number): string => { if (score >= SCORE_GOOD_THRESHOLD) return "Great"; - if (score >= SCORE_OK_THRESHOLD) return "OK"; - return "Needs work"; + if (score >= SCORE_OK_THRESHOLD) return "Needs work"; + return "Critical"; }; const buildSummaryLine = (entry: ProjectScanEntry, longestProjectNameLength: number): string => { diff --git a/packages/react-doctor/src/cli/utils/resolve-cli-inspect-options.ts b/packages/react-doctor/src/cli/utils/resolve-cli-inspect-options.ts index 6e8ae4806..2f766e0ed 100644 --- a/packages/react-doctor/src/cli/utils/resolve-cli-inspect-options.ts +++ b/packages/react-doctor/src/cli/utils/resolve-cli-inspect-options.ts @@ -45,6 +45,7 @@ export const resolveCliInspectOptions = ( noScore: flags.score === false || flags.telemetry === false || (userConfig?.noScore ?? false), isCi: isCiEnvironment(), silent: Boolean(flags.json), + suppressRendering: Boolean(flags.json), concurrency: resolveParallelFlag(flags.parallel), categoryFilters: resolveCliCategories(flags.category), }; diff --git a/packages/react-doctor/tests/resolve-cli-inspect-options.test.ts b/packages/react-doctor/tests/resolve-cli-inspect-options.test.ts index b8f09b8eb..a5be4b8a0 100644 --- a/packages/react-doctor/tests/resolve-cli-inspect-options.test.ts +++ b/packages/react-doctor/tests/resolve-cli-inspect-options.test.ts @@ -101,6 +101,13 @@ describe("resolveCliInspectOptions: --no-telemetry alias", () => { it("keeps scoring on by default", () => { expect(resolveCliInspectOptions({}, null).noScore).toBe(false); }); + + it("suppresses rendering when JSON owns stdout", () => { + expect(resolveCliInspectOptions({ json: true }, null)).toMatchObject({ + silent: true, + suppressRendering: true, + }); + }); }); describe("resolveCliInspectOptions: category filtering", () => { diff --git a/packages/website/public/llms.txt b/packages/website/public/llms.txt index 2fe7ea914..e97c51634 100644 --- a/packages/website/public/llms.txt +++ b/packages/website/public/llms.txt @@ -2,7 +2,7 @@ Diagnose React codebase health. Scans for security, performance, correctness, and architecture issues, then outputs a 0–100 score with actionable diagnostics. -Note: dead-code detection was removed in v0.2 (previously powered by knip). For dead-code analysis, run `npx knip` directly. +Dead-code detection is included by default and powered by deslop-js. Use `--no-dead-code` when you only want lint diagnostics. ## Usage diff --git a/packages/website/src/components/terminal.tsx b/packages/website/src/components/terminal.tsx index ac2099956..96a5e0a13 100644 --- a/packages/website/src/components/terminal.tsx +++ b/packages/website/src/components/terminal.tsx @@ -52,7 +52,7 @@ const DIAGNOSTICS: RuleDiagnostic[] = [ location: "src/components/Dashboard.tsx:42", }, { - ruleKey: "react-doctor/no-server-action-auth", + ruleKey: "react-doctor/server-auth-actions", severity: "error", message: 'Server action "deleteUser" has no auth check, so any caller could run it', help: "Add an authentication check at the top of every server action.", @@ -60,7 +60,7 @@ const DIAGNOSTICS: RuleDiagnostic[] = [ location: "src/app/actions/users.ts:18", }, { - ruleKey: "react/no-array-index-key", + ruleKey: "react-doctor/no-array-index-key", severity: "error", message: "Array index is used as a key, so reordered items can keep the wrong state", help: "Use a unique, stable identifier from each item as the key prop.", diff --git a/scripts/convert-node-imports.mjs b/scripts/convert-node-imports.mjs deleted file mode 100644 index 1a9be5408..000000000 --- a/scripts/convert-node-imports.mjs +++ /dev/null @@ -1,131 +0,0 @@ -import { readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; -import { extname, join } from "node:path"; - -const ROOT = join(import.meta.dirname, ".."); -const EXTENSIONS = new Set([".ts", ".tsx", ".js", ".mjs", ".cjs"]); - -const collectFiles = (directory) => { - const entries = readdirSync(directory); - const files = []; - for (const entry of entries) { - if (entry === "node_modules" || entry === "dist" || entry === ".git") continue; - const fullPath = join(directory, entry); - const stats = statSync(fullPath); - if (stats.isDirectory()) { - files.push(...collectFiles(fullPath)); - continue; - } - if (EXTENSIONS.has(extname(fullPath))) files.push(fullPath); - } - return files; -}; - -const parseNamedImport = (source, moduleSpecifier) => { - const pattern = new RegExp( - `import\\s+\\{([^}]+)\\}\\s+from\\s+["']${moduleSpecifier.replace(":", "\\:")}["'];?`, - "g", - ); - const names = []; - let match; - while ((match = pattern.exec(source)) !== null) { - const imported = match[1] - .split(",") - .map((part) => part.trim()) - .filter(Boolean) - .map((part) => { - const aliasMatch = part.match(/^(\w+)\s+as\s+(\w+)$/); - if (aliasMatch) return { local: aliasMatch[2], imported: aliasMatch[1] }; - return { local: part, imported: part }; - }); - names.push(...imported); - } - return names; -}; - -const removeNamedImports = (source, moduleSpecifier) => { - const pattern = new RegExp( - `import\\s+\\{[^}]+\\}\\s+from\\s+["']${moduleSpecifier.replace(":", "\\:")}["'];?\\n?`, - "g", - ); - return source.replace(pattern, ""); -}; - -const hasNamespaceImport = (source, moduleSpecifier, alias) => { - const pattern = new RegExp( - `import\\s+\\*\\s+as\\s+${alias}\\s+from\\s+["']${moduleSpecifier.replace(":", "\\:")}["']`, - ); - return pattern.test(source); -}; - -const ensureNamespaceImport = (source, moduleSpecifier, alias) => { - if (hasNamespaceImport(source, moduleSpecifier, alias)) return source; - const importLine = `import * as ${alias} from "${moduleSpecifier}";\n`; - const importMatch = source.match(/^((?:import\s.+;\n)*)/); - if (importMatch) { - return source.replace(importMatch[1], `${importMatch[1]}${importLine}`); - } - return `${importLine}${source}`; -}; - -const prefixUsages = (source, names, alias) => { - let result = source; - for (const { local, imported } of names) { - const member = imported === local ? local : imported; - const replacement = `${alias}.${member}`; - const pattern = new RegExp(`(? { - let source = readFileSync(filePath, "utf8"); - const original = source; - - source = source.replace( - /import\s+fs\s+from\s+["']node:fs["'];?/g, - 'import * as fs from "node:fs";', - ); - source = source.replace( - /import\s+path\s+from\s+["']node:path["'];?/g, - 'import * as path from "node:path";', - ); - source = source.replace( - /import\s+\*\s+as\s+Path\s+from\s+["']node:path["'];?/g, - 'import * as path from "node:path";', - ); - source = source.replace(/\bPath\./g, "path."); - - const fsNames = parseNamedImport(source, "node:fs"); - const pathNames = parseNamedImport(source, "node:path"); - - if (fsNames.length > 0) { - source = removeNamedImports(source, "node:fs"); - source = ensureNamespaceImport(source, "node:fs", "fs"); - source = prefixUsages(source, fsNames, "fs"); - } - - if (pathNames.length > 0) { - source = removeNamedImports(source, "node:path"); - source = ensureNamespaceImport(source, "node:path", "path"); - source = prefixUsages(source, pathNames, "path"); - } - - source = source.replace(/\n{3,}/g, "\n\n"); - - if (source !== original) { - writeFileSync(filePath, source); - return true; - } - return false; -}; - -const files = collectFiles(ROOT).filter( - (filePath) => !filePath.includes("convert-node-imports.mjs"), -); -const changed = files.filter(convertFile); -console.log(`Updated ${changed.length} files`); diff --git a/skills/react-doctor/references/explain.md b/skills/react-doctor/references/explain.md index 8e4defe4a..722c6f642 100644 --- a/skills/react-doctor/references/explain.md +++ b/skills/react-doctor/references/explain.md @@ -21,7 +21,7 @@ npx react-doctor@latest rules explain react-doctor/no-array-index-as-key 5. Validate the change did what they wanted: ```bash -npx react-doctor@latest --verbose --diff +npx react-doctor@latest --verbose --scope changed ``` ## Commands From dfb846d97c0421fb8926589f0fa82a8cd28f9673 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 27 Jun 2026 16:17:11 +0000 Subject: [PATCH 2/3] chore: format cleanup tests Co-authored-by: Aiden Bai --- packages/core/tests/list-source-files-with-size.test.ts | 5 ++++- packages/core/tests/services/dead-code.test.ts | 1 - 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/core/tests/list-source-files-with-size.test.ts b/packages/core/tests/list-source-files-with-size.test.ts index 75ba6f77b..34ee76dd4 100644 --- a/packages/core/tests/list-source-files-with-size.test.ts +++ b/packages/core/tests/list-source-files-with-size.test.ts @@ -66,7 +66,10 @@ describe("listSourceFilesWithSize", () => { fs.mkdirSync(appDirectory, { recursive: true }); fs.mkdirSync(packageDirectory, { recursive: true }); fs.writeFileSync(path.join(appDirectory, "App.tsx"), "export const App = () => null;\n"); - fs.writeFileSync(path.join(packageDirectory, "Button.tsx"), "export const Button = () => null;\n"); + fs.writeFileSync( + path.join(packageDirectory, "Button.tsx"), + "export const Button = () => null;\n", + ); spawnSync("git", ["init"], { cwd: workspaceDirectory }); spawnSync("git", ["add", "."], { cwd: workspaceDirectory }); diff --git a/packages/core/tests/services/dead-code.test.ts b/packages/core/tests/services/dead-code.test.ts index fcb6005df..2b01a12e6 100644 --- a/packages/core/tests/services/dead-code.test.ts +++ b/packages/core/tests/services/dead-code.test.ts @@ -62,4 +62,3 @@ describe("DeadCode.layerNode", () => { } }); }); - From bc067ad335fd88e4f2d21fa9ffa5917b7a131bd8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 27 Jun 2026 16:19:45 +0000 Subject: [PATCH 3/3] fix: address terminal demo diagnostics Co-authored-by: Aiden Bai --- packages/website/src/components/terminal.tsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/website/src/components/terminal.tsx b/packages/website/src/components/terminal.tsx index 96a5e0a13..2ce49a496 100644 --- a/packages/website/src/components/terminal.tsx +++ b/packages/website/src/components/terminal.tsx @@ -171,6 +171,7 @@ const DiagnosticItem = ({ diagnostic }: { diagnostic: RuleDiagnostic }) => { return (
@@ -272,14 +273,11 @@ const markAnimationCompleted = () => { }; const Terminal = () => { - const [state, setState] = useState(INITIAL_STATE); + const [state, setState] = useState(() => + didAnimationComplete() ? COMPLETED_STATE : INITIAL_STATE, + ); useEffect(() => { - if (didAnimationComplete()) { - setState(COMPLETED_STATE); - return; - } - const abortController = new AbortController(); const { signal } = abortController; @@ -408,6 +406,7 @@ const Terminal = () => { {state.showCta && (