From 476efcfe8a84c92020f13cd8acaa4c4e088f373e Mon Sep 17 00:00:00 2001 From: "Sophia (Turner)" Date: Sat, 28 Mar 2026 16:30:09 +0900 Subject: [PATCH 1/2] fix(react): resolve builtin elements from namespace-imported styled-components Record ImportNamespaceSpecifier bindings and handle JSXMemberExpression references (e.g. ``) so the analyzer follows namespace imports to resolve the underlying builtin HTML elements. Co-Authored-By: Claude Opus 4.6 (1M context) --- e2e/react.test.ts | 71 +++++++++++++++++++ src/analyzers/react/bindings.ts | 9 +++ src/analyzers/react/entries.ts | 5 +- src/analyzers/react/references.ts | 41 +++++++++++ src/analyzers/react/usage.ts | 6 ++ src/analyzers/react/walk.ts | 22 ++++++ .../src/components/FeatureSection.styled.tsx | 9 +++ .../react-mode/src/namespace-styled-entry.tsx | 9 +++ 8 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 test/fixtures/react-mode/src/components/FeatureSection.styled.tsx create mode 100644 test/fixtures/react-mode/src/namespace-styled-entry.tsx diff --git a/e2e/react.test.ts b/e2e/react.test.ts index 0ce0a06..4cf0215 100644 --- a/e2e/react.test.ts +++ b/e2e/react.test.ts @@ -510,6 +510,77 @@ describe('analyzeReactUsage', () => { }) }) + it('resolves builtin elements from namespace-imported styled-components', () => { + const graph = analyzeReactUsage('src/namespace-styled-entry.tsx', { + cwd: fixtureDirectory, + includeBuiltins: true, + }) + + const output = printReactUsageTree(graph, { + color: false, + }) + const jsonTree = graphToSerializableReactTree(graph) + + expect(output).toContain( + ' [component] (src/namespace-styled-entry.tsx)', + ) + expect(output).toContain( + '
as Styled.Section [component] (src/components/FeatureSection.styled.tsx)', + ) + expect(output).toContain( + ' as Styled.Container [component] (src/components/FeatureSection.styled.tsx)', + ) + expect(output).toContain('
[builtin] (html)') + expect(output).toContain('
[builtin] (html)') + + expect(jsonTree).toMatchObject({ + entries: [ + expect.objectContaining({ + referenceName: 'FeaturePage', + node: expect.objectContaining({ + name: 'FeaturePage', + symbolKind: 'component', + }), + }), + ], + roots: [ + expect.objectContaining({ + name: 'FeaturePage', + usages: expect.arrayContaining([ + expect.objectContaining({ + node: expect.objectContaining({ + name: 'Section', + symbolKind: 'component', + usages: expect.arrayContaining([ + expect.objectContaining({ + node: expect.objectContaining({ + name: 'section', + symbolKind: 'builtin', + }), + }), + ]), + }), + }), + expect.objectContaining({ + node: expect.objectContaining({ + name: 'Container', + symbolKind: 'component', + usages: expect.arrayContaining([ + expect.objectContaining({ + node: expect.objectContaining({ + name: 'div', + symbolKind: 'builtin', + }), + }), + ]), + }), + }), + ]), + }), + ], + }) + }) + it('prints multiple React entry locations when the entry file renders more than one root', () => { const graph = analyzeReactUsage('src/multi-entry.tsx', { cwd: fixtureDirectory, diff --git a/src/analyzers/react/bindings.ts b/src/analyzers/react/bindings.ts index 41cd176..87f41cc 100644 --- a/src/analyzers/react/bindings.ts +++ b/src/analyzers/react/bindings.ts @@ -117,6 +117,15 @@ function getImportBinding( } } + if (specifier.type === 'ImportNamespaceSpecifier') { + return { + localName: specifier.local.name, + importedName: '*', + sourceSpecifier, + ...(sourcePath === undefined ? {} : { sourcePath }), + } + } + return undefined } diff --git a/src/analyzers/react/entries.ts b/src/analyzers/react/entries.ts index fdfd47d..c45802e 100644 --- a/src/analyzers/react/entries.ts +++ b/src/analyzers/react/entries.ts @@ -9,6 +9,7 @@ import { getComponentReferenceName, getCreateElementComponentReferenceName, getHookReferenceName, + getMemberExpressionComponentReferenceName, isNode, } from './walk.js' @@ -73,7 +74,9 @@ function collectNodeEntryUsages( let nextHasComponentAncestor = hasComponentAncestor if (node.type === 'JSXElement') { - const referenceName = getComponentReferenceName(node) + const referenceName = + getComponentReferenceName(node) ?? + getMemberExpressionComponentReferenceName(node) if (referenceName !== undefined) { if (!hasComponentAncestor) { addPendingReactUsageEntry( diff --git a/src/analyzers/react/references.ts b/src/analyzers/react/references.ts index 817333c..2b29707 100644 --- a/src/analyzers/react/references.ts +++ b/src/analyzers/react/references.ts @@ -15,6 +15,17 @@ export function resolveReactReference( return getBuiltinNodeId(name) } + const dotIndex = name.indexOf('.') + if (dotIndex !== -1) { + return resolveNamespaceMemberReference( + fileAnalysis, + fileAnalyses, + name.slice(0, dotIndex), + name.slice(dotIndex + 1), + kind, + ) + } + const localSymbol = fileAnalysis.allSymbolsByName.get(name) if (localSymbol !== undefined && localSymbol.kind === kind) { return localSymbol.id @@ -50,6 +61,36 @@ export function resolveReactReference( return targetId } +function resolveNamespaceMemberReference( + fileAnalysis: FileAnalysis, + fileAnalyses: ReadonlyMap, + namespaceName: string, + propertyName: string, + kind: ReactSymbolKind, +): string | undefined { + const importBinding = fileAnalysis.importsByLocalName.get(namespaceName) + if ( + importBinding === undefined || + importBinding.importedName !== '*' || + importBinding.sourcePath === undefined + ) { + return undefined + } + + const sourceFileAnalysis = fileAnalyses.get(importBinding.sourcePath) + if (sourceFileAnalysis === undefined) { + return undefined + } + + return resolveExportedSymbol( + sourceFileAnalysis, + propertyName, + kind, + fileAnalyses, + new Set(), + ) +} + function resolveExportedSymbol( fileAnalysis: FileAnalysis, exportName: string, diff --git a/src/analyzers/react/usage.ts b/src/analyzers/react/usage.ts index 969fbba..f7b33bb 100644 --- a/src/analyzers/react/usage.ts +++ b/src/analyzers/react/usage.ts @@ -4,6 +4,7 @@ import { getComponentReferenceName, getCreateElementComponentReferenceName, getHookReferenceName, + getMemberExpressionComponentReferenceName, getStyledBuiltinReferenceName, getStyledComponentReferenceName, walkReactUsageTree, @@ -18,6 +19,11 @@ export function analyzeSymbolUsages( const name = getComponentReferenceName(node) if (name !== undefined) { symbol.componentReferences.add(name) + } else { + const memberName = getMemberExpressionComponentReferenceName(node) + if (memberName !== undefined) { + symbol.componentReferences.add(memberName) + } } if (includeBuiltins) { diff --git a/src/analyzers/react/walk.ts b/src/analyzers/react/walk.ts index 10da610..6f2ae03 100644 --- a/src/analyzers/react/walk.ts +++ b/src/analyzers/react/walk.ts @@ -124,6 +124,28 @@ export function getComponentReferenceName( return name !== undefined && isComponentName(name) ? name : undefined } +export function getMemberExpressionComponentReferenceName( + node: JSXElement, +): string | undefined { + const name = node.openingElement.name + if (name.type !== 'JSXMemberExpression') { + return undefined + } + + if (name.object.type !== 'JSXIdentifier') { + return undefined + } + + const objectName = name.object.name + const propertyName = name.property.name + + if (isComponentName(objectName)) { + return `${objectName}.${propertyName}` + } + + return undefined +} + export function getBuiltinReferenceName(node: JSXElement): string | undefined { const name = getJsxName(node.openingElement.name) return name !== undefined && isIntrinsicElementName(name) ? name : undefined diff --git a/test/fixtures/react-mode/src/components/FeatureSection.styled.tsx b/test/fixtures/react-mode/src/components/FeatureSection.styled.tsx new file mode 100644 index 0000000..0753a82 --- /dev/null +++ b/test/fixtures/react-mode/src/components/FeatureSection.styled.tsx @@ -0,0 +1,9 @@ +import styled from 'styled-components' + +export const Section = styled.section` + display: flex; +` + +export const Container = styled.div` + padding: 1rem; +` diff --git a/test/fixtures/react-mode/src/namespace-styled-entry.tsx b/test/fixtures/react-mode/src/namespace-styled-entry.tsx new file mode 100644 index 0000000..9d7567e --- /dev/null +++ b/test/fixtures/react-mode/src/namespace-styled-entry.tsx @@ -0,0 +1,9 @@ +import * as Styled from './components/FeatureSection.styled' + +export function FeaturePage() { + return ( + + Hello + + ) +} From 47da80187a58034dedab2369ee6e4648bf49bbb6 Mon Sep 17 00:00:00 2001 From: "Sophia (Turner)" Date: Sat, 28 Mar 2026 16:30:19 +0900 Subject: [PATCH 2/2] perf(react): replace per-file git cat-file with streaming git archive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the N × git-cat-file + fs.writeFileSync loop in materializeGitTreeSnapshot with a single `git archive | tar` pipe. On a 4 140-file repository this reduces diff snapshot time from ~2 min to ~2 s by eliminating thousands of process spawns and disk round-trips. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/analyzers/react/diff.ts | 55 +++++++++++++------------------------ 1 file changed, 19 insertions(+), 36 deletions(-) diff --git a/src/analyzers/react/diff.ts b/src/analyzers/react/diff.ts index 623281a..f885348 100644 --- a/src/analyzers/react/diff.ts +++ b/src/analyzers/react/diff.ts @@ -1,4 +1,4 @@ -import { execFileSync } from 'node:child_process' +import { execFileSync, spawnSync } from 'node:child_process' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -989,46 +989,29 @@ function materializeGitTreeSnapshot( const snapshotRoot = fs.mkdtempSync( path.join(os.tmpdir(), 'foresthouse-react-diff-'), ) - const trackedFiles = runGit(repositoryRoot, [ - 'ls-tree', - '-r', - '-z', - '--name-only', - tree, - ]) - .split('\u0000') - .filter((filePath) => filePath.length > 0) - - trackedFiles.forEach((filePath) => { - ensureSnapshotDirectory(snapshotRoot, filePath) - }) - trackedFiles.forEach((filePath) => { - const fileContent = runGit( + const result = spawnSync( + 'sh', + [ + '-c', + 'git -C "$0" archive "$1" | tar -xf - -C "$2"', repositoryRoot, - ['cat-file', '-p', `${tree}:${filePath}`], - { - trim: false, - }, - ) - const absolutePath = path.join(snapshotRoot, ...filePath.split('/')) - - fs.writeFileSync(absolutePath, fileContent) - }) - - return snapshotRoot -} - -function ensureSnapshotDirectory(snapshotRoot: string, filePath: string): void { - const parentDirectory = path.dirname(filePath) + tree, + snapshotRoot, + ], + { + stdio: ['ignore', 'pipe', 'pipe'], + }, + ) - if (parentDirectory === '.') { - return + if (result.error !== undefined || result.status !== 0) { + fs.rmSync(snapshotRoot, { recursive: true, force: true }) + throw new Error( + `Failed to extract Git tree snapshot: ${result.stderr?.toString('utf8').trim() ?? result.error?.message ?? 'Unknown error'}`, + ) } - fs.mkdirSync(path.join(snapshotRoot, ...parentDirectory.split('/')), { - recursive: true, - }) + return snapshotRoot } function runGit(