diff --git a/.agents/skills/deslop/SKILL.md b/.agents/skills/deslop/SKILL.md index 9f56fba0ed..15feb85dec 100644 --- a/.agents/skills/deslop/SKILL.md +++ b/.agents/skills/deslop/SKILL.md @@ -54,14 +54,14 @@ Avoid over-simplification that could: Only refine code that has been recently modified or touched in the current session, unless explicitly instructed to review a broader scope. -## Consolidate Duplicates with truffler +## Consolidate Duplicates with React Doctor -Slop often hides as near-duplicate functions - the same behavior copied across modules under slightly different names. Use `truffler` (the `find-similar-functions` skill) to find symbols that do the same or similar thing, then collapse them into one shared utility instead of leaving parallel copies behind. +Slop often hides as near-duplicate functions - the same behavior copied across modules under slightly different names. Use `react-doctor find` (the `find-similar-functions` skill) to find symbols that do the same or similar thing, then collapse them into one shared utility instead of leaving parallel copies behind. For each helper, type, or constant you touched or added, search by its name and by the behavior it implements (domain noun + verb): ```bash -bunx @rayhanadev/truffler "format duration" packages --kind function,method,constant,type --limit 20 +react-doctor find "format duration" packages --kind function,method,constant,type --limit 20 ``` Then consolidate deliberately: diff --git a/.agents/skills/find-similar-functions/SKILL.md b/.agents/skills/find-similar-functions/SKILL.md index 2b882d3a79..2f08500a36 100644 --- a/.agents/skills/find-similar-functions/SKILL.md +++ b/.agents/skills/find-similar-functions/SKILL.md @@ -1,13 +1,13 @@ --- name: find-similar-functions -description: Use truffler to find similar or pre-existing JavaScript/TypeScript symbols before implementing new code, especially helpers, utilities, parsers, formatters, scanners, fuzzy matchers, and other reusable functions. Agents should use this skill whenever they are about to add or refactor functionality in a JS/TS repository and need to avoid duplicating existing code, even if the user does not explicitly mention deduplication. +description: Use react-doctor find to locate similar or pre-existing JavaScript/TypeScript symbols before implementing new code, especially helpers, utilities, parsers, formatters, scanners, fuzzy matchers, and other reusable functions. Agents should use this skill whenever they are about to add or refactor functionality in a JS/TS repository and need to avoid duplicating existing code, even if the user does not explicitly mention deduplication. --- # Similar Function Finder Use this skill before writing new JavaScript or TypeScript code that might overlap with existing helpers. The goal is to discover nearby functions, methods, constants, types, and interfaces early enough to reuse or extend them instead of creating duplicate behavior. -`truffler` is a fuzzy symbol search tool. Treat it as a discovery layer: it points you to likely symbols, but you still need to inspect the code before deciding whether something is reusable. +`react-doctor find` is a fuzzy symbol search command. Treat it as a discovery layer: it points you to likely symbols, but you still need to inspect the code before deciding whether something is reusable. ## Workflow @@ -17,47 +17,48 @@ Use this skill before writing new JavaScript or TypeScript code that might overl - domain nouns, such as `symbol`, `path`, `file`, `route`, `token`, or `config` - verbs, such as `parse`, `normalize`, `discover`, `scan`, `format`, `rank`, `score`, `resolve`, or `validate` - common abbreviations and synonyms, such as `btn` for `button` or `cfg` for `config` -3. Run `truffler` against the narrowest useful root first, then broaden if needed. +3. Run `react-doctor find` against the narrowest useful root first, then broaden if needed. 4. Inspect the top matches with normal code-reading tools before editing. 5. Prefer reusing, extending, or moving existing code when the behavior substantially matches. Create new code only after the search shows there is not a suitable existing symbol. 6. In your final response, briefly mention what you found and whether you reused something or intentionally added a new implementation. ## Command Recipes -When `truffler` is installed in the target project: +When React Doctor is installed in the target project: ```bash -truffler "normalize path" src --kind function,method,constant,type --limit 20 -truffler "score" src --kind function,method --format json --limit 15 +react-doctor find "normalize path" src --kind function,method,constant,type --limit 20 +react-doctor find "score" src --kind function,method --json --limit 15 ``` -When working inside this `truffler` repository: +When React Doctor is not installed locally and package execution is acceptable: ```bash -bun src/cli.ts "discover file" src --kind function,method,constant,type --limit 20 -bun src/cli.ts "format result" src --kind function,method --format json --limit 15 +npx react-doctor@latest find "discover file" src --kind function,method,constant,type --limit 20 +npx react-doctor@latest find "format result" src --kind function,method --json --limit 15 ``` -When the project has no installed binary and you should not add dependencies, use `bunx` if network/package execution is acceptable for the environment: +When working inside the React Doctor repository, use the workspace binary after building it: ```bash -bunx @rayhanadev/truffler "parse config" src --kind function,method,type --limit 20 +nr --filter react-doctor build +pnpm exec react-doctor find "parse config" src --kind function,method,type --limit 20 ``` -If `truffler` cannot be run, say so and fall back to the repository's available search tools. Still follow the same deduplication intent: search before implementing. +If `react-doctor find` cannot be run, say so and fall back to the repository's available search tools. Still follow the same deduplication intent: search before implementing. ## Search Strategy Start with symbols most likely to represent reusable behavior: ```bash -truffler "" --kind function,method,constant,type,interface --limit 20 +react-doctor find "" --kind function,method,constant,type,interface --limit 20 ``` Use JSON output when you need structured fields for ranking, locations, signatures, or automation: ```bash -truffler "" --format json --limit 20 +react-doctor find "" --json --limit 20 ``` Broaden deliberately: @@ -80,11 +81,11 @@ When no suitable symbol exists, keep the new implementation near the closest rel Use a short note like this when the search affects your implementation: ```markdown -I checked for existing symbols with `truffler` using queries like `normalize`, `path`, and `resolve`. The closest match was `normalizePath` in `src/files.ts`, so I reused that behavior instead of adding a separate helper. +I checked for existing symbols with `react-doctor find` using queries like `normalize`, `path`, and `resolve`. The closest match was `normalizePath` in `src/files.ts`, so I reused that behavior instead of adding a separate helper. ``` If nothing relevant exists: ```markdown -I searched with `truffler` for `parse`, `config`, and `loadConfig` across `src/`; the matches were unrelated, so I added a new helper in the nearest module. +I searched with `react-doctor find` for `parse`, `config`, and `loadConfig` across `src/`; the matches were unrelated, so I added a new helper in the nearest module. ``` diff --git a/.agents/skills/product-thinking/SKILL.md b/.agents/skills/product-thinking/SKILL.md index 8563d6ad86..83a7ff6d14 100644 --- a/.agents/skills/product-thinking/SKILL.md +++ b/.agents/skills/product-thinking/SKILL.md @@ -55,7 +55,7 @@ Then run two filters before you commit to building: Reuse beats adding every time — the cheapest surface is the one you never create. Before adding a flag, option, or report field, search for an existing one to extend: ```bash -bunx @rayhanadev/truffler "" packages --kind function,interface,type,constant --limit 20 +react-doctor find "" packages --kind function,interface,type,constant --limit 20 rg -n "" packages/react-doctor/src/cli packages/core/src/types ``` diff --git a/.agents/skills/rule-research/SKILL.md b/.agents/skills/rule-research/SKILL.md index 4afedfa3bf..be7c16b69b 100644 --- a/.agents/skills/rule-research/SKILL.md +++ b/.agents/skills/rule-research/SKILL.md @@ -24,10 +24,10 @@ If the user requested implementation, make the contract concise and continue. 1. Define the rule in one sentence: `This rule catches that causes .` 2. Explain the runtime reason. 3. Inspect nearby rules, tests, utilities, and the generated registry. -4. Use `truffler` before proposing a new detector or helper: +4. Use `react-doctor find` before proposing a new detector or helper: ```sh - bunx @rayhanadev/truffler "" \ + react-doctor find "" \ packages/oxlint-plugin-react-doctor/src/plugin \ --kind function,interface,type,constant --limit 20 ``` diff --git a/.agents/skills/rule-validate/SKILL.md b/.agents/skills/rule-validate/SKILL.md index 5a12b682d2..78518fbb18 100644 --- a/.agents/skills/rule-validate/SKILL.md +++ b/.agents/skills/rule-validate/SKILL.md @@ -21,10 +21,10 @@ Check for: - messages that overstate detection - missing valid and invalid tests -Use `truffler` before accepting a new helper: +Use `react-doctor find` before accepting a new helper: ```sh -bunx @rayhanadev/truffler "" packages \ +react-doctor find "" packages \ --kind function,method,interface,type,constant --limit 20 ``` diff --git a/.agents/skills/rule-writing/SKILL.md b/.agents/skills/rule-writing/SKILL.md index 312512d7f3..06e3f72651 100644 --- a/.agents/skills/rule-writing/SKILL.md +++ b/.agents/skills/rule-writing/SKILL.md @@ -14,7 +14,7 @@ State the diagnostic condition, required syntax and bindings, unsupported cases, Before adding a helper, search for one to reuse: ```sh -bunx @rayhanadev/truffler "" \ +react-doctor find "" \ packages/oxlint-plugin-react-doctor/src/plugin \ --kind function,interface,type,constant --limit 20 ``` diff --git a/.agents/skills/ship/SKILL.md b/.agents/skills/ship/SKILL.md index 5380e4e09f..5b88f9dbfe 100644 --- a/.agents/skills/ship/SKILL.md +++ b/.agents/skills/ship/SKILL.md @@ -27,7 +27,7 @@ Read `AGENTS.md` first (its rules change), then run `/review` on this branch's d ## 2. Deslop -Run `/deslop` ([`../deslop/SKILL.md`](../deslop/SKILL.md)) to simplify the recently modified code while preserving functionality, including its `truffler` duplicate-consolidation pass. Apply the refinements before committing. +Run `/deslop` ([`../deslop/SKILL.md`](../deslop/SKILL.md)) to simplify the recently modified code while preserving functionality, including its `react-doctor find` duplicate-consolidation pass. Apply the refinements before committing. ## 3. Commit and push diff --git a/.changeset/tall-mammals-check.md b/.changeset/tall-mammals-check.md new file mode 100644 index 0000000000..4019e14092 --- /dev/null +++ b/.changeset/tall-mammals-check.md @@ -0,0 +1,5 @@ +--- +"react-doctor": patch +--- + +Add `react-doctor find` with `search` and `grep` aliases for fuzzy JavaScript and TypeScript symbol lookup, including React component, HOC, styled-component, and hook kinds. diff --git a/AGENTS.md b/AGENTS.md index 012fa9b677..6350f4e5ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,16 +13,16 @@ - MUST: Frequently re-evaluate and refactor variable names to be more accurate and descriptive. - MUST: Do not type cast ("as") unless absolutely necessary - MUST: Remove unused code and don't repeat yourself. -- MUST: Use `truffler` to find existing symbols before adding a utility, helper, type, or rule, and again after finishing a task to catch duplicates and dead code (see "Symbol Search & Deduplication"). +- MUST: Use `react-doctor find` to find existing symbols before adding a utility, helper, type, or rule, and again after finishing a task to catch duplicates and dead code (see "Symbol Search & Deduplication"). - MUST: Always search the codebase, think of many solutions, then implement the most _elegant_ solution. - MUST: Before adding or changing the **public surface** (CLI flags/commands, the score, config, the JSON report, package APIs, the GitHub Action, website, or terminal output), run the `product-thinking` pass (`.agents/skills/product-thinking/`): name the user's job, reuse before adding, wire one telemetry metric, add the compatibility artifacts, and set a kill metric. Lint rules use the rule pipeline instead. - MUST: Put all magic numbers in `constants.ts` using `SCREAMING_SNAKE_CASE` with unit suffixes (`_MS`, `_PX`). - MUST: Put small, focused utility functions in `utils/` with one utility per file. - MUST: Use Boolean over !!. -## Symbol Search & Deduplication (truffler) +## Symbol Search & Deduplication -`@rayhanadev/truffler` (dev dependency) is fuzzy JS/TS symbol search powered by `oxc-parser`. +`react-doctor find` is fuzzy JS/TS symbol search powered by `oxc-parser`. Use it to avoid duplicating existing code. The `find-similar-functions` skill (`.agents/skills/find-similar-functions/`) carries the full workflow; the short version: @@ -35,12 +35,11 @@ Use it to avoid duplicating existing code. The `find-similar-functions` skill duplicate an existing helper, and delete any code your change superseded. ```bash -bunx @rayhanadev/truffler "" packages --kind function,method,interface,type,constant --limit 20 +react-doctor find "" packages --kind function,method,interface,type,constant --limit 20 ``` -Run it with `bunx @rayhanadev/truffler` (the published `bin` is a TypeScript entry Bun runs -directly, and the pinned dev dependency is reused rather than re-downloaded). Narrow `` -and the root (e.g. `packages/core/src`) for precision; broaden only when nothing matches. +Narrow `` and the root (e.g. `packages/core/src`) for precision; broaden only when +nothing matches. Use `--json` when structured results are easier to inspect. ## Package Layout diff --git a/package.json b/package.json index 42c9835249..46cae7e88b 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,6 @@ "devDependencies": { "@changesets/changelog-github": "^0.7.0", "@changesets/cli": "^2.31.0", - "@rayhanadev/truffler": "^0.4.2", "@sentry/cli": "^3.4.3", "@types/node": "^25.6.0", "@voidzero-dev/vite-plus-core": "^0.1.15", diff --git a/packages/react-doctor/README.md b/packages/react-doctor/README.md index 78e1f545c8..5702d7eaa3 100644 --- a/packages/react-doctor/README.md +++ b/packages/react-doctor/README.md @@ -37,6 +37,18 @@ npx react-doctor@latest install Works with Claude Code, Cursor, Codex, OpenCode, and many more. +### Find code symbols + +Fuzzy-search JavaScript and TypeScript declarations without falling back to text grep. Results include file locations, symbol kinds, and signatures. + +```bash +npx react-doctor@latest find Button src +npx react-doctor@latest find use --kind hook +npx react-doctor@latest find card --kind component,interface --json +``` + +`find` is also available as `search` and `grep`. The generic symbol kinds include functions, classes, interfaces, types, methods, properties, variables, imports, and exports. React-aware `component` and `hook` kinds follow React naming conventions. + ### 3. Run in CI React Doctor reviews every pull request and reports only the issues your change introduced, not your existing backlog. Set it up with one command: diff --git a/packages/react-doctor/package.json b/packages/react-doctor/package.json index 50cb86a5b7..32f6a9a65e 100644 --- a/packages/react-doctor/package.json +++ b/packages/react-doctor/package.json @@ -58,6 +58,7 @@ "dependencies": { "@astrojs/compiler": "^4.0.0", "@babel/code-frame": "^7.29.0", + "@rayhanadev/truffler": "^0.4.2", "@sentry/node": "^10.54.0", "agent-install": "0.0.5", "conf": "^15.1.0", @@ -67,6 +68,7 @@ "figures": "^6.1.0", "jiti": "^2.7.0", "magicast": "^0.5.3", + "oxc-parser": "^0.132.0", "oxc-resolver": "^11.24.2", "oxlint": ">=1.77.0 <1.78.0", "oxlint-plugin-react-doctor": "workspace:*", diff --git a/packages/react-doctor/src/cli/commands/find.ts b/packages/react-doctor/src/cli/commands/find.ts new file mode 100644 index 0000000000..3ec4c08583 --- /dev/null +++ b/packages/react-doctor/src/cli/commands/find.ts @@ -0,0 +1,139 @@ +import { stat } from "node:fs/promises"; +import * as path from "node:path"; +import { isErrnoException } from "@react-doctor/core"; +import { searchSymbols } from "@rayhanadev/truffler"; +import type { SymbolSearchResult } from "@rayhanadev/truffler"; +import { CliInputError } from "../utils/cli-input-error.js"; +import { DEFAULT_FIND_LIMIT, DEFAULT_SOURCE_POSITION, METRIC } from "../utils/constants.js"; +import { formatFindPath } from "../utils/format-find-path.js"; +import { formatFindSymbolResult } from "../utils/format-find-symbol-result.js"; +import { parseFindKinds } from "../utils/parse-find-kinds.js"; +import { recordCount } from "../utils/record-metric.js"; +import { resolveFindSymbolKind } from "../utils/resolve-find-symbol-kind.js"; + +export interface FindOptions { + readonly cwd?: string; + readonly json?: boolean; + readonly kind?: string; + readonly limit?: number | string; +} + +interface FindJsonResult { + readonly name: string; + readonly kind: string; + readonly symbolKind: SymbolSearchResult["kind"]; + readonly location: FindJsonLocation; + readonly container?: string; + readonly signature?: string; + readonly parameters?: SymbolSearchResult["parameters"]; + readonly returnType?: string; + readonly score: number; + readonly matches: ReadonlyArray; +} + +interface FindJsonOutput { + readonly query: string; + readonly root: string; + readonly count: number; + readonly results: ReadonlyArray; +} + +interface FindJsonLocation { + readonly file: string; + readonly line: number; + readonly column: number; +} + +const parseFindLimit = (value: number | string | undefined): number => { + if (value === undefined) return DEFAULT_FIND_LIMIT; + if (typeof value === "string" && !/^\d+$/.test(value)) { + throw new CliInputError(`Invalid limit "${value}". Expected a non-negative integer.`); + } + const parsedLimit = typeof value === "number" ? value : Number(value); + if (!Number.isSafeInteger(parsedLimit) || parsedLimit < 0) { + throw new CliInputError(`Invalid limit "${value}". Expected a non-negative integer.`); + } + return parsedLimit; +}; + +const isRequestedFindKind = ( + result: SymbolSearchResult, + requestedKinds: ReadonlySet, +): boolean => requestedKinds.has(result.kind) || requestedKinds.has(resolveFindSymbolKind(result)); + +const buildFindJsonOutput = ( + query: string, + root: string, + cwd: string, + results: ReadonlyArray, +): FindJsonOutput => ({ + query, + root, + count: results.length, + results: results.map((result) => ({ + name: result.name, + kind: resolveFindSymbolKind(result), + symbolKind: result.kind, + location: { + file: formatFindPath(result.file, cwd), + line: result.line ?? DEFAULT_SOURCE_POSITION, + column: result.column ?? DEFAULT_SOURCE_POSITION, + }, + container: result.container, + signature: result.signature, + parameters: result.parameters, + returnType: result.returnType, + score: result.score, + matches: result.matches, + })), +}); + +export const findAction = async ( + query: string, + directory = ".", + options: FindOptions = {}, +): Promise => { + recordCount(METRIC.cliInvoked, 1, { command: "find" }); + const displayQuery = query.trim(); + const searchQuery = displayQuery.replace(/[^\p{L}\p{N}_$]+/gu, ""); + if (searchQuery.length === 0) throw new CliInputError("Search query cannot be empty."); + + const cwd = path.resolve(options.cwd ?? process.cwd()); + const rootPath = path.resolve(cwd, directory); + try { + await stat(rootPath); + } catch (error) { + if (isErrnoException(error) && error.code === "ENOENT") { + throw new CliInputError(`Search path "${directory}" does not exist.`); + } + throw error; + } + const limit = parseFindLimit(options.limit); + const parsedKinds = parseFindKinds(options.kind); + const results = await searchSymbols(searchQuery, { + root: directory, + cwd, + symbolKinds: parsedKinds.symbolKinds, + ignoreParseErrors: true, + onParseError: (error) => { + process.stderr.write( + `Warning: skipped ${formatFindPath(error.file, cwd)}: ${error.message}\n`, + ); + }, + }); + const filteredResults = results + .filter((result) => isRequestedFindKind(result, parsedKinds.requestedKinds)) + .slice(0, limit); + + if (options.json) { + process.stdout.write( + `${JSON.stringify(buildFindJsonOutput(displayQuery, directory, cwd, filteredResults), null, 2)}\n`, + ); + return; + } + + if (filteredResults.length === 0) return; + process.stdout.write( + `${filteredResults.map((result) => formatFindSymbolResult(result, cwd)).join("\n")}\n`, + ); +}; diff --git a/packages/react-doctor/src/cli/index.ts b/packages/react-doctor/src/cli/index.ts index b0414e9ec5..14f2c99c20 100644 --- a/packages/react-doctor/src/cli/index.ts +++ b/packages/react-doctor/src/cli/index.ts @@ -3,6 +3,7 @@ import { CANONICAL_GITHUB_URL, CI_URL, highlighter } from "@react-doctor/core"; import { flushSentry, initializeSentry } from "../instrument.js"; import { shutdownTelemetry } from "./utils/telemetry-runtime.js"; import { applyColorPreference } from "./utils/apply-color-preference.js"; +import { DEFAULT_FIND_LIMIT } from "./utils/constants.js"; import { ensureWindowsUtf8Console } from "./utils/ensure-windows-utf8-console.js"; import { exitGracefully } from "./utils/exit-gracefully.js"; import { guardStdin } from "./utils/guard-stdin.js"; @@ -12,6 +13,7 @@ import { isExpectedUserError } from "./utils/is-expected-user-error.js"; import { isJsonModeActive, writeJsonErrorReport } from "./utils/json-mode.js"; import type { InspectFlags } from "./utils/inspect-flags.js"; import { normalizeHelpInvocation } from "./utils/normalize-help-command.js"; +import { FIND_KIND_HELP } from "./utils/parse-find-kinds.js"; import { printDebugTrace } from "./utils/print-debug-trace.js"; import { assertNoRemovedFlags } from "./utils/removed-cli-flags.js"; import { reportErrorToSentry } from "./utils/report-error.js"; @@ -66,6 +68,7 @@ ${formatExampleLines([ ["react-doctor --blocking warning", "fail CI on warnings too (default: error)"], ["react-doctor --json > report.json", "write a machine-readable report"], ["react-doctor why src/App.tsx:42", "explain why a rule fired there"], + ["react-doctor find Button src", "find matching JS/TS symbols"], ["react-doctor ci install", "scan every pull request in CI"], ["react-doctor install", "set up the agent skill and git hook"], ])} @@ -112,6 +115,19 @@ ${highlighter.dim("Scope:")} Standard scan flags such as ${highlighter.info("--scope")}, ${highlighter.info("--project")}, ${highlighter.info("--verbose")}, and ${highlighter.info("--json")} still work. `; +const renderFindHelpEpilog = (): string => ` +${highlighter.dim("Examples:")} +${formatExampleLines([ + ["react-doctor find Button src", "fuzzy-search symbols under src"], + ["react-doctor find use --kind hook", "find React hooks by naming convention"], + ["react-doctor find card --kind component,interface", "find components and related types"], + ["react-doctor find Button --json", "write structured results"], +])} + +${highlighter.dim("React-aware kinds:")} + ${highlighter.info("component")} matches PascalCase functions, classes, and common wrappers. ${highlighter.info("hook")} matches ${highlighter.info("use")}, ${highlighter.info("useThing")}, and ${highlighter.info("use2D")} bindings. +`; + const MAX_DURATION_OPTION_DESCRIPTION = "scan time budget for the whole run, shared across workspace projects: past it, queued projects, remaining lint batches, and dead-code are skipped and partial results are reported (skipped files and projects are listed in the JSON report)"; @@ -272,6 +288,22 @@ program return whyAction(location, options); }); +program + .command("find [directory]") + .aliases(["search", "grep"]) + .description("Fuzzy-search JavaScript and TypeScript symbols") + .option("-k, --kind ", `comma-separated kinds: ${FIND_KIND_HELP}`) + .addOption( + new Option("-l, --limit ", "maximum results to print").default(DEFAULT_FIND_LIMIT), + ) + .option("--json", "output structured JSON") + .option("-c, --cwd ", "working directory", process.cwd()) + .addHelpText("after", renderFindHelpEpilog) + .action(async (query, directory, _options, command) => { + const { findAction } = await import("./commands/find.js"); + return findAction(query, directory ?? ".", command.optsWithGlobals()); + }); + program .command("install") .alias("setup") diff --git a/packages/react-doctor/src/cli/utils/constants.ts b/packages/react-doctor/src/cli/utils/constants.ts index f2149e5e00..8fab7679df 100644 --- a/packages/react-doctor/src/cli/utils/constants.ts +++ b/packages/react-doctor/src/cli/utils/constants.ts @@ -251,6 +251,9 @@ export const METRIC_DISTRIBUTION_BOUNDARIES = [ 600_000, ]; +export const DEFAULT_FIND_LIMIT = 50; +export const DEFAULT_SOURCE_POSITION = 1; + // Metric names. Centralized so emit sites can't drift on a typo'd string and // the full counter surface stays greppable in one place. // Dotted, domain-grouped names (Sentry convention); high-cardinality diff --git a/packages/react-doctor/src/cli/utils/format-find-path.ts b/packages/react-doctor/src/cli/utils/format-find-path.ts new file mode 100644 index 0000000000..17877f6594 --- /dev/null +++ b/packages/react-doctor/src/cli/utils/format-find-path.ts @@ -0,0 +1,9 @@ +import * as path from "node:path"; +import { toForwardSlashes } from "./path-format.js"; + +export const formatFindPath = (filePath: string, cwd: string): string => { + const relativePath = path.relative(cwd, filePath); + const displayPath = + relativePath.length > 0 && !relativePath.startsWith("..") ? relativePath : filePath; + return toForwardSlashes(displayPath); +}; diff --git a/packages/react-doctor/src/cli/utils/format-find-symbol-result.ts b/packages/react-doctor/src/cli/utils/format-find-symbol-result.ts new file mode 100644 index 0000000000..284cf7f022 --- /dev/null +++ b/packages/react-doctor/src/cli/utils/format-find-symbol-result.ts @@ -0,0 +1,11 @@ +import type { SymbolSearchResult } from "@rayhanadev/truffler"; +import { DEFAULT_SOURCE_POSITION } from "./constants.js"; +import { formatFindPath } from "./format-find-path.js"; +import { resolveFindSymbolKind } from "./resolve-find-symbol-kind.js"; + +export const formatFindSymbolResult = (result: SymbolSearchResult, cwd: string): string => { + const displayPath = formatFindPath(result.file, cwd); + const location = `${displayPath}:${result.line ?? DEFAULT_SOURCE_POSITION}:${result.column ?? DEFAULT_SOURCE_POSITION}`; + const declaration = result.signature ?? result.snippet ?? result.name; + return `${location} ${resolveFindSymbolKind(result)} ${declaration}`; +}; diff --git a/packages/react-doctor/src/cli/utils/parse-find-kinds.ts b/packages/react-doctor/src/cli/utils/parse-find-kinds.ts new file mode 100644 index 0000000000..21bc2e77da --- /dev/null +++ b/packages/react-doctor/src/cli/utils/parse-find-kinds.ts @@ -0,0 +1,76 @@ +import type { SymbolKind } from "@rayhanadev/truffler"; +import { CliInputError } from "./cli-input-error.js"; + +export interface ParsedFindKinds { + readonly requestedKinds: ReadonlySet; + readonly symbolKinds: ReadonlyArray; +} + +const DEFAULT_SYMBOL_KINDS: ReadonlyArray = [ + "class", + "enum", + "function", + "interface", + "method", + "property", + "type", +]; + +const ALL_SYMBOL_KINDS: ReadonlyArray = [ + "class", + "constant", + "enum", + "enum-member", + "export", + "function", + "import", + "interface", + "method", + "property", + "type", + "variable", +]; + +const SYMBOL_KIND_SET: ReadonlySet = new Set(ALL_SYMBOL_KINDS); +const REACT_SYMBOL_KINDS: ReadonlySet = new Set(["component", "hook"]); + +export const FIND_KIND_HELP = [...ALL_SYMBOL_KINDS, ...REACT_SYMBOL_KINDS].join(","); + +const isSymbolKind = (value: string): value is SymbolKind => SYMBOL_KIND_SET.has(value); + +export const parseFindKinds = (value: string | undefined): ParsedFindKinds => { + if (value === undefined) { + return { + requestedKinds: new Set([...DEFAULT_SYMBOL_KINDS, "component", "hook"]), + symbolKinds: [...DEFAULT_SYMBOL_KINDS, "constant", "variable"], + }; + } + + const requestedKinds = new Set(value.split(",").map((kind) => kind.trim())); + const unsupportedKind = [...requestedKinds].find( + (kind) => !isSymbolKind(kind) && !REACT_SYMBOL_KINDS.has(kind), + ); + if (unsupportedKind !== undefined) { + throw new CliInputError( + `Unsupported symbol kind "${unsupportedKind}". Expected one of: ${FIND_KIND_HELP}.`, + ); + } + + const symbolKinds = new Set(); + for (const kind of requestedKinds) { + if (isSymbolKind(kind)) symbolKinds.add(kind); + if (kind === "component") { + symbolKinds.add("class"); + symbolKinds.add("constant"); + symbolKinds.add("function"); + symbolKinds.add("variable"); + } + if (kind === "hook") { + symbolKinds.add("constant"); + symbolKinds.add("function"); + symbolKinds.add("variable"); + } + } + + return { requestedKinds, symbolKinds: [...symbolKinds] }; +}; diff --git a/packages/react-doctor/src/cli/utils/resolve-find-symbol-kind.ts b/packages/react-doctor/src/cli/utils/resolve-find-symbol-kind.ts new file mode 100644 index 0000000000..c813e31402 --- /dev/null +++ b/packages/react-doctor/src/cli/utils/resolve-find-symbol-kind.ts @@ -0,0 +1,28 @@ +import type { SymbolSearchResult } from "@rayhanadev/truffler"; + +const REACT_COMPONENT_NAME_PATTERN = /^[A-Z]/; +const REACT_HOOK_NAME_PATTERN = /^use(?:$|[A-Z0-9])/; +const REACT_COMPONENT_WRAPPER_PATTERN = + /=\s*(?:(?:React\.)?(?:forwardRef|lazy|memo)|observer|styled)(?:\s*[.(<])/; + +const isCallableBindingKind = (kind: SymbolSearchResult["kind"]): boolean => + kind === "constant" || kind === "function" || kind === "variable"; + +export const resolveFindSymbolKind = (result: SymbolSearchResult): string => { + if (isCallableBindingKind(result.kind) && REACT_HOOK_NAME_PATTERN.test(result.name)) + return "hook"; + if ( + (result.kind === "class" || result.kind === "function") && + REACT_COMPONENT_NAME_PATTERN.test(result.name) + ) { + return "component"; + } + if ( + (result.kind === "constant" || result.kind === "variable") && + REACT_COMPONENT_NAME_PATTERN.test(result.name) && + REACT_COMPONENT_WRAPPER_PATTERN.test(result.signature ?? result.snippet ?? "") + ) { + return "component"; + } + return result.kind; +}; diff --git a/packages/react-doctor/src/cli/utils/strip-unknown-cli-flags.ts b/packages/react-doctor/src/cli/utils/strip-unknown-cli-flags.ts index bf850fcf5c..28ee3c7a4f 100644 --- a/packages/react-doctor/src/cli/utils/strip-unknown-cli-flags.ts +++ b/packages/react-doctor/src/cli/utils/strip-unknown-cli-flags.ts @@ -76,6 +76,14 @@ const VERSION_FLAG_SPEC: CliFlagSpec = { shortOptionsWithRequiredValues: new Set(), }; +const FIND_FLAG_SPEC: CliFlagSpec = { + longOptionsWithoutValues: new Set(["--help", "--json"]), + longOptionsWithRequiredValues: new Set(["--cwd", "--kind", "--limit"]), + longOptionsWithOptionalValues: new Set(), + shortOptionsWithoutValues: new Set(["-h"]), + shortOptionsWithRequiredValues: new Set(["-c", "-k", "-l"]), +}; + // Union of every flag across the `rules` subcommands (list / explain / // set / enable / disable / category / ignore-tag / unignore-tag). The // subcommand name and positionals (rule key, severity, tag, category) @@ -155,6 +163,9 @@ const COMMAND_FLAG_SPECS = new Map([ ["install", INSTALL_FLAG_SPEC], ["setup", INSTALL_FLAG_SPEC], ["version", VERSION_FLAG_SPEC], + ["find", FIND_FLAG_SPEC], + ["search", FIND_FLAG_SPEC], + ["grep", FIND_FLAG_SPEC], ["rules", RULES_FLAG_SPEC], ["ci", CI_FLAG_SPEC], ["why", WHY_FLAG_SPEC], diff --git a/packages/react-doctor/tests/find-command.test.ts b/packages/react-doctor/tests/find-command.test.ts new file mode 100644 index 0000000000..2e2b1f1e87 --- /dev/null +++ b/packages/react-doctor/tests/find-command.test.ts @@ -0,0 +1,165 @@ +import * as path from "node:path"; +import { searchSymbols } from "@rayhanadev/truffler"; +import type { SymbolSearchResult } from "@rayhanadev/truffler"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { findAction } from "../src/cli/commands/find.js"; +import { CliInputError } from "../src/cli/utils/cli-input-error.js"; +import { METRIC } from "../src/cli/utils/constants.js"; +import { formatFindSymbolResult } from "../src/cli/utils/format-find-symbol-result.js"; +import { parseFindKinds } from "../src/cli/utils/parse-find-kinds.js"; +import { recordCount } from "../src/cli/utils/record-metric.js"; +import { resolveFindSymbolKind } from "../src/cli/utils/resolve-find-symbol-kind.js"; +import { captureStdout } from "./helpers/capture-stdout.js"; + +vi.mock("@rayhanadev/truffler", () => ({ searchSymbols: vi.fn() })); +vi.mock("../src/cli/utils/record-metric.js", () => ({ recordCount: vi.fn() })); + +const TEST_CWD = process.cwd(); + +const createResult = ( + name: string, + kind: SymbolSearchResult["kind"], + file = path.join(TEST_CWD, "src/example.tsx"), +): SymbolSearchResult => ({ + name, + kind, + file, + start: 14, + end: 14 + name.length, + line: 2, + column: 15, + signature: `${kind} ${name}`, + score: 100, + matches: [0], +}); + +afterEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); +}); + +describe("React-aware find kinds", () => { + it("classifies PascalCase functions and classes as components", () => { + expect(resolveFindSymbolKind(createResult("Button", "function"))).toBe("component"); + expect(resolveFindSymbolKind(createResult("ErrorBoundary", "class"))).toBe("component"); + expect(resolveFindSymbolKind(createResult("ButtonProps", "interface"))).toBe("interface"); + }); + + it("classifies common HOC and styled bindings as components", () => { + expect( + resolveFindSymbolKind({ + ...createResult("Profile", "constant"), + signature: "Profile = observer(memo(ProfileView))", + }), + ).toBe("component"); + expect( + resolveFindSymbolKind({ + ...createResult("Button", "constant"), + signature: "Button = styled.button`color: red`", + }), + ).toBe("component"); + expect(resolveFindSymbolKind(createResult("DEFAULT_THEME", "constant"))).toBe("constant"); + }); + + it("recognizes custom hooks, numbered hooks, and React 19 use", () => { + expect(resolveFindSymbolKind(createResult("useCounter", "function"))).toBe("hook"); + expect(resolveFindSymbolKind(createResult("use2D", "function"))).toBe("hook"); + expect(resolveFindSymbolKind(createResult("use", "function"))).toBe("hook"); + expect(resolveFindSymbolKind(createResult("useStore", "constant"))).toBe("hook"); + expect(resolveFindSymbolKind(createResult("useful", "function"))).toBe("function"); + }); + + it("expands component and hook filters to their syntax kinds", () => { + expect(parseFindKinds("component").symbolKinds).toEqual([ + "class", + "constant", + "function", + "variable", + ]); + expect(parseFindKinds("hook").symbolKinds).toEqual(["constant", "function", "variable"]); + expect(parseFindKinds("component,interface").symbolKinds).toEqual([ + "class", + "constant", + "function", + "variable", + "interface", + ]); + }); + + it("rejects unsupported filters", () => { + expect(() => parseFindKinds("component,banana")).toThrow(CliInputError); + }); +}); + +describe("findAction", () => { + it("prints grep-friendly component results and records adoption", async () => { + vi.mocked(searchSymbols).mockResolvedValue([ + createResult("Button", "function"), + createResult("ButtonProps", "interface"), + ]); + const output = captureStdout(); + + await findAction("btn", "src", { cwd: TEST_CWD, kind: "component", limit: "10" }); + + expect(recordCount).toHaveBeenCalledWith(METRIC.cliInvoked, 1, { command: "find" }); + expect(searchSymbols).toHaveBeenCalledWith( + "btn", + expect.objectContaining({ + cwd: TEST_CWD, + root: "src", + symbolKinds: ["class", "constant", "function", "variable"], + }), + ); + expect(output.lines.join("")).toBe("src/example.tsx:2:15 component function Button\n"); + output.restore(); + }); + + it("normalizes behavior phrases into identifier queries", async () => { + vi.mocked(searchSymbols).mockResolvedValue([createResult("parseFindKinds", "function")]); + const output = captureStdout(); + + await findAction(" parse find kinds ", "src", { cwd: TEST_CWD }); + + expect(searchSymbols).toHaveBeenCalledWith("parsefindkinds", expect.any(Object)); + expect(output.lines.join("")).toContain("parseFindKinds"); + output.restore(); + }); + + it("rejects invalid limits", async () => { + await expect(findAction("Button", ".", { cwd: TEST_CWD, limit: -1 })).rejects.toThrow( + CliInputError, + ); + await expect(findAction("Button", ".", { cwd: TEST_CWD, limit: "1.5" })).rejects.toThrow( + CliInputError, + ); + await expect(findAction("Button", ".", { cwd: TEST_CWD, limit: "" })).rejects.toThrow( + CliInputError, + ); + }); + + it("emits structured hook results with their syntax kind", async () => { + vi.mocked(searchSymbols).mockResolvedValue([ + createResult("useCounter", "function"), + createResult("useful", "function"), + ]); + const output = captureStdout(); + + await findAction("use", ".", { cwd: TEST_CWD, kind: "hook", json: true }); + + const parsedOutput = JSON.parse(output.lines.join("")); + expect(parsedOutput.count).toBe(1); + expect(parsedOutput.results[0]).toMatchObject({ + name: "useCounter", + kind: "hook", + symbolKind: "function", + location: { file: "src/example.tsx", line: 2, column: 15 }, + }); + output.restore(); + }); + + it("formats files outside the working directory as absolute paths", () => { + expect( + formatFindSymbolResult(createResult("Button", "function", "/other/Button.tsx"), "/repo"), + ).toBe("/other/Button.tsx:2:15 component function Button"); + }); +}); diff --git a/packages/react-doctor/tests/helpers/capture-stdout.ts b/packages/react-doctor/tests/helpers/capture-stdout.ts new file mode 100644 index 0000000000..1f430cd607 --- /dev/null +++ b/packages/react-doctor/tests/helpers/capture-stdout.ts @@ -0,0 +1,15 @@ +import { vi } from "vite-plus/test"; + +export interface CapturedStdout { + readonly lines: string[]; + readonly restore: () => void; +} + +export const captureStdout = (): CapturedStdout => { + const lines: string[] = []; + const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + lines.push(String(chunk)); + return true; + }); + return { lines, restore: () => writeSpy.mockRestore() }; +}; diff --git a/packages/react-doctor/tests/json-mode.test.ts b/packages/react-doctor/tests/json-mode.test.ts index f8470aaac6..439d5f3796 100644 --- a/packages/react-doctor/tests/json-mode.test.ts +++ b/packages/react-doctor/tests/json-mode.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; @@ -14,6 +14,8 @@ import { writeJsonErrorReport, writeJsonReport, } from "../src/cli/utils/json-mode.js"; +import { captureStdout } from "./helpers/capture-stdout.js"; +import type { CapturedStdout } from "./helpers/capture-stdout.js"; const buildOkReport = (overrides: Partial = {}): JsonReport => ({ schemaVersion: 1, @@ -42,22 +44,6 @@ const buildOkReport = (overrides: Partial = {}): JsonReport => ({ // and we can read `isJsonModeActive()` to confirm the prior state was // replaced. -interface CapturedStdout { - lines: string[]; - restore: () => void; -} - -const captureStdout = (): CapturedStdout => { - const lines: string[] = []; - const spy = vi.spyOn(process.stdout, "write").mockImplementation((( - chunk: string | Uint8Array, - ) => { - lines.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf-8")); - return true; - }) as never); - return { lines, restore: () => spy.mockRestore() }; -}; - describe("json-mode lifecycle", () => { let captured: CapturedStdout; diff --git a/packages/react-doctor/tests/strip-unknown-cli-flags.test.ts b/packages/react-doctor/tests/strip-unknown-cli-flags.test.ts index 2b5ba1c4cd..37a5335cab 100644 --- a/packages/react-doctor/tests/strip-unknown-cli-flags.test.ts +++ b/packages/react-doctor/tests/strip-unknown-cli-flags.test.ts @@ -191,6 +191,33 @@ describe("stripUnknownCliFlags", () => { ]); }); + it("keeps find options for find, search, and grep", () => { + for (const command of ["find", "search", "grep"]) { + expect( + stripUserArguments([ + command, + "Button", + "src", + "--kind", + "component,interface", + "--limit", + "10", + "--json", + "--offline", + ]), + ).toEqual([ + command, + "Button", + "src", + "--kind", + "component,interface", + "--limit", + "10", + "--json", + ]); + } + }); + it("keeps color flags on rules subcommands so the color resolver can see them", () => { expect(stripUserArguments(["rules", "list", "--no-color"])).toEqual([ "rules", diff --git a/packages/react-doctor/tsconfig.json b/packages/react-doctor/tsconfig.json index 18ba9031ba..6d575cfdde 100644 --- a/packages/react-doctor/tsconfig.json +++ b/packages/react-doctor/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "../../tsconfig.json", "compilerOptions": { + "allowImportingTsExtensions": true, "noEmit": true, "declarationMap": true, "jsx": "react-jsx" diff --git a/packages/react-doctor/vite.config.ts b/packages/react-doctor/vite.config.ts index 6ee26f0426..7a0e4cb9a3 100644 --- a/packages/react-doctor/vite.config.ts +++ b/packages/react-doctor/vite.config.ts @@ -71,6 +71,7 @@ export default defineConfig({ // `yaml` (pure JS, no native deps) backs the `ci config` in-place // workflow editor; inline it so end users get no extra install. alwaysBundle: [ + "@rayhanadev/truffler", "commander", "ink", "ink-spinner", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 044e59b086..d9856c10e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,9 +22,6 @@ importers: '@changesets/cli': specifier: ^2.31.0 version: 2.31.0(@types/node@25.6.0) - '@rayhanadev/truffler': - specifier: ^0.4.2 - version: 0.4.2 '@sentry/cli': specifier: ^3.4.3 version: 3.4.3 @@ -268,6 +265,9 @@ importers: '@babel/code-frame': specifier: ^7.29.0 version: 7.29.0 + '@rayhanadev/truffler': + specifier: ^0.4.2 + version: 0.4.2 '@sentry/node': specifier: ^10.54.0 version: 10.54.0(@opentelemetry/exporter-trace-otlp-http@0.219.0(@opentelemetry/api@1.9.1)) @@ -295,6 +295,9 @@ importers: magicast: specifier: ^0.5.3 version: 0.5.3 + oxc-parser: + specifier: ^0.132.0 + version: 0.132.0 oxc-resolver: specifier: ^11.24.2 version: 11.24.2 diff --git a/skills/react-doctor/SKILL.md b/skills/react-doctor/SKILL.md index d9e4c463da..5b9edbac21 100644 --- a/skills/react-doctor/SKILL.md +++ b/skills/react-doctor/SKILL.md @@ -40,6 +40,10 @@ Pair it with the matching per-rule prompts at `https://www.react.doctor/prompts/ When the user wants to understand a rule, disagrees with one, or wants to disable / tune which rules run (not fix code), read [references/explain.md](references/explain.md) and follow it. Start with `npx react-doctor@latest rules explain `, then apply the narrowest control via `npx react-doctor@latest rules disable|set|category|ignore-tag …`, which edits your `doctor.config.*` (or `package.json#reactDoctor`). +## Finding existing code + +Before adding a component, hook, helper, or type, run `npx react-doctor@latest find [directory]`. Use `--kind component`, `--kind hook`, or a comma-separated mix of generic JS/TS kinds to narrow the results. Add `--json` when structured output is easier to inspect. + ## Command ```bash