From 5ac50f9b45ed748f94d536a7443423e7e0ceacab Mon Sep 17 00:00:00 2001 From: "Sophia (Turner)" Date: Sat, 28 Mar 2026 14:41:57 +0900 Subject: [PATCH 1/2] feat(react): add usage diff and benchmarks --- README.md | 5 +- bench/helpers.ts | 79 ++ e2e/deps.test.ts | 2 + e2e/react.test.ts | 231 +++++- src/analyzers/deps/diff.bench.ts | 133 ++++ src/analyzers/deps/diff.ts | 2 + src/analyzers/import/graph.bench.ts | 54 ++ src/analyzers/react/diff.bench.ts | 137 ++++ src/analyzers/react/diff.ts | 1070 +++++++++++++++++++++++++++ src/analyzers/react/file.bench.ts | 77 ++ src/analyzers/react/index.spec.ts | 5 + src/app/args.ts | 1 + src/app/cli.ts | 6 + src/app/react-entry-files.spec.ts | 2 + src/commands/react.ts | 38 +- src/index.ts | 15 +- src/output/ascii/react.spec.ts | 3 +- src/output/ascii/react.ts | 260 +++++++ src/output/json/react.spec.ts | 6 +- src/output/json/react.ts | 80 ++ src/types/react-usage-diff-edge.ts | 14 + src/types/react-usage-diff-entry.ts | 18 + src/types/react-usage-diff-graph.ts | 10 + src/types/react-usage-diff-node.ts | 16 + 24 files changed, 2252 insertions(+), 12 deletions(-) create mode 100644 bench/helpers.ts create mode 100644 src/analyzers/deps/diff.bench.ts create mode 100644 src/analyzers/import/graph.bench.ts create mode 100644 src/analyzers/react/diff.bench.ts create mode 100644 src/analyzers/react/diff.ts create mode 100644 src/analyzers/react/file.bench.ts create mode 100644 src/types/react-usage-diff-edge.ts create mode 100644 src/types/react-usage-diff-entry.ts create mode 100644 src/types/react-usage-diff-graph.ts create mode 100644 src/types/react-usage-diff-node.ts diff --git a/README.md b/README.md index 11005c9..546d42d 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ bunx foresthouse --help - Honors the nearest `tsconfig.json` or `jsconfig.json`, including `baseUrl` and `paths` - Expands sibling workspace packages by default, including their own `tsconfig` alias rules - Analyzes React component and hook usage trees from explicit entry files or inferred Next.js app and pages routes +- Shows React tree changes relative to a Git ref or range with `foresthouse react --diff` - Reads `package.json` manifests to show package-level dependency trees for single packages and monorepos - Shows dependency-tree changes relative to a Git ref or range with `foresthouse deps --diff` - Prints an ASCII tree by default, or JSON with `--json` @@ -58,7 +59,7 @@ Options: ### Commands - `foresthouse import `: analyzes a JavaScript or TypeScript entry file and prints a dependency tree by following imports, re-exports, `require()`, and dynamic `import()`. -- `foresthouse react [entry-file]`: analyzes React component, hook, and render relationships and prints a React usage tree. It also supports automatic Next.js entry discovery with `--nextjs`. +- `foresthouse react [entry-file]`: analyzes React component, hook, and render relationships and prints a React usage tree. It also supports automatic Next.js entry discovery with `--nextjs`, plus Git-based tree diffs with `--diff`. - `foresthouse deps `: reads `package.json` manifests and workspace structure from a package directory and prints a package dependency tree. Use `--diff` to show only changes relative to a Git ref or range. ### Example @@ -107,6 +108,7 @@ Usage: $ foresthouse react [entry-file] [options] Options: + --diff Show only React tree changes relative to a Git revision or range. --cwd Working directory used for relative paths. --config Explicit tsconfig.json or jsconfig.json path. --nextjs Infer Next.js page entries from app/ and pages/ when no entry is provided. @@ -137,6 +139,7 @@ Common examples: - `foresthouse import src/main.ts` - `foresthouse import --entry src/main.ts --cwd test/fixtures/basic` - `foresthouse react src/App.tsx` +- `foresthouse react src/App.tsx --diff origin/dev...HEAD` - `foresthouse react --nextjs --cwd .` - `foresthouse deps .` - `foresthouse deps ./packages/a` diff --git a/bench/helpers.ts b/bench/helpers.ts new file mode 100644 index 0000000..d0047f4 --- /dev/null +++ b/bench/helpers.ts @@ -0,0 +1,79 @@ +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +const temporaryDirectories: string[] = [] + +let cleanupRegistered = false +const GIT_EXEC_MAX_BUFFER = 64 * 1024 * 1024 + +export function createGitRepository( + prefix: string, + commits: readonly { + readonly message: string + readonly files: Readonly> + }[], +): string { + registerCleanup() + + const repositoryRoot = fs.mkdtempSync(path.join(os.tmpdir(), prefix)) + temporaryDirectories.push(repositoryRoot) + + runGit(repositoryRoot, ['init', '--initial-branch=main']) + runGit(repositoryRoot, ['config', 'user.name', 'Foresthouse Benchmarks']) + runGit(repositoryRoot, ['config', 'user.email', 'benchmarks@example.com']) + + commits.forEach((commit) => { + replaceRepositoryFiles(repositoryRoot, commit.files) + runGit(repositoryRoot, ['add', '-A']) + runGit(repositoryRoot, ['commit', '-m', commit.message]) + }) + + return repositoryRoot +} + +function replaceRepositoryFiles( + repositoryRoot: string, + files: Readonly>, +): void { + fs.readdirSync(repositoryRoot, { withFileTypes: true }).forEach((entry) => { + if (entry.name === '.git') { + return + } + + fs.rmSync(path.join(repositoryRoot, entry.name), { + recursive: true, + force: true, + }) + }) + + Object.entries(files).forEach(([relativePath, fileContent]) => { + const absolutePath = path.join(repositoryRoot, relativePath) + fs.mkdirSync(path.dirname(absolutePath), { recursive: true }) + fs.writeFileSync(absolutePath, fileContent) + }) +} + +function runGit(repositoryRoot: string, args: readonly string[]): string { + return execFileSync('git', args, { + cwd: repositoryRoot, + encoding: 'utf8', + maxBuffer: GIT_EXEC_MAX_BUFFER, + stdio: ['ignore', 'pipe', 'pipe'], + }).trim() +} + +function registerCleanup(): void { + if (cleanupRegistered) { + return + } + + cleanupRegistered = true + + process.on('exit', () => { + temporaryDirectories.splice(0).forEach((directory) => { + fs.rmSync(directory, { recursive: true, force: true }) + }) + }) +} diff --git a/e2e/deps.test.ts b/e2e/deps.test.ts index 1c94311..bd74d49 100644 --- a/e2e/deps.test.ts +++ b/e2e/deps.test.ts @@ -44,6 +44,7 @@ const pnpmMonorepoFixtureDirectory = path.join( 'deps-pnpm-monorepo', ) const temporaryDirectories: string[] = [] +const GIT_EXEC_MAX_BUFFER = 64 * 1024 * 1024 afterEach(() => { temporaryDirectories.splice(0).forEach((directory) => { @@ -1078,6 +1079,7 @@ function runGit(repositoryRoot: string, args: readonly string[]): string { return execFileSync('git', args, { cwd: repositoryRoot, encoding: 'utf8', + maxBuffer: GIT_EXEC_MAX_BUFFER, stdio: ['ignore', 'pipe', 'pipe'], }).trim() } diff --git a/e2e/react.test.ts b/e2e/react.test.ts index a8c328d..0ce0a06 100644 --- a/e2e/react.test.ts +++ b/e2e/react.test.ts @@ -1,7 +1,10 @@ +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' import path from 'node:path' import { fileURLToPath } from 'node:url' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { main } from '../src/app/cli.js' import { @@ -11,7 +14,10 @@ import { import { runCli } from '../src/app/run.js' import { analyzeReactUsage, + analyzeReactUsageDiff, + diffGraphToSerializableReactTree, graphToSerializableReactTree, + printReactUsageDiffTree, printReactUsageTree, } from '../src/index.js' @@ -43,6 +49,14 @@ const nextjsFixtureDirectory = path.join( 'fixtures', 'nextjs-mode', ) +const temporaryDirectories: string[] = [] +const GIT_EXEC_MAX_BUFFER = 64 * 1024 * 1024 + +afterEach(() => { + temporaryDirectories.splice(0).forEach((directory) => { + fs.rmSync(directory, { recursive: true, force: true }) + }) +}) beforeEach(() => { vi.mocked(runCli).mockReset() @@ -61,6 +75,8 @@ describe('react command options', () => { '--project-only', '--json', '--builtin', + '--diff', + 'HEAD~1', '--filter', 'hook', ]) @@ -68,6 +84,7 @@ describe('react command options', () => { expect(runCli).toHaveBeenCalledWith({ command: 'react', entryFile: 'src/main.tsx', + diff: 'HEAD~1', cwd: 'test/fixtures/react-mode', configPath: 'tsconfig.json', expandWorkspaces: false, @@ -85,6 +102,7 @@ describe('react command options', () => { expect(runCli).toHaveBeenCalledWith({ command: 'react', entryFile: undefined, + diff: undefined, cwd: 'test/fixtures/nextjs-mode', configPath: undefined, expandWorkspaces: true, @@ -102,6 +120,7 @@ describe('react command options', () => { expect(runCli).toHaveBeenCalledWith({ command: 'react', entryFile: 'src/main.tsx', + diff: undefined, cwd: undefined, configPath: undefined, expandWorkspaces: true, @@ -119,6 +138,7 @@ describe('react command options', () => { expect(runCli).toHaveBeenCalledWith({ command: 'react', entryFile: 'pages/index.tsx', + diff: undefined, cwd: undefined, configPath: undefined, expandWorkspaces: true, @@ -146,6 +166,7 @@ describe('react entry discovery', () => { resolveReactEntryFiles({ command: 'react', entryFile: 'pages/index.tsx', + diff: undefined, cwd: nextjsFixtureDirectory, configPath: undefined, expandWorkspaces: true, @@ -781,3 +802,211 @@ describe('analyzeReactUsage', () => { }) }) }) + +describe('analyzeReactUsageDiff', () => { + it('prints React tree changes for a Git range', () => { + const repositoryRoot = createGitRepository([ + { + message: 'initial', + files: { + 'package.json': JSON.stringify( + { + name: 'react-diff-fixture', + private: true, + }, + null, + 2, + ), + 'tsconfig.json': JSON.stringify( + { + compilerOptions: { + jsx: 'react-jsx', + }, + }, + null, + 2, + ), + 'src/main.tsx': [ + "import { AppShell } from './AppShell'", + '', + 'void ()', + '', + ].join('\n'), + 'src/AppShell.tsx': [ + "import { Panel } from './components/Panel'", + '', + 'export function AppShell() {', + ' return ', + '}', + '', + ].join('\n'), + 'src/components/Panel.tsx': [ + 'export function Panel() {', + ' return
', + '}', + '', + ].join('\n'), + }, + }, + { + message: 'swap rendered component', + files: { + 'package.json': JSON.stringify( + { + name: 'react-diff-fixture', + private: true, + }, + null, + 2, + ), + 'tsconfig.json': JSON.stringify( + { + compilerOptions: { + jsx: 'react-jsx', + }, + }, + null, + 2, + ), + 'src/main.tsx': [ + "import { AppShell } from './AppShell'", + '', + 'void ()', + '', + ].join('\n'), + 'src/AppShell.tsx': [ + "import { Button } from './components/Button'", + '', + 'export function AppShell() {', + ' return