A high-performance static analysis and visualization system for React/TypeScript repositories. Instantly generates interactive dependency graphs with layer classification, circular dependency detection, and API endpoint tracking.
npx structdecipherThat's it. Opens an interactive dashboard in your browser showing your project's architecture.
# Scan a specific project
npx structdecipher ./my-react-app
# Output JSON only
npx structdecipher . --json
# Save to file
npx structdecipher . -o structure.jsonSee the npm package README for full CLI docs and programmatic API.
This section is for contributing to StructDecipher itself.
┌─────────────────────────────────────────────────────────────────────┐
│ packages/cli/ (npm package — TypeScript) │
│ ┌──────────────┐ scanRepository() ┌──────────────────────┐ │
│ │ Scanner │ ────────────────────► │ HTTP Server │ │
│ │ (8 modules) │ ScanResult │ (pure Node.js) │ │
│ └──────────────┘ └──────┬───────────────┘ │
│ │ │
│ ┌────────────────────────────────────────────────┘ │
│ │ dashboard/ (pre-built Vite static files) │
│ │ React Flow + Dagre │
│ └────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ server/ (Python — original dev server) │
│ ┌──────────────┐ scan_repository() ┌──────────────────────┐ │
│ │ Scanner │ ────────────────────► │ FastAPI Backend │ │
│ │ (8 modules) │ structure.json │ :8000 │ │
│ └──────────────┘ │ REST + WebSocket │ │
│ └──────┬──────┬───────┘ │
└──────────────────────────────────────────────────┼──────┼──────────┘
WebSocket │ │ WebSocket
┌──────────────────────┘ └───────────────────┐
┌───────▼──────────┐ ┌──────────▼──────┐
│ Dashboard (dev) │ │ Demo Apps │
│ :5173 │ │ :3001 :3002 │
└──────────────────┘ │ :3003 │
└─────────────────┘
| Component | Tech | Port | Purpose |
|---|---|---|---|
| packages/cli | TypeScript + Node.js | 5173 | npm package: scanner + server + dashboard |
| server/ | Python 3.12 + FastAPI | 8000 | Dev server with WebSocket + runtime tracking |
| dashboard/ | React Flow + Dagre + Vite | 5173 | Interactive graph visualization + analytics panel |
| demo-app | Classic React + Router | 3001 | 12 files, 15 nodes, 22 edges, 4 API endpoints |
| demo-openapi | openapi-fetch + React Query | 3002 | 17 files, 21 nodes, 36 edges, 6 API endpoints |
| demo-fsd | Feature-Sliced Design | 3003 | 42 files, 30 nodes, 56 edges, FSD violation detection |
chmod +x start.sh
./start.shOpen the Dashboard at http://localhost:5173, enter a repo path, and click Sync.
# 1. Backend
pip3 install -r server/requirements.txt
python3 -m uvicorn server.app:app --reload --port 8000
# 2. Dashboard
cd dashboard && npm install && npm run dev
# 3. Demo App (any of the three)
cd demo-app && npm install && npm run dev- Multi-framework detection — Auto-detects Next.js (App/Pages Router), TanStack Router, Remix, Gatsby, and classic React
- Feature-Sliced Design (FSD) — Full FSD architecture support with 7-layer classification, slice/segment detection, and import violation checking
- openapi-ts support — Recognizes
openapi-fetch(client.GET("/path")) andopenapi-react-query($api.useQuery("get", "/path")) patterns - Path alias resolution — Reads
tsconfig.json/jsconfig.jsonpaths+ common aliases (@/,~/,#/) - Barrel file resolution — Traces
export * fromchains through index files to find actual source modules - API endpoint extraction — Detects
fetch,axios,ky,$fetch,useFetch, openapi-fetch, openapi-react-query calls and normalizes endpoints - Monorepo support — Detects npm/yarn/pnpm workspaces and scans each package independently
- Tree-shaking analysis — BFS from page entry points; files not transitively reachable are flagged as dead code
- Circular dependency detection — DFS 3-color cycle finder + Tarjan's SCC for dependency groups
- Incremental scanning — mtime+size fingerprint cache skips unchanged files on rescan
- Hierarchical Dagre layout — Nodes organized in strict layers with clean edge routing
- Collapse/Expand — Double-click a Page node to collapse/expand its subtree
- Real-time tracking —
useTrackinghook reports mount/unmount/click/navigate/api_call events via WebSocket - Active node highlighting — Nodes glow when their component is mounted in the demo app
- Search & filter — Fuzzy search across all nodes with keyboard shortcut (
/) - Export to PNG — One-click export of the current graph view
- Analytics panel — Circular deps, dead files, dependency impact analysis, heatmap
- Keyboard shortcuts —
Ctrl+Ssync,/search,Escapeclear selection - Event log — Live stream of runtime events in the sidebar
| Endpoint | Method | Purpose |
|---|---|---|
/api/structure |
GET | Current analyzed structure |
/api/scan |
POST | Trigger a (re-)scan of a repository |
/api/interaction |
POST | Receive an interaction event |
/api/interactions |
GET/DEL | List or clear interaction history |
/api/analytics |
GET | Heatmap, circular deps, dead files |
/api/analytics/impact |
GET | Transitive impact analysis for a node |
/api/health |
GET | Health check |
/ws/dashboard |
WS | Real-time structure & interaction updates |
/ws/tracker |
WS | Tracker connection for demo apps |
| Layer | Source Patterns | Index |
|---|---|---|
| Pages / Layouts | pages/, views/, screens/, routes/, App Router |
0 |
| Features | features/, modules/, containers/, domains/ |
1 |
| Shared / UI | components/, ui/, shared/, hooks/, lib/, utils/ |
2 |
| API Services | services/, api/, queries/, mutations/, hooks w/ API calls |
3 |
| Backend Endpoints | Extracted from fetch/axios/ky/openapi-fetch calls | 4 |
| FSD Layer | Index | Description |
|---|---|---|
| App | 0 | App-wide setup, providers, routes |
| Processes | 1 | Cross-page business processes |
| Pages | 2 | Full page compositions |
| Widgets | 3 | Compositional blocks with logic |
| Features | 4 | User interactions, actions |
| Entities | 5 | Business entities (user, post, etc) |
| Shared | 6 | Reusable UI, utilities, configs |
FSD import rule: A layer can only import from layers strictly below (higher index). Cross-slice imports at the same layer are also violations (except app/ and shared/).
The scanner is split into 8 focused modules under server/scanner/:
server/scanner/
├── patterns.py # Regex constants (IMPORT_RE, API_CALL_RE, OPENAPI_FETCH_RE, etc.)
├── frameworks.py # Framework detection (Next.js, TanStack, Remix, Gatsby)
├── fsd.py # FSD detection, classification, violations
├── resolver.py # Import resolution with caching + pre-indexed file set
├── parser.py # Single-file parsing (imports, API calls, routes, barrels)
├── layers.py # Layer classification, display names, route extraction
├── analytics.py # Cycle detection (DFS), Tarjan's SCC, tree-shaking BFS
└── core.py # Orchestration: scan_repository(), monorepo, parallel I/O
Problem: Each import tries up to 21 candidate paths (exact, +5 extensions, /index+5 ext, /FolderName+5 ext). With N files averaging 10 imports each, that's 210N filesystem is_file() syscalls.
Solution: Pre-index all discovered project files into a set[Path] before resolution begins. Existence checks become O(1) hash lookups instead of syscalls.
# Before: 21 × is_file() per import = expensive I/O
# After: 21 × set.__contains__() = O(1) memory lookup
_known_files: set[Path] = set()
def set_known_files(files: set[Path]):
global _known_files
_known_files = filesProblem: Standard DFS cycle detection uses path.index(node) to find where a cycle starts, which is O(V) per back-edge.
Solution: Maintain a path_pos: dict[Path, int] alongside the DFS path stack. Back-edge cycle extraction becomes O(1).
path: list[Path] = []
path_pos: dict[Path, int] = {} # O(1) lookup replaces path.index()
def dfs(fp):
path_pos[fp] = len(path)
path.append(fp)
for dep in graph[fp]:
if color[dep] == GRAY:
idx = path_pos[dep] # O(1) instead of path.index(dep) O(n)
cycle = path[idx:]
path.pop()
del path_pos[fp]Problem: Circular dependency groups (mutually dependent file sets) are useful for understanding which files are tightly coupled. Naive approaches require repeated DFS.
Solution: Single-pass Tarjan's SCC algorithm finds all non-trivial SCCs (size ≥ 2) in O(V+E). Each SCC represents a group of files that mutually depend on each other.
def find_strongly_connected_components(graph) -> list[list[Path]]:
# Tarjan's algorithm with index/lowlink tracking
# Returns only SCCs with >= 2 nodes (actual circular groups)Problem: Finding all transitively used files from page entry points. Original implementation used list.pop(0) which is O(n) per dequeue.
Solution: Replace with collections.deque.popleft() for O(1) dequeue operations.
from collections import deque
queue: deque[Path] = deque(entry_files) # O(1) popleft
while queue:
current = queue.popleft() # O(1) instead of list.pop(0) O(n)Problem: Matching API endpoint nodes to the files that call them was O(endpoints × files) — quadratic.
Solution: Build an inverted index endpoint_string → node_id in one pass, then look up each file's API calls in O(1).
# Build inverted index: endpoint_url → node_id
endpoint_index: dict[str, str] = {}
for node in api_nodes:
endpoint_index[node["label"]] = node["id"]
# O(1) lookup per API call instead of O(N) linear scan
for file_node in file_nodes:
for api_call in file_data["api_calls"]:
target_id = endpoint_index.get(api_call) # O(1)Problem: export * from './sub' chains can be deeply nested and even circular. Without memoization, resolution is exponential in the worst case.
Solution: Cache resolved barrels in _barrel_cache and use a _barrel_resolving set to detect and break cycles.
_barrel_cache: dict[Path, list[Path]] = {}
_barrel_resolving: set[Path] = set() # cycle guard
def resolve_through_barrels(fp):
if fp in _barrel_cache:
return _barrel_cache[fp]
if fp in _barrel_resolving:
return [fp] # break cycle
_barrel_resolving.add(fp)
# ... resolve ...
_barrel_cache[fp] = result
return resultProblem: Re-scanning an unchanged project re-parses every file with regex, which is wasteful.
Solution: Cache parsing results keyed by (mtime, file_size). If the fingerprint matches, return the cached parse result.
_parse_cache: dict[Path, tuple[float, int, dict]] = {}
def _parse_file_incremental(fp: Path) -> dict:
st = fp.stat()
mtime, size = st.st_mtime, st.st_size
cached = _parse_cache.get(fp)
if cached and cached[0] == mtime and cached[1] == size:
return cached[2] # cache hit — skip regex parsing
result = scan_file(fp)
_parse_cache[fp] = (mtime, size, result)
return resultProblem: Sequential file parsing becomes a bottleneck for large projects (>50 files).
Solution: Use concurrent.futures.ThreadPoolExecutor to parallelize file I/O when the file count exceeds a threshold.
PARALLEL_THRESHOLD = 50
if len(files) > PARALLEL_THRESHOLD:
with ThreadPoolExecutor() as pool:
results = list(pool.map(_parse_file_incremental, files))
else:
results = [_parse_file_incremental(f) for f in files]Problem: The interaction event log used list.pop(0) for eviction — O(n) per operation.
Solution: Replace with collections.deque(maxlen=N) which auto-evicts the oldest entry in O(1).
interaction_log: deque = deque(maxlen=500) # auto-evicts oldest, O(1) append| Project | Files | First Scan | Incremental | Speedup |
|---|---|---|---|---|
| demo-app | 12 | ~4.5ms | ~2.7ms | 1.7× |
| demo-openapi | 16 | ~6.5ms | ~4.1ms | 1.6× |
| demo-fsd | 42 | ~11.3ms | ~9.2ms | 1.2× |
Add real-time tracking to any React component:
import { useTracking } from "./hooks/useTracking";
function MyComponent() {
const { trackClick, trackApiCall } = useTracking("MyComponent", {
filePath: "src/features/MyComponent.tsx",
});
return <button onClick={() => trackClick("save")}>Save</button>;
}{
"repoPath": "/absolute/path/to/repo",
"framework": "react",
"isFsd": false,
"layers": [
{ "id": "page", "index": 0, "label": "Pages", "color": "#4F46E5" }
],
"nodes": [
{
"id": "file_hash",
"label": "HomePage",
"layer": "page",
"layerIndex": 0,
"filePath": "src/pages/HomePage.tsx",
"lineCount": 42,
"route": "/home"
}
],
"edges": [{ "id": "edge_hash", "source": "src_id", "target": "tgt_id" }],
"groups": [
{ "parentId": "page_id", "childIds": ["feature_id", "shared_id"] }
],
"analytics": {
"circularDeps": [["fileA", "fileB"]],
"circularGroups": [["fileA", "fileB", "fileC"]],
"deadFiles": ["unused_component"],
"dependents": { "nodeId": ["parent1", "parent2"] }
},
"metadata": {
"totalFiles": 42,
"analyzedFiles": 30,
"treeShakedFiles": 12,
"totalEdges": 56,
"scanTimeMs": 11.3
}
}

