Merge main into feat/dashboard-export-filtering

Resolved conflicts by keeping main's features (ThemeProvider, TokenGate,
WarningBanner, graph validation, layer navigation, sidebar composing
behavior) and integrating PR's new features (FilterPanel, ExportMenu,
PathFinderModal, NodeTooltip, ProjectOverview stats) on top.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lum1104
2026-03-29 11:02:15 +08:00
co-authored by Claude Opus 4.6
129 changed files with 18916 additions and 959 deletions
+2 -2
View File
@@ -9,8 +9,8 @@
{
"name": "understand-anything",
"description": "Multi-agent codebase analysis with interactive dashboard, guided tours, and skill commands",
"version": "1.2.0",
"version": "2.0.0",
"source": "./understand-anything-plugin"
}
]
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "understand-anything",
"description": "AI-powered codebase understanding — analyze, visualize, and explain any project",
"version": "1.2.0",
"version": "2.0.0",
"author": {
"name": "Lum1104"
},
@@ -15,4 +15,4 @@
"onboarding",
"dashboard"
]
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "understand-anything",
"displayName": "Understand Anything",
"description": "AI-powered codebase understanding — analyze, visualize, and explain any project",
"version": "1.2.0",
"version": "2.0.0",
"author": {
"name": "Lum1104"
},
+2
View File
@@ -7,3 +7,5 @@ dist
.env.*
coverage/
*.log
.claude/
.worktrees/
+2 -2
View File
@@ -237,9 +237,9 @@ The `/understand` command orchestrates 5 specialized agents:
| `file-analyzer` | Extract functions, classes, imports; produce graph nodes and edges |
| `architecture-analyzer` | Identify architectural layers |
| `tour-builder` | Generate guided learning tours |
| `graph-reviewer` | Validate graph completeness and referential integrity |
| `graph-reviewer` | Validate graph completeness and referential integrity (runs inline by default; use `--review` for full LLM review) |
File analyzers run in parallel (up to 3 concurrent). Supports incremental updates — only re-analyzes files that changed since the last run.
File analyzers run in parallel (up to 5 concurrent, 20-30 files per batch). Supports incremental updates — only re-analyzes files that changed since the last run.
### Project Structure
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,149 @@
# Design: Dashboard Robustness — Permissive Graph Loading
## Problem
When the LLM agent produces a knowledge-graph.json that deviates from the strict Zod schema, the dashboard shows a blank screen with cryptic Zod error paths. Users don't know whether it's a system bug or an agent generation issue, and their only recourse is a full re-run of `/understand`.
## Goals
1. **Maximize what the user can see** — load valid nodes/edges even if some are broken
2. **Clearly communicate generation issues** — amber warnings (not red errors) with copy-paste-friendly messages
3. **Empower targeted fixes** — users can copy the issue report and ask their agent to fix specific problems instead of a full re-run
## Design
### Three-Layer Robustness Pipeline
```
Raw JSON → Sanitize (Tier 1) → Normalize + Auto-fix (Tier 2) → Validate per-item (Tier 3) → Fatal check (Tier 4) → Dashboard
```
### Tier 1: Sanitize Silently
Common LLM quirks that are pure noise — fix without reporting.
| Issue | Fix |
|-------|-----|
| `null` on optional fields (`filePath`, `lineRange`, `description`, `languageNotes`) | Convert to `undefined` |
| Mixed-case enum strings (`"Forward"`, `"SIMPLE"`) | Lowercase before matching |
### Tier 2: Auto-fix With Info Notice
Recoverable issues — apply sensible defaults, track as `auto-corrected` issues.
| Issue | Default | Notes |
|-------|---------|-------|
| Missing `complexity` | `"moderate"` | Most common LLM omission |
| Missing `tags` | `[]` | Empty is valid |
| Missing `weight` | `0.5` | Middle of 01 range |
| `weight` as string | Coerce to number | e.g., `"0.8"``0.8` |
| Missing `direction` | `"forward"` | Safe default |
| Missing `summary` | Use node `name` | Better than empty |
| `tour: null` / `layers: null` | `[]` | Null vs empty array |
| Complexity aliases | `low/easy→simple`, `medium/intermediate→moderate`, `high/hard→complex` | |
| Direction aliases | `to/outbound→forward`, `from/inbound→backward`, `both→bidirectional` | |
| Existing node/edge type aliases | Already handled by `normalizeGraph` | No change needed |
| Missing node `type` | `"file"` | Safe fallback |
| Missing edge `type` | `"depends_on"` | Generic fallback |
### Tier 3: Drop With Warning
Can't safely guess — remove the item, track as `dropped` issue.
| Issue | Action |
|-------|--------|
| Edge references non-existent node ID | Drop edge |
| Node missing `id` | Drop node |
| Node missing `name` | Drop node |
| Edge missing `source` or `target` | Drop edge |
| Unrecognizable `type` value (not in canonical or alias list) | Drop item |
| `weight` not coercible to number | Drop edge |
### Tier 4: Fatal
Graph is unsalvageable — show red error banner.
| Condition | Message |
|-----------|---------|
| 0 valid nodes after filtering | "No valid nodes found in knowledge graph" |
| Missing `project` metadata entirely | "Missing project metadata" |
| Input is not an object / not valid JSON | "Invalid input format" |
### Return Type
```typescript
interface GraphIssue {
level: 'auto-corrected' | 'dropped' | 'fatal';
category: string; // e.g., "missing-field", "invalid-reference", "type-coercion"
message: string; // human-readable, copy-paste friendly
path?: string; // e.g., "nodes[3].complexity"
}
interface ValidationResult {
success: boolean;
data?: KnowledgeGraph;
issues: GraphIssue[];
fatal?: string;
}
```
### Dashboard UI: WarningBanner Component
**New component** in `packages/dashboard/src/components/WarningBanner.tsx`.
**Visual design:**
- **Amber/gold theme** — `bg-amber-900/20`, `border-amber-700`, `text-amber-200`
- Matches dashboard's gold accent aesthetic; signals "generation quality issue" not "system crash"
- **Collapsed by default** — summary line: "Knowledge graph loaded with 5 auto-corrections and 2 dropped items"
- **Expandable** — click to reveal categorized issue list
- **Copy button** — one-click copies the full issue report as a pre-formatted message
- **Actionable footer** — tells users to copy issues and ask their agent to fix them
**Copy-paste output format:**
```
The following issues were found in your knowledge-graph.json.
These are LLM generation errors — not a system bug.
You can ask your agent to fix these specific issues in the knowledge-graph.json file:
[Auto-corrected] nodes[3] ("AuthService"): missing "complexity" — defaulted to "moderate"
[Auto-corrected] nodes[7] ("utils.ts"): missing "tags" — defaulted to []
[Auto-corrected] edges[12]: weight was string "0.8" — coerced to number
[Dropped] edges[5]: target "file:src/nonexistent.ts" does not exist in nodes
[Dropped] nodes[14]: missing required "id" field — cannot recover
```
**Fatal errors** stay red (`bg-red-900/30`) with message: "Knowledge graph is unsalvageable: [reason]. Please re-run `/understand` to generate a new one."
**Existing red error banner** for network/JSON-parse errors stays as-is (those ARE system/infra issues).
### App.tsx Changes
- On `result.success === true` with `result.issues.length > 0`: show `WarningBanner` with issues, load graph normally
- On `result.fatal`: show existing red banner with fatal message
- `console.warn` for auto-corrected items, `console.error` for dropped items
### Test Coverage
All in `packages/core/src/__tests__/schema.test.ts`:
- **Tier 1:** `null` optional fields silently become `undefined`
- **Tier 2:** Missing `complexity`/`tags`/`weight`/`direction`/`summary` get defaults; issues tracked
- **Tier 2:** String `weight` coerced; complexity/direction aliases mapped
- **Tier 3:** Dangling edge references dropped; nodes missing `id` dropped; issues recorded
- **Tier 4:** Empty graph after filtering → fatal; missing `project` → fatal
- **Integration:** Graph with mixed good/bad nodes → loads with correct node count + correct issues list
### Files Changed
| File | Change |
|------|--------|
| `packages/core/src/schema.ts` | Sanitize, expanded normalize, permissive validate, new types |
| `packages/dashboard/src/components/WarningBanner.tsx` | New component |
| `packages/dashboard/src/App.tsx` | Wire issues to WarningBanner |
| `packages/core/src/__tests__/schema.test.ts` | Tests for all tiers |
### Files NOT Changed
- Agent prompts (can be tightened later as a separate effort)
- GraphView / store logic (they already handle valid `KnowledgeGraph` objects)
- Existing node/edge type alias maps (preserved, extended around)
@@ -0,0 +1,415 @@
# Theme System Design
## Overview
Add a curated theme preset system with accent color customization to the dashboard. Users select from 5 hand-designed theme presets and optionally swap the accent color within each preset from a set of 8-10 tested swatches.
### Goals
- Support 5 theme presets: Dark Gold (current), Dark Ocean, Dark Forest, Dark Rose, Light Minimal
- Allow accent color customization within each preset (curated swatches only, no free picker)
- Persist theme preference in both `localStorage` (personal) and `meta.json` (project-level)
- Maintain visual coherence — no user-breakable color combinations
- Zero-reload theme switching via CSS variable injection at runtime
### Non-Goals
- Free color picker (risk of ugly/unreadable combos)
- Per-component color overrides
- Multiple simultaneous themes
---
## 1. Theme Presets & Color System
### 1.1 Preset Definitions
Each preset is a complete mapping of CSS variable names to values. The 5 presets:
| Token | Dark Gold | Dark Ocean | Dark Forest | Dark Rose | Light Minimal |
|-------|-----------|------------|-------------|-----------|---------------|
| `--color-root` | `#0a0a0a` | `#0a0e14` | `#0a100a` | `#100a0a` | `#f5f3f0` |
| `--color-surface` | `#111111` | `#111820` | `#111811` | `#181111` | `#eae7e3` |
| `--color-elevated` | `#1a1a1a` | `#1a222c` | `#1a241a` | `#221a1a` | `#ffffff` |
| `--color-panel` | `#141414` | `#141c24` | `#141c14` | `#1c1414` | `#f0ede9` |
| `--color-gold`* | `#d4a574` | `#5ba4cf` | `#5ea67a` | `#cf7a8a` | `#4a6fa5` |
| `--color-gold-dim`* | `#c9a96e` | `#4e93ba` | `#4e9468` | `#b96e7e` | `#3d5f8f` |
| `--color-gold-bright`* | `#e8c49a` | `#7abce0` | `#78c492` | `#e094a4` | `#6088bf` |
| `--color-text-primary` | `#f5f0eb` | `#e8edf2` | `#ebf0eb` | `#f2e8ea` | `#1a1a1a` |
| `--color-text-secondary` | `#a39787` | `#87939f` | `#87a38f` | `#9f8790` | `#6b6b6b` |
| `--color-text-muted` | `#6b5f53` | `#536b7a` | `#536b5a` | `#6b535a` | `#a0a0a0` |
| `--color-border-subtle` | `rgba(212,165,116,0.12)` | `rgba(91,164,207,0.12)` | `rgba(94,166,122,0.12)` | `rgba(207,122,138,0.12)` | `rgba(74,111,165,0.10)` |
| `--color-border-medium` | `rgba(212,165,116,0.25)` | `rgba(91,164,207,0.25)` | `rgba(94,166,122,0.25)` | `rgba(207,122,138,0.25)` | `rgba(74,111,165,0.18)` |
*\* The CSS variable names stay as `--color-gold`, `--color-gold-dim`, `--color-gold-bright` even for non-gold themes. They represent "the accent color" generically. Renaming them to `--color-accent` is a refactor we can do, but not required — the variable name is an implementation detail invisible to users.*
**Decision: Rename `--color-gold*` to `--color-accent*`** to avoid confusion. This is a find-and-replace across the codebase with no behavioral change.
### 1.2 Glass Effects
Glass effects derive from base colors and need per-preset values:
| Token | Dark themes | Light Minimal |
|-------|-------------|---------------|
| `--glass-bg` | `rgba(20,20,20,0.8)` | `rgba(255,255,255,0.8)` |
| `--glass-bg-heavy` | `rgba(20,20,20,0.95)` | `rgba(255,255,255,0.95)` |
| `--glass-border` | `rgba(accent,0.1)` | `rgba(accent,0.08)` |
| `--glass-border-heavy` | `rgba(accent,0.15)` | `rgba(accent,0.12)` |
The `.glass` and `.glass-heavy` CSS classes will reference these variables instead of hardcoded values.
### 1.3 Scrollbar & Glow Colors
These also derive from the accent color and need to become CSS variables:
| Token | Purpose |
|-------|---------|
| `--scrollbar-thumb` | `rgba(accent, 0.2)` |
| `--scrollbar-thumb-hover` | `rgba(accent, 0.35)` |
| `--glow-color` | `rgba(accent, 0.4)` for node selection glow |
| `--glow-pulse` | `rgba(accent, 0.6)` for tour highlight pulse |
### 1.4 Node-Type & Diff Colors
These are **semantic** and stay fixed across all dark themes:
| Variable | Value | Purpose |
|----------|-------|---------|
| `--color-node-file` | `#4a7c9b` | File nodes |
| `--color-node-function` | `#5a9e6f` | Function nodes |
| `--color-node-class` | `#8b6fb0` | Class nodes |
| `--color-node-module` | `#c9a06c` | Module nodes |
| `--color-node-concept` | `#b07a8a` | Concept nodes |
| `--color-diff-changed` | `#e05252` | Changed nodes |
| `--color-diff-affected` | `#d4a030` | Affected nodes |
For **Light Minimal only**, these are slightly desaturated/darkened to maintain readability on light backgrounds:
| Variable | Light Minimal Value |
|----------|-------------------|
| `--color-node-file` | `#3a6a87` |
| `--color-node-function` | `#488a5b` |
| `--color-node-class` | `#755d99` |
| `--color-node-module` | `#a88a56` |
| `--color-node-concept` | `#966674` |
### 1.5 Accent Swatches
Each preset offers 8 accent color options. The first is the "native" default for that preset. Each swatch provides 3 values (accent, accent-dim, accent-bright) plus auto-derived border and glass opacities.
**Dark theme accent swatches** (shared across all 4 dark presets):
| Name | Accent | Dim | Bright |
|------|--------|-----|--------|
| Gold | `#d4a574` | `#c9a96e` | `#e8c49a` |
| Ocean | `#5ba4cf` | `#4e93ba` | `#7abce0` |
| Emerald | `#5ea67a` | `#4e9468` | `#78c492` |
| Rose | `#cf7a8a` | `#b96e7e` | `#e094a4` |
| Purple | `#9b7abf` | `#876bb0` | `#b494d4` |
| Amber | `#c9963a` | `#b5862e` | `#ddb05c` |
| Teal | `#4aab9a` | `#3d9686` | `#68c4b4` |
| Silver | `#a0a8b0` | `#8e959c` | `#b8bfc6` |
**Light Minimal accent swatches:**
| Name | Accent | Dim | Bright |
|------|--------|-----|--------|
| Indigo | `#4a6fa5` | `#3d5f8f` | `#6088bf` |
| Ocean | `#3a8ab5` | `#2e7aa0` | `#55a0cc` |
| Emerald | `#3a8a5c` | `#2e7a4e` | `#55a878` |
| Rose | `#a5566a` | `#8f4a5c` | `#bf6e82` |
| Purple | `#6b5a9e` | `#5c4d8a` | `#8474b5` |
| Amber | `#9e7a30` | `#8a6a28` | `#b5923e` |
| Teal | `#2e8a7a` | `#267a6c` | `#45a595` |
| Slate | `#5a6570` | `#4e5860` | `#6e7a85` |
### 1.6 Border & Glass Derivation
When an accent swatch is selected, borders and glass effects are auto-derived:
```typescript
function deriveFromAccent(accentHex: string, isDark: boolean) {
return {
borderSubtle: `rgba(${hexToRgb(accentHex)}, ${isDark ? 0.12 : 0.10})`,
borderMedium: `rgba(${hexToRgb(accentHex)}, ${isDark ? 0.25 : 0.18})`,
glassBorder: `rgba(${hexToRgb(accentHex)}, ${isDark ? 0.1 : 0.08})`,
glassBorderHeavy: `rgba(${hexToRgb(accentHex)}, ${isDark ? 0.15 : 0.12})`,
scrollbarThumb: `rgba(${hexToRgb(accentHex)}, 0.2)`,
scrollbarThumbHover: `rgba(${hexToRgb(accentHex)}, 0.35)`,
glowColor: `rgba(${hexToRgb(accentHex)}, 0.4)`,
glowPulse: `rgba(${hexToRgb(accentHex)}, 0.6)`,
};
}
```
---
## 2. Architecture & Data Flow
### 2.1 File Structure
```
packages/dashboard/src/
themes/
types.ts # ThemePreset, AccentSwatch, ThemeConfig types
presets.ts # 5 preset definitions + accent swatch arrays
theme-engine.ts # applyTheme(), deriveFromAccent(), hexToRgb()
ThemeContext.tsx # React context + provider + useTheme() hook
components/
ThemePicker.tsx # Popover UI for preset + accent selection
```
### 2.2 Type Definitions
```typescript
// themes/types.ts
export type PresetId = 'dark-gold' | 'dark-ocean' | 'dark-forest' | 'dark-rose' | 'light-minimal';
export interface ThemePreset {
id: PresetId;
name: string; // Display name: "Dark Gold"
isDark: boolean; // true for dark themes, false for light
colors: Record<string, string>; // CSS variable name -> value (without --)
accentSwatches: AccentSwatch[];
defaultAccentId: string; // Which swatch is the native default
}
export interface AccentSwatch {
id: string; // e.g. 'gold', 'ocean'
name: string; // Display name: "Gold"
accent: string; // Primary accent hex
accentDim: string; // Dimmed accent hex
accentBright: string; // Bright accent hex
}
export interface ThemeConfig {
presetId: PresetId;
accentId: string; // Selected accent swatch ID
}
```
### 2.3 Theme Engine
The theme engine is a pure function layer (no React dependency):
```typescript
// themes/theme-engine.ts
export function applyTheme(config: ThemeConfig): void {
const preset = getPreset(config.presetId);
const accent = getAccent(preset, config.accentId);
// 1. Apply base preset colors
for (const [key, value] of Object.entries(preset.colors)) {
document.documentElement.style.setProperty(`--color-${key}`, value);
}
// 2. Override accent colors from swatch
document.documentElement.style.setProperty('--color-accent', accent.accent);
document.documentElement.style.setProperty('--color-accent-dim', accent.accentDim);
document.documentElement.style.setProperty('--color-accent-bright', accent.accentBright);
// 3. Apply derived values (borders, glass, scrollbar, glow)
const derived = deriveFromAccent(accent.accent, preset.isDark);
for (const [key, value] of Object.entries(derived)) {
document.documentElement.style.setProperty(`--${key}`, value);
}
// 4. Set data-theme attribute for any CSS-only selectors needed
document.documentElement.setAttribute('data-theme', preset.isDark ? 'dark' : 'light');
}
```
### 2.4 React Context
```typescript
// themes/ThemeContext.tsx
interface ThemeContextValue {
config: ThemeConfig;
preset: ThemePreset;
setPreset: (presetId: PresetId) => void;
setAccent: (accentId: string) => void;
}
```
The provider:
1. On mount: resolves theme from `localStorage` > `meta.json` field in loaded graph > default (`dark-gold`)
2. Calls `applyTheme()` on every config change
3. Persists to `localStorage` on every change
4. Does NOT write to `meta.json` from the dashboard (the dashboard is read-only for meta.json; meta.json is written by the CLI/plugin side)
### 2.5 Integration with Zustand Store
The theme system is **separate from the Zustand store** — it uses its own React context. Rationale:
- Theme state is orthogonal to graph/UI state
- Theme needs to apply before the graph even loads (avoid flash of wrong theme)
- Keeps the store focused on graph interaction
The store does NOT gain any theme-related fields.
---
## 3. UI Components
### 3.1 Theme Picker Button (Header)
A small palette icon button in the top header bar, positioned after existing controls (PersonaSelector, DiffToggle, etc.).
- Click opens a popover/dropdown panel
- Popover has two sections:
- **Presets**: 5 cards/buttons showing preset name + small color preview circles
- **Accent Colors**: row of 8 color circles for the active preset
- Active preset and accent are highlighted with a ring/check
- Selecting a preset instantly applies it; selecting an accent instantly applies it
- Clicking outside or pressing Escape closes the popover
### 3.2 Preset Preview
Each preset card shows:
- Name (e.g., "Dark Gold")
- 3-4 small circles showing root, surface, and accent colors as a visual preview
- Check mark or ring on the active one
### 3.3 Accent Swatch Row
- 8 small filled circles in a horizontal row
- Tooltip or label on hover showing the accent name
- Active one has a ring/border indicator
### 3.4 Transitions
When switching themes:
- CSS variables update instantly (no transition needed for most properties)
- Optionally add a subtle `transition: background-color 0.2s, color 0.2s` on `html` for a smooth feel
- No page reload required
---
## 4. Persistence & Resolution
### 4.1 Storage Locations
| Location | Format | Written by | Read by |
|----------|--------|-----------|---------|
| `localStorage` key: `ua-theme` | `JSON.stringify(ThemeConfig)` | Dashboard (on every change) | Dashboard (on mount) |
| `.understand-anything/meta.json` | `{ ..., theme?: ThemeConfig }` | CLI/plugin (during analysis or explicit set) | Dashboard (on mount, as fallback) |
### 4.2 Resolution Order
```
1. localStorage('ua-theme') → user's personal preference (wins)
2. meta.json.theme → project-level default (fallback)
3. { presetId: 'dark-gold', accentId: 'gold' } → hard default
```
### 4.3 meta.json Schema Extension
Extend `AnalysisMeta` in `packages/core/src/types.ts`:
```typescript
export interface AnalysisMeta {
lastAnalyzedAt: string;
gitCommitHash: string;
version: string;
analyzedFiles: number;
theme?: ThemeConfig; // NEW — optional, project-level theme preference
}
```
### 4.4 Dashboard Reads meta.json Theme
The dashboard currently loads `/knowledge-graph.json` on mount. It also needs to load `/meta.json` (or the theme field can be embedded in `knowledge-graph.json`).
**Decision:** Load `/meta.json` separately — it's a small file and keeps concerns separated. The dashboard fetches `/meta.json` on mount, extracts the `theme` field if present, and uses it as fallback when `localStorage` has no theme.
---
## 5. Hardcoded Color Consolidation
### 5.1 Problem
Many components use hardcoded RGBA values instead of CSS variables:
- `rgba(212,165,116,0.3)` scattered in GraphView, CustomNode, etc.
- `rgba(20,20,20,0.8)` in glass effects
- `rgba(224,82,82,0.25)` in diff overlays
These won't respond to theme changes.
### 5.2 Solution
Before implementing theme switching, consolidate all hardcoded color references:
1. **Audit**: grep for hardcoded hex/rgba values in component files
2. **Replace with CSS variables**: create new variables where needed (e.g., `--edge-color`, `--edge-color-dim`)
3. **Glass classes**: update `.glass` and `.glass-heavy` in `index.css` to use variables
4. **Scrollbar**: update scrollbar styles to use variables
5. **Glow effects**: update `.node-glow`, `.diff-changed-glow`, `.diff-affected-glow` to use variables
Key hardcoded patterns to consolidate:
| Hardcoded Value | Replace With |
|-----------------|-------------|
| `rgba(212,165,116,X)` | `var(--color-accent)` with opacity modifier or dedicated variable |
| `rgba(20,20,20,0.8)` | `var(--glass-bg)` |
| `rgba(20,20,20,0.95)` | `var(--glass-bg-heavy)` |
| `color="rgba(212,165,116,0.15)"` in React Flow | Variable reference |
| Amber colors in WarningBanner | Keep as-is (semantic warning color, theme-independent) |
### 5.3 CSS Variable Rename
Rename throughout codebase:
- `--color-gold` -> `--color-accent`
- `--color-gold-dim` -> `--color-accent-dim`
- `--color-gold-bright` -> `--color-accent-bright`
- All Tailwind class usages: `text-gold` -> `text-accent`, `bg-gold` -> `bg-accent`, etc.
---
## 6. Light Theme Considerations
The Light Minimal theme requires special attention:
### 6.1 Inverted Contrast
- Text is dark on light backgrounds (flipped from dark themes)
- Borders need lower opacity to avoid looking harsh
- Glass effects use white-based rgba instead of black-based
### 6.2 Node Colors
Slightly darker/desaturated variants for readability on light backgrounds (see Section 1.4).
### 6.3 data-theme Attribute
Set `data-theme="light"` on `<html>` for any styles that can't be handled purely through CSS variables (e.g., third-party component overrides, box-shadow directions).
### 6.4 React Flow
React Flow's background, minimap, and edge colors all need to respect the theme. The existing `!important` override on `.react-flow__background` already uses `var(--color-root)`, which is good. MiniMap colors in GraphView.tsx are currently hardcoded and need to be updated.
---
## 7. Summary of Changes by Package
### packages/core
- Extend `AnalysisMeta` type with optional `theme?: ThemeConfig`
- Export `ThemeConfig` and `PresetId` types from `./types` subpath
### packages/dashboard
- New `themes/` directory with types, presets, engine, and context
- New `ThemePicker` component in header
- Rename `--color-gold*` to `--color-accent*` across all files
- Consolidate hardcoded RGBA values into CSS variables
- Update `index.css`: glass classes, scrollbar, glow effects to use variables
- Update `App.tsx`: wrap with ThemeProvider, add ThemePicker to header, fetch meta.json
- Update components with hardcoded colors: GraphView, CustomNode, LayerLegend, etc.
---
## 8. Out of Scope
- Theme import/export
- Custom theme creation UI
- Per-node color customization
- Animated theme transitions beyond simple CSS transitions
- Syncing theme across browser tabs (nice-to-have for later)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,395 @@
# Token Reduction Design
**Date:** 2026-03-27
**Status:** Draft
**Goal:** Reduce total token cost of `/understand` by ~85-90% on large codebases (200+ files)
---
## Problem
For large codebases, the `/understand` pipeline spends the vast majority of its tokens on **repeated context injection**. The same data is sent to every subagent independently, even when that data could be computed once and shared.
### Token cost breakdown (500-file TypeScript+React project, baseline)
| Source | Phase | Tokens (input) | % of total |
|---|---|---|---|
| `allProjectFiles` list × 67 batches | Phase 2 | ~167,000 | ~50% |
| `file-analyzer-prompt.md` × 67 batches | Phase 2 | ~134,000 | ~40% |
| Language/framework addendums × 67 batches | Phase 2 | ~68,000 | ~20% |
| Tour builder payload (all nodes + edges) | Phase 5 | ~80,000 | ~24% |
| Graph reviewer (assembled graph + inventory) | Phase 6 | ~58,000 | ~17% |
| Architecture analyzer payload | Phase 4 | ~22,000 | ~7% |
| **Total** | | **~529,000** | |
The root cause: **Phase 2 runs 67 batches (at 5-10 files each), and every single batch receives the full 500-file list for import resolution.** The file list alone costs ~2,500 tokens × 67 repetitions = 167,000 tokens on input, doing work that is entirely redundant between batches.
---
## Goals
- Reduce total input tokens by 85%+ on a 500-file project
- No degradation in graph quality for standard projects
- Preserve the `--full` / incremental / scope flags
- Maintain backward compatibility with existing `knowledge-graph.json` output schema
---
## Changes
Five changes compose the full approach (C1C5). Each is independent and can be shipped separately, but all five are needed for the full reduction.
---
### C1 — Pre-resolve imports in the project scanner
**Root cause addressed:** `allProjectFiles` (the entire file list) is injected into every file-analyzer batch solely so each batch's extraction script can resolve relative imports. This is redundant: the full file list is available during Phase 1, and import resolution is deterministic. It should happen once, not 67 times.
**Change:** Extend the Phase 1 scanner script to also parse import statements from every source file and resolve relative imports against the discovered file list. The resolved results are written into `scan-result.json` as a new `importMap` field. File-analyzer batches then receive only their own batch's pre-resolved imports — not the full file list.
#### Scanner output addition
`scan-result.json` gains:
```json
{
"importMap": {
"src/index.ts": ["src/utils.ts", "src/config.ts"],
"src/utils.ts": [],
"src/components/App.tsx": ["src/hooks/useAuth.ts", "src/store/index.ts"]
}
}
```
- Keys are project-relative paths (matching `files[*].path`)
- Values are resolved project-relative paths only (external/unresolvable imports are omitted)
- External imports (`node_modules`, unresolvable paths) are excluded from the map entirely
#### Scanner script additions (Phase 1 Step 8)
After the existing 7 steps, the scanner script adds a new step:
```
Step 8 — Import Resolution
For each file in the discovered source list:
1. Read the file content
2. Extract import statements (language-specific patterns per Step 3's language detection):
- TypeScript/JavaScript: `import ... from '...'`, `require('...')`
- Python: `import ...`, `from ... import ...`
- Go: `import "..."` blocks
- Rust: `use ...` statements
- Java/Kotlin: `import ...` statements
- Ruby: `require`, `require_relative`
3. For each relative import (starts with `./` or `../`):
a. Compute the resolved path from the current file's directory
b. Normalize to project-relative format
c. Try common extension variants if the import has no extension:
`.ts`, `.tsx`, `.js`, `.jsx`, `/index.ts`, `/index.js`, `/index.tsx`
d. If any variant exists in the discovered file list, record it; otherwise skip
4. For absolute imports (no `.` prefix): skip (external package)
Output the full importMap in the JSON result.
```
#### File-analyzer input schema change
**Before:**
```json
{
"projectRoot": "/path/to/project",
"allProjectFiles": ["src/index.ts", "src/utils.ts", "...500 paths..."],
"batchFiles": [
{"path": "src/index.ts", "language": "typescript", "sizeLines": 150}
]
}
```
**After:**
```json
{
"projectRoot": "/path/to/project",
"batchFiles": [
{"path": "src/index.ts", "language": "typescript", "sizeLines": 150}
],
"batchImportData": {
"src/index.ts": ["src/utils.ts", "src/config.ts"],
"src/components/App.tsx": ["src/hooks/useAuth.ts"]
}
}
```
`allProjectFiles` is removed entirely. `batchImportData` contains only the pre-resolved imports for the files in this batch (sliced from `importMap` by the orchestrator).
#### File-analyzer extraction script change
The extraction script no longer performs import resolution. It:
- Still extracts: functions, classes, exports, metrics (unchanged)
- For imports: reads `batchImportData[file.path]` from the input JSON — no cross-referencing needed
- The `imports` array in each file result becomes: `batchImportData[file.path]` mapped to import edge objects with `resolvedPath` already populated, `isExternal: false`
#### SKILL.md Phase 2 change
Remove the `allProjectFiles` injection from the batch dispatch prompt. Replace with a per-batch `batchImportData` slice:
```
For each batch, slice importData from the importMap read in Phase 1:
batchImportData = { [file.path]: importMap[file.path] ?? [] }
for each file in this batch
```
#### Token savings estimate
| | Batches | Tokens/batch | Total |
|---|---|---|---|
| Before | 67 | ~2,500 (file list) | ~167,500 |
| After (C1 alone) | 67 | ~200 (batch importData) | ~13,400 |
| **Savings** | | | **~154,100** |
---
### C2 — Increase batch size from 5-10 to 20-30 files
**Root cause addressed:** Every batch incurs the full cost of `file-analyzer-prompt.md` (~2,000 tokens) plus the batch dispatch overhead. With 67 batches, this adds up even without `allProjectFiles`. Fewer, larger batches directly reduce this repetition.
**Change:** In SKILL.md Phase 2, change the batch size guidance:
- **Before:** "Batch the file list from Phase 1 into groups of **5-10 files each**"
- **After:** "Batch the file list from Phase 1 into groups of **20-30 files each** (aim for ~25 per batch)"
Also update the concurrency limit from 3 to **5** concurrent batches. Fewer total batches means we can afford more parallelism without overwhelming the system.
#### Trade-offs
| | Smaller batches (current) | Larger batches (new) |
|---|---|---|
| Files per batch | 5-10 | 20-30 |
| Total batches (500 files) | ~67 | ~20 |
| Prompt repetition | 67× | 20× |
| Quality risk | Lower (focused) | Slightly higher (more files per subagent) |
| Concurrency | 3 | 5 |
Quality risk is low: each subagent still operates on distinct, non-overlapping file groups. The extraction script is deterministic regardless of batch size. Semantic analysis (summaries, tags) may be marginally less focused, but the quality difference is negligible in practice for well-structured files.
#### Token savings estimate (combined with C1)
| | Batches | Tokens/batch (prompt) | Total |
|---|---|---|---|
| Before (C1 only) | 67 | ~2,000 | ~134,000 |
| After (C1+C2) | 20 | ~2,000 | ~40,000 |
| **Savings from C2** | | | **~94,000** |
C1+C2 combined eliminate ~248,000 tokens from Phase 2 (down from ~301,500 to ~53,500, a ~82% Phase 2 reduction).
---
### C3 — Remove language/framework addendums from file-analyzer batches
**Root cause addressed:** `languages/typescript.md` (~600 tokens) and `frameworks/react.md` (~700 tokens) are read and injected into every file-analyzer batch prompt. For a TypeScript+React project with 20 batches (after C2), this costs 20 × 1,300 = 26,000 additional tokens — and the model already has deep knowledge of these languages from training.
**Change:** Stop injecting addendum files into Phase 2 batch prompts entirely. The addendums remain injected into Phase 4 (architecture analyzer) where there is only **one** subagent call, making the cost acceptable.
Instead, add a compact "Language and Framework Hints" reference section directly into `file-analyzer-prompt.md`. This section is a distilled, one-time addition (~150 tokens total) that captures the most useful patterns from all addendums in a concise lookup table.
#### New section in `file-analyzer-prompt.md` (replace addendum injection)
```markdown
## Language and Framework Quick Reference
Use these hints to improve tag and edge accuracy. These supplement your training knowledge.
| Signal | Tag(s) | Note |
|---|---|---|
| File in `hooks/`, exports function starting with `use` | `hook`, `service` | React custom hook |
| File in `contexts/`, exports a Provider | `service`, `state` | React context |
| File in `pages/` or `views/` | `ui`, `routing` | Page-level component |
| File in `store/`, `slices/`, `reducers/` | `state` | State management |
| File in `services/`, `api/` | `service` | Data-fetching / API client |
| `__init__.py` with re-exports | `entry-point`, `barrel` | Python package root |
| `manage.py` at project root | `entry-point` | Django management entry |
| File named `mod.rs` | `barrel` | Rust module barrel |
| File named `main.go` in `cmd/` | `entry-point` | Go binary entry |
For React: create `depends_on` edges from components to hooks they call. Create `publishes`/`subscribes` edges for Context provider/consumer patterns.
```
#### SKILL.md Phase 2 change
Remove steps 2 and 3 from the "Build the combined prompt template" block:
- **Remove:** Step 2 (Language context injection — read `./languages/<language-id>.md` per detected language)
- **Remove:** Step 3 (Framework addendum injection — read `./frameworks/<framework-id>.md` per detected framework)
- **Keep:** Step 1 (Read the base template at `./file-analyzer-prompt.md`)
The addendum injection steps **remain unchanged** in Phase 4 (architecture analyzer), since they run once.
#### Token savings estimate
| | Batches | Addendum tokens/batch | Total |
|---|---|---|---|
| Before (after C2) | 20 | ~1,300 (TS+React) | ~26,000 |
| After | 20 | ~150 (inline hints) | ~3,000 |
| **Savings** | | | **~23,000** |
---
### C4 — Slim Phase 4 and Phase 5 payloads
**Root cause addressed:** Phase 5 (tour builder) receives all nodes (file + function + class) and all edges (imports + contains + calls + exports + ...). For a 500-file project, this can include 1,500+ nodes and 3,000+ edges. Most of this data is not needed for tour design.
#### Phase 4 (Architecture Analyzer) — minor trim
Phase 4 already only sends file-type nodes, which is correct. Minor change: explicitly strip `languageNotes` from each node object in the payload (it's not useful for layer assignment and can be verbose). Also strip `name` — it is always derivable as the basename of `filePath`.
**Before per node:** `{id, name, filePath, summary, tags, complexity, languageNotes?}`
**After per node:** `{id, filePath, summary, tags}`
Savings: ~15-20% fewer tokens per node, ~3,0005,000 tokens total for Phase 4.
#### Phase 5 (Tour Builder) — major trim
Three changes to what the orchestrator injects into the tour-builder subagent:
**1. File nodes only (strip function/class nodes)**
The tour references node IDs for wayfinding. In practice the tour always references `file:` nodes — function and class nodes are visible in the dashboard's NodeInfo sidebar once a file is selected, but the tour itself navigates at the file level.
- **Before:** all nodes (file + function + class) — for 500 files, maybe 1,500+ nodes
- **After:** file-type nodes only — 500 nodes
**2. Slim node format**
The tour builder script only uses node IDs, names, and types for graph computation. Summaries and tags are used in Phase 2 (pedagogical narrative writing). Strip heavy optional fields from the injected payload:
- **Before per node:** `{id, name, filePath, summary, type, tags, complexity, languageNotes?}`
- **After per node:** `{id, name, filePath, summary, type}` (drop tags, complexity, languageNotes)
**3. Slim edges (imports + calls only) and slim layers**
The tour's BFS traversal only traverses `imports` and `calls` edges. `contains`, `exports`, `tested_by`, `depends_on`, and other edge types add no value to the traversal and inflate the payload.
- **Before edges:** all edge types (~3,000+ edges including all `contains` edges to function/class nodes)
- **After edges:** only `imports` and `calls` edge types (~400800 edges for typical projects)
For layers, the tour builder uses layer data only to inform the tour's narrative arc (which layer to introduce first, second, etc.). It does not need the full `nodeIds` arrays — those can be very large.
- **Before per layer:** `{id, name, description, nodeIds: [...hundreds of IDs]}`
- **After per layer:** `{id, name, description}` (drop nodeIds)
#### Token savings estimate (Phase 5)
| Data | Before | After |
|---|---|---|
| Node count | ~1,500 × ~180 chars | ~500 × ~120 chars |
| Node tokens | ~67,500 | ~15,000 |
| Edge count | ~3,000 × ~80 chars | ~600 × ~80 chars |
| Edge tokens | ~60,000 | ~12,000 |
| Layer tokens | ~5,000 | ~500 |
| **Phase 5 total** | **~132,500** | **~27,500** |
| **Savings** | | **~105,000** |
#### SKILL.md changes
In **Phase 4** dispatch prompt template, update the file node format:
```
File nodes:
[list of {id, filePath, summary, tags} for all file-type nodes]
```
In **Phase 5** dispatch prompt template, update all three payload specs:
```
Nodes (file nodes only):
[list of {id, name, filePath, summary, type} for all file-type nodes only — do NOT include function or class nodes]
Key edges (imports and calls only):
[list of edges where type is "imports" or "calls" only]
Layers:
[list of {id, name, description} — omit nodeIds]
```
---
### C5 — Gate the graph-reviewer subagent behind `--review`
**Root cause addressed:** The graph-reviewer subagent (Phase 6) reads the entire assembled graph (~500 nodes, all edges, layers, tour) and runs a LLM-powered validation. However, its Phase 1 is entirely a deterministic script, and its Phase 2 is a simple threshold decision: if `issues.length === 0`, approve. There is no LLM judgment needed for the happy path.
**Change:** By default, skip the graph-reviewer subagent. The orchestrator performs inline deterministic validation using a pre-written script. Only when `--review` is explicitly passed in `$ARGUMENTS` does the full LLM reviewer subagent run.
#### Default path (no `--review`)
In Phase 6, instead of dispatching the graph-reviewer subagent, the orchestrator:
1. Writes a compact validation script inline (embedded in SKILL.md, ~50 lines of Node.js):
- Check: every edge source/target references a real node ID
- Check: every file node appears in exactly one layer
- Check: every tour step nodeId exists
- Check: no duplicate node IDs
- Check: required fields present on nodes and edges
2. Runs the script against `assembled-graph.json`
3. If `issues.length === 0`: proceed to Phase 7 (save)
4. If `issues.length > 0`: apply the same automated fixes as before (remove dangling edges, fill defaults), then save
This is sufficient for standard runs. The LLM reviewer adds value for catching subtle quality issues (generic summaries, orphan nodes, tour step coherence) — but those are nice-to-have, not blocking.
#### `--review` path
When `--review` is in `$ARGUMENTS`, the full graph-reviewer subagent runs as it does today. No change to that code path.
#### Token savings estimate
| Path | Tokens |
|---|---|
| Current (always runs LLM reviewer) | ~58,000 input + ~500 output |
| Default (inline script, no LLM) | ~0 |
| `--review` (unchanged) | ~58,000 (same as current) |
| **Savings for default runs** | **~58,500** |
---
## Combined savings summary
| Change | Tokens before | Tokens after | Savings |
|---|---|---|---|
| C1+C2: import map + batch consolidation | ~301,500 | ~53,500 | ~248,000 |
| C3: remove addendums from batches | ~26,000 | ~3,000 | ~23,000 |
| C4: slim Phase 4+5 payloads | ~154,500 | ~33,000 | ~121,500 |
| C5: gate reviewer (default path) | ~58,500 | ~0 | ~58,500 |
| **Total** | **~540,500** | **~89,500** | **~451,000 (~83%)** |
Estimates are for a 500-file TypeScript+React project. Actual savings scale with project size — a 1,000-file project would see proportionally larger savings from C1+C2 (more batches = more repetition eliminated).
---
## File changes
| File | Change |
|---|---|
| `skills/understand/project-scanner-prompt.md` | Add Step 8 (import resolution); add `importMap` to output schema |
| `skills/understand/file-analyzer-prompt.md` | Replace `allProjectFiles` with `batchImportData` in input schema; update extraction script to use pre-resolved imports; add compact Language/Framework Quick Reference section; remove addendum injection steps |
| `skills/understand/SKILL.md` | Phase 1: note importMap in scan result; Phase 2: remove addendum injection (steps 2+3), increase batch size 5-10→20-30, increase concurrency 3→5, replace `allProjectFiles` injection with `batchImportData` slice; Phase 4: slim node format in dispatch; Phase 5: file nodes only + slim edges + slim layers in dispatch; Phase 6: conditional reviewer — default inline script, `--review` flag for LLM reviewer |
| `skills/understand/architecture-analyzer-prompt.md` | No change (addendums still injected here) |
| `skills/understand/tour-builder-prompt.md` | Update input schema to reflect file-only nodes, imports+calls-only edges, slim layer format |
| `skills/understand/graph-reviewer-prompt.md` | No change (only used when `--review` flag is passed) |
---
## Risks and mitigations
| Risk | Likelihood | Mitigation |
|---|---|---|
| Scanner import resolution misses edge cases (complex re-exports, dynamic imports) | Medium | Log unresolved imports; file-analyzer still uses resolved data and creates edges only for confirmed matches. Missed imports = missing edges, which is same behavior as before for unresolvable imports |
| Larger batches (C2) reduce summary quality | Low | Summary quality is driven by the model's analysis of individual files. Batch size mainly affects how many files share one subagent's context window, not per-file quality. 20-30 files remains well within context limits |
| Stripping function/class nodes from tour (C4) breaks existing tour steps | None | Tour steps reference `file:` node IDs. No existing tour data references function/class nodes at the step level |
| Removing reviewer by default (C5) misses graph errors | Low | The inline deterministic script catches all critical structural issues (dangling refs, missing layers, duplicate IDs). The LLM reviewer's additional value is quality warnings (orphan nodes, generic summaries), which are non-blocking |
| Import map generation slows down Phase 1 | Low | The scanner script already reads all files for line counting. Import parsing adds one regex pass per file — negligible overhead |
---
## Phased rollout recommendation
Given the risk profile, implement in this order:
1. **C5 first** — gate the reviewer, lowest risk, immediate 58K token savings per run
2. **C4** — slim Phase 5 payload, no scanner changes, no quality risk
3. **C3** — remove addendums from batches, add inline hints
4. **C1+C2 together** — scanner changes and batch consolidation, test thoroughly on small/medium/large projects before releasing
@@ -0,0 +1,971 @@
# Token Reduction Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Reduce `/understand` token cost by ~85% on large codebases through import pre-resolution, batch consolidation, addendum removal, payload slimming, and gating the LLM reviewer.
**Architecture:** Five changes (C5 → C4 → C3 → C1+C2) applied in rollout order — lowest risk first. All changes are to prompt/skill markdown files in `understand-anything-plugin/skills/understand/`. No TypeScript source changes required.
**Tech Stack:** Markdown skill files, Node.js inline scripts embedded in SKILL.md, knowledge-graph JSON pipeline.
**Design doc:** `docs/plans/2026-03-27-token-reduction-design.md`
---
## Task 1: C5 — Gate graph-reviewer behind `--review` flag
Replaces the always-on LLM graph-reviewer subagent with a deterministic inline validation script. The LLM reviewer only runs when `--review` is in `$ARGUMENTS`. Saves ~58,500 tokens per default run.
**Files:**
- Modify: `understand-anything-plugin/skills/understand/SKILL.md` (Phase 6, lines 330362)
### Step 1: Open SKILL.md and locate Phase 6
Read the file and find "## Phase 6 — REVIEW" (line 297). Identify steps 36 (lines 330362) which currently always dispatch the LLM graph-reviewer subagent.
### Step 2: Replace Phase 6 steps 36 with conditional reviewer logic
Replace lines 330362 (from "3. Dispatch a subagent using the prompt template" through "6. **If `approved: true`:** Proceed to Phase 7.") with:
```markdown
3. **Check `$ARGUMENTS` for `--review` flag.** Then run the appropriate validation path:
---
#### Default path (no `--review`): inline deterministic validation
Write the following Node.js script to `$PROJECT_ROOT/.understand-anything/tmp/ua-inline-validate.js`:
```javascript
#!/usr/bin/env node
const fs = require('fs');
const graphPath = process.argv[2];
const outputPath = process.argv[3];
try {
const graph = JSON.parse(fs.readFileSync(graphPath, 'utf8'));
const issues = [], warnings = [];
const nodeIds = new Set();
const seen = new Map();
graph.nodes.forEach((n, i) => {
if (!n.id) { issues.push(`Node[${i}] missing id`); return; }
if (!n.type) issues.push(`Node[${i}] '${n.id}' missing type`);
if (!n.name) issues.push(`Node[${i}] '${n.id}' missing name`);
if (!n.summary) issues.push(`Node[${i}] '${n.id}' missing summary`);
if (!n.tags || !n.tags.length) issues.push(`Node[${i}] '${n.id}' missing tags`);
if (seen.has(n.id)) issues.push(`Duplicate node ID '${n.id}' at indices ${seen.get(n.id)} and ${i}`);
else seen.set(n.id, i);
nodeIds.add(n.id);
});
graph.edges.forEach((e, i) => {
if (!nodeIds.has(e.source)) issues.push(`Edge[${i}] source '${e.source}' not found`);
if (!nodeIds.has(e.target)) issues.push(`Edge[${i}] target '${e.target}' not found`);
});
const fileNodes = graph.nodes.filter(n => n.type === 'file').map(n => n.id);
const assigned = new Map();
(graph.layers || []).forEach(layer => {
(layer.nodeIds || []).forEach(id => {
if (!nodeIds.has(id)) issues.push(`Layer '${layer.id}' refs missing node '${id}'`);
if (assigned.has(id)) issues.push(`Node '${id}' appears in multiple layers`);
assigned.set(id, layer.id);
});
});
fileNodes.forEach(id => {
if (!assigned.has(id)) issues.push(`File node '${id}' not in any layer`);
});
(graph.tour || []).forEach((step, i) => {
(step.nodeIds || []).forEach(id => {
if (!nodeIds.has(id)) issues.push(`Tour step[${i}] refs missing node '${id}'`);
});
});
const withEdges = new Set([
...graph.edges.map(e => e.source),
...graph.edges.map(e => e.target)
]);
graph.nodes.forEach(n => {
if (!withEdges.has(n.id)) warnings.push(`Node '${n.id}' has no edges (orphan)`);
});
const stats = {
totalNodes: graph.nodes.length,
totalEdges: graph.edges.length,
totalLayers: (graph.layers || []).length,
tourSteps: (graph.tour || []).length,
nodeTypes: graph.nodes.reduce((a, n) => { a[n.type] = (a[n.type]||0)+1; return a; }, {}),
edgeTypes: graph.edges.reduce((a, e) => { a[e.type] = (a[e.type]||0)+1; return a; }, {})
};
fs.writeFileSync(outputPath, JSON.stringify({ issues, warnings, stats }, null, 2));
process.exit(0);
} catch (err) { process.stderr.write(err.message + '\n'); process.exit(1); }
```
Execute it:
```bash
node $PROJECT_ROOT/.understand-anything/tmp/ua-inline-validate.js \
"$PROJECT_ROOT/.understand-anything/intermediate/assembled-graph.json" \
"$PROJECT_ROOT/.understand-anything/intermediate/review.json"
```
If the script exits non-zero, read stderr, fix the script, and retry once.
---
#### `--review` path: full LLM reviewer
If `--review` IS in `$ARGUMENTS`, dispatch the LLM graph-reviewer subagent as follows:
Dispatch a subagent using the prompt template at `./graph-reviewer-prompt.md`. Read the template file and pass the full content as the subagent's prompt, appending the following additional context:
> **Additional context from main session:**
>
> Phase 1 scan results (file inventory):
> ```json
> [list of {path, sizeLines} from scan-result.json]
> ```
>
> Phase warnings/errors accumulated during analysis:
> - [list any batch failures, skipped files, or warnings from Phases 2-5]
>
> Cross-validate: every file in the scan inventory should have a corresponding `file:` node in the graph. Flag any missing files. Also flag any graph nodes whose `filePath` doesn't appear in the scan inventory.
Pass these parameters in the dispatch prompt:
> Validate the knowledge graph at `$PROJECT_ROOT/.understand-anything/intermediate/assembled-graph.json`.
> Project root: `$PROJECT_ROOT`
> Read the file and validate it for completeness and correctness.
> Write output to: `$PROJECT_ROOT/.understand-anything/intermediate/review.json`
---
4. Read `$PROJECT_ROOT/.understand-anything/intermediate/review.json`.
5. **If `issues` array is non-empty:**
- Review the `issues` list
- Apply automated fixes where possible:
- Remove edges with dangling references
- Fill missing required fields with sensible defaults (e.g., empty `tags` -> `["untagged"]`, empty `summary` -> `"No summary available"`)
- Remove nodes with invalid types
- Re-run the final graph validation after automated fixes
- If critical issues remain after one fix attempt, save the graph anyway but include the warnings in the final report and mark dashboard auto-launch as skipped
6. **If `issues` array is empty:** Proceed to Phase 7.
```
### Step 3: Verify the edit
Re-read SKILL.md lines 297380 and confirm:
- Phase 6 step 3 now checks for `--review` flag
- The inline validation script is present and complete
- The `--review` path still dispatches the LLM subagent identically to before
- Steps 46 handle the `review.json` output the same way as before
### Step 4: Commit
```bash
git add understand-anything-plugin/skills/understand/SKILL.md
git commit -m "perf(understand): gate LLM graph-reviewer behind --review flag, add inline deterministic validation"
```
---
## Task 2: C4a — Slim Phase 4 (architecture) node payload
Removes `name` and `languageNotes` from the file node format injected into the architecture-analyzer subagent. These fields are not needed for architectural layer assignment and add unnecessary tokens.
**Files:**
- Modify: `understand-anything-plugin/skills/understand/SKILL.md` (Phase 4, around line 188196)
### Step 1: Locate the Phase 4 dispatch prompt in SKILL.md
Find the block starting "Pass these parameters in the dispatch prompt:" under Phase 4 (around line 181). Look for:
```
> File nodes:
> ```json
> [list of {id, name, filePath, summary, tags} for all file-type nodes]
> ```
```
### Step 2: Update the file node format
Change the file nodes line from:
```
> [list of {id, name, filePath, summary, tags} for all file-type nodes]
```
To:
```
> [list of {id, filePath, summary, tags} for all file-type nodes — omit name, complexity, languageNotes]
```
### Step 3: Verify
Re-read Phase 4 and confirm the node format line is updated. Import edges line below it (`[list of edges with type "imports"]`) is unchanged.
### Step 4: Commit
```bash
git add understand-anything-plugin/skills/understand/SKILL.md
git commit -m "perf(understand): slim Phase 4 architecture payload — drop redundant node fields"
```
---
## Task 3: C4b — Slim Phase 5 (tour builder) payload
Phase 5 currently injects all nodes (including function/class), all edge types, and full layer objects (with nodeIds arrays). Only file nodes, import+calls edges, and slim layers are needed for tour design. This is the largest single payload change, saving ~105,000 tokens on a 500-file project.
**Files:**
- Modify: `understand-anything-plugin/skills/understand/SKILL.md` (Phase 5, lines 257270)
- Modify: `understand-anything-plugin/skills/understand/tour-builder-prompt.md` (input schema)
### Step 1: Locate the Phase 5 dispatch prompt in SKILL.md
Find the block starting with (around line 257):
```
> Nodes (summarized):
> ```json
> [list of {id, name, filePath, summary, type} for key nodes]
> ```
>
> Layers:
> ```json
> [layers from Phase 4]
> ```
>
> Key edges:
> ```json
> [imports and calls edges]
> ```
```
### Step 2: Replace all three payload sections
Replace those lines with:
```markdown
> Nodes (file nodes only):
> ```json
> [list of {id, name, filePath, summary, type} for file-type nodes ONLY — do NOT include function or class nodes]
> ```
>
> Layers:
> ```json
> [list of {id, name, description} for each layer — omit nodeIds]
> ```
>
> Edges (imports and calls only):
> ```json
> [list of edges where type is "imports" or "calls" only — exclude all other edge types]
> ```
```
### Step 3: Update tour-builder-prompt.md input schema
Open `tour-builder-prompt.md` and find the "Script Requirements" section (around line 1835). The input schema currently shows:
```json
{
"nodes": [...],
"edges": [...],
"layers": [
{"id": "layer:core", "name": "Core", "nodeIds": ["file:src/index.ts"]}
]
}
```
Update the layers example to reflect the slim format:
```json
{
"nodes": [
{"id": "file:src/index.ts", "type": "file", "name": "index.ts", "filePath": "src/index.ts", "summary": "..."}
],
"edges": [
{"source": "file:src/index.ts", "target": "file:src/utils.ts", "type": "imports"}
],
"layers": [
{"id": "layer:core", "name": "Core", "description": "Core application logic"}
]
}
```
Also update the "G. Node Summary Index" description (around line 84) to reflect that input nodes are file-type only:
Find:
```
**G. Node Summary Index**
Create a lookup of each node ID to its `summary`, `type`, `tags` (default to empty array `[]` if not present in input), and `name` for easy reference.
```
Add a note after it:
```
Note: input nodes are file-type only. The nodeSummaryIndex will contain only file nodes.
```
### Step 4: Verify
- Re-read SKILL.md Phase 5 payload block: confirms file-only nodes, slim layers (no nodeIds), imports+calls edges only
- Re-read tour-builder-prompt.md input schema: layers no longer have nodeIds
### Step 5: Commit
```bash
git add understand-anything-plugin/skills/understand/SKILL.md \
understand-anything-plugin/skills/understand/tour-builder-prompt.md
git commit -m "perf(understand): slim Phase 5 tour payload — file nodes only, imports+calls edges, slim layers"
```
---
## Task 4: C3 — Remove language/framework addendums from file-analyzer batches
The addendums (`languages/typescript.md`, `frameworks/react.md`, etc.) are currently injected into every file-analyzer batch prompt. They cost ~1,300 tokens × N batches. The model already knows these languages. Replace with a compact inline reference table (~150 tokens, paid once, embedded in the base template).
**Files:**
- Modify: `understand-anything-plugin/skills/understand/SKILL.md` (Phase 2, lines 104117)
- Modify: `understand-anything-plugin/skills/understand/file-analyzer-prompt.md` (add quick reference section)
### Step 1: Update the "Build the combined prompt template" block in SKILL.md Phase 2
Find the block at lines 104117:
```
**Build the combined prompt template:**
1. Read the base template at `./file-analyzer-prompt.md`.
2. **Language context injection:** ...
3. **Framework addendum injection:** ...
Then for each batch pass the combined template content as the subagent's prompt, appending the following additional context:
> **Additional context from main session:**
>
> Project: `<projectName>` — `<projectDescription>`
> Frameworks detected: `<frameworks from Phase 1>`
> Languages: `<languages from Phase 1>`
>
> Use the language context and framework addendums (appended above) to produce more accurate summaries and better classify file roles.
```
Replace it with:
```markdown
**Build the prompt for each batch:**
1. Read the base template at `./file-analyzer-prompt.md`. (Language and framework hints are embedded in the template — do NOT append addendum files for Phase 2 batches. Addendums are reserved for Phase 4.)
Then for each batch pass the template content as the subagent's prompt, appending the following additional context:
> **Additional context from main session:**
>
> Project: `<projectName>` — `<projectDescription>`
> Languages: `<languages from Phase 1>`
```
This removes steps 2 and 3 (the addendum injection loops) entirely from Phase 2.
### Step 2: Add Language and Framework Quick Reference to file-analyzer-prompt.md
Open `file-analyzer-prompt.md`. Find the "## Critical Constraints" section near the bottom (around line 299). Insert the following new section **before** "## Critical Constraints":
```markdown
## Language and Framework Quick Reference
Use these hints to improve tag and edge accuracy for common patterns. Your training knowledge covers these — this is a fast lookup for the most impactful signals.
**Tag signals:**
| Signal | Tags to apply |
|---|---|
| File in `hooks/`, exports a function starting with `use` | `hook`, `service` |
| File in `contexts/` or `context/`, exports a Provider component | `service`, `state` |
| File in `pages/` or `views/` | `ui`, `routing` |
| File in `store/`, `slices/`, `reducers/`, `state/` | `state` |
| File in `services/`, `api/`, `client/` | `service` |
| `__init__.py` at a package root with re-exports | `entry-point`, `barrel` |
| `manage.py` at the project root | `entry-point` |
| `mod.rs` in a directory | `barrel` |
| `main.go` in a `cmd/` subdirectory | `entry-point` |
**Edge signals:**
| Pattern | Edge to create |
|---|---|
| React component renders another component in its JSX | `contains` from parent to child |
| Component/hook calls a custom hook (`useX`) | `depends_on` from consumer to hook file |
| Context provider wraps components | `publishes` from provider to context definition |
| Component calls `useContext` or custom context hook | `subscribes` from consumer to context definition |
| Python file uses `from x import y` where x is a project file | `imports` edge (same rule as JS/TS) |
| Go file `import`s an internal package path | `imports` edge to the resolved file |
```
### Step 3: Verify
- Re-read SKILL.md Phase 2 "Build the prompt" block: steps 2 and 3 (addendum loops) are gone; "Frameworks detected" line in additional context is gone
- Re-read file-analyzer-prompt.md: new "Language and Framework Quick Reference" section appears before Critical Constraints; no reference to addendum files
- Confirm Phase 4 "Build the combined prompt template" (lines 163167) is **unchanged** — addendums still apply there
### Step 4: Commit
```bash
git add understand-anything-plugin/skills/understand/SKILL.md \
understand-anything-plugin/skills/understand/file-analyzer-prompt.md
git commit -m "perf(understand): remove addendum injection from Phase 2 batches, add compact inline hints to file-analyzer"
```
---
## Task 5: C1a — Extend scanner to pre-resolve imports
Adds a new Step 8 to the project scanner script: parse import statements from every source file and resolve relative imports against the discovered file list. The resolved map is written into `scan-result.json` as `importMap`. This is the data that lets us eliminate `allProjectFiles` from every batch in Task 7.
**Files:**
- Modify: `understand-anything-plugin/skills/understand/project-scanner-prompt.md`
### Step 1: Add Step 8 to the scanner script requirements
Open `project-scanner-prompt.md`. Find "**Step 7 -- Project Name**" (around line 100). After its content (the priority list), add a new step:
```markdown
**Step 8 -- Import Resolution**
For each file in the discovered source list, extract and resolve relative import statements. The goal is to produce a map from each file's path to the list of project-internal files it imports. External package imports are ignored.
For each file, read its content and extract import paths using language-appropriate patterns:
| Language | Import patterns to match |
|---|---|
| TypeScript/JavaScript | `import ... from './...'` or `'../'`, `require('./...')` or `require('../...')` |
| Python | `from .x import y`, `from ..x import y`, `import .x` (relative only) |
| Go | Paths in `import (...)` blocks that start with the module path from `go.mod` |
| Rust | `use crate::`, `use super::`, `mod x` (within the same crate) |
| Java/Kotlin | Not resolvable by path — skip import resolution for these languages |
| Ruby | `require_relative '...'` paths |
For each extracted import path:
1. Compute the resolved file path relative to project root:
- For relative imports (`./x`, `../x`): resolve from the importing file's directory
- Try these extension variants in order if the import has no extension: `.ts`, `.tsx`, `.js`, `.jsx`, `/index.ts`, `/index.js`, `/index.tsx`, `/index.jsx`, `.py`, `.go`, `.rs`, `.rb`
2. Check if the resolved path exists in the discovered file list
3. If yes: add to this file's resolved imports list
4. If no: skip (external, unresolvable, or dynamic import)
Output format in the script result:
```json
"importMap": {
"src/index.ts": ["src/utils.ts", "src/config.ts"],
"src/utils.ts": [],
"src/components/App.tsx": ["src/hooks/useAuth.ts", "src/store/index.ts"]
}
```
Keys are project-relative paths. Values are arrays of resolved project-relative paths. Every key in the file list must appear in `importMap` (use an empty array `[]` if no imports were resolved). External packages and unresolvable imports are omitted entirely.
```
### Step 2: Update the scanner script output format
Find the "### Script Output Format" section (around line 109) and update the example JSON to include `importMap`:
Find this in the example:
```json
{
"scriptCompleted": true,
"name": "project-name",
...
"estimatedComplexity": "moderate"
}
```
Add `importMap` to the example:
```json
{
"scriptCompleted": true,
"name": "project-name",
"rawDescription": "...",
"readmeHead": "...",
"languages": ["javascript", "typescript"],
"frameworks": ["React", "Vite"],
"files": [
{"path": "src/index.ts", "language": "typescript", "sizeLines": 150}
],
"totalFiles": 42,
"estimatedComplexity": "moderate",
"importMap": {
"src/index.ts": ["src/utils.ts", "src/config.ts"],
"src/utils.ts": []
}
}
```
Also update the field documentation list below the example to add:
```
- `importMap` (object) — map from every source file path to its list of resolved project-internal import paths; empty array if no resolved imports; external packages excluded
```
### Step 3: Update the final assembly section to preserve importMap
Find "## Phase 2 -- Description and Final Assembly" (around line 153). Find the IMPORTANT note:
```
**IMPORTANT:** The final output must NOT contain the `scriptCompleted`, `rawDescription`, or `readmeHead` fields.
```
Update it to:
```
**IMPORTANT:** The final output must NOT contain the `scriptCompleted`, `rawDescription`, or `readmeHead` fields. All other fields — including `importMap` — MUST be preserved exactly as output by the script.
```
Also update the final output example to include `importMap`:
```json
{
"name": "project-name",
"description": "...",
"languages": ["typescript"],
"frameworks": ["React"],
"files": [...],
"totalFiles": 42,
"estimatedComplexity": "moderate",
"importMap": {
"src/index.ts": ["src/utils.ts"]
}
}
```
### Step 4: Verify
Re-read `project-scanner-prompt.md` and confirm:
- Step 8 is present with full import resolution logic
- Script output format includes `importMap`
- Field documentation includes `importMap`
- Final assembly section preserves `importMap` in output
### Step 5: Commit
```bash
git add understand-anything-plugin/skills/understand/project-scanner-prompt.md
git commit -m "perf(understand): extend scanner to pre-resolve imports, output importMap in scan-result.json"
```
---
## Task 6: C1b — Update file-analyzer to use batchImportData
Removes `allProjectFiles` from the file-analyzer input schema and replaces it with `batchImportData` (pre-resolved imports for this batch's files only). Updates the extraction script section to skip import resolution entirely (already done by scanner). Updates the edge creation step to use `batchImportData` directly.
**Files:**
- Modify: `understand-anything-plugin/skills/understand/file-analyzer-prompt.md`
### Step 1: Update the input JSON schema (Script Requirements, step 1)
Find the input schema block around line 19:
```json
{
"projectRoot": "/path/to/project",
"allProjectFiles": ["src/index.ts", "src/utils.ts", "..."],
"batchFiles": [
{"path": "src/index.ts", "language": "typescript", "sizeLines": 150},
{"path": "src/utils.ts", "language": "typescript", "sizeLines": 80}
]
}
```
Replace with:
```json
{
"projectRoot": "/path/to/project",
"batchFiles": [
{"path": "src/index.ts", "language": "typescript", "sizeLines": 150},
{"path": "src/utils.ts", "language": "typescript", "sizeLines": 80}
],
"batchImportData": {
"src/index.ts": ["src/utils.ts", "src/config.ts"],
"src/utils.ts": []
}
}
```
Update the field descriptions:
- Remove: `allProjectFiles` description
- Add: `batchImportData` (object) — map from each batch file's project-relative path to its list of pre-resolved project-internal imports. Produced by the project scanner. Use this directly for import edge creation — do NOT attempt to re-resolve imports yourself.
### Step 2: Remove the imports extraction from "What the Script Must Extract"
Find the "**Imports:**" subsection under "What the Script Must Extract" (around lines 4953):
```
**Imports:**
- Source module path (exactly as written in the import statement)
- Imported specifiers (named imports, default import, namespace import)
- Line number
- For relative imports (starting with `./` or `../`), compute the resolved path...
```
Replace this entire subsection with:
```markdown
**Imports:**
- Do NOT extract imports in the script. Import resolution has already been performed by the project scanner.
- The pre-resolved imports for each file are provided in `batchImportData` in the input JSON.
- Do not include an `imports` field in the script output — import edges will be created in Phase 2 using `batchImportData` directly.
```
### Step 3: Update the script output format to remove imports
Find the `results` array in the script output format (around line 67). The current `imports` array in the output:
```json
"imports": [
{"source": "./utils", "resolvedPath": "src/utils.ts", "specifiers": ["formatDate"], "line": 1, "isExternal": false},
{"source": "express", "resolvedPath": null, "specifiers": ["default"], "line": 2, "isExternal": true}
],
```
Remove the `imports` array from the script output format entirely. The result for each file should be:
```json
{
"path": "src/index.ts",
"language": "typescript",
"totalLines": 150,
"nonEmptyLines": 120,
"functions": [...],
"classes": [...],
"exports": [...],
"metrics": {
"importCount": 5,
"exportCount": 3,
"functionCount": 4,
"classCount": 1
}
}
```
Keep `metrics.importCount` (derived from `batchImportData[path].length`) as a useful metric.
Update the metrics description to say:
```
- `importCount` (integer) — use `batchImportData[file.path].length` from the input JSON
```
### Step 4: Update "Preparing the Script Input" section
Find the `cat` command around line 113 that creates the input JSON:
```bash
cat > $PROJECT_ROOT/.understand-anything/tmp/ua-file-analyzer-input-<batchIndex>.json << 'ENDJSON'
{
"projectRoot": "<project-root>",
"allProjectFiles": [<full file list from scan>],
"batchFiles": [<this batch's files>]
}
ENDJSON
```
Replace with:
```bash
cat > $PROJECT_ROOT/.understand-anything/tmp/ua-file-analyzer-input-<batchIndex>.json << 'ENDJSON'
{
"projectRoot": "<project-root>",
"batchFiles": [<this batch's files>],
"batchImportData": <batchImportData JSON object — provided in your dispatch prompt>
}
ENDJSON
```
### Step 5: Update Step 3 (Create Edges) — Import edge creation rule
Find the "**Import edge creation rule:**" in the "Step 3 -- Create Edges" section (around line 213):
```
**Import edge creation rule:** For each import in the script output where `isExternal` is `false` and `resolvedPath` is non-null, create an `imports` edge from the current file node to `file:<resolvedPath>`. Do NOT create edges for external package imports.
```
Replace with:
```markdown
**Import edge creation rule:** For each resolved path in `batchImportData[filePath]` (provided in the input JSON), create an `imports` edge from the current file node to `file:<resolvedPath>`. The `batchImportData` values contain only resolved project-internal paths — external packages have already been filtered out. Do NOT attempt to re-resolve imports from source.
```
### Step 6: Remove `allProjectFiles` references from Critical Constraints
Find the last bullet in "## Critical Constraints" (around line 304):
```
- For import edges, use the script's `resolvedPath` field directly. Do NOT attempt to resolve import paths yourself -- the script already did this deterministically.
```
Replace with:
```markdown
- For import edges, use `batchImportData[filePath]` directly from the input JSON. Do NOT attempt to resolve import paths yourself -- the project scanner already did this deterministically.
```
### Step 7: Verify
Re-read `file-analyzer-prompt.md` and confirm:
- Input schema has `batchImportData`, no `allProjectFiles`
- Script "What to Extract" section: imports extraction replaced with "do not extract"
- Script output format: no `imports` array per file
- Preparing the Script Input: cat command has no `allProjectFiles`
- Import edge creation rule: uses `batchImportData` not script output
- Critical Constraints: no reference to `resolvedPath` from script
### Step 8: Commit
```bash
git add understand-anything-plugin/skills/understand/file-analyzer-prompt.md
git commit -m "perf(understand): replace allProjectFiles with batchImportData in file-analyzer — import resolution now done by scanner"
```
---
## Task 7: C1c + C2 — Update SKILL.md Phase 2 orchestration
Wires up the `importMap` from Phase 1 into per-batch `batchImportData` slices. Increases batch size from 5-10 to 20-30 files. Increases concurrency from 3 to 5. Removes `allProjectFiles` from the dispatch prompt.
**Files:**
- Modify: `understand-anything-plugin/skills/understand/SKILL.md` (Phase 0, Phase 1, Phase 2)
### Step 1: Update Phase 1 to note importMap is now in scan-result.json
Find Phase 1 (around line 62) where it says:
```
After the subagent completes, read `$PROJECT_ROOT/.understand-anything/intermediate/scan-result.json` to get:
- Project name, description
- Languages, frameworks
- File list with line counts
- Complexity estimate
```
Add one item to the list:
```
- Import map (`importMap`): pre-resolved project-internal imports per file
```
Also add a note:
```
Store `importMap` in memory as `$IMPORT_MAP` for use in Phase 2 batch construction.
```
### Step 2: Change batch size and concurrency in Phase 2
Find line 100:
```
Batch the file list from Phase 1 into groups of **5-10 files each** (aim for balanced batch sizes).
```
Replace with:
```
Batch the file list from Phase 1 into groups of **20-30 files each** (aim for ~25 files per batch for balanced sizes).
```
Find line 102:
```
For each batch, dispatch a subagent using the prompt template at `./file-analyzer-prompt.md`. Run up to **3 subagents concurrently** using parallel dispatch.
```
Replace with:
```
For each batch, dispatch a subagent using the prompt template at `./file-analyzer-prompt.md`. Run up to **5 subagents concurrently** using parallel dispatch.
```
### Step 3: Add batchImportData construction to the dispatch block
Find the dispatch prompt block (around lines 119134):
```
Fill in batch-specific parameters below and dispatch:
> Analyze these source files and produce GraphNode and GraphEdge objects.
> Project root: `$PROJECT_ROOT`
> Project: `<projectName>`
> Languages: `<languages>`
> Batch index: `<batchIndex>`
> Write output to: `$PROJECT_ROOT/.understand-anything/intermediate/batch-<batchIndex>.json`
>
> All project files (for import resolution):
> `<full file path list from scan>`
>
> Files to analyze in this batch:
> 1. `<path>` (<sizeLines> lines)
> ...
```
Replace with:
```markdown
Before dispatching each batch, construct `batchImportData` from `$IMPORT_MAP`:
```json
batchImportData = {}
for each file in this batch:
batchImportData[file.path] = $IMPORT_MAP[file.path] ?? []
```
Fill in batch-specific parameters below and dispatch:
> Analyze these source files and produce GraphNode and GraphEdge objects.
> Project root: `$PROJECT_ROOT`
> Project: `<projectName>`
> Languages: `<languages>`
> Batch index: `<batchIndex>`
> Write output to: `$PROJECT_ROOT/.understand-anything/intermediate/batch-<batchIndex>.json`
>
> Pre-resolved import data for this batch (use this for all import edge creation — do NOT re-resolve imports from source):
> ```json
> <batchImportData JSON>
> ```
>
> Files to analyze in this batch:
> 1. `<path>` (<sizeLines> lines)
> 2. `<path>` (<sizeLines> lines)
> ...
```
### Step 4: Update incremental update path
Find "### Incremental update path" (around line 140):
```
Use the changed files list from Phase 0. Batch and dispatch file-analyzer subagents using the same process as above, but only for changed files.
```
Update to clarify that batchImportData still applies:
```
Use the changed files list from Phase 0. Batch and dispatch file-analyzer subagents using the same process as above (20-30 files per batch, up to 5 concurrent, with batchImportData constructed from $IMPORT_MAP), but only for changed files.
```
### Step 5: Verify all Phase 2 changes
Re-read SKILL.md Phase 2 in full and confirm:
- Batch size says "20-30 files"
- Concurrency says "5 subagents concurrently"
- "Build the prompt" block: only step 1 (read base template), no addendum steps
- Additional context block: no "Frameworks detected" line, no addendum reference
- Dispatch prompt: has `batchImportData` injection, no `allProjectFiles`
- Incremental path: mentions batchImportData
### Step 6: Commit
```bash
git add understand-anything-plugin/skills/understand/SKILL.md
git commit -m "perf(understand): wire importMap into batchImportData per batch, increase batch size 5-10→20-30, concurrency 3→5"
```
---
## Task 8: Version bump
Per project convention, all four version files must stay in sync when changes are pushed.
**Files:**
- Modify: `understand-anything-plugin/package.json`
- Modify: `.claude-plugin/marketplace.json`
- Modify: `.claude-plugin/plugin.json`
- Modify: `.cursor-plugin/plugin.json`
### Step 1: Read current version
```bash
node -e "const p = require('./understand-anything-plugin/package.json'); console.log(p.version)"
```
Expected: `1.2.1` (or whatever the current version is).
### Step 2: Bump patch version in all four files
New version: `1.2.2` (patch bump — internal optimization, no API changes).
Update each file:
- `understand-anything-plugin/package.json`: `"version": "1.2.2"`
- `.claude-plugin/marketplace.json`: `"version": "1.2.2"` in `plugins[0]`
- `.claude-plugin/plugin.json`: `"version": "1.2.2"`
- `.cursor-plugin/plugin.json`: `"version": "1.2.2"`
### Step 3: Verify all four files match
```bash
grep -r '"version"' understand-anything-plugin/package.json .claude-plugin/marketplace.json .claude-plugin/plugin.json .cursor-plugin/plugin.json
```
All four should show `"version": "1.2.2"`.
### Step 4: Commit
```bash
git add understand-anything-plugin/package.json \
.claude-plugin/marketplace.json \
.claude-plugin/plugin.json \
.cursor-plugin/plugin.json
git commit -m "chore: bump version to 1.2.2"
```
---
## Task 9: Build and smoke test
Verifies all changes work end-to-end by running `/understand --full` against a real project.
**Files:** None (testing only)
### Step 1: Build the packages
```bash
pnpm --filter @understand-anything/core build
pnpm --filter @understand-anything/skill build
```
Expected: both build without errors.
### Step 2: Find installed plugin version and copy to cache
```bash
ls ~/.claude/plugins/cache/understand-anything/understand-anything/
```
Note the version (e.g., `1.0.1`). Copy local build into the cache:
```bash
VERSION=$(node -e "const p = require('./understand-anything-plugin/package.json'); console.log(p.version)")
rm -rf ~/.claude/plugins/cache/understand-anything/understand-anything/$VERSION
cp -R ./understand-anything-plugin ~/.claude/plugins/cache/understand-anything/understand-anything/$VERSION
```
### Step 3: Smoke test on a small project (~20 files)
Open a fresh Claude Code session in a small TypeScript project. Run:
```
/understand --full
```
Verify:
- Phases 07 complete without errors
- `knowledge-graph.json` is created
- Node count and edge count are reasonable
- Layers and tour are present
- No "allProjectFiles" or addendum errors in the output
### Step 4: Smoke test on a larger project (~100+ files)
Run `/understand --full` on a medium/large TypeScript+React project.
Verify:
- Batch count is ~4-6 (at 20-30 files per batch for 100 files), not 10-20
- No errors about missing import resolution
- `importMap` is present in `scan-result.json` (check `.understand-anything/intermediate/` before cleanup, or add a temporary debug log)
- Graph quality is comparable to before (summaries are descriptive, layers are correct)
### Step 5: Test `--review` flag
Run `/understand --full --review` on the same project.
Verify:
- Phase 6 now dispatches the LLM graph-reviewer subagent (not the inline script)
- `review.json` is produced with `approved` field
- Pipeline completes normally
### Step 6: Final commit (if any fixes needed from smoke test)
```bash
git add -A
git commit -m "fix(understand): smoke test fixes for token reduction changes"
```
---
## Summary
| Task | Change | Risk |
|---|---|---|
| 1 | C5: Gate reviewer | Low |
| 2 | C4a: Slim Phase 4 payload | Low |
| 3 | C4b: Slim Phase 5 payload | Low |
| 4 | C3: Remove addendums from batches | Low |
| 5 | C1a: Scanner import resolution | Medium |
| 6 | C1b: File-analyzer uses batchImportData | Medium |
| 7 | C1c+C2: SKILL.md orchestration + batch size | Medium |
| 8 | Version bump | Low |
| 9 | Smoke test | — |
Tasks 14 are independent of Tasks 57. They can be shipped separately if needed. Tasks 5, 6, and 7 are tightly coupled (scanner produces importMap → SKILL.md passes batchImportData → file-analyzer consumes it) and must be shipped together.
@@ -0,0 +1,266 @@
# Understand Anything: Universal File Type Support
**Date**: 2026-03-28
**Status**: Approved
**Approach**: Big Bang — all file types in one release
## Goals
1. Extend Understand Anything to analyze **any** file type, not just code
2. Support both holistic project enrichment (non-code files enrich code graphs) and standalone analysis (docs-only repos, SQL schema collections, IaC projects)
3. Maintain backward compatibility with existing code-only analysis
## Supported File Types (26 new)
### Documentation (3)
| Type | Extensions | Parser | Node Types |
|------|-----------|--------|------------|
| Markdown | `.md`, `.mdx` | LLM + regex heading extraction | `document` |
| reStructuredText | `.rst` | LLM | `document` |
| Plain text | `.txt` | LLM | `document` |
### Configuration (5)
| Type | Extensions | Parser | Node Types |
|------|-----------|--------|------------|
| YAML | `.yaml`, `.yml` | `yaml` npm package | `config` |
| JSON | `.json`, `.jsonc` | `JSON.parse` / `jsonc-parser` | `config`, `schema` |
| TOML | `.toml` | `@iarna/toml` or similar | `config` |
| .env | `.env`, `.env.*` | Regex line parser | `config` |
| XML | `.xml` | LLM (optionally `fast-xml-parser`) | `config` |
### Infrastructure & DevOps (7)
| Type | Extensions | Parser | Node Types |
|------|-----------|--------|------------|
| Dockerfile | `Dockerfile`, `Dockerfile.*`, `.dockerfile` | Custom instruction parser | `service`, `pipeline` |
| Docker Compose | `docker-compose.yml`, `compose.yml` | YAML parser + service extraction | `service` |
| Terraform | `.tf`, `.tfvars` | Regex block parser | `resource` |
| Kubernetes | K8s YAML (detected by `apiVersion` field) | YAML + kind detection | `service`, `resource` |
| GitHub Actions | `.github/workflows/*.yml` | YAML + job/step extraction | `pipeline` |
| Jenkinsfile | `Jenkinsfile` | LLM (Groovy DSL) | `pipeline` |
| Makefile | `Makefile`, `*.mk` | Regex target parser | `pipeline` |
### Data & Schema (6)
| Type | Extensions | Parser | Node Types |
|------|-----------|--------|------------|
| SQL | `.sql` | Simple DDL parser | `table`, `endpoint` |
| GraphQL | `.graphql`, `.gql` | Regex type/query parser | `schema`, `endpoint` |
| OpenAPI/Swagger | `openapi.yaml`, `swagger.json` | YAML/JSON + path extraction | `endpoint`, `schema` |
| Protocol Buffers | `.proto` | Regex message/service parser | `schema` |
| JSON Schema | `*.schema.json` | JSON + `$ref`/`$defs` extraction | `schema` |
| CSV/TSV | `.csv`, `.tsv` | Header row extraction | `table` |
### Shell & Scripts (3)
| Type | Extensions | Parser | Node Types |
|------|-----------|--------|------------|
| Shell | `.sh`, `.bash`, `.zsh` | Regex function parser | `file`, `function` |
| PowerShell | `.ps1`, `.psm1` | LLM | `file`, `function` |
| Batch | `.bat`, `.cmd` | LLM | `file` |
### Markup (2)
| Type | Extensions | Parser | Node Types |
|------|-----------|--------|------------|
| HTML | `.html`, `.htm` | LLM (tag structure) | `document` |
| CSS/SCSS/Less | `.css`, `.scss`, `.less` | LLM | `file` |
## Schema Extensions
### New Node Types (8)
Added to the existing `file | function | class | module | concept`:
| Node Type | Purpose | Example |
|-----------|---------|---------|
| `config` | Configuration files and key settings | `package.json`, `tsconfig.json`, env vars |
| `document` | Documentation, prose, guides | `README.md`, API docs |
| `service` | Deployable services/containers | Docker containers, K8s Deployments |
| `table` | Data tables, database objects | SQL tables, CSV datasets |
| `endpoint` | API routes, queries, mutations | REST paths, GraphQL queries |
| `pipeline` | CI/CD workflows, build steps | GitHub Actions jobs, Makefile targets |
| `schema` | Type definitions for data interchange | Protobuf messages, JSON Schema |
| `resource` | Infrastructure resources | Terraform resources, K8s ConfigMaps |
### New Edge Types (8)
Added to the existing 18 edge types:
| Edge Type | Category | Meaning | Example |
|-----------|----------|---------|---------|
| `deploys` | Infrastructure | Service deploys code | Dockerfile -> app source |
| `serves` | Infrastructure | Service exposes endpoint | K8s Service -> API endpoint |
| `migrates` | Data flow | Migration modifies table | SQL migration -> table |
| `documents` | Semantic | Doc describes code | README -> module |
| `provisions` | Infrastructure | IaC creates resource | Terraform -> AWS resource |
| `routes` | Behavioral | Routes traffic to service | nginx config -> service |
| `defines_schema` | Data flow | Defines data shape | Protobuf -> endpoint |
| `triggers` | Behavioral | Triggers pipeline/action | Git push -> GitHub Actions |
### Schema Validation Auto-Fix Aliases
New node type aliases:
- `container` -> `service`, `migration` -> `table`, `workflow` -> `pipeline`
- `route` -> `endpoint`, `doc` -> `document`, `setting` -> `config`, `infra` -> `resource`
New edge type aliases:
- `describes` -> `documents`, `creates` -> `provisions`, `exposes` -> `serves`
## Plugin Architecture Changes
### Generalized AnalyzerPlugin Interface
```typescript
interface AnalyzerPlugin {
name: string;
languages: string[];
analyzeFile(filePath: string, content: string): StructuralAnalysis;
resolveImports?(filePath: string, content: string): ImportResolution[]; // Now optional
extractCallGraph?(filePath: string, content: string): CallGraphEntry[];
extractReferences?(filePath: string, content: string): ReferenceResolution[]; // NEW
}
interface ReferenceResolution {
source: string; // File making the reference
target: string; // Referenced file or identifier
type: string; // Reference type: "file", "image", "schema", "service"
line?: number;
}
```
### Extended StructuralAnalysis
```typescript
interface StructuralAnalysis {
// Existing (unchanged)
functions: FunctionInfo[];
classes: ClassInfo[];
imports: ImportInfo[];
exports: ExportInfo[];
// New (all optional for backward compat)
sections?: SectionInfo[]; // Documents: headings, chapters
definitions?: DefinitionInfo[]; // Schemas: types, messages, tables
services?: ServiceInfo[]; // Infra: containers, deployments
endpoints?: EndpointInfo[]; // APIs: routes, queries
steps?: StepInfo[]; // Pipelines: jobs, stages, targets
resources?: ResourceInfo[]; // IaC: terraform resources, K8s objects
}
```
### Custom Parsers (12)
All lightweight — mostly regex-based, minimal dependencies:
| Parser | Implementation | Extracts |
|--------|---------------|----------|
| `MarkdownParser` | Regex | Headings, links, code blocks, front matter |
| `YAMLParser` | `yaml` npm | Key hierarchy, anchors, multi-doc |
| `JSONParser` | Built-in `JSON.parse` | Key structure, `$ref`/`$defs` |
| `TOMLParser` | `@iarna/toml` | Section structure |
| `EnvParser` | Regex | Variable names and references |
| `DockerfileParser` | Regex | FROM stages, EXPOSE ports, COPY sources |
| `SQLParser` | Regex | CREATE TABLE/VIEW/INDEX, columns, foreign keys |
| `GraphQLParser` | Regex | Types, queries, mutations, subscriptions |
| `ProtobufParser` | Regex | Messages, services, enums, RPCs |
| `TerraformParser` | Regex | Resources, modules, variables, outputs |
| `MakefileParser` | Regex | Targets, dependencies, variables |
| `ShellParser` | Regex | Functions, sourced files |
## Agent Pipeline Changes
### Project Scanner
1. Scan ALL file types (remove code-only filter)
2. Tag each file with category: `code`, `config`, `docs`, `infra`, `data`, `script`, `markup`
3. Smart batch grouping: keep related files together (e.g., Dockerfile + docker-compose.yml)
### File Analyzer
Type-aware prompt templates by category:
- **Code**: Current behavior (functions, classes, imports, call graph)
- **Config**: Extract key settings, what they configure, which code files they affect
- **Documentation**: Extract sections, key concepts, which code components are documented
- **Infrastructure**: Extract services, ports, volumes, dependencies, which code they deploy
- **Data/Schema**: Extract tables, columns, types, relationships, which code consumes this data
- **Pipelines**: Extract jobs, steps, triggers, which code/infra they build/deploy
### Cross-Type Reference Resolution
Post-analysis step connecting:
- Dockerfile `COPY` -> source code directories
- CI config `run: npm test` -> test files
- K8s manifest `image:` -> Dockerfile
- SQL foreign keys -> other tables
- OpenAPI `$ref` -> schema definitions
- Markdown links -> referenced files
### Architecture Analyzer
New pattern detection:
- Deployment topology: Dockerfile -> compose -> K8s chain
- Data flow: Schema -> migration -> API endpoint -> client code
- Documentation coverage: which modules have docs vs. not
- Configuration dependency: which config files affect which code paths
### Tour Builder
Include non-code tour stops:
- Project README overview
- Dockerfile containerization
- SQL migration database schema
- CI/CD pipeline explanation
## Dashboard Visualization
### New Node Visual Styles
| Node Type | Shape | Color | Icon |
|-----------|-------|-------|------|
| `config` | Rounded rect | Teal (#5eead4) | Gear |
| `document` | Rounded rect | Sky blue (#7dd3fc) | Document |
| `service` | Hexagon | Violet (#a78bfa) | Container/Box |
| `table` | Rectangle | Emerald (#6ee7b7) | Grid |
| `endpoint` | Pill/Stadium | Orange (#fdba74) | Arrow-right |
| `pipeline` | Rounded rect | Rose (#fda4af) | Play/Workflow |
| `schema` | Diamond | Amber (#fcd34d) | Blueprint |
| `resource` | Cloud shape | Indigo (#a5b4fc) | Cloud |
### Graph Layout
1. Layer grouping by category — non-code nodes cluster separately from code nodes
2. Legend update with 8 new node types
3. Filter controls — checkboxes to show/hide each file category
### Sidebar Enhancements
NodeInfo panel updates per node type:
- **Config**: key-value pairs, referencing code files
- **Document**: heading outline, linked code components
- **Service**: ports, volumes, dependencies, deployed code
- **Table**: columns, types, foreign key relationships
- **Endpoint**: HTTP method, path, request/response schema
- **Pipeline**: jobs, triggers, deployed targets
- **Schema**: fields, nested types, consumers
- **Resource**: provider, type, dependencies
ProjectOverview panel: add "File Types" breakdown (code vs. non-code distribution).
## New Dependencies
- `yaml` — YAML parsing (already common, ~50KB)
- `@iarna/toml` — TOML parsing (~30KB)
- `jsonc-parser` — JSON with comments (~20KB)
No tree-sitter WASM additions. All other parsers are regex-based with zero dependencies.
## Backward Compatibility
- All new `StructuralAnalysis` fields are optional
- `resolveImports` becomes optional on `AnalyzerPlugin`
- Existing `LanguageConfig` entries unchanged
- Schema validation auto-fixes new type aliases
- Existing knowledge graphs remain valid (new types are additive)
File diff suppressed because it is too large Load Diff
+159 -9
View File
@@ -1,10 +1,15 @@
#!/usr/bin/env node
/**
* Generate a large fake knowledge graph for testing PR #18
* (Web Worker layout for large graphs).
* Generate a large fake knowledge graph for testing.
*
* Usage:
* node scripts/generate-large-graph.mjs [nodeCount]
* node scripts/generate-large-graph.mjs [nodeCount] --messy
*
* Flags:
* --messy Inject LLM-style issues into ~20% of nodes/edges to test the
* dashboard robustness pipeline (Tier 1-3: null fields, wrong cases,
* missing fields, aliases, dangling refs, unrecognizable types).
*
* Default: 3000 nodes. Writes to .understand-anything/knowledge-graph.json
*/
@@ -12,7 +17,10 @@
import { writeFileSync, mkdirSync } from "node:fs";
import { resolve } from "node:path";
const NODE_COUNT = parseInt(process.argv[2] || "3000", 10);
const args = process.argv.slice(2);
const MESSY = args.includes("--messy");
const numArg = args.find((a) => !a.startsWith("--"));
const NODE_COUNT = parseInt(numArg || "3000", 10);
const EDGE_RATIO = 1.7; // edges per node (realistic for codebases)
const nodeTypes = ["file", "function", "class", "module", "concept"];
@@ -110,6 +118,137 @@ function generateTour(nodes) {
return steps;
}
// ── Messy injection (--messy flag) ──
// Tier 1: silent fixes — null optional fields, mixed-case enums
function injectTier1(node) {
const issues = [];
if (Math.random() < 0.5 && node.filePath !== undefined) {
node.filePath = null; // null on optional field
issues.push("null filePath");
}
if (Math.random() < 0.5) {
node.type = node.type.toUpperCase(); // "FILE", "FUNCTION"
issues.push(`uppercase type "${node.type}"`);
}
if (Math.random() < 0.5) {
node.complexity = node.complexity[0].toUpperCase() + node.complexity.slice(1); // "Simple"
issues.push(`mixed-case complexity "${node.complexity}"`);
}
return issues;
}
// Tier 2: auto-fixable — missing fields, aliases, string weights
function injectTier2Node(node) {
const issues = [];
const r = Math.random();
if (r < 0.2) {
delete node.complexity;
issues.push("missing complexity");
} else if (r < 0.4) {
node.complexity = pick(["low", "easy", "medium", "intermediate", "high", "hard"]);
issues.push(`complexity alias "${node.complexity}"`);
}
if (Math.random() < 0.3) {
delete node.tags;
issues.push("missing tags");
}
if (Math.random() < 0.2) {
delete node.summary;
issues.push("missing summary");
}
if (Math.random() < 0.15) {
node.type = pick(["func", "fn", "method", "interface", "struct", "mod", "pkg"]);
issues.push(`type alias "${node.type}"`);
}
return issues;
}
function injectTier2Edge(edge) {
const issues = [];
if (Math.random() < 0.3) {
edge.weight = String(edge.weight); // string weight
issues.push(`string weight "${edge.weight}"`);
}
if (Math.random() < 0.2) {
delete edge.direction;
issues.push("missing direction");
} else if (Math.random() < 0.3) {
edge.direction = pick(["to", "outbound", "from", "inbound", "both"]);
issues.push(`direction alias "${edge.direction}"`);
}
if (Math.random() < 0.15) {
edge.type = pick(["extends", "invokes", "uses", "requires", "relates_to"]);
issues.push(`edge type alias "${edge.type}"`);
}
return issues;
}
// Tier 3: unrecoverable — missing id/name, dangling refs, bad types
function injectTier3Node(node) {
const r = Math.random();
if (r < 0.4) {
delete node.id;
return "missing id";
} else if (r < 0.7) {
delete node.name;
return "missing name";
} else {
node.type = "totally_bogus_type";
return `unrecognizable type "${node.type}"`;
}
}
function injectTier3Edge(edge, validNodeIds) {
const r = Math.random();
if (r < 0.4) {
edge.target = "nonexistent-node-999999";
return "dangling target ref";
} else if (r < 0.7) {
edge.source = "nonexistent-node-888888";
return "dangling source ref";
} else {
edge.weight = "not_a_number";
return "non-coercible weight";
}
}
function applyMessy(nodes, edges) {
const stats = { tier1: 0, tier2: 0, tier3: 0 };
for (const node of nodes) {
const r = Math.random();
if (r < 0.10) {
// ~10% get Tier 3 issues (will be dropped)
injectTier3Node(node);
stats.tier3++;
} else if (r < 0.30) {
// ~20% get Tier 2 issues (will be auto-corrected)
injectTier2Node(node);
stats.tier2++;
} else if (r < 0.40) {
// ~10% get Tier 1 issues (silently fixed)
injectTier1(node);
stats.tier1++;
}
}
const validIds = new Set(nodes.filter((n) => n.id).map((n) => n.id));
for (const edge of edges) {
const r = Math.random();
if (r < 0.05) {
injectTier3Edge(edge, validIds);
stats.tier3++;
} else if (r < 0.20) {
injectTier2Edge(edge);
stats.tier2++;
}
}
// Also set tour/layers to null (Tier 1 null-vs-empty)
return stats;
}
// ── Generate ──
const nodes = generateNodes(NODE_COUNT);
@@ -118,20 +257,25 @@ const edges = generateEdges(nodes, edgeCount);
const layers = generateLayers(nodes);
const tour = generateTour(nodes);
let messyStats = null;
if (MESSY) {
messyStats = applyMessy(nodes, edges);
}
const graph = {
version: "1.0",
project: {
name: "large-test-project",
languages: languages.slice(0, 3),
frameworks: frameworks.slice(0, 2),
description: `Auto-generated project with ${NODE_COUNT} nodes for performance testing.`,
description: `Auto-generated project with ${NODE_COUNT} nodes for ${MESSY ? "robustness" : "performance"} testing.`,
analyzedAt: new Date().toISOString(),
gitCommitHash: "0000000000000000000000000000000000000000",
},
nodes,
edges,
layers,
tour,
layers: MESSY && Math.random() < 0.5 ? null : layers,
tour: MESSY && Math.random() < 0.5 ? null : tour,
};
const outDir = resolve(process.cwd(), ".understand-anything");
@@ -139,9 +283,15 @@ mkdirSync(outDir, { recursive: true });
const outPath = resolve(outDir, "knowledge-graph.json");
writeFileSync(outPath, JSON.stringify(graph, null, 2));
console.log(`Generated knowledge graph:`);
console.log(`Generated knowledge graph${MESSY ? " (messy mode)" : ""}:`);
console.log(` Nodes: ${nodes.length}`);
console.log(` Edges: ${edges.length}`);
console.log(` Layers: ${layers.length}`);
console.log(` Tour steps: ${tour.length}`);
console.log(` Layers: ${graph.layers === null ? "null (Tier 1 test)" : layers.length}`);
console.log(` Tour steps: ${graph.tour === null ? "null (Tier 1 test)" : tour.length}`);
if (messyStats) {
console.log(` Injected issues:`);
console.log(` Tier 1 (silent fix): ~${messyStats.tier1} items`);
console.log(` Tier 2 (auto-correct): ~${messyStats.tier2} items`);
console.log(` Tier 3 (will be dropped): ~${messyStats.tier3} items`);
}
console.log(` Written to: ${outPath}`);
@@ -0,0 +1,226 @@
# Auto-Update Knowledge Graph (Internal — Hook-Triggered)
Incrementally update the knowledge graph using deterministic structural fingerprinting to minimize token usage. This prompt is triggered automatically by the post-commit hook when `autoUpdate` is enabled. It is NOT a user-facing skill.
**Key principle:** Spend zero LLM tokens when changes are cosmetic (formatting, internal logic). Only invoke LLM agents when structural changes (new/removed functions, classes, imports, exports) are detected.
---
## Phase 0 — Pre-flight (Zero Token Cost)
1. Set `PROJECT_ROOT` to the current working directory.
2. Check that `$PROJECT_ROOT/.understand-anything/knowledge-graph.json` exists.
- If not: report "No existing knowledge graph found. Run `/understand` first to create one." and **STOP**.
3. Check that `$PROJECT_ROOT/.understand-anything/meta.json` exists and read `gitCommitHash`.
- If not: report "No analysis metadata found. Run `/understand` to create a baseline." and **STOP**.
4. Get current commit hash:
```bash
git rev-parse HEAD
```
5. If commit hashes match and `--force` is NOT in `$ARGUMENTS`: report "Knowledge graph is already up to date." and **STOP**.
6. Get changed files:
```bash
git diff <lastCommitHash>..HEAD --name-only
```
If no files changed: update `meta.json` with the new commit hash and **STOP**.
7. Filter to source files only (`.ts`, `.tsx`, `.js`, `.jsx`, `.py`, `.go`, `.rs`, `.java`, `.rb`, `.cpp`, `.c`, `.h`, `.cs`, `.swift`, `.kt`, `.php`).
If no source files changed: update `meta.json` with the new commit hash, report "Only non-source files changed. Metadata updated." and **STOP**.
8. Create intermediate directory:
```bash
mkdir -p $PROJECT_ROOT/.understand-anything/intermediate
```
---
## Phase 1 — Structural Fingerprint Check (Zero LLM Tokens)
This phase runs a deterministic Node.js script that compares file structures against stored fingerprints. It costs **zero LLM tokens** — only the script execution cost.
1. Write and execute a Node.js script (`$PROJECT_ROOT/.understand-anything/intermediate/fingerprint-check.mjs`):
```javascript
// The script should:
// 1. Read fingerprints.json from .understand-anything/fingerprints.json
// 2. For each changed source file:
// a. Read the file content
// b. Compute SHA-256 content hash
// c. If content hash matches stored hash → NONE (skip)
// d. Extract structural elements via regex:
// - Functions: match patterns like `function NAME(`, `const NAME = (`, `export function NAME(`
// - Classes: match `class NAME`, `export class NAME`
// - Imports: match `import ... from '...'`, `import '...'`
// - Exports: match `export { ... }`, `export default`, `export function`, `export class`, `export const`
// e. Compare extracted elements against stored fingerprint
// f. Classify as NONE, COSMETIC, or STRUCTURAL
// 3. For new files (not in fingerprints.json): classify as STRUCTURAL
// 4. For deleted files (in fingerprints.json but not on disk): classify as STRUCTURAL
// 5. Determine overall decision:
// - All NONE/COSMETIC → action: "SKIP"
// - Some STRUCTURAL, ≤10 files, same directories → action: "PARTIAL_UPDATE"
// - New/deleted directories or >10 structural files → action: "ARCHITECTURE_UPDATE"
// - >30 structural files or >50% of graph → action: "FULL_UPDATE"
// 6. Write result to .understand-anything/intermediate/change-analysis.json
```
The output JSON should have this shape:
```json
{
"action": "SKIP | PARTIAL_UPDATE | ARCHITECTURE_UPDATE | FULL_UPDATE",
"filesToReanalyze": ["src/new-feature.ts"],
"rerunArchitecture": false,
"rerunTour": false,
"reason": "1 file has structural changes (new function added)",
"fileChanges": [
{ "filePath": "src/utils.ts", "changeLevel": "COSMETIC", "details": ["internal logic changed"] },
{ "filePath": "src/new-feature.ts", "changeLevel": "STRUCTURAL", "details": ["new function: handleRequest"] }
]
}
```
2. Read `.understand-anything/intermediate/change-analysis.json`.
3. **Decision gate:**
| Action | What to do |
|---|---|
| `SKIP` | Update `meta.json` with new commit hash. Report: "No structural changes detected. Graph metadata updated. Zero tokens spent." **STOP.** |
| `FULL_UPDATE` | Report: "Major structural changes detected (reason). Recommend running `/understand --full` for a complete rebuild." **STOP.** |
| `PARTIAL_UPDATE` | Proceed to Phase 2 with `filesToReanalyze` |
| `ARCHITECTURE_UPDATE` | Proceed to Phase 2 with `filesToReanalyze`, flag architecture re-run |
---
## Phase 2 — Targeted Re-Analysis (Minimal Token Cost)
Only re-analyze files with structural changes. This is the **only** phase that costs LLM tokens.
1. Read the existing knowledge graph from `$PROJECT_ROOT/.understand-anything/knowledge-graph.json`.
2. Batch the files from `filesToReanalyze` (from Phase 1). Use a single batch if ≤10 files, otherwise batch into groups of 5-10.
3. For each batch, dispatch a subagent using the prompt template at `../skills/understand/file-analyzer-prompt.md`. Read the template file and pass the full content as the subagent's prompt, appending:
> **Additional context from main session:**
>
> Project: `<projectName from existing graph>` — `<projectDescription>`
> Frameworks detected: `<frameworks from existing graph>`
> Languages: `<languages from existing graph>`
>
> **IMPORTANT:** This is an incremental update. Only the files listed below have structural changes. Analyze them thoroughly but do not invent nodes for files not in this batch.
Fill in batch-specific parameters:
> Analyze these source files and produce GraphNode and GraphEdge objects.
> Project root: `$PROJECT_ROOT`
> Project: `<projectName>`
> Languages: `<languages>`
> Batch index: `1`
> Write output to: `$PROJECT_ROOT/.understand-anything/intermediate/batch-1.json`
>
> All project files (for import resolution):
> `<file list from existing graph nodes>`
>
> Files to analyze in this batch:
> 1. `<path>` (`<sizeLines>` lines)
> ...
4. After batch(es) complete, read each `batch-<N>.json` and merge results.
5. **Merge with existing graph:**
- Remove old nodes whose `filePath` matches any file in `filesToReanalyze` or in the deleted files list
- Remove old edges whose `source` or `target` references a removed node
- Add new nodes and edges from the fresh analysis
- Deduplicate nodes by ID (keep latest), edges by `source + target + type`
- Remove any edge with dangling `source` or `target` references
---
## Phase 3 — Conditional Architecture/Tour + Save
### 3a. Architecture update (only if `rerunArchitecture === true`)
If the change analysis flagged `ARCHITECTURE_UPDATE`:
1. Dispatch a subagent using the prompt template at `../skills/understand/architecture-analyzer-prompt.md`, passing the full merged node set and import edges. Include previous layer definitions for naming consistency:
> Previous layer definitions (for naming consistency):
> ```json
> [previous layers from existing graph]
> ```
> Maintain the same layer names and IDs where possible. Only add/remove layers if the file structure has materially changed.
2. After completion, read and normalize layers (same normalization as `/understand` Phase 4).
3. Optionally re-run tour builder if layers changed significantly.
### 3b. Lite layer update (if `rerunArchitecture === false`)
If only a partial update:
1. For **new files**: assign them to the most likely existing layer based on directory path matching
2. For **deleted files**: remove their IDs from layer `nodeIds` arrays
3. Remove any layer that ends up with zero nodeIds
### 3c. Lite validation
Perform lightweight validation (no graph-reviewer agent):
1. Remove any edge with dangling `source` or `target`
2. Remove any layer `nodeIds` entry that doesn't exist in the node set
3. Ensure every file node appears in exactly one layer (add to a catch-all layer if missing)
### 3d. Save
1. Write the final knowledge graph to `$PROJECT_ROOT/.understand-anything/knowledge-graph.json`.
2. Write updated metadata to `$PROJECT_ROOT/.understand-anything/meta.json`:
```json
{
"lastAnalyzedAt": "<ISO 8601 timestamp>",
"gitCommitHash": "<current commit hash>",
"version": "1.0.0",
"analyzedFiles": <total file count in graph>
}
```
3. **Update fingerprints:** Write and execute a Node.js script that:
- Reads the existing `fingerprints.json`
- For each re-analyzed file: computes new content hash and extracts structural elements via regex
- For deleted files: removes their entries
- Merges with existing fingerprints (keep unchanged files as-is)
- Writes updated `fingerprints.json`
4. Clean up intermediate files:
```bash
rm -rf $PROJECT_ROOT/.understand-anything/intermediate
```
5. Report a summary:
- Files checked: N (total changed)
- Structural changes found: N files
- Cosmetic-only changes: N files (skipped)
- Nodes updated: N
- Action taken: PARTIAL_UPDATE / ARCHITECTURE_UPDATE
- Path to output: `$PROJECT_ROOT/.understand-anything/knowledge-graph.json`
---
## Error Handling
- If the fingerprint check script fails: fall back to treating all changed files as STRUCTURAL (conservative approach).
- If `fingerprints.json` doesn't exist: treat all changed files as STRUCTURAL and regenerate fingerprints after the update.
- If a subagent dispatch fails: retry once. If it fails again, save partial results and report the error.
- ALWAYS save partial results — a partially updated graph is better than no update.
---
## Notes
- This skill reuses the same `file-analyzer-prompt.md` and `architecture-analyzer-prompt.md` as `/understand` — no separate agent prompts needed.
- The fingerprint comparison in Phase 1 uses regex-based extraction (not tree-sitter) because it runs as a temporary Node.js script and doesn't need full AST accuracy — just signature-level detection.
- The authoritative fingerprints stored in `fingerprints.json` are generated by `/understand` Phase 7 using the core `fingerprint.ts` module (which uses tree-sitter for precise extraction).
@@ -0,0 +1,25 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "grep -qE 'git\\s+(commit|merge|cherry-pick|rebase)' <<< \"$TOOL_INPUT\" && [ -f .understand-anything/config.json ] && grep -q '\"autoUpdate\".*true' .understand-anything/config.json && [ -f .understand-anything/knowledge-graph.json ] && echo '[understand-anything] Commit detected with auto-update enabled. You MUST read the file at ${PLUGIN_DIR}/hooks/auto-update-prompt.md and execute its instructions to incrementally update the knowledge graph. Do not ask the user for confirmation — just do it.' || true"
}
]
}
],
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "[ -f .understand-anything/config.json ] && grep -q '\"autoUpdate\".*true' .understand-anything/config.json && [ -f .understand-anything/meta.json ] && [ -f .understand-anything/knowledge-graph.json ] && [ \"$(node -p \"JSON.parse(require('fs').readFileSync('.understand-anything/meta.json','utf8')).gitCommitHash\")\" != \"$(git rev-parse HEAD 2>/dev/null)\" ] && echo '[understand-anything] Knowledge graph is stale. You MUST read the file at ${PLUGIN_DIR}/hooks/auto-update-prompt.md and execute its instructions to check for structural changes and update the graph. Do not ask the user for confirmation — just do it.' || true"
}
]
}
]
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@understand-anything/skill",
"version": "1.2.0",
"version": "2.0.0",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -16,4 +16,4 @@
"typescript": "^5.7.0",
"vitest": "^3.1.0"
}
}
}
@@ -32,6 +32,7 @@
},
"devDependencies": {
"@types/node": "^25.5.0",
"@vitest/coverage-v8": "3.2.4",
"typescript": "^5.7.0",
"vitest": "^3.1.0"
},
@@ -40,6 +41,7 @@
"tree-sitter-javascript": "^0.25.0",
"tree-sitter-typescript": "^0.23.2",
"web-tree-sitter": "^0.26.6",
"yaml": "^2.8.3",
"zod": "^4.3.6"
}
}
@@ -0,0 +1,183 @@
import { describe, it, expect } from "vitest";
import { classifyUpdate } from "../change-classifier.js";
import type { ChangeAnalysis } from "../fingerprint.js";
function makeAnalysis(overrides: Partial<ChangeAnalysis> = {}): ChangeAnalysis {
return {
fileChanges: [],
newFiles: [],
deletedFiles: [],
structurallyChangedFiles: [],
cosmeticOnlyFiles: [],
unchangedFiles: [],
...overrides,
};
}
describe("classifyUpdate", () => {
it("returns SKIP when all files are unchanged", () => {
const analysis = makeAnalysis({
unchangedFiles: ["src/a.ts", "src/b.ts"],
});
const decision = classifyUpdate(analysis, 50);
expect(decision.action).toBe("SKIP");
expect(decision.filesToReanalyze).toHaveLength(0);
expect(decision.rerunArchitecture).toBe(false);
expect(decision.rerunTour).toBe(false);
});
it("returns SKIP when all changes are cosmetic", () => {
const analysis = makeAnalysis({
cosmeticOnlyFiles: ["src/a.ts", "src/b.ts"],
});
const decision = classifyUpdate(analysis, 50);
expect(decision.action).toBe("SKIP");
expect(decision.reason).toContain("cosmetic-only");
});
it("returns PARTIAL_UPDATE for a few structural changes", () => {
const analysis = makeAnalysis({
structurallyChangedFiles: ["src/a.ts", "src/b.ts"],
newFiles: ["src/c.ts"],
cosmeticOnlyFiles: ["src/d.ts"],
});
// src/ already exists in the project, so adding src/c.ts is not a directory change
const allKnownFiles = ["src/a.ts", "src/b.ts", "src/d.ts", "lib/util.ts"];
const decision = classifyUpdate(analysis, 50, allKnownFiles);
expect(decision.action).toBe("PARTIAL_UPDATE");
expect(decision.filesToReanalyze).toEqual(["src/a.ts", "src/b.ts", "src/c.ts"]);
expect(decision.rerunArchitecture).toBe(false);
expect(decision.rerunTour).toBe(false);
});
it("returns ARCHITECTURE_UPDATE when >10 structural files", () => {
const files = Array.from({ length: 12 }, (_, i) => `src/file${i}.ts`);
const analysis = makeAnalysis({
structurallyChangedFiles: files,
});
const decision = classifyUpdate(analysis, 50);
expect(decision.action).toBe("ARCHITECTURE_UPDATE");
expect(decision.rerunArchitecture).toBe(true);
expect(decision.rerunTour).toBe(true);
});
it("returns ARCHITECTURE_UPDATE when new directories appear", () => {
const analysis = makeAnalysis({
structurallyChangedFiles: ["src/existing.ts"],
newFiles: ["newdir/file.ts"],
});
const allKnownFiles = ["src/existing.ts", "src/other.ts", "lib/util.ts"];
const decision = classifyUpdate(analysis, 50, allKnownFiles);
expect(decision.action).toBe("ARCHITECTURE_UPDATE");
expect(decision.rerunArchitecture).toBe(true);
});
it("returns ARCHITECTURE_UPDATE when directories are deleted", () => {
const analysis = makeAnalysis({
structurallyChangedFiles: ["src/existing.ts"],
deletedFiles: ["olddir/removed.ts"],
});
const allKnownFiles = ["src/existing.ts", "src/other.ts"];
const decision = classifyUpdate(analysis, 50, allKnownFiles);
expect(decision.action).toBe("ARCHITECTURE_UPDATE");
expect(decision.rerunArchitecture).toBe(true);
});
it("does NOT trigger ARCHITECTURE_UPDATE for new file in existing directory", () => {
const analysis = makeAnalysis({
newFiles: ["src/newfile.ts"],
});
// src/ is already known via other files in the project
const allKnownFiles = ["src/a.ts", "src/b.ts", "lib/util.ts"];
const decision = classifyUpdate(analysis, 50, allKnownFiles);
expect(decision.action).toBe("PARTIAL_UPDATE");
expect(decision.rerunArchitecture).toBe(false);
});
it("triggers ARCHITECTURE_UPDATE for new file in genuinely new directory", () => {
const analysis = makeAnalysis({
newFiles: ["brand-new-pkg/index.ts"],
});
// allKnownFiles only contains src/ and lib/ — no brand-new-pkg/
const allKnownFiles = ["src/a.ts", "src/b.ts", "lib/util.ts"];
const decision = classifyUpdate(analysis, 50, allKnownFiles);
expect(decision.action).toBe("ARCHITECTURE_UPDATE");
expect(decision.rerunArchitecture).toBe(true);
});
it("returns FULL_UPDATE when >30 structural files", () => {
const files = Array.from({ length: 35 }, (_, i) => `src/file${i}.ts`);
const analysis = makeAnalysis({
structurallyChangedFiles: files,
});
const decision = classifyUpdate(analysis, 100);
expect(decision.action).toBe("FULL_UPDATE");
expect(decision.rerunArchitecture).toBe(true);
expect(decision.rerunTour).toBe(true);
});
it("returns FULL_UPDATE when >50% of project is structurally changed", () => {
const files = Array.from({ length: 6 }, (_, i) => `src/file${i}.ts`);
const analysis = makeAnalysis({
structurallyChangedFiles: files,
});
// 6 out of 10 files = 60%
const decision = classifyUpdate(analysis, 10);
expect(decision.action).toBe("FULL_UPDATE");
});
it("includes new and structural files in filesToReanalyze for PARTIAL", () => {
const analysis = makeAnalysis({
structurallyChangedFiles: ["src/modified.ts"],
newFiles: ["src/added.ts"],
deletedFiles: ["src/removed.ts"],
});
const decision = classifyUpdate(analysis, 50);
expect(decision.filesToReanalyze).toContain("src/modified.ts");
expect(decision.filesToReanalyze).toContain("src/added.ts");
// Deleted files shouldn't be re-analyzed
expect(decision.filesToReanalyze).not.toContain("src/removed.ts");
});
it("handles empty analysis (no changes at all)", () => {
const analysis = makeAnalysis();
const decision = classifyUpdate(analysis, 50);
expect(decision.action).toBe("SKIP");
expect(decision.reason).toContain("No changes detected");
});
it("counts deleted files toward structural total", () => {
// 8 structural + 3 deleted = 11 total structural > 10 threshold
const analysis = makeAnalysis({
structurallyChangedFiles: Array.from({ length: 8 }, (_, i) => `src/file${i}.ts`),
deletedFiles: ["src/old1.ts", "src/old2.ts", "src/old3.ts"],
});
const decision = classifyUpdate(analysis, 50);
expect(decision.action).toBe("ARCHITECTURE_UPDATE");
});
});
@@ -0,0 +1,427 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { StructuralAnalysis } from "../types.js";
import {
contentHash,
extractFileFingerprint,
compareFingerprints,
analyzeChanges,
type FileFingerprint,
type FingerprintStore,
} from "../fingerprint.js";
// Mock fs and path for analyzeChanges
vi.mock("node:fs", () => ({
readFileSync: vi.fn(),
existsSync: vi.fn(),
}));
import { readFileSync, existsSync } from "node:fs";
const mockedReadFileSync = vi.mocked(readFileSync);
const mockedExistsSync = vi.mocked(existsSync);
beforeEach(() => {
vi.clearAllMocks();
});
describe("contentHash", () => {
it("produces consistent SHA-256 hashes", () => {
const hash1 = contentHash("hello world");
const hash2 = contentHash("hello world");
expect(hash1).toBe(hash2);
expect(hash1).toMatch(/^[a-f0-9]{64}$/);
});
it("produces different hashes for different content", () => {
expect(contentHash("hello")).not.toBe(contentHash("world"));
});
});
describe("extractFileFingerprint", () => {
it("extracts function fingerprints from analysis", () => {
const analysis: StructuralAnalysis = {
functions: [
{ name: "main", lineRange: [1, 20], params: ["config", "options"], returnType: "void" },
{ name: "helper", lineRange: [22, 30], params: [], returnType: "string" },
],
classes: [],
imports: [],
exports: [{ name: "main", lineNumber: 1 }],
};
const fp = extractFileFingerprint("src/index.ts", "const x = 1;\n".repeat(30), analysis);
expect(fp.filePath).toBe("src/index.ts");
expect(fp.functions).toHaveLength(2);
expect(fp.functions[0]).toEqual({
name: "main",
params: ["config", "options"],
returnType: "void",
exported: true,
lineCount: 20,
});
expect(fp.functions[1]).toEqual({
name: "helper",
params: [],
returnType: "string",
exported: false,
lineCount: 9,
});
});
it("extracts class fingerprints", () => {
const analysis: StructuralAnalysis = {
functions: [],
classes: [
{ name: "MyClass", lineRange: [1, 50], methods: ["doStuff", "init"], properties: ["name"] },
],
imports: [],
exports: [{ name: "MyClass", lineNumber: 1 }],
};
const fp = extractFileFingerprint("src/my-class.ts", "x\n".repeat(50), analysis);
expect(fp.classes).toHaveLength(1);
expect(fp.classes[0]).toEqual({
name: "MyClass",
methods: ["doStuff", "init"],
properties: ["name"],
exported: true,
lineCount: 50,
});
});
it("extracts import and export fingerprints", () => {
const analysis: StructuralAnalysis = {
functions: [],
classes: [],
imports: [
{ source: "./utils", specifiers: ["format", "parse"], lineNumber: 1 },
{ source: "node:fs", specifiers: ["readFileSync"], lineNumber: 2 },
],
exports: [{ name: "main", lineNumber: 5 }, { name: "default", lineNumber: 10 }],
};
const fp = extractFileFingerprint("src/index.ts", "x\n", analysis);
expect(fp.imports).toHaveLength(2);
expect(fp.imports[0]).toEqual({ source: "./utils", specifiers: ["format", "parse"] });
expect(fp.exports).toEqual(["main", "default"]);
});
it("computes content hash and total lines", () => {
const content = "line1\nline2\nline3\n";
const analysis: StructuralAnalysis = {
functions: [],
classes: [],
imports: [],
exports: [],
};
const fp = extractFileFingerprint("src/empty.ts", content, analysis);
expect(fp.contentHash).toBe(contentHash(content));
expect(fp.totalLines).toBe(4); // 3 lines + trailing newline = 4 elements
});
});
describe("compareFingerprints", () => {
const baseFp: FileFingerprint = {
filePath: "src/index.ts",
contentHash: "abc123",
functions: [
{ name: "main", params: ["config"], returnType: "void", exported: true, lineCount: 20 },
],
classes: [],
imports: [{ source: "./utils", specifiers: ["format"] }],
exports: ["main"],
totalLines: 30,
hasStructuralAnalysis: true,
};
it("returns NONE when content hash is identical", () => {
const result = compareFingerprints(baseFp, { ...baseFp });
expect(result.changeLevel).toBe("NONE");
expect(result.details).toHaveLength(0);
});
it("returns COSMETIC when content changed but structure is identical", () => {
const newFp = { ...baseFp, contentHash: "different_hash" };
const result = compareFingerprints(baseFp, newFp);
expect(result.changeLevel).toBe("COSMETIC");
expect(result.details).toContain("internal logic changed (no structural impact)");
});
it("detects new functions", () => {
const newFp: FileFingerprint = {
...baseFp,
contentHash: "different",
functions: [
...baseFp.functions,
{ name: "newFunc", params: [], exported: false, lineCount: 10 },
],
};
const result = compareFingerprints(baseFp, newFp);
expect(result.changeLevel).toBe("STRUCTURAL");
expect(result.details).toContain("new function: newFunc");
});
it("detects removed functions", () => {
const newFp: FileFingerprint = {
...baseFp,
contentHash: "different",
functions: [],
};
const result = compareFingerprints(baseFp, newFp);
expect(result.changeLevel).toBe("STRUCTURAL");
expect(result.details).toContain("removed function: main");
});
it("detects parameter changes", () => {
const newFp: FileFingerprint = {
...baseFp,
contentHash: "different",
functions: [
{ name: "main", params: ["config", "options"], returnType: "void", exported: true, lineCount: 20 },
],
};
const result = compareFingerprints(baseFp, newFp);
expect(result.changeLevel).toBe("STRUCTURAL");
expect(result.details).toContain("params changed: main");
});
it("detects export status changes", () => {
const newFp: FileFingerprint = {
...baseFp,
contentHash: "different",
functions: [
{ name: "main", params: ["config"], returnType: "void", exported: false, lineCount: 20 },
],
};
const result = compareFingerprints(baseFp, newFp);
expect(result.changeLevel).toBe("STRUCTURAL");
expect(result.details).toContain("export status changed: main");
});
it("detects significant size changes (>50%)", () => {
const newFp: FileFingerprint = {
...baseFp,
contentHash: "different",
functions: [
{ name: "main", params: ["config"], returnType: "void", exported: true, lineCount: 60 },
],
};
const result = compareFingerprints(baseFp, newFp);
expect(result.changeLevel).toBe("STRUCTURAL");
expect(result.details.some((d) => d.includes("significant size change"))).toBe(true);
});
it("detects import changes", () => {
const newFp: FileFingerprint = {
...baseFp,
contentHash: "different",
imports: [{ source: "./helpers", specifiers: ["doStuff"] }],
};
const result = compareFingerprints(baseFp, newFp);
expect(result.changeLevel).toBe("STRUCTURAL");
expect(result.details).toContain("imports changed");
});
it("detects export list changes", () => {
const newFp: FileFingerprint = {
...baseFp,
contentHash: "different",
exports: ["main", "helper"],
};
const result = compareFingerprints(baseFp, newFp);
expect(result.changeLevel).toBe("STRUCTURAL");
expect(result.details).toContain("exports changed");
});
it("detects new and removed classes", () => {
const withClass: FileFingerprint = {
...baseFp,
contentHash: "different",
classes: [{ name: "MyClass", methods: ["init"], properties: [], exported: true, lineCount: 30 }],
hasStructuralAnalysis: true,
};
const result = compareFingerprints(baseFp, withClass);
expect(result.changeLevel).toBe("STRUCTURAL");
expect(result.details).toContain("new class: MyClass");
});
it("detects class method changes", () => {
const oldFp: FileFingerprint = {
...baseFp,
classes: [{ name: "Foo", methods: ["a", "b"], properties: [], exported: true, lineCount: 30 }],
hasStructuralAnalysis: true,
};
const newFp: FileFingerprint = {
...baseFp,
contentHash: "different",
classes: [{ name: "Foo", methods: ["a", "c"], properties: [], exported: true, lineCount: 30 }],
hasStructuralAnalysis: true,
};
const result = compareFingerprints(oldFp, newFp);
expect(result.changeLevel).toBe("STRUCTURAL");
expect(result.details).toContain("methods changed: Foo");
});
it("does NOT mutate input arrays (sort must use spread-copy)", () => {
const oldFp: FileFingerprint = {
...baseFp,
classes: [{ name: "Foo", methods: ["b", "a"], properties: ["y", "x"], exported: true, lineCount: 30 }],
imports: [{ source: "./utils", specifiers: ["z", "a"] }],
hasStructuralAnalysis: true,
};
const newFp: FileFingerprint = {
...baseFp,
contentHash: "different",
classes: [{ name: "Foo", methods: ["b", "a"], properties: ["y", "x"], exported: true, lineCount: 30 }],
imports: [{ source: "./utils", specifiers: ["z", "a"] }],
hasStructuralAnalysis: true,
};
// Snapshot original order before comparison
const oldMethodsBefore = [...oldFp.classes[0].methods];
const oldPropertiesBefore = [...oldFp.classes[0].properties];
const oldSpecifiersBefore = [...oldFp.imports[0].specifiers];
const newMethodsBefore = [...newFp.classes[0].methods];
const newPropertiesBefore = [...newFp.classes[0].properties];
const newSpecifiersBefore = [...newFp.imports[0].specifiers];
compareFingerprints(oldFp, newFp);
// Arrays must remain in their original order (not sorted in-place)
expect(oldFp.classes[0].methods).toEqual(oldMethodsBefore);
expect(oldFp.classes[0].properties).toEqual(oldPropertiesBefore);
expect(oldFp.imports[0].specifiers).toEqual(oldSpecifiersBefore);
expect(newFp.classes[0].methods).toEqual(newMethodsBefore);
expect(newFp.classes[0].properties).toEqual(newPropertiesBefore);
expect(newFp.imports[0].specifiers).toEqual(newSpecifiersBefore);
});
it("classifies as STRUCTURAL when hasStructuralAnalysis is false (no tree-sitter)", () => {
const oldFp: FileFingerprint = {
filePath: "config.yaml",
contentHash: "hash_old",
functions: [],
classes: [],
imports: [],
exports: [],
totalLines: 10,
hasStructuralAnalysis: false,
};
const newFp: FileFingerprint = {
filePath: "config.yaml",
contentHash: "hash_new",
functions: [],
classes: [],
imports: [],
exports: [],
totalLines: 12,
hasStructuralAnalysis: false,
};
const result = compareFingerprints(oldFp, newFp);
expect(result.changeLevel).toBe("STRUCTURAL");
expect(result.details).toContain("no structural analysis available — conservative classification");
});
});
describe("analyzeChanges", () => {
const mockRegistry = {
analyzeFile: vi.fn(),
} as any;
const existingStore: FingerprintStore = {
version: "1.0.0",
gitCommitHash: "abc123",
generatedAt: "2026-01-01T00:00:00.000Z",
files: {
"src/index.ts": {
filePath: "src/index.ts",
contentHash: "hash_a",
functions: [{ name: "main", params: [], exported: true, lineCount: 20 }],
classes: [],
imports: [],
exports: ["main"],
totalLines: 30,
hasStructuralAnalysis: true,
},
"src/utils.ts": {
filePath: "src/utils.ts",
contentHash: "hash_b",
functions: [],
classes: [],
imports: [],
exports: [],
totalLines: 10,
hasStructuralAnalysis: true,
},
},
};
it("classifies new files as STRUCTURAL", () => {
mockedExistsSync.mockReturnValue(true);
mockedReadFileSync.mockReturnValue("new content");
mockRegistry.analyzeFile.mockReturnValue({
functions: [],
classes: [],
imports: [],
exports: [],
});
const result = analyzeChanges("/project", ["src/new-file.ts"], existingStore, mockRegistry);
expect(result.newFiles).toContain("src/new-file.ts");
expect(result.fileChanges[0].changeLevel).toBe("STRUCTURAL");
});
it("classifies deleted files as STRUCTURAL", () => {
mockedExistsSync.mockReturnValue(false);
const result = analyzeChanges("/project", ["src/utils.ts"], existingStore, mockRegistry);
expect(result.deletedFiles).toContain("src/utils.ts");
expect(result.fileChanges[0].changeLevel).toBe("STRUCTURAL");
});
it("classifies unchanged content as NONE", () => {
mockedExistsSync.mockReturnValue(true);
// Return content that produces the same hash
const content = "test content";
const hash = contentHash(content);
const store: FingerprintStore = {
...existingStore,
files: {
"src/index.ts": {
...existingStore.files["src/index.ts"],
contentHash: hash,
},
},
};
mockedReadFileSync.mockReturnValue(content);
mockRegistry.analyzeFile.mockReturnValue({
functions: [{ name: "main", lineRange: [1, 20], params: [] }],
classes: [],
imports: [],
exports: [{ name: "main", lineNumber: 1 }],
});
const result = analyzeChanges("/project", ["src/index.ts"], store, mockRegistry);
expect(result.unchangedFiles).toContain("src/index.ts");
});
it("ignores deleted files not in the store", () => {
mockedExistsSync.mockReturnValue(false);
const result = analyzeChanges("/project", ["src/unknown.ts"], existingStore, mockRegistry);
expect(result.deletedFiles).toHaveLength(0);
expect(result.fileChanges).toHaveLength(0);
});
});
@@ -1,5 +1,6 @@
import { describe, it, expect } from "vitest";
import { LanguageRegistry } from "../languages/language-registry.js";
import { StrictLanguageConfigSchema } from "../languages/types.js";
import { typescriptConfig } from "../languages/configs/typescript.js";
import { pythonConfig } from "../languages/configs/python.js";
@@ -32,9 +33,9 @@ describe("LanguageRegistry", () => {
expect(registry.getForFile("file.unknown")).toBeNull();
});
it("returns null for files without extensions", () => {
it("returns null for files without extensions and no filename match", () => {
const registry = new LanguageRegistry();
expect(registry.getForFile("Makefile")).toBeNull();
expect(registry.getForFile("SOMEFILE")).toBeNull();
});
it("lists all registered languages", () => {
@@ -48,10 +49,10 @@ describe("LanguageRegistry", () => {
});
describe("createDefault", () => {
it("registers all 12 built-in language configs", () => {
it("registers all 38 built-in language configs", () => {
const registry = LanguageRegistry.createDefault();
const all = registry.getAllLanguages();
expect(all.length).toBe(12);
expect(all.length).toBe(38);
});
it("maps all expected extensions", () => {
@@ -88,4 +89,107 @@ describe("LanguageRegistry", () => {
}
});
});
describe("Non-code language configs", () => {
it("detects all non-code file types via extension", () => {
const registry = LanguageRegistry.createDefault();
const expectations: [string, string][] = [
["README.md", "markdown"],
["config.yaml", "yaml"],
["package.json", "json"],
["config.toml", "toml"],
[".env", "env"],
["pom.xml", "xml"],
["Dockerfile", "dockerfile"],
["schema.sql", "sql"],
["schema.graphql", "graphql"],
["types.proto", "protobuf"],
["main.tf", "terraform"],
["Makefile", "makefile"],
["deploy.sh", "shell"],
["index.html", "html"],
["styles.css", "css"],
["data.csv", "csv"],
["deploy.ps1", "powershell"],
];
for (const [file, expectedId] of expectations) {
const config = registry.getForFile(file);
expect(config?.id, `${file} should be detected as ${expectedId}`).toBe(expectedId);
}
});
it("detects filename-based configs (Dockerfile, Makefile, Jenkinsfile)", () => {
const registry = LanguageRegistry.createDefault();
expect(registry.getForFile("Dockerfile")?.id).toBe("dockerfile");
expect(registry.getForFile("Makefile")?.id).toBe("makefile");
expect(registry.getForFile("Jenkinsfile")?.id).toBe("jenkinsfile");
expect(registry.getForFile("src/Dockerfile")?.id).toBe("dockerfile");
expect(registry.getForFile("build/Makefile")?.id).toBe("makefile");
});
it("detects filename-based configs for docker-compose", () => {
const registry = LanguageRegistry.createDefault();
expect(registry.getForFile("docker-compose.yml")?.id).toBe("docker-compose");
expect(registry.getForFile("docker-compose.yaml")?.id).toBe("docker-compose");
expect(registry.getForFile("compose.yml")?.id).toBe("docker-compose");
});
it("detects .env file variants", () => {
const registry = LanguageRegistry.createDefault();
expect(registry.getForFile(".env")?.id).toBe("env");
expect(registry.getForFile(".env.local")?.id).toBe("env");
expect(registry.getForFile(".env.production")?.id).toBe("env");
});
});
describe("StrictLanguageConfigSchema refinement", () => {
it("rejects configs with empty extensions AND no filenames", () => {
const result = StrictLanguageConfigSchema.safeParse({
id: "empty-lang",
displayName: "Empty",
extensions: [],
concepts: ["nothing"],
filePatterns: { entryPoints: [], barrels: [], tests: [], config: [] },
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toContain("at least one extension or filename");
}
});
it("rejects configs with empty extensions AND empty filenames", () => {
const result = StrictLanguageConfigSchema.safeParse({
id: "empty-lang",
displayName: "Empty",
extensions: [],
filenames: [],
concepts: ["nothing"],
filePatterns: { entryPoints: [], barrels: [], tests: [], config: [] },
});
expect(result.success).toBe(false);
});
it("accepts configs with extensions but no filenames", () => {
const result = StrictLanguageConfigSchema.safeParse({
id: "ext-lang",
displayName: "ExtLang",
extensions: [".ext"],
concepts: ["something"],
filePatterns: { entryPoints: [], barrels: [], tests: [], config: [] },
});
expect(result.success).toBe(true);
});
it("accepts configs with filenames but empty extensions", () => {
const result = StrictLanguageConfigSchema.safeParse({
id: "filename-lang",
displayName: "FilenameLang",
extensions: [],
filenames: ["Specialfile"],
concepts: ["something"],
filePatterns: { entryPoints: [], barrels: [], tests: [], config: [] },
});
expect(result.success).toBe(true);
});
});
});
@@ -0,0 +1,503 @@
import { describe, it, expect } from "vitest";
import { MarkdownParser } from "../plugins/parsers/markdown-parser.js";
import { YAMLConfigParser } from "../plugins/parsers/yaml-parser.js";
import { JSONConfigParser } from "../plugins/parsers/json-parser.js";
import { TOMLParser } from "../plugins/parsers/toml-parser.js";
import { EnvParser } from "../plugins/parsers/env-parser.js";
import { DockerfileParser } from "../plugins/parsers/dockerfile-parser.js";
import { SQLParser } from "../plugins/parsers/sql-parser.js";
import { GraphQLParser } from "../plugins/parsers/graphql-parser.js";
import { ProtobufParser } from "../plugins/parsers/protobuf-parser.js";
import { TerraformParser } from "../plugins/parsers/terraform-parser.js";
import { MakefileParser } from "../plugins/parsers/makefile-parser.js";
import { ShellParser } from "../plugins/parsers/shell-parser.js";
import { registerAllParsers } from "../plugins/parsers/index.js";
import { PluginRegistry } from "../plugins/registry.js";
describe("MarkdownParser", () => {
const parser = new MarkdownParser();
it("extracts heading sections", () => {
const content = "# Title\n\nIntro\n\n## Section A\n\nContent A\n\n### Subsection\n\nContent B";
const result = parser.analyzeFile("README.md", content);
expect(result.sections).toHaveLength(3);
expect(result.sections![0]).toMatchObject({ name: "Title", level: 1 });
expect(result.sections![1]).toMatchObject({ name: "Section A", level: 2 });
expect(result.sections![2]).toMatchObject({ name: "Subsection", level: 3 });
});
it("extracts YAML front matter as imports", () => {
const content = "---\ntitle: Test\ntags: [a, b]\n---\n# Content";
const result = parser.analyzeFile("post.md", content);
expect(result.imports).toHaveLength(0);
});
it("extracts file references", () => {
const content = "See [guide](./docs/guide.md) and ![img](./assets/logo.png)";
const refs = parser.extractReferences!("README.md", content);
expect(refs).toHaveLength(2);
expect(refs[0]).toMatchObject({ target: "./docs/guide.md", referenceType: "file" });
expect(refs[1]).toMatchObject({ target: "./assets/logo.png", referenceType: "image" });
});
it("skips external URLs in references", () => {
const content = "[link](https://example.com) and [local](./file.md)";
const refs = parser.extractReferences!("README.md", content);
expect(refs).toHaveLength(1);
expect(refs[0].target).toBe("./file.md");
});
it("returns empty sections for empty content", () => {
const result = parser.analyzeFile("empty.md", "");
expect(result.sections).toHaveLength(0);
});
});
describe("YAMLConfigParser", () => {
const parser = new YAMLConfigParser();
it("extracts top-level key sections", () => {
const content = "name: my-app\nversion: 1.0\nservices:\n web:\n image: node\n db:\n image: postgres";
const result = parser.analyzeFile("config.yaml", content);
expect(result.sections).toBeDefined();
expect(result.sections!.length).toBeGreaterThanOrEqual(3);
expect(result.sections!.map(s => s.name)).toContain("name");
expect(result.sections!.map(s => s.name)).toContain("services");
});
it("handles invalid YAML gracefully", () => {
const content = "invalid: yaml: content: [[[";
const result = parser.analyzeFile("broken.yaml", content);
expect(result.sections).toBeDefined();
});
});
describe("JSONConfigParser", () => {
const parser = new JSONConfigParser();
it("extracts top-level key sections", () => {
const content = '{\n "name": "my-app",\n "version": "1.0",\n "dependencies": {}\n}';
const result = parser.analyzeFile("package.json", content);
expect(result.sections).toBeDefined();
expect(result.sections!.map(s => s.name)).toContain("name");
expect(result.sections!.map(s => s.name)).toContain("dependencies");
});
it("extracts $ref references", () => {
const content = '{\n "$ref": "./common.json#/defs/User"\n}';
const refs = parser.extractReferences!("schema.json", content);
expect(refs).toHaveLength(1);
expect(refs[0]).toMatchObject({ target: "./common.json#/defs/User", referenceType: "schema" });
});
it("skips internal $ref references", () => {
const content = '{\n "$ref": "#/definitions/User"\n}';
const refs = parser.extractReferences!("schema.json", content);
expect(refs).toHaveLength(0);
});
it("handles invalid JSON gracefully", () => {
const content = "not json at all";
const result = parser.analyzeFile("broken.json", content);
expect(result.sections).toHaveLength(0);
});
});
describe("TOMLParser", () => {
const parser = new TOMLParser();
it("extracts section headers", () => {
const content = "[package]\nname = \"my-app\"\n\n[dependencies]\nfoo = \"1.0\"\n\n[[bin]]\nname = \"cli\"";
const result = parser.analyzeFile("Cargo.toml", content);
expect(result.sections).toBeDefined();
expect(result.sections!.length).toBe(3);
expect(result.sections![0].name).toBe("package");
expect(result.sections![1].name).toBe("dependencies");
expect(result.sections![2].name).toBe("[[bin]]");
});
});
describe("EnvParser", () => {
const parser = new EnvParser();
it("extracts variable names", () => {
const content = "# Database config\nDB_HOST=localhost\nDB_PORT=5432\n\n# API\nAPI_KEY=secret123";
const result = parser.analyzeFile(".env", content);
expect(result.definitions).toBeDefined();
expect(result.definitions!).toHaveLength(3);
expect(result.definitions!.map(d => d.name)).toEqual(["DB_HOST", "DB_PORT", "API_KEY"]);
});
it("skips comments and empty lines", () => {
const content = "# comment\n\nVAR=value";
const result = parser.analyzeFile(".env", content);
expect(result.definitions!).toHaveLength(1);
});
});
describe("DockerfileParser", () => {
const parser = new DockerfileParser();
it("extracts FROM stages", () => {
const content = "FROM node:22-slim AS builder\nRUN npm install\n\nFROM node:22-slim AS runner\nCOPY --from=builder /app /app\nEXPOSE 3000";
const result = parser.analyzeFile("Dockerfile", content);
expect(result.services).toBeDefined();
expect(result.services!).toHaveLength(2);
expect(result.services![0]).toMatchObject({ name: "builder", image: "node:22-slim" });
expect(result.services![1]).toMatchObject({ name: "runner", image: "node:22-slim" });
});
it("extracts EXPOSE ports", () => {
const content = "FROM node:22\nEXPOSE 3000 8080\nCMD [\"node\", \"server.js\"]";
const result = parser.analyzeFile("Dockerfile", content);
expect(result.services![0].ports).toContain(3000);
expect(result.services![0].ports).toContain(8080);
});
it("extracts steps", () => {
const content = "FROM node:22\nWORKDIR /app\nCOPY . .\nRUN npm install\nCMD [\"node\", \"start\"]";
const result = parser.analyzeFile("Dockerfile", content);
expect(result.steps).toBeDefined();
expect(result.steps!.length).toBe(5);
});
});
describe("SQLParser", () => {
const parser = new SQLParser();
it("extracts CREATE TABLE definitions with columns", () => {
const content = `CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE
);
CREATE TABLE posts (
id INTEGER PRIMARY KEY,
user_id INTEGER,
title TEXT,
FOREIGN KEY (user_id) REFERENCES users(id)
);`;
const result = parser.analyzeFile("schema.sql", content);
expect(result.definitions).toBeDefined();
expect(result.definitions!).toHaveLength(2);
expect(result.definitions![0]).toMatchObject({ name: "users", kind: "table" });
expect(result.definitions![0].fields).toContain("id");
expect(result.definitions![0].fields).toContain("name");
expect(result.definitions![0].fields).toContain("email");
expect(result.definitions![1]).toMatchObject({ name: "posts", kind: "table" });
});
it("extracts CREATE VIEW", () => {
const content = "CREATE VIEW active_users AS SELECT * FROM users WHERE active = true;";
const result = parser.analyzeFile("views.sql", content);
expect(result.definitions!.some(d => d.name === "active_users" && d.kind === "view")).toBe(true);
});
it("extracts CREATE INDEX", () => {
const content = "CREATE UNIQUE INDEX idx_users_email ON users(email);";
const result = parser.analyzeFile("indexes.sql", content);
expect(result.definitions!.some(d => d.name === "idx_users_email" && d.kind === "index")).toBe(true);
});
});
describe("GraphQLParser", () => {
const parser = new GraphQLParser();
it("extracts type definitions", () => {
const content = `type User {
id: ID!
name: String!
email: String!
}
type Post {
id: ID!
title: String!
author: User!
}`;
const result = parser.analyzeFile("schema.graphql", content);
expect(result.definitions).toBeDefined();
expect(result.definitions!).toHaveLength(2);
expect(result.definitions![0]).toMatchObject({ name: "User", kind: "type" });
expect(result.definitions![0].fields).toContain("id");
expect(result.definitions![0].fields).toContain("name");
expect(result.definitions![1]).toMatchObject({ name: "Post", kind: "type" });
});
it("extracts Query/Mutation endpoints", () => {
const content = `type Query {
users: [User!]!
user(id: ID!): User
}
type Mutation {
createUser(name: String!): User!
}`;
const result = parser.analyzeFile("schema.graphql", content);
expect(result.endpoints).toBeDefined();
expect(result.endpoints!.length).toBeGreaterThanOrEqual(3);
expect(result.endpoints!.some(e => e.method === "Query" && e.path === "users")).toBe(true);
expect(result.endpoints!.some(e => e.method === "Mutation" && e.path === "createUser")).toBe(true);
});
it("extracts enum definitions", () => {
const content = "enum Role {\n ADMIN\n USER\n GUEST\n}";
const result = parser.analyzeFile("schema.graphql", content);
expect(result.definitions!.some(d => d.name === "Role" && d.kind === "enum")).toBe(true);
});
});
describe("ProtobufParser", () => {
const parser = new ProtobufParser();
it("extracts message definitions with fields", () => {
const content = `message User {
string name = 1;
int32 age = 2;
repeated string emails = 3;
}`;
const result = parser.analyzeFile("user.proto", content);
expect(result.definitions).toBeDefined();
expect(result.definitions!).toHaveLength(1);
expect(result.definitions![0]).toMatchObject({ name: "User", kind: "message" });
expect(result.definitions![0].fields).toContain("name");
expect(result.definitions![0].fields).toContain("age");
expect(result.definitions![0].fields).toContain("emails");
});
it("extracts enum definitions", () => {
const content = "enum Status {\n UNKNOWN = 0;\n ACTIVE = 1;\n INACTIVE = 2;\n}";
const result = parser.analyzeFile("status.proto", content);
expect(result.definitions!.some(d => d.name === "Status" && d.kind === "enum")).toBe(true);
expect(result.definitions![0].fields).toContain("UNKNOWN");
expect(result.definitions![0].fields).toContain("ACTIVE");
});
it("extracts service RPC methods", () => {
const content = `service UserService {
rpc GetUser (GetUserRequest) returns (User);
rpc CreateUser (CreateUserRequest) returns (User);
}`;
const result = parser.analyzeFile("service.proto", content);
expect(result.endpoints).toBeDefined();
expect(result.endpoints!).toHaveLength(2);
expect(result.endpoints![0]).toMatchObject({ method: "rpc", path: "UserService.GetUser" });
expect(result.endpoints![1]).toMatchObject({ method: "rpc", path: "UserService.CreateUser" });
});
});
describe("TerraformParser", () => {
const parser = new TerraformParser();
it("extracts resource blocks", () => {
const content = `resource "aws_s3_bucket" "main" {
bucket = "my-bucket"
}
resource "aws_iam_role" "lambda" {
name = "lambda-role"
}`;
const result = parser.analyzeFile("main.tf", content);
expect(result.resources).toBeDefined();
expect(result.resources!).toHaveLength(2);
expect(result.resources![0]).toMatchObject({ name: "aws_s3_bucket.main", kind: "aws_s3_bucket" });
expect(result.resources![1]).toMatchObject({ name: "aws_iam_role.lambda", kind: "aws_iam_role" });
});
it("extracts data blocks", () => {
const content = 'data "aws_ami" "ubuntu" {\n most_recent = true\n}';
const result = parser.analyzeFile("data.tf", content);
expect(result.resources!.some(r => r.name === "data.aws_ami.ubuntu")).toBe(true);
});
it("extracts module blocks", () => {
const content = 'module "vpc" {\n source = "./modules/vpc"\n}';
const result = parser.analyzeFile("modules.tf", content);
expect(result.resources!.some(r => r.name === "module.vpc" && r.kind === "module")).toBe(true);
});
it("extracts variables and outputs", () => {
const content = 'variable "region" {\n default = "us-east-1"\n}\n\noutput "bucket_arn" {\n value = aws_s3_bucket.main.arn\n}';
const result = parser.analyzeFile("variables.tf", content);
expect(result.definitions).toBeDefined();
expect(result.definitions!.some(d => d.name === "region" && d.kind === "variable")).toBe(true);
expect(result.definitions!.some(d => d.name === "bucket_arn" && d.kind === "output")).toBe(true);
});
});
describe("MakefileParser", () => {
const parser = new MakefileParser();
it("extracts make targets", () => {
const content = "build:\n\tgo build -o bin/app\n\ntest:\n\tgo test ./...\n\nclean:\n\trm -rf bin/";
const result = parser.analyzeFile("Makefile", content);
expect(result.steps).toBeDefined();
expect(result.steps!).toHaveLength(3);
expect(result.steps!.map(s => s.name)).toEqual(["build", "test", "clean"]);
});
it("does not confuse variable assignments with targets", () => {
const content = "CC := gcc\nCFLAGS := -Wall\n\nbuild:\n\t$(CC) $(CFLAGS) main.c";
const result = parser.analyzeFile("Makefile", content);
expect(result.steps!).toHaveLength(1);
expect(result.steps![0].name).toBe("build");
});
});
describe("ShellParser", () => {
const parser = new ShellParser();
it("extracts function definitions", () => {
const content = "#!/bin/bash\n\ngreet() {\n echo \"Hello $1\"\n}\n\nfunction cleanup {\n rm -rf tmp/\n}";
const result = parser.analyzeFile("script.sh", content);
expect(result.functions).toHaveLength(2);
expect(result.functions[0].name).toBe("greet");
expect(result.functions[1].name).toBe("cleanup");
});
it("extracts source references", () => {
const content = "#!/bin/bash\nsource ./lib/utils.sh\n. ./lib/config.sh";
const refs = parser.extractReferences!("script.sh", content);
expect(refs).toHaveLength(2);
expect(refs[0]).toMatchObject({ target: "./lib/utils.sh", referenceType: "file" });
expect(refs[1]).toMatchObject({ target: "./lib/config.sh", referenceType: "file" });
});
});
// --- Edge case tests ---
describe("SQLParser edge cases", () => {
const parser = new SQLParser();
it("handles CREATE TABLE IF NOT EXISTS", () => {
const content = "CREATE TABLE IF NOT EXISTS users (id INT);";
const result = parser.analyzeFile("schema.sql", content);
expect(result.definitions).toBeDefined();
expect(result.definitions!).toHaveLength(1);
expect(result.definitions![0]).toMatchObject({ name: "users", kind: "table" });
expect(result.definitions![0].fields).toContain("id");
});
it("handles CREATE OR REPLACE VIEW", () => {
const content = "CREATE OR REPLACE VIEW active AS SELECT * FROM users;";
const result = parser.analyzeFile("views.sql", content);
expect(result.definitions).toBeDefined();
expect(result.definitions!.some(d => d.name === "active" && d.kind === "view")).toBe(true);
});
});
describe("GraphQLParser edge cases", () => {
const parser = new GraphQLParser();
it("extracts input type definitions", () => {
const content = "input CreateUserInput {\n name: String!\n email: String!\n}";
const result = parser.analyzeFile("schema.graphql", content);
expect(result.definitions).toBeDefined();
const inputDef = result.definitions!.find(d => d.name === "CreateUserInput");
expect(inputDef).toBeDefined();
expect(inputDef!.kind).toBe("input");
expect(inputDef!.fields).toContain("name");
});
});
describe("MakefileParser edge cases", () => {
const parser = new MakefileParser();
it("does not extract .PHONY as a target", () => {
const content = ".PHONY: build test\n\nbuild:\n\tgo build\n\ntest:\n\tgo test";
const result = parser.analyzeFile("Makefile", content);
expect(result.steps).toBeDefined();
const targetNames = result.steps!.map(s => s.name);
expect(targetNames).not.toContain(".PHONY");
expect(targetNames).toContain("build");
expect(targetNames).toContain("test");
});
});
describe("ShellParser edge cases", () => {
const parser = new ShellParser();
it("handles function with opening brace on next line", () => {
const content = "greet()\n{\n echo \"Hello\"\n}";
const result = parser.analyzeFile("script.sh", content);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].name).toBe("greet");
expect(result.functions[0].lineRange[1]).toBeGreaterThan(result.functions[0].lineRange[0]);
});
});
describe("TOMLParser edge cases", () => {
const parser = new TOMLParser();
it("returns empty sections for empty string", () => {
const result = parser.analyzeFile("empty.toml", "");
expect(result.sections).toBeDefined();
expect(result.sections).toHaveLength(0);
});
it("returns empty sections for garbage text", () => {
const result = parser.analyzeFile("garbage.toml", "this is not toml at all\nrandom garbage 123");
expect(result.sections).toBeDefined();
expect(result.sections).toHaveLength(0);
});
});
describe("DockerfileParser edge cases", () => {
const parser = new DockerfileParser();
it("assigns EXPOSE ports to the correct stage in multi-stage build", () => {
const content = "FROM node:22 AS builder\nRUN npm install\n\nFROM node:22-slim AS runner\nCOPY --from=builder /app /app\nEXPOSE 3000 8080\nCMD [\"node\", \"server.js\"]";
const result = parser.analyzeFile("Dockerfile", content);
expect(result.services).toBeDefined();
expect(result.services!).toHaveLength(2);
// Ports should be on the runner stage (second stage), not the builder
expect(result.services![0].ports).toHaveLength(0); // builder has no EXPOSE
expect(result.services![1].ports).toContain(3000);
expect(result.services![1].ports).toContain(8080);
});
it("includes lineRange for each stage", () => {
const content = "FROM node:22 AS builder\nRUN npm install\n\nFROM node:22-slim AS runner\nCOPY . .\nCMD [\"node\", \"start\"]";
const result = parser.analyzeFile("Dockerfile", content);
expect(result.services).toBeDefined();
expect(result.services!).toHaveLength(2);
expect(result.services![0].lineRange).toBeDefined();
expect(result.services![0].lineRange![0]).toBe(1);
expect(result.services![1].lineRange).toBeDefined();
expect(result.services![1].lineRange![0]).toBe(4);
});
});
describe("EnvParser edge cases", () => {
const parser = new EnvParser();
it("does not handle export VAR=value syntax", () => {
const content = "export DB_HOST=localhost\nAPI_KEY=secret";
const result = parser.analyzeFile(".env", content);
// The `export` prefix is not handled — only plain KEY=value is parsed
const names = result.definitions!.map(d => d.name);
expect(names).toContain("API_KEY");
expect(names).not.toContain("DB_HOST");
});
});
describe("registerAllParsers", () => {
it("registers all 12 parsers with a PluginRegistry", () => {
const registry = new PluginRegistry();
registerAllParsers(registry);
expect(registry.getPlugins()).toHaveLength(12);
expect(registry.getSupportedLanguages()).toContain("markdown");
expect(registry.getSupportedLanguages()).toContain("yaml");
expect(registry.getSupportedLanguages()).toContain("json");
expect(registry.getSupportedLanguages()).toContain("toml");
expect(registry.getSupportedLanguages()).toContain("env");
expect(registry.getSupportedLanguages()).toContain("dockerfile");
expect(registry.getSupportedLanguages()).toContain("sql");
expect(registry.getSupportedLanguages()).toContain("graphql");
expect(registry.getSupportedLanguages()).toContain("protobuf");
expect(registry.getSupportedLanguages()).toContain("terraform");
expect(registry.getSupportedLanguages()).toContain("makefile");
expect(registry.getSupportedLanguages()).toContain("shell");
});
});
@@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest";
import {
parsePluginConfig,
serializePluginConfig,
type PluginConfig,
type PluginEntry,
DEFAULT_PLUGIN_CONFIG,
@@ -53,6 +54,22 @@ describe("plugin-discovery", () => {
const config = parsePluginConfig(json);
expect(config.plugins[0].enabled).toBe(true);
});
it("returns default config when plugins field is not an array", () => {
const json = JSON.stringify({
plugins: "not an array",
});
const config = parsePluginConfig(json);
expect(config).toEqual(DEFAULT_PLUGIN_CONFIG);
});
it("returns default config when plugins field is missing", () => {
const json = JSON.stringify({
someOtherField: "value",
});
const config = parsePluginConfig(json);
expect(config).toEqual(DEFAULT_PLUGIN_CONFIG);
});
});
describe("DEFAULT_PLUGIN_CONFIG", () => {
@@ -62,4 +79,38 @@ describe("plugin-discovery", () => {
expect(DEFAULT_PLUGIN_CONFIG.plugins[0].enabled).toBe(true);
});
});
describe("serializePluginConfig", () => {
it("serializes plugin config to formatted JSON", () => {
const config: PluginConfig = {
plugins: [
{
name: "tree-sitter",
enabled: true,
languages: ["typescript", "javascript"],
},
],
};
const json = serializePluginConfig(config);
expect(json).toContain('"name": "tree-sitter"');
expect(json).toContain('"enabled": true');
expect(json).toContain('"languages"');
});
it("serializes config with options field", () => {
const config: PluginConfig = {
plugins: [
{
name: "custom-plugin",
enabled: true,
languages: ["python"],
options: { strict: true, timeout: 5000 },
},
],
};
const json = serializePluginConfig(config);
expect(json).toContain('"options"');
expect(json).toContain('"strict": true');
});
});
});
@@ -1,5 +1,6 @@
import { describe, it, expect } from "vitest";
import { PluginRegistry } from "../plugins/registry.js";
import { registerAllParsers } from "../plugins/parsers/index.js";
import type { AnalyzerPlugin, StructuralAnalysis, ImportResolution } from "../types.js";
const emptyAnalysis: StructuralAnalysis = {
@@ -110,4 +111,118 @@ describe("PluginRegistry", () => {
const result = registry.analyzeFile("main.py", "print('hello')");
expect(result).toBeNull();
});
it("unregister rebuilds language map correctly", () => {
const registry = new PluginRegistry();
const plugin1 = createMockPlugin("plugin1", ["typescript", "javascript"]);
const plugin2 = createMockPlugin("plugin2", ["python"]);
registry.register(plugin1);
registry.register(plugin2);
expect(registry.getPluginForLanguage("typescript")).toBe(plugin1);
expect(registry.getPluginForLanguage("python")).toBe(plugin2);
registry.unregister("plugin1");
expect(registry.getPluginForLanguage("typescript")).toBeNull();
expect(registry.getPluginForLanguage("python")).toBe(plugin2);
});
it("unregister does nothing for non-existent plugin", () => {
const registry = new PluginRegistry();
const plugin = createMockPlugin("existing", ["typescript"]);
registry.register(plugin);
registry.unregister("non-existent");
expect(registry.getPlugins()).toHaveLength(1);
expect(registry.getPluginForLanguage("typescript")).toBe(plugin);
});
it("getLanguageForFile returns correct language id", () => {
const registry = new PluginRegistry();
registry.register(createMockPlugin("ts-plugin", ["typescript"]));
expect(registry.getLanguageForFile("src/index.ts")).toBe("typescript");
expect(registry.getLanguageForFile("src/component.tsx")).toBe("typescript");
});
it("getLanguageForFile returns null for unsupported extensions", () => {
const registry = new PluginRegistry();
registry.register(createMockPlugin("ts-plugin", ["typescript"]));
expect(registry.getLanguageForFile("unknown.xyz")).toBeNull();
});
it("resolveImports delegates to correct plugin", () => {
const registry = new PluginRegistry();
const plugin = createMockPlugin("ts-plugin", ["typescript"]);
const mockImports: ImportResolution[] = [
{
source: "./utils",
resolvedPath: "./utils.ts",
specifiers: [],
},
];
plugin.resolveImports = () => mockImports;
registry.register(plugin);
const result = registry.resolveImports("src/index.ts", "import './utils'");
expect(result).toEqual(mockImports);
});
it("resolveImports returns null for unsupported files", () => {
const registry = new PluginRegistry();
registry.register(createMockPlugin("ts-plugin", ["typescript"]));
const result = registry.resolveImports("main.py", "import os");
expect(result).toBeNull();
});
it("handles plugins with optional resolveImports (non-code plugins)", () => {
const markdownPlugin: AnalyzerPlugin = {
name: "markdown",
languages: ["markdown"],
analyzeFile: () => ({ functions: [], classes: [], imports: [], exports: [] }),
// No resolveImports — optional for non-code plugins
};
const registry = new PluginRegistry();
registry.register(markdownPlugin);
const result = registry.resolveImports("README.md", "# Hello");
expect(result).toBeNull();
});
});
describe("registerAllParsers smoke test", () => {
it("all registered parsers return valid StructuralAnalysis for minimal content", () => {
const registry = new PluginRegistry();
registerAllParsers(registry);
// Map of file extension -> minimal content for each parser
const testCases: [string, string][] = [
["README.md", "# Hello"],
["config.yaml", "key: value"],
["config.json", '{"key": "value"}'],
["config.toml", 'key = "value"'],
[".env", "KEY=value"],
["Dockerfile", "FROM node:22"],
["schema.sql", "CREATE TABLE t (id INT);"],
["schema.graphql", "type Query { hello: String }"],
["types.proto", 'syntax = "proto3";'],
["main.tf", 'resource "null" "r" {}'],
["Makefile", "build:\n\techo build"],
["script.sh", "#!/bin/bash\necho hello"],
];
for (const [filePath, content] of testCases) {
const result = registry.analyzeFile(filePath, content);
expect(result, `analyzeFile should return a result for ${filePath}`).not.toBeNull();
// Verify basic structural analysis shape
expect(result).toHaveProperty("functions");
expect(result).toHaveProperty("classes");
expect(result).toHaveProperty("imports");
expect(result).toHaveProperty("exports");
}
});
});
@@ -2,6 +2,8 @@ import { describe, it, expect } from "vitest";
import {
validateGraph,
normalizeGraph,
sanitizeGraph,
autoFixGraph,
NODE_TYPE_ALIASES,
EDGE_TYPE_ALIASES,
} from "../schema.js";
@@ -32,7 +34,7 @@ const validGraph: KnowledgeGraph = {
edges: [
{
source: "node-1",
target: "node-2",
target: "node-1",
type: "imports",
direction: "forward",
weight: 0.8,
@@ -62,57 +64,60 @@ describe("schema validation", () => {
expect(result.success).toBe(true);
expect(result.data).toBeDefined();
expect(result.data!.version).toBe("1.0.0");
expect(result.errors).toBeUndefined();
expect(result.issues).toEqual([]);
});
it("rejects graph with missing required fields", () => {
const incomplete = {
version: "1.0.0",
// missing project, nodes, edges, layers, tour
};
const incomplete = { version: "1.0.0" };
const result = validateGraph(incomplete);
expect(result.success).toBe(false);
expect(result.errors).toBeDefined();
expect(result.errors!.length).toBeGreaterThan(0);
expect(result.fatal).toBeDefined();
});
it("rejects node with invalid type", () => {
it("rejects node with invalid type — drops node, fatal if none remain", () => {
const graph = structuredClone(validGraph);
(graph.nodes[0] as any).type = "invalid_type";
const result = validateGraph(graph);
expect(result.success).toBe(false);
expect(result.errors).toBeDefined();
expect(result.errors!.some((e) => e.includes("type"))).toBe(true);
expect(result.fatal).toContain("No valid nodes");
expect(result.issues).toContainEqual(
expect.objectContaining({ level: "dropped", category: "invalid-node" })
);
});
it("rejects edge with invalid EdgeType", () => {
it("drops edge with invalid EdgeType but loads graph", () => {
const graph = structuredClone(validGraph);
(graph.edges[0] as any).type = "not_a_real_edge_type";
const result = validateGraph(graph);
expect(result.success).toBe(false);
expect(result.errors).toBeDefined();
expect(result.errors!.some((e) => e.includes("type"))).toBe(true);
expect(result.success).toBe(true);
expect(result.data!.edges.length).toBe(0);
expect(result.issues).toContainEqual(
expect.objectContaining({ level: "dropped", category: "invalid-edge" })
);
});
it("rejects weight out of range (>1)", () => {
it("auto-corrects weight >1 by clamping", () => {
const graph = structuredClone(validGraph);
graph.edges[0].weight = 1.5;
const result = validateGraph(graph);
expect(result.success).toBe(false);
expect(result.errors).toBeDefined();
expect(result.success).toBe(true);
expect(result.issues).toContainEqual(
expect.objectContaining({ level: "auto-corrected", category: "out-of-range" })
);
});
it("rejects weight out of range (<0)", () => {
it("auto-corrects weight <0 by clamping", () => {
const graph = structuredClone(validGraph);
graph.edges[0].weight = -0.1;
const result = validateGraph(graph);
expect(result.success).toBe(false);
expect(result.errors).toBeDefined();
expect(result.success).toBe(true);
expect(result.issues).toContainEqual(
expect.objectContaining({ level: "auto-corrected", category: "out-of-range" })
);
});
it('normalizes "func" node type to "function"', () => {
@@ -229,20 +234,28 @@ describe("schema validation", () => {
expect(result.data!.edges[0].type).toBe("depends_on");
});
it('rejects "tests" edge type — direction-inverting alias is unsafe', () => {
it('drops "tests" edge type — direction-inverting alias is unsafe', () => {
const graph = structuredClone(validGraph);
(graph.edges[0] as any).type = "tests";
const result = validateGraph(graph);
expect(result.success).toBe(false);
expect(result.success).toBe(true);
expect(result.data!.edges.length).toBe(0);
expect(result.issues).toContainEqual(
expect.objectContaining({ level: "dropped" })
);
});
it("still rejects truly invalid edge types after normalization", () => {
it("drops truly invalid edge types after normalization", () => {
const graph = structuredClone(validGraph);
(graph.edges[0] as any).type = "totally_bogus";
const result = validateGraph(graph);
expect(result.success).toBe(false);
expect(result.success).toBe(true);
expect(result.data!.edges.length).toBe(0);
expect(result.issues).toContainEqual(
expect.objectContaining({ level: "dropped" })
);
});
it("NODE_TYPE_ALIASES values are never alias keys (no chains)", () => {
@@ -263,3 +276,447 @@ describe("schema validation", () => {
}
});
});
describe("sanitizeGraph", () => {
it("converts null optional node fields to undefined", () => {
const graph = structuredClone(validGraph);
(graph.nodes[0] as any).filePath = null;
(graph.nodes[0] as any).lineRange = null;
(graph.nodes[0] as any).languageNotes = null;
const result = sanitizeGraph(graph as any);
const node = (result as any).nodes[0];
expect(node.filePath).toBeUndefined();
expect(node.lineRange).toBeUndefined();
expect(node.languageNotes).toBeUndefined();
});
it("converts null optional edge fields to undefined", () => {
const graph = structuredClone(validGraph);
(graph.edges[0] as any).description = null;
const result = sanitizeGraph(graph as any);
const edge = (result as any).edges[0];
expect(edge.description).toBeUndefined();
});
it("lowercases enum-like strings on nodes", () => {
const graph = structuredClone(validGraph);
(graph.nodes[0] as any).type = "FILE";
(graph.nodes[0] as any).complexity = "Simple";
const result = sanitizeGraph(graph as any);
const node = (result as any).nodes[0];
expect(node.type).toBe("file");
expect(node.complexity).toBe("simple");
});
it("lowercases enum-like strings on edges", () => {
const graph = structuredClone(validGraph);
(graph.edges[0] as any).type = "IMPORTS";
(graph.edges[0] as any).direction = "Forward";
const result = sanitizeGraph(graph as any);
const edge = (result as any).edges[0];
expect(edge.type).toBe("imports");
expect(edge.direction).toBe("forward");
});
it("converts null tour/layers to empty arrays", () => {
const graph = structuredClone(validGraph);
(graph as any).tour = null;
(graph as any).layers = null;
const result = sanitizeGraph(graph as any);
expect((result as any).tour).toEqual([]);
expect((result as any).layers).toEqual([]);
});
it("converts null optional tour step fields to undefined", () => {
const graph = structuredClone(validGraph);
(graph.tour[0] as any).languageLesson = null;
const result = sanitizeGraph(graph as any);
expect((result as any).tour[0].languageLesson).toBeUndefined();
});
it("passes through non-object node/edge items unchanged", () => {
const graph = { nodes: [null, "garbage", 42], edges: [null], tour: [], layers: [] };
const result = sanitizeGraph(graph as any);
expect((result as any).nodes).toEqual([null, "garbage", 42]);
expect((result as any).edges).toEqual([null]);
});
});
describe("autoFixGraph", () => {
it("defaults missing complexity to moderate with issue", () => {
const graph = structuredClone(validGraph);
delete (graph.nodes[0] as any).complexity;
const { data, issues } = autoFixGraph(graph as any);
expect((data as any).nodes[0].complexity).toBe("moderate");
expect(issues).toContainEqual(
expect.objectContaining({ level: "auto-corrected", category: "missing-field", path: "nodes[0].complexity" })
);
});
it("maps complexity aliases with issue", () => {
const graph = structuredClone(validGraph);
(graph.nodes[0] as any).complexity = "low";
const { data, issues } = autoFixGraph(graph as any);
expect((data as any).nodes[0].complexity).toBe("simple");
expect(issues.length).toBe(1);
expect(issues[0].level).toBe("auto-corrected");
});
it("maps all complexity aliases correctly", () => {
const mapping: Record<string, string> = {
low: "simple", easy: "simple",
medium: "moderate", intermediate: "moderate",
high: "complex", hard: "complex", difficult: "complex",
};
for (const [alias, expected] of Object.entries(mapping)) {
const graph = structuredClone(validGraph);
(graph.nodes[0] as any).complexity = alias;
const { data } = autoFixGraph(graph as any);
expect((data as any).nodes[0].complexity).toBe(expected);
}
});
it("defaults missing tags to empty array with issue", () => {
const graph = structuredClone(validGraph);
delete (graph.nodes[0] as any).tags;
const { data, issues } = autoFixGraph(graph as any);
expect((data as any).nodes[0].tags).toEqual([]);
expect(issues).toContainEqual(
expect.objectContaining({ level: "auto-corrected", category: "missing-field", path: "nodes[0].tags" })
);
});
it("defaults missing summary to node name with issue", () => {
const graph = structuredClone(validGraph);
delete (graph.nodes[0] as any).summary;
const { data, issues } = autoFixGraph(graph as any);
expect((data as any).nodes[0].summary).toBe("index.ts");
expect(issues).toContainEqual(
expect.objectContaining({ level: "auto-corrected", category: "missing-field", path: "nodes[0].summary" })
);
});
it("defaults missing node type to file with issue", () => {
const graph = structuredClone(validGraph);
delete (graph.nodes[0] as any).type;
const { data, issues } = autoFixGraph(graph as any);
expect((data as any).nodes[0].type).toBe("file");
expect(issues).toContainEqual(
expect.objectContaining({ level: "auto-corrected", category: "missing-field", path: "nodes[0].type" })
);
});
it("defaults missing direction to forward with issue", () => {
const graph = structuredClone(validGraph);
delete (graph.edges[0] as any).direction;
const { data, issues } = autoFixGraph(graph as any);
expect((data as any).edges[0].direction).toBe("forward");
expect(issues).toContainEqual(
expect.objectContaining({ level: "auto-corrected", category: "missing-field", path: "edges[0].direction" })
);
});
it("maps direction aliases with issue", () => {
const mapping: Record<string, string> = {
to: "forward", outbound: "forward",
from: "backward", inbound: "backward",
both: "bidirectional", mutual: "bidirectional",
};
for (const [alias, expected] of Object.entries(mapping)) {
const graph = structuredClone(validGraph);
(graph.edges[0] as any).direction = alias;
const { data } = autoFixGraph(graph as any);
expect((data as any).edges[0].direction).toBe(expected);
}
});
it("defaults missing weight to 0.5 with issue", () => {
const graph = structuredClone(validGraph);
delete (graph.edges[0] as any).weight;
const { data, issues } = autoFixGraph(graph as any);
expect((data as any).edges[0].weight).toBe(0.5);
expect(issues).toContainEqual(
expect.objectContaining({ level: "auto-corrected", category: "missing-field", path: "edges[0].weight" })
);
});
it("coerces string weight to number with issue", () => {
const graph = structuredClone(validGraph);
(graph.edges[0] as any).weight = "0.8";
const { data, issues } = autoFixGraph(graph as any);
expect((data as any).edges[0].weight).toBe(0.8);
expect(issues).toContainEqual(
expect.objectContaining({ level: "auto-corrected", category: "type-coercion", path: "edges[0].weight" })
);
});
it("clamps out-of-range weight with issue", () => {
const graph = structuredClone(validGraph);
(graph.edges[0] as any).weight = 1.5;
const { data, issues } = autoFixGraph(graph as any);
expect((data as any).edges[0].weight).toBe(1);
expect(issues).toContainEqual(
expect.objectContaining({ level: "auto-corrected", category: "out-of-range", path: "edges[0].weight" })
);
});
it("defaults missing edge type to depends_on with issue", () => {
const graph = structuredClone(validGraph);
delete (graph.edges[0] as any).type;
const { data, issues } = autoFixGraph(graph as any);
expect((data as any).edges[0].type).toBe("depends_on");
expect(issues).toContainEqual(
expect.objectContaining({ level: "auto-corrected", category: "missing-field", path: "edges[0].type" })
);
});
it("returns no issues for a valid graph", () => {
const { issues } = autoFixGraph(validGraph as any);
expect(issues).toEqual([]);
});
it("passes through non-object node/edge items unchanged", () => {
const graph = { nodes: [null, "garbage"], edges: [null], tour: [], layers: [] };
const { data, issues } = autoFixGraph(graph as any);
expect((data as any).nodes).toEqual([null, "garbage"]);
expect((data as any).edges).toEqual([null]);
expect(issues).toEqual([]);
});
});
describe("permissive validation", () => {
it("drops nodes missing id with dropped issue", () => {
const graph = structuredClone(validGraph);
delete (graph.nodes[0] as any).id;
// Add a second valid node so graph isn't fatal
graph.nodes.push({
id: "node-2", type: "file", name: "other.ts",
summary: "Other file", tags: ["util"], complexity: "simple",
});
const result = validateGraph(graph);
expect(result.success).toBe(true);
expect(result.data!.nodes.length).toBe(1);
expect(result.data!.nodes[0].id).toBe("node-2");
expect(result.issues).toContainEqual(
expect.objectContaining({ level: "dropped", category: "invalid-node" })
);
});
it("drops edges referencing non-existent nodes with dropped issue", () => {
const graph = structuredClone(validGraph);
graph.edges[0].target = "non-existent-node";
const result = validateGraph(graph);
expect(result.success).toBe(true);
expect(result.data!.edges.length).toBe(0);
expect(result.issues).toContainEqual(
expect.objectContaining({ level: "dropped", category: "invalid-reference" })
);
});
it("returns fatal when 0 valid nodes remain", () => {
const graph = structuredClone(validGraph);
delete (graph.nodes[0] as any).id;
const result = validateGraph(graph);
expect(result.success).toBe(false);
expect(result.fatal).toContain("No valid nodes");
});
it("returns fatal when project metadata is missing", () => {
const graph = structuredClone(validGraph);
delete (graph as any).project;
const result = validateGraph(graph);
expect(result.success).toBe(false);
expect(result.fatal).toContain("project metadata");
});
it("returns fatal when input is not an object", () => {
const result = validateGraph("not an object");
expect(result.success).toBe(false);
expect(result.fatal).toContain("Invalid input");
});
it("loads graph with mixed good and bad nodes", () => {
const graph = structuredClone(validGraph);
// Add a good node
graph.nodes.push({
id: "node-2", type: "function", name: "doThing",
summary: "Does a thing", tags: ["util"], complexity: "moderate",
});
// Add a bad node (missing id AND name -- unrecoverable)
(graph.nodes as any[]).push({ type: "file", summary: "broken" });
const result = validateGraph(graph);
expect(result.success).toBe(true);
expect(result.data!.nodes.length).toBe(2);
expect(result.issues.some((i) => i.level === "dropped")).toBe(true);
});
it("filters dangling nodeIds from layers", () => {
const graph = structuredClone(validGraph);
graph.layers[0].nodeIds.push("non-existent-node");
const result = validateGraph(graph);
expect(result.success).toBe(true);
expect(result.data!.layers[0].nodeIds).toEqual(["node-1"]);
});
it("filters dangling nodeIds from tour steps", () => {
const graph = structuredClone(validGraph);
graph.tour[0].nodeIds.push("non-existent-node");
const result = validateGraph(graph);
expect(result.success).toBe(true);
expect(result.data!.tour[0].nodeIds).toEqual(["node-1"]);
});
it("returns empty issues array for a perfect graph", () => {
const result = validateGraph(validGraph);
expect(result.success).toBe(true);
expect(result.issues).toEqual([]);
expect(result.errors).toBeUndefined();
});
it("auto-corrects and loads graph that would have failed strict validation", () => {
// Graph with many Tier 2 issues: missing complexity, weight as string, null filePath
const messy = {
version: "1.0.0",
project: validGraph.project,
nodes: [{
id: "n1", type: "FILE", name: "app.ts",
filePath: null, summary: "App entry",
tags: null, complexity: "HIGH",
}],
edges: [{
source: "n1", target: "n1", type: "CALLS",
direction: "TO", weight: "0.9",
}],
layers: [{ id: "l1", name: "Core", description: "Core", nodeIds: ["n1"] }],
tour: [],
};
const result = validateGraph(messy);
expect(result.success).toBe(true);
expect(result.data!.nodes[0].complexity).toBe("complex");
expect(result.data!.nodes[0].tags).toEqual([]);
expect(result.data!.edges[0].weight).toBe(0.9);
expect(result.data!.edges[0].direction).toBe("forward");
expect(result.issues.length).toBeGreaterThan(0);
expect(result.issues.every((i) => i.level === "auto-corrected")).toBe(true);
});
it("handles non-parseable string weight by defaulting to 0.5", () => {
const graph = structuredClone(validGraph);
(graph.edges[0] as any).weight = "not_a_number";
const result = validateGraph(graph);
expect(result.success).toBe(true);
expect(result.data!.edges[0].weight).toBe(0.5);
expect(result.issues).toContainEqual(
expect.objectContaining({ level: "auto-corrected", category: "type-coercion" })
);
});
it("returns fatal when edges is present but not an array", () => {
const graph = structuredClone(validGraph) as any;
graph.edges = { source: "node-1", target: "node-1" };
const result = validateGraph(graph);
expect(result.success).toBe(false);
expect(result.fatal).toContain('"edges" must be an array');
expect(result.errors).toContain('"edges" must be an array when present');
expect(result.issues).toContainEqual(
expect.objectContaining({
level: "fatal",
category: "invalid-collection",
path: "edges",
})
);
});
it("preserves deprecated errors for dropped-item callers", () => {
const graph = structuredClone(validGraph);
graph.edges[0].target = "non-existent-node";
const result = validateGraph(graph);
expect(result.success).toBe(true);
expect(result.errors).toContain('edges[0]: target "non-existent-node" does not exist in nodes — removed');
});
});
describe("Extended node/edge types", () => {
it("validates nodes with new types: config, document, service, table, endpoint, pipeline, schema, resource", () => {
const newTypes = ["config", "document", "service", "table", "endpoint", "pipeline", "schema", "resource"];
for (const type of newTypes) {
const graph = structuredClone(validGraph);
(graph.nodes[0] as any).type = type;
const result = validateGraph(graph);
expect(result.success).toBe(true);
expect(result.data!.nodes[0].type).toBe(type);
}
});
it("validates edges with new types: deploys, serves, migrates, documents, provisions, routes, defines_schema, triggers", () => {
const newTypes = ["deploys", "serves", "migrates", "documents", "provisions", "routes", "defines_schema", "triggers"];
for (const type of newTypes) {
const graph = structuredClone(validGraph);
(graph.edges[0] as any).type = type;
const result = validateGraph(graph);
expect(result.success).toBe(true);
expect(result.data!.edges[0].type).toBe(type);
}
});
it("auto-fixes new node type aliases: container->service, doc->document, workflow->pipeline, etc.", () => {
const aliases: Record<string, string> = {
container: "service",
doc: "document",
workflow: "pipeline",
route: "endpoint",
setting: "config",
infra: "resource",
migration: "table",
};
for (const [alias, canonical] of Object.entries(aliases)) {
const graph = structuredClone(validGraph);
(graph.nodes[0] as any).type = alias;
const result = validateGraph(graph);
expect(result.success).toBe(true);
expect(result.data!.nodes[0].type).toBe(canonical);
}
});
it("auto-fixes new edge type aliases: describes->documents, creates->provisions, exposes->serves", () => {
const aliases: Record<string, string> = {
describes: "documents",
creates: "provisions",
exposes: "serves",
};
for (const [alias, canonical] of Object.entries(aliases)) {
const graph = structuredClone(validGraph);
(graph.edges[0] as any).type = alias;
const result = validateGraph(graph);
expect(result.success).toBe(true);
expect(result.data!.edges[0].type).toBe(canonical);
}
});
});
@@ -1,4 +1,4 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { GraphBuilder } from "./graph-builder.js";
import type { StructuralAnalysis } from "../types.js";
@@ -212,4 +212,193 @@ describe("GraphBuilder", () => {
const graph = builder.build();
expect(graph.project.languages).toEqual(["go", "javascript", "rust"]);
});
describe("Non-code file support", () => {
it("adds non-code file nodes with correct types and nodeType-prefixed ID", () => {
const builder = new GraphBuilder("test", "abc123");
builder.addNonCodeFile("README.md", {
nodeType: "document",
summary: "Project documentation",
tags: ["documentation"],
complexity: "simple",
});
const graph = builder.build();
expect(graph.nodes).toHaveLength(1);
expect(graph.nodes[0].type).toBe("document");
expect(graph.nodes[0].id).toBe("document:README.md");
});
it("adds non-code child nodes (definitions)", () => {
const builder = new GraphBuilder("test", "abc123");
builder.addNonCodeFileWithAnalysis("schema.sql", {
nodeType: "file",
summary: "Database schema",
tags: ["database"],
complexity: "moderate",
definitions: [
{ name: "users", kind: "table", lineRange: [1, 20] as [number, number], fields: ["id", "name", "email"] },
],
});
const graph = builder.build();
// File node + table child node
expect(graph.nodes).toHaveLength(2);
expect(graph.nodes[1].type).toBe("table");
expect(graph.nodes[1].name).toBe("users");
// Contains edge
expect(graph.edges.some(e => e.type === "contains" && e.target.includes("users"))).toBe(true);
});
it("adds service child nodes", () => {
const builder = new GraphBuilder("test", "abc123");
builder.addNonCodeFileWithAnalysis("docker-compose.yml", {
nodeType: "config",
summary: "Docker compose config",
tags: ["infra"],
complexity: "moderate",
services: [
{ name: "web", image: "node:22", ports: [3000] },
{ name: "db", image: "postgres:15", ports: [5432] },
],
});
const graph = builder.build();
// File node + 2 service child nodes
expect(graph.nodes).toHaveLength(3);
expect(graph.nodes[1].type).toBe("service");
expect(graph.nodes[1].name).toBe("web");
expect(graph.nodes[2].type).toBe("service");
expect(graph.nodes[2].name).toBe("db");
});
it("adds endpoint child nodes", () => {
const builder = new GraphBuilder("test", "abc123");
builder.addNonCodeFileWithAnalysis("schema.graphql", {
nodeType: "schema",
summary: "GraphQL schema",
tags: ["api"],
complexity: "moderate",
endpoints: [
{ method: "Query", path: "users", lineRange: [5, 5] as [number, number] },
],
});
const graph = builder.build();
expect(graph.nodes).toHaveLength(2);
expect(graph.nodes[1].type).toBe("endpoint");
});
it("adds resource child nodes", () => {
const builder = new GraphBuilder("test", "abc123");
builder.addNonCodeFileWithAnalysis("main.tf", {
nodeType: "resource",
summary: "Terraform config",
tags: ["infra"],
complexity: "moderate",
resources: [
{ name: "aws_s3_bucket.main", kind: "aws_s3_bucket", lineRange: [1, 10] as [number, number] },
],
});
const graph = builder.build();
expect(graph.nodes).toHaveLength(2);
expect(graph.nodes[1].type).toBe("resource");
expect(graph.nodes[1].name).toBe("aws_s3_bucket.main");
});
it("adds step child nodes", () => {
const builder = new GraphBuilder("test", "abc123");
builder.addNonCodeFileWithAnalysis("Makefile", {
nodeType: "pipeline",
summary: "Build targets",
tags: ["build"],
complexity: "simple",
steps: [
{ name: "build", lineRange: [1, 3] as [number, number] },
{ name: "test", lineRange: [5, 7] as [number, number] },
],
});
const graph = builder.build();
expect(graph.nodes).toHaveLength(3);
expect(graph.nodes[1].type).toBe("pipeline");
expect(graph.nodes[1].name).toBe("build");
});
it("detects non-code languages from EXTENSION_LANGUAGE map", () => {
const builder = new GraphBuilder("test", "abc123");
builder.addFile("config.yaml", { summary: "Config", tags: [], complexity: "simple" });
const graph = builder.build();
expect(graph.project.languages).toContain("yaml");
});
it("detects new non-code extensions", () => {
const builder = new GraphBuilder("test", "abc123");
builder.addFile("schema.graphql", { summary: "Schema", tags: [], complexity: "simple" });
builder.addFile("main.tf", { summary: "Terraform", tags: [], complexity: "simple" });
builder.addFile("types.proto", { summary: "Protobuf", tags: [], complexity: "simple" });
const graph = builder.build();
expect(graph.project.languages).toContain("graphql");
expect(graph.project.languages).toContain("terraform");
expect(graph.project.languages).toContain("protobuf");
});
it("mapKindToNodeType falls back to concept for unknown kinds and warns", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const builder = new GraphBuilder("test", "abc123");
builder.addNonCodeFileWithAnalysis("schema.sql", {
nodeType: "file",
summary: "Schema",
tags: [],
complexity: "simple",
definitions: [
{ name: "doStuff", kind: "procedure", lineRange: [1, 10] as [number, number], fields: [] },
],
});
const graph = builder.build();
const childNode = graph.nodes.find(n => n.name === "doStuff");
expect(childNode).toBeDefined();
expect(childNode!.type).toBe("concept");
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('Unknown definition kind "procedure"'),
);
warnSpy.mockRestore();
});
it("skips duplicate node IDs in addNonCodeFileWithAnalysis and warns", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const builder = new GraphBuilder("test", "abc123");
builder.addNonCodeFileWithAnalysis("schema.sql", {
nodeType: "file",
summary: "Schema",
tags: [],
complexity: "simple",
definitions: [
{ name: "users", kind: "table", lineRange: [1, 10] as [number, number], fields: ["id"] },
{ name: "users", kind: "table", lineRange: [12, 20] as [number, number], fields: ["id", "name"] },
],
});
const graph = builder.build();
// Only the file node + one table node (duplicate skipped)
const tableNodes = graph.nodes.filter(n => n.name === "users");
expect(tableNodes).toHaveLength(1);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('Duplicate node ID "table:schema.sql:users"'),
);
warnSpy.mockRestore();
});
it("uses nodeType in fileId for contains edges", () => {
const builder = new GraphBuilder("test", "abc123");
builder.addNonCodeFileWithAnalysis("docker-compose.yml", {
nodeType: "config",
summary: "Docker compose config",
tags: [],
complexity: "simple",
services: [
{ name: "web", ports: [3000] },
],
});
const graph = builder.build();
const containsEdge = graph.edges.find(e => e.type === "contains");
expect(containsEdge).toBeDefined();
expect(containsEdge!.source).toBe("config:docker-compose.yml");
expect(containsEdge!.target).toBe("service:docker-compose.yml:web");
});
});
});
@@ -3,6 +3,12 @@ import type {
GraphNode,
GraphEdge,
StructuralAnalysis,
DefinitionInfo,
ServiceInfo,
EndpointInfo,
StepInfo,
ResourceInfo,
SectionInfo,
} from "../types.js";
interface FileMeta {
@@ -16,7 +22,21 @@ interface FileAnalysisMeta extends FileMeta {
fileSummary: string;
}
interface NonCodeFileMeta extends FileMeta {
nodeType: GraphNode["type"];
}
interface NonCodeFileAnalysisMeta extends NonCodeFileMeta {
definitions?: DefinitionInfo[];
services?: ServiceInfo[];
endpoints?: EndpointInfo[];
steps?: StepInfo[];
resources?: ResourceInfo[];
sections?: SectionInfo[];
}
const EXTENSION_LANGUAGE: Record<string, string> = {
// Code languages
".ts": "typescript",
".tsx": "typescript",
".js": "javascript",
@@ -37,20 +57,41 @@ const EXTENSION_LANGUAGE: Record<string, string> = {
".cs": "csharp",
".php": "php",
".lua": "lua",
// Non-code languages
".sh": "shell",
".bash": "shell",
".zsh": "shell",
".json": "json",
".jsonc": "json",
".yaml": "yaml",
".yml": "yaml",
".toml": "toml",
".xml": "xml",
".html": "html",
".htm": "html",
".css": "css",
".scss": "scss",
".less": "less",
".scss": "css",
".less": "css",
".md": "markdown",
".mdx": "markdown",
".sql": "sql",
".graphql": "graphql",
".gql": "graphql",
".proto": "protobuf",
".tf": "terraform",
".tfvars": "terraform",
".mk": "makefile",
".env": "env",
".csv": "csv",
".tsv": "csv",
".rst": "restructuredtext",
".ps1": "powershell",
".psm1": "powershell",
".psd1": "powershell",
".bat": "batch",
".cmd": "batch",
".txt": "plaintext",
".svg": "xml",
};
function detectLanguage(filePath: string): string {
@@ -187,6 +228,160 @@ export class GraphBuilder {
});
}
addNonCodeFile(filePath: string, meta: NonCodeFileMeta): void {
const lang = detectLanguage(filePath);
if (lang !== "unknown") this.languages.add(lang);
const name = filePath.split("/").pop() ?? filePath;
this.nodes.push({
id: `${meta.nodeType ?? "file"}:${filePath}`,
type: meta.nodeType,
name,
filePath,
summary: meta.summary,
tags: meta.tags,
complexity: meta.complexity,
});
}
addNonCodeFileWithAnalysis(filePath: string, meta: NonCodeFileAnalysisMeta): void {
this.addNonCodeFile(filePath, meta);
const fileId = `${meta.nodeType ?? "file"}:${filePath}`;
const existingIds = new Set(this.nodes.map(n => n.id));
// Create child nodes for definitions (tables, schemas, etc.)
for (const def of meta.definitions ?? []) {
const childId = `${def.kind}:${filePath}:${def.name}`;
if (existingIds.has(childId)) {
console.warn(`[GraphBuilder] Duplicate node ID "${childId}" — skipping`);
continue;
}
existingIds.add(childId);
this.nodes.push({
id: childId,
type: this.mapKindToNodeType(def.kind),
name: def.name,
filePath,
lineRange: def.lineRange,
summary: `${def.kind}: ${def.name} (${def.fields.length} fields)`,
tags: [],
complexity: meta.complexity,
});
this.edges.push({ source: fileId, target: childId, type: "contains", direction: "forward", weight: 1 });
}
// Create child nodes for services
for (const svc of meta.services ?? []) {
const childId = `service:${filePath}:${svc.name}`;
if (existingIds.has(childId)) {
console.warn(`[GraphBuilder] Duplicate node ID "${childId}" — skipping`);
continue;
}
existingIds.add(childId);
this.nodes.push({
id: childId,
type: "service",
name: svc.name,
filePath,
summary: `Service ${svc.name}${svc.image ? ` (image: ${svc.image})` : ""}`,
tags: [],
complexity: meta.complexity,
});
this.edges.push({ source: fileId, target: childId, type: "contains", direction: "forward", weight: 1 });
}
// Create child nodes for endpoints
for (const ep of meta.endpoints ?? []) {
const childId = `endpoint:${filePath}:${ep.path}`;
if (existingIds.has(childId)) {
console.warn(`[GraphBuilder] Duplicate node ID "${childId}" — skipping`);
continue;
}
existingIds.add(childId);
this.nodes.push({
id: childId,
type: "endpoint",
name: `${ep.method ?? ""} ${ep.path}`.trim(),
filePath,
lineRange: ep.lineRange,
summary: `Endpoint: ${ep.method ?? ""} ${ep.path}`.trim(),
tags: [],
complexity: meta.complexity,
});
this.edges.push({ source: fileId, target: childId, type: "contains", direction: "forward", weight: 1 });
}
// Create child nodes for steps (pipeline/makefile targets)
for (const step of meta.steps ?? []) {
const childId = `step:${filePath}:${step.name}`;
if (existingIds.has(childId)) {
console.warn(`[GraphBuilder] Duplicate node ID "${childId}" — skipping`);
continue;
}
existingIds.add(childId);
this.nodes.push({
id: childId,
type: "pipeline",
name: step.name,
filePath,
lineRange: step.lineRange,
summary: `Step: ${step.name}`,
tags: [],
complexity: meta.complexity,
});
this.edges.push({ source: fileId, target: childId, type: "contains", direction: "forward", weight: 1 });
}
// Create child nodes for resources (Terraform, etc.)
for (const res of meta.resources ?? []) {
const childId = `resource:${filePath}:${res.name}`;
if (existingIds.has(childId)) {
console.warn(`[GraphBuilder] Duplicate node ID "${childId}" — skipping`);
continue;
}
existingIds.add(childId);
this.nodes.push({
id: childId,
type: "resource",
name: res.name,
filePath,
lineRange: res.lineRange,
summary: `Resource: ${res.name} (${res.kind})`,
tags: [],
complexity: meta.complexity,
});
this.edges.push({ source: fileId, target: childId, type: "contains", direction: "forward", weight: 1 });
}
}
private mapKindToNodeType(kind: string): GraphNode["type"] {
const mapping: Record<string, GraphNode["type"]> = {
table: "table",
view: "table",
index: "table",
message: "schema",
type: "schema",
enum: "schema",
resource: "resource",
module: "resource",
service: "service",
deployment: "service",
job: "pipeline",
stage: "pipeline",
target: "pipeline",
route: "endpoint",
query: "endpoint",
mutation: "endpoint",
variable: "config",
output: "config",
};
const mapped = mapping[kind];
if (!mapped) {
console.warn(`[GraphBuilder] Unknown definition kind "${kind}" — falling back to "concept" node type`);
}
return mapped ?? "concept";
}
build(): KnowledgeGraph {
return {
version: "1.0.0",
@@ -39,6 +39,16 @@ const LAYER_PATTERNS: Array<{ patterns: string[]; layerName: string; description
layerName: "Middleware Layer",
description: "Request/response middleware and interceptors",
},
{
patterns: ["client", "integration", "external", "sdk", "vendor", "adapter"],
layerName: "External Services",
description: "External service integrations, SDKs, and third-party adapters",
},
{
patterns: ["worker", "job", "queue", "cron", "consumer", "processor", "scheduler", "background"],
layerName: "Background Tasks",
description: "Background workers, job processors, and scheduled tasks",
},
{
patterns: ["util", "helper", "lib", "common", "shared"],
layerName: "Utility Layer",
@@ -0,0 +1,143 @@
import { dirname } from "node:path";
import type { ChangeAnalysis } from "./fingerprint.js";
export interface UpdateDecision {
action: "SKIP" | "PARTIAL_UPDATE" | "ARCHITECTURE_UPDATE" | "FULL_UPDATE";
filesToReanalyze: string[];
rerunArchitecture: boolean;
rerunTour: boolean;
reason: string;
}
/**
* Classify the type of graph update needed based on structural change analysis.
*
* Decision matrix:
* - SKIP: all files NONE or COSMETIC only
* - PARTIAL_UPDATE: some STRUCTURAL, same directories
* - ARCHITECTURE_UPDATE: new/deleted directories or >10 structural files
* - FULL_UPDATE: >30 structural files or >50% of total files changed structurally
*/
export function classifyUpdate(
analysis: ChangeAnalysis,
totalFilesInGraph: number,
allKnownFiles: string[] = [],
): UpdateDecision {
const { newFiles, deletedFiles, structurallyChangedFiles, cosmeticOnlyFiles, unchangedFiles } = analysis;
const structuralCount = structurallyChangedFiles.length + newFiles.length + deletedFiles.length;
// No structural changes at all — skip
if (structuralCount === 0) {
const cosmeticCount = cosmeticOnlyFiles.length;
const reason = cosmeticCount > 0
? `${cosmeticCount} file(s) have cosmetic-only changes (no structural impact)`
: "No changes detected";
return {
action: "SKIP",
filesToReanalyze: [],
rerunArchitecture: false,
rerunTour: false,
reason,
};
}
// Too many structural changes — suggest full rebuild
const triggeredByCount = structuralCount > 30;
const triggeredByPercentage = totalFilesInGraph > 0 && structuralCount / totalFilesInGraph > 0.5;
if (triggeredByCount || triggeredByPercentage) {
const thresholdReason =
triggeredByCount && triggeredByPercentage
? ">30 files and >50% of project"
: triggeredByCount
? ">30 files"
: ">50% of project";
return {
action: "FULL_UPDATE",
filesToReanalyze: [...structurallyChangedFiles, ...newFiles],
rerunArchitecture: true,
rerunTour: true,
reason: `${structuralCount} files have structural changes (${thresholdReason}) — full rebuild recommended`,
};
}
// Check if directory structure changed (new/deleted top-level directories)
const hasDirectoryChanges = detectDirectoryChanges(newFiles, deletedFiles, allKnownFiles);
if (hasDirectoryChanges || structuralCount > 10) {
return {
action: "ARCHITECTURE_UPDATE",
filesToReanalyze: [...structurallyChangedFiles, ...newFiles],
rerunArchitecture: true,
rerunTour: true,
reason: hasDirectoryChanges
? `Directory structure changed (${newFiles.length} new, ${deletedFiles.length} deleted files)`
: `${structuralCount} files have structural changes — architecture re-analysis needed`,
};
}
// Localized structural changes — partial update
return {
action: "PARTIAL_UPDATE",
filesToReanalyze: [...structurallyChangedFiles, ...newFiles],
rerunArchitecture: false,
rerunTour: false,
reason: `${structuralCount} file(s) have structural changes: ${summarizeChanges(analysis)}`,
};
}
/**
* Detect if the changes affect the directory structure (new or removed directories).
* Uses all known files in the project as the baseline for existing directories,
* then checks if any new/deleted files introduce or remove a top-level source directory.
*/
function detectDirectoryChanges(
newFiles: string[],
deletedFiles: string[],
allKnownFiles: string[],
): boolean {
const existingDirs = new Set(
allKnownFiles.map((f) => topDirectory(f)).filter(Boolean),
);
for (const f of newFiles) {
const dir = topDirectory(f);
if (dir && !existingDirs.has(dir)) return true;
}
for (const f of deletedFiles) {
const dir = topDirectory(f);
if (dir && !existingDirs.has(dir)) return true;
}
return false;
}
/**
* Get the top-level directory of a file path (first path segment).
*/
function topDirectory(filePath: string): string | null {
const dir = dirname(filePath);
if (dir === "." || dir === "") return null;
const segments = dir.split("/");
return segments[0] || null;
}
/**
* Produce a concise human-readable summary of structural changes.
*/
function summarizeChanges(analysis: ChangeAnalysis): string {
const parts: string[] = [];
if (analysis.newFiles.length > 0) {
parts.push(`${analysis.newFiles.length} new`);
}
if (analysis.deletedFiles.length > 0) {
parts.push(`${analysis.deletedFiles.length} deleted`);
}
if (analysis.structurallyChangedFiles.length > 0) {
parts.push(`${analysis.structurallyChangedFiles.length} modified`);
}
return parts.join(", ");
}
@@ -0,0 +1,385 @@
import { createHash } from "node:crypto";
import { readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import type { StructuralAnalysis } from "./types.js";
import type { PluginRegistry } from "./plugins/registry.js";
// ---- Fingerprint types ----
export interface FunctionFingerprint {
name: string;
params: string[];
returnType?: string;
exported: boolean;
lineCount: number;
}
export interface ClassFingerprint {
name: string;
methods: string[];
properties: string[];
exported: boolean;
lineCount: number;
}
export interface ImportFingerprint {
source: string;
specifiers: string[];
}
export interface FileFingerprint {
filePath: string;
contentHash: string;
functions: FunctionFingerprint[];
classes: ClassFingerprint[];
imports: ImportFingerprint[];
exports: string[];
totalLines: number;
hasStructuralAnalysis: boolean;
}
export interface FingerprintStore {
version: "1.0.0";
gitCommitHash: string;
generatedAt: string;
files: Record<string, FileFingerprint>;
}
export type ChangeLevel = "NONE" | "COSMETIC" | "STRUCTURAL";
export interface FileChangeResult {
filePath: string;
changeLevel: ChangeLevel;
details: string[];
}
export interface ChangeAnalysis {
fileChanges: FileChangeResult[];
newFiles: string[];
deletedFiles: string[];
structurallyChangedFiles: string[];
cosmeticOnlyFiles: string[];
unchangedFiles: string[];
}
// ---- Core functions ----
/**
* Compute SHA-256 content hash for a file's content.
*/
export function contentHash(content: string): string {
return createHash("sha256").update(content).digest("hex");
}
/**
* Extract a structural fingerprint from a file using its tree-sitter analysis.
* The fingerprint captures only the elements that affect the knowledge graph
* (function/class/import/export signatures), not implementation details.
*/
export function extractFileFingerprint(
filePath: string,
content: string,
analysis: StructuralAnalysis,
): FileFingerprint {
const hash = contentHash(content);
const exportedNames = new Set(analysis.exports.map((e) => e.name));
const functions: FunctionFingerprint[] = analysis.functions.map((fn) => ({
name: fn.name,
params: [...fn.params],
returnType: fn.returnType,
exported: exportedNames.has(fn.name),
lineCount: fn.lineRange[1] - fn.lineRange[0] + 1,
}));
const classes: ClassFingerprint[] = analysis.classes.map((cls) => ({
name: cls.name,
methods: [...cls.methods],
properties: [...cls.properties],
exported: exportedNames.has(cls.name),
lineCount: cls.lineRange[1] - cls.lineRange[0] + 1,
}));
const imports: ImportFingerprint[] = analysis.imports.map((imp) => ({
source: imp.source,
specifiers: [...imp.specifiers],
}));
const exports = analysis.exports.map((e) => e.name);
const totalLines = content.split("\n").length;
return {
filePath,
contentHash: hash,
functions,
classes,
imports,
exports,
totalLines,
hasStructuralAnalysis: true,
};
}
/**
* Compare two file fingerprints and determine the change level.
*
* - NONE: content hash identical (file unchanged)
* - COSMETIC: content differs but structural signatures match (internal logic only)
* - STRUCTURAL: signature-level changes detected
*/
export function compareFingerprints(
oldFp: FileFingerprint,
newFp: FileFingerprint,
): FileChangeResult {
const details: string[] = [];
// Fast path: identical content
if (oldFp.contentHash === newFp.contentHash) {
return { filePath: newFp.filePath, changeLevel: "NONE", details: [] };
}
// Conservative path: if either fingerprint lacks structural analysis,
// we cannot verify structure didn't change — classify as STRUCTURAL.
if (!oldFp.hasStructuralAnalysis || !newFp.hasStructuralAnalysis) {
return {
filePath: newFp.filePath,
changeLevel: "STRUCTURAL",
details: ["no structural analysis available — conservative classification"],
};
}
// Compare function signatures
const oldFuncNames = new Set(oldFp.functions.map((f) => f.name));
const newFuncNames = new Set(newFp.functions.map((f) => f.name));
for (const name of newFuncNames) {
if (!oldFuncNames.has(name)) {
details.push(`new function: ${name}`);
}
}
for (const name of oldFuncNames) {
if (!newFuncNames.has(name)) {
details.push(`removed function: ${name}`);
}
}
// Compare shared functions for signature changes
for (const newFn of newFp.functions) {
const oldFn = oldFp.functions.find((f) => f.name === newFn.name);
if (!oldFn) continue;
if (JSON.stringify(oldFn.params) !== JSON.stringify(newFn.params)) {
details.push(`params changed: ${newFn.name}`);
}
if (oldFn.returnType !== newFn.returnType) {
details.push(`return type changed: ${newFn.name}`);
}
if (oldFn.exported !== newFn.exported) {
details.push(`export status changed: ${newFn.name}`);
}
// Flag large line count changes (>50% growth or shrink)
if (oldFn.lineCount > 0) {
const ratio = newFn.lineCount / oldFn.lineCount;
if (ratio > 1.5 || ratio < 0.5) {
details.push(`significant size change: ${newFn.name} (${oldFn.lineCount}${newFn.lineCount} lines)`);
}
}
}
// Compare class signatures
const oldClassNames = new Set(oldFp.classes.map((c) => c.name));
const newClassNames = new Set(newFp.classes.map((c) => c.name));
for (const name of newClassNames) {
if (!oldClassNames.has(name)) {
details.push(`new class: ${name}`);
}
}
for (const name of oldClassNames) {
if (!newClassNames.has(name)) {
details.push(`removed class: ${name}`);
}
}
for (const newCls of newFp.classes) {
const oldCls = oldFp.classes.find((c) => c.name === newCls.name);
if (!oldCls) continue;
if (JSON.stringify([...oldCls.methods].sort()) !== JSON.stringify([...newCls.methods].sort())) {
details.push(`methods changed: ${newCls.name}`);
}
if (JSON.stringify([...oldCls.properties].sort()) !== JSON.stringify([...newCls.properties].sort())) {
details.push(`properties changed: ${newCls.name}`);
}
if (oldCls.exported !== newCls.exported) {
details.push(`export status changed: ${newCls.name}`);
}
}
// Compare imports
const oldImports = oldFp.imports.map((i) => `${i.source}:${[...i.specifiers].sort().join(",")}`).sort();
const newImports = newFp.imports.map((i) => `${i.source}:${[...i.specifiers].sort().join(",")}`).sort();
if (JSON.stringify(oldImports) !== JSON.stringify(newImports)) {
details.push("imports changed");
}
// Compare exports
const oldExports = [...oldFp.exports].sort();
const newExports = [...newFp.exports].sort();
if (JSON.stringify(oldExports) !== JSON.stringify(newExports)) {
details.push("exports changed");
}
if (details.length > 0) {
return { filePath: newFp.filePath, changeLevel: "STRUCTURAL", details };
}
// Content changed but structure is identical
return {
filePath: newFp.filePath,
changeLevel: "COSMETIC",
details: ["internal logic changed (no structural impact)"],
};
}
/**
* Build a fingerprint store for a set of files.
* Files without tree-sitter support get content-hash-only fingerprints
* (conservative: any change is treated as STRUCTURAL).
*/
export function buildFingerprintStore(
projectDir: string,
filePaths: string[],
registry: PluginRegistry,
gitCommitHash: string,
): FingerprintStore {
const files: Record<string, FileFingerprint> = {};
for (const filePath of filePaths) {
const absolutePath = join(projectDir, filePath);
if (!existsSync(absolutePath)) continue;
const content = readFileSync(absolutePath, "utf-8");
const analysis = registry.analyzeFile(filePath, content);
if (analysis) {
files[filePath] = extractFileFingerprint(filePath, content, analysis);
} else {
// No tree-sitter support: content hash only (conservative)
files[filePath] = {
filePath,
contentHash: contentHash(content),
functions: [],
classes: [],
imports: [],
exports: [],
totalLines: content.split("\n").length,
hasStructuralAnalysis: false,
};
}
}
return {
version: "1.0.0",
gitCommitHash,
generatedAt: new Date().toISOString(),
files,
};
}
/**
* Analyze changes between the current state of files and stored fingerprints.
* Returns a detailed breakdown of what changed and at what level.
*/
export function analyzeChanges(
projectDir: string,
changedFiles: string[],
existingStore: FingerprintStore,
registry: PluginRegistry,
): ChangeAnalysis {
const fileChanges: FileChangeResult[] = [];
const newFiles: string[] = [];
const deletedFiles: string[] = [];
const structurallyChangedFiles: string[] = [];
const cosmeticOnlyFiles: string[] = [];
const unchangedFiles: string[] = [];
for (const filePath of changedFiles) {
const absolutePath = join(projectDir, filePath);
const existedBefore = filePath in existingStore.files;
const existsNow = existsSync(absolutePath);
// File was deleted
if (!existsNow) {
if (existedBefore) {
deletedFiles.push(filePath);
fileChanges.push({
filePath,
changeLevel: "STRUCTURAL",
details: ["file deleted"],
});
}
continue;
}
// File is new
if (!existedBefore) {
newFiles.push(filePath);
fileChanges.push({
filePath,
changeLevel: "STRUCTURAL",
details: ["new file"],
});
continue;
}
// File exists in both — compare fingerprints
const content = readFileSync(absolutePath, "utf-8");
const analysis = registry.analyzeFile(filePath, content);
const oldFp = existingStore.files[filePath];
let newFp: FileFingerprint;
if (analysis) {
newFp = extractFileFingerprint(filePath, content, analysis);
} else {
// No tree-sitter support: content hash only
newFp = {
filePath,
contentHash: contentHash(content),
functions: [],
classes: [],
imports: [],
exports: [],
totalLines: content.split("\n").length,
hasStructuralAnalysis: false,
};
}
const result = compareFingerprints(oldFp, newFp);
fileChanges.push(result);
switch (result.changeLevel) {
case "NONE":
unchangedFiles.push(filePath);
break;
case "COSMETIC":
cosmeticOnlyFiles.push(filePath);
break;
case "STRUCTURAL":
structurallyChangedFiles.push(filePath);
break;
}
}
return {
fileChanges,
newFiles,
deletedFiles,
structurallyChangedFiles,
cosmeticOnlyFiles,
unchangedFiles,
};
}
@@ -1,6 +1,15 @@
export * from "./types.js";
export * from "./persistence/index.js";
export { KnowledgeGraphSchema, validateGraph, type ValidationResult } from "./schema.js";
export {
KnowledgeGraphSchema,
validateGraph,
sanitizeGraph,
autoFixGraph,
COMPLEXITY_ALIASES,
DIRECTION_ALIASES,
type ValidationResult,
type GraphIssue,
} from "./schema.js";
export { TreeSitterPlugin } from "./plugins/tree-sitter-plugin.js";
export { GraphBuilder } from "./analyzer/graph-builder.js";
export {
@@ -62,3 +71,38 @@ export {
cosineSimilarity,
type SemanticSearchOptions,
} from "./embedding-search.js";
export {
extractFileFingerprint,
compareFingerprints,
analyzeChanges,
buildFingerprintStore,
contentHash,
type FunctionFingerprint,
type ClassFingerprint,
type ImportFingerprint,
type FileFingerprint,
type FingerprintStore,
type ChangeLevel,
type FileChangeResult,
type ChangeAnalysis,
} from "./fingerprint.js";
export {
classifyUpdate,
type UpdateDecision,
} from "./change-classifier.js";
// Non-code parsers
export {
MarkdownParser,
YAMLConfigParser,
JSONConfigParser,
TOMLParser,
EnvParser,
DockerfileParser,
SQLParser,
GraphQLParser,
ProtobufParser,
TerraformParser,
MakefileParser,
ShellParser,
registerAllParsers,
} from "./plugins/parsers/index.js";
@@ -0,0 +1,14 @@
import type { LanguageConfig } from "../types.js";
export const batchConfig = {
id: "batch",
displayName: "Batch Script",
extensions: [".bat", ".cmd"],
concepts: ["commands", "variables", "labels", "goto", "call", "echo", "set", "for loops", "if conditions"],
filePatterns: {
entryPoints: [],
barrels: [],
tests: [],
config: [],
},
} satisfies LanguageConfig;
@@ -0,0 +1,14 @@
import type { LanguageConfig } from "../types.js";
export const cssConfig = {
id: "css",
displayName: "CSS",
extensions: [".css", ".scss", ".less"],
concepts: ["selectors", "properties", "media queries", "flexbox", "grid", "variables", "animations", "specificity"],
filePatterns: {
entryPoints: [],
barrels: [],
tests: [],
config: [],
},
} satisfies LanguageConfig;
@@ -0,0 +1,14 @@
import type { LanguageConfig } from "../types.js";
export const csvConfig = {
id: "csv",
displayName: "CSV",
extensions: [".csv", ".tsv"],
concepts: ["headers", "rows", "delimiters", "quoting", "escaping"],
filePatterns: {
entryPoints: [],
barrels: [],
tests: [],
config: [],
},
} satisfies LanguageConfig;
@@ -0,0 +1,15 @@
import type { LanguageConfig } from "../types.js";
export const dockerComposeConfig = {
id: "docker-compose",
displayName: "Docker Compose",
extensions: [],
filenames: ["docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"],
concepts: ["services", "networks", "volumes", "ports", "environment", "depends_on", "build context", "healthchecks"],
filePatterns: {
entryPoints: ["docker-compose.yml", "compose.yml"],
barrels: [],
tests: [],
config: [],
},
} satisfies LanguageConfig;
@@ -0,0 +1,15 @@
import type { LanguageConfig } from "../types.js";
export const dockerfileConfig = {
id: "dockerfile",
displayName: "Dockerfile",
extensions: [],
filenames: ["Dockerfile", "Dockerfile.dev", "Dockerfile.prod", "Dockerfile.test"],
concepts: ["multi-stage builds", "layers", "base images", "COPY/ADD", "EXPOSE", "ENTRYPOINT", "CMD", "ARG", "ENV"],
filePatterns: {
entryPoints: ["Dockerfile"],
barrels: [],
tests: [],
config: [],
},
} satisfies LanguageConfig;
@@ -0,0 +1,15 @@
import type { LanguageConfig } from "../types.js";
export const envConfig = {
id: "env",
displayName: "Environment Variables",
extensions: [".env"],
filenames: [".env", ".env.local", ".env.development", ".env.production", ".env.test", ".env.example"],
concepts: ["key-value pairs", "variable interpolation", "secrets", "environment-specific config"],
filePatterns: {
entryPoints: [],
barrels: [],
tests: [],
config: [".env", ".env.*"],
},
} satisfies LanguageConfig;
@@ -0,0 +1,14 @@
import type { LanguageConfig } from "../types.js";
export const githubActionsConfig = {
id: "github-actions",
displayName: "GitHub Actions",
extensions: [],
concepts: ["workflows", "jobs", "steps", "actions", "triggers", "secrets", "matrix strategy", "artifacts"],
filePatterns: {
entryPoints: [],
barrels: [],
tests: [],
config: [".github/workflows/*.yml"],
},
} satisfies LanguageConfig;
@@ -0,0 +1,14 @@
import type { LanguageConfig } from "../types.js";
export const graphqlConfig = {
id: "graphql",
displayName: "GraphQL",
extensions: [".graphql", ".gql"],
concepts: ["types", "queries", "mutations", "subscriptions", "resolvers", "directives", "fragments", "schema"],
filePatterns: {
entryPoints: ["schema.graphql"],
barrels: [],
tests: [],
config: [],
},
} satisfies LanguageConfig;
@@ -0,0 +1,14 @@
import type { LanguageConfig } from "../types.js";
export const htmlConfig = {
id: "html",
displayName: "HTML",
extensions: [".html", ".htm"],
concepts: ["elements", "attributes", "semantic tags", "forms", "meta tags", "scripts", "stylesheets", "accessibility"],
filePatterns: {
entryPoints: ["index.html"],
barrels: [],
tests: [],
config: [],
},
} satisfies LanguageConfig;
@@ -11,8 +11,36 @@ import { swiftConfig } from "./swift.js";
import { kotlinConfig } from "./kotlin.js";
import { cppConfig } from "./cpp.js";
import { csharpConfig } from "./csharp.js";
// Non-code language configs
import { markdownConfig } from "./markdown.js";
import { yamlConfig } from "./yaml.js";
import { jsonConfigConfig } from "./json-config.js";
import { tomlConfig } from "./toml.js";
import { envConfig } from "./env.js";
import { xmlConfig } from "./xml.js";
import { dockerfileConfig } from "./dockerfile.js";
import { sqlConfig } from "./sql.js";
import { graphqlConfig } from "./graphql.js";
import { protobufConfig } from "./protobuf.js";
import { terraformConfig } from "./terraform.js";
import { githubActionsConfig } from "./github-actions.js";
import { makefileConfig } from "./makefile.js";
import { shellConfig } from "./shell.js";
import { htmlConfig } from "./html.js";
import { cssConfig } from "./css.js";
import { openapiConfig } from "./openapi.js";
import { kubernetesConfig } from "./kubernetes.js";
import { dockerComposeConfig } from "./docker-compose.js";
import { jsonSchemaConfig } from "./json-schema.js";
import { csvConfig } from "./csv.js";
import { restructuredtextConfig } from "./restructuredtext.js";
import { powershellConfig } from "./powershell.js";
import { batchConfig } from "./batch.js";
import { jenkinsfileConfig } from "./jenkinsfile.js";
import { plaintextConfig } from "./plaintext.js";
export const builtinLanguageConfigs: LanguageConfig[] = [
// Code languages
typescriptConfig,
javascriptConfig,
pythonConfig,
@@ -25,9 +53,37 @@ export const builtinLanguageConfigs: LanguageConfig[] = [
kotlinConfig,
cppConfig,
csharpConfig,
// Non-code languages
markdownConfig,
yamlConfig,
jsonConfigConfig,
tomlConfig,
envConfig,
xmlConfig,
dockerfileConfig,
sqlConfig,
graphqlConfig,
protobufConfig,
terraformConfig,
githubActionsConfig,
makefileConfig,
shellConfig,
htmlConfig,
cssConfig,
openapiConfig,
kubernetesConfig,
dockerComposeConfig,
jsonSchemaConfig,
csvConfig,
restructuredtextConfig,
powershellConfig,
batchConfig,
jenkinsfileConfig,
plaintextConfig,
];
export {
// Code languages
typescriptConfig,
javascriptConfig,
pythonConfig,
@@ -40,4 +96,31 @@ export {
kotlinConfig,
cppConfig,
csharpConfig,
// Non-code languages
markdownConfig,
yamlConfig,
jsonConfigConfig,
tomlConfig,
envConfig,
xmlConfig,
dockerfileConfig,
sqlConfig,
graphqlConfig,
protobufConfig,
terraformConfig,
githubActionsConfig,
makefileConfig,
shellConfig,
htmlConfig,
cssConfig,
openapiConfig,
kubernetesConfig,
dockerComposeConfig,
jsonSchemaConfig,
csvConfig,
restructuredtextConfig,
powershellConfig,
batchConfig,
jenkinsfileConfig,
plaintextConfig,
};
@@ -0,0 +1,15 @@
import type { LanguageConfig } from "../types.js";
export const jenkinsfileConfig = {
id: "jenkinsfile",
displayName: "Jenkinsfile",
extensions: [],
filenames: ["Jenkinsfile"],
concepts: ["pipeline", "stages", "steps", "agents", "environment", "post actions", "parallel execution", "shared libraries"],
filePatterns: {
entryPoints: ["Jenkinsfile"],
barrels: [],
tests: [],
config: [],
},
} satisfies LanguageConfig;
@@ -0,0 +1,14 @@
import type { LanguageConfig } from "../types.js";
export const jsonConfigConfig = {
id: "json",
displayName: "JSON",
extensions: [".json", ".jsonc"],
concepts: ["objects", "arrays", "nesting", "schema references", "comments (JSONC)"],
filePatterns: {
entryPoints: ["package.json"],
barrels: [],
tests: [],
config: ["tsconfig.json", "package.json", ".eslintrc.json"],
},
} satisfies LanguageConfig;
@@ -0,0 +1,18 @@
import type { LanguageConfig } from "../types.js";
// TODO: JSON Schema files have no unique extension — *.schema.json files will match
// `jsonConfigConfig` by the `.json` extension. Detection requires content-based
// heuristics (e.g., checking for `"$schema"` or `"type"` keys at the root level).
// A future content-based detection pass could re-classify them as JSON Schema.
export const jsonSchemaConfig = {
id: "json-schema",
displayName: "JSON Schema",
extensions: [],
concepts: ["types", "properties", "required fields", "$ref", "$defs", "allOf/anyOf/oneOf", "patterns", "validation"],
filePatterns: {
entryPoints: [],
barrels: [],
tests: [],
config: [],
},
} satisfies LanguageConfig;
@@ -0,0 +1,19 @@
import type { LanguageConfig } from "../types.js";
// TODO: Kubernetes manifests are YAML files with no unique extension or filename.
// Detection requires content-based or path-pattern heuristics (e.g., checking for
// `apiVersion`/`kind` fields in YAML, or matching paths like `k8s/`, `kubernetes/`,
// `deploy/`). Currently these files will match `yamlConfig` by extension (.yaml/.yml).
// A future content-based detection pass could re-classify them as Kubernetes.
export const kubernetesConfig = {
id: "kubernetes",
displayName: "Kubernetes",
extensions: [],
concepts: ["deployments", "services", "pods", "configmaps", "secrets", "ingress", "volumes", "namespaces"],
filePatterns: {
entryPoints: [],
barrels: [],
tests: [],
config: ["k8s/*.yaml", "kubernetes/*.yaml"],
},
} satisfies LanguageConfig;
@@ -0,0 +1,15 @@
import type { LanguageConfig } from "../types.js";
export const makefileConfig = {
id: "makefile",
displayName: "Makefile",
extensions: [".mk"],
filenames: ["Makefile", "GNUmakefile", "makefile"],
concepts: ["targets", "dependencies", "recipes", "variables", "pattern rules", "phony targets", "includes"],
filePatterns: {
entryPoints: ["Makefile"],
barrels: [],
tests: [],
config: [],
},
} satisfies LanguageConfig;
@@ -0,0 +1,14 @@
import type { LanguageConfig } from "../types.js";
export const markdownConfig = {
id: "markdown",
displayName: "Markdown",
extensions: [".md", ".mdx"],
concepts: ["headings", "links", "code blocks", "front matter", "lists", "tables", "images"],
filePatterns: {
entryPoints: ["README.md"],
barrels: [],
tests: [],
config: [],
},
} satisfies LanguageConfig;
@@ -0,0 +1,15 @@
import type { LanguageConfig } from "../types.js";
export const openapiConfig = {
id: "openapi",
displayName: "OpenAPI",
extensions: [],
filenames: ["openapi.yaml", "openapi.json", "swagger.yaml", "swagger.json"],
concepts: ["paths", "operations", "schemas", "parameters", "responses", "security schemes", "tags", "servers"],
filePatterns: {
entryPoints: ["openapi.yaml", "swagger.yaml"],
barrels: [],
tests: [],
config: [],
},
} satisfies LanguageConfig;
@@ -0,0 +1,14 @@
import type { LanguageConfig } from "../types.js";
export const plaintextConfig = {
id: "plaintext",
displayName: "Plain Text",
extensions: [".txt", ".text"],
concepts: ["paragraphs", "lists", "sections"],
filePatterns: {
entryPoints: [],
barrels: [],
tests: [],
config: [],
},
} satisfies LanguageConfig;
@@ -0,0 +1,14 @@
import type { LanguageConfig } from "../types.js";
export const powershellConfig = {
id: "powershell",
displayName: "PowerShell",
extensions: [".ps1", ".psm1", ".psd1"],
concepts: ["cmdlets", "pipelines", "modules", "functions", "parameters", "variables", "error handling"],
filePatterns: {
entryPoints: [],
barrels: [],
tests: [],
config: [],
},
} satisfies LanguageConfig;
@@ -0,0 +1,14 @@
import type { LanguageConfig } from "../types.js";
export const protobufConfig = {
id: "protobuf",
displayName: "Protocol Buffers",
extensions: [".proto"],
concepts: ["messages", "services", "enums", "oneof", "repeated fields", "maps", "packages", "imports"],
filePatterns: {
entryPoints: [],
barrels: [],
tests: [],
config: [],
},
} satisfies LanguageConfig;
@@ -0,0 +1,14 @@
import type { LanguageConfig } from "../types.js";
export const restructuredtextConfig = {
id: "restructuredtext",
displayName: "reStructuredText",
extensions: [".rst"],
concepts: ["headings", "directives", "roles", "cross-references", "toctree", "code blocks", "admonitions"],
filePatterns: {
entryPoints: ["index.rst"],
barrels: [],
tests: [],
config: [],
},
} satisfies LanguageConfig;
@@ -0,0 +1,14 @@
import type { LanguageConfig } from "../types.js";
export const shellConfig = {
id: "shell",
displayName: "Shell Script",
extensions: [".sh", ".bash", ".zsh"],
concepts: ["variables", "functions", "conditionals", "loops", "pipes", "redirection", "subshells", "exit codes"],
filePatterns: {
entryPoints: [],
barrels: [],
tests: [],
config: [".bashrc", ".zshrc", ".profile"],
},
} satisfies LanguageConfig;
@@ -0,0 +1,14 @@
import type { LanguageConfig } from "../types.js";
export const sqlConfig = {
id: "sql",
displayName: "SQL",
extensions: [".sql"],
concepts: ["tables", "columns", "indexes", "foreign keys", "views", "stored procedures", "triggers", "migrations"],
filePatterns: {
entryPoints: [],
barrels: [],
tests: [],
config: [],
},
} satisfies LanguageConfig;
@@ -0,0 +1,14 @@
import type { LanguageConfig } from "../types.js";
export const terraformConfig = {
id: "terraform",
displayName: "Terraform",
extensions: [".tf", ".tfvars"],
concepts: ["resources", "data sources", "variables", "outputs", "modules", "providers", "state", "workspaces"],
filePatterns: {
entryPoints: ["main.tf"],
barrels: [],
tests: [],
config: ["terraform.tfvars", "variables.tf"],
},
} satisfies LanguageConfig;
@@ -0,0 +1,14 @@
import type { LanguageConfig } from "../types.js";
export const tomlConfig = {
id: "toml",
displayName: "TOML",
extensions: [".toml"],
concepts: ["tables", "inline tables", "arrays of tables", "key-value pairs", "dotted keys"],
filePatterns: {
entryPoints: [],
barrels: [],
tests: [],
config: ["Cargo.toml", "pyproject.toml", "netlify.toml"],
},
} satisfies LanguageConfig;
@@ -0,0 +1,14 @@
import type { LanguageConfig } from "../types.js";
export const xmlConfig = {
id: "xml",
displayName: "XML",
extensions: [".xml", ".xsl", ".xsd", ".svg", ".plist"],
concepts: ["elements", "attributes", "namespaces", "DTD", "XPath", "XSLT", "schemas"],
filePatterns: {
entryPoints: [],
barrels: [],
tests: [],
config: ["pom.xml", "web.xml", "AndroidManifest.xml"],
},
} satisfies LanguageConfig;
@@ -0,0 +1,14 @@
import type { LanguageConfig } from "../types.js";
export const yamlConfig = {
id: "yaml",
displayName: "YAML",
extensions: [".yaml", ".yml"],
concepts: ["mappings", "sequences", "anchors", "aliases", "multi-document", "tags"],
filePatterns: {
entryPoints: [],
barrels: [],
tests: [],
config: ["*.yaml", "*.yml"],
},
} satisfies LanguageConfig;
@@ -9,6 +9,7 @@ import { builtinLanguageConfigs } from "./configs/index.js";
export class LanguageRegistry {
private byId = new Map<string, LanguageConfig>();
private byExtension = new Map<string, LanguageConfig>();
private byFilename = new Map<string, LanguageConfig>();
register(config: LanguageConfig): void {
const parsed = LanguageConfigSchema.parse(config);
@@ -18,6 +19,11 @@ export class LanguageRegistry {
const key = ext.startsWith(".") ? ext : `.${ext}`;
this.byExtension.set(key, parsed);
}
if (parsed.filenames) {
for (const filename of parsed.filenames) {
this.byFilename.set(filename.toLowerCase(), parsed);
}
}
}
getById(id: string): LanguageConfig | null {
@@ -30,6 +36,11 @@ export class LanguageRegistry {
}
getForFile(filePath: string): LanguageConfig | null {
// Try filename-based lookup first (more specific: docker-compose.yml, Makefile, etc.)
const basename = filePath.split("/").pop() ?? filePath;
const filenameMatch = this.byFilename.get(basename.toLowerCase());
if (filenameMatch) return filenameMatch;
// Fall back to extension-based lookup
const lastDot = filePath.lastIndexOf(".");
if (lastDot === -1) return null;
const ext = filePath.slice(lastDot).toLowerCase();
@@ -22,11 +22,12 @@ export const FilePatternConfigSchema = z.object({
export type FilePatternConfig = z.infer<typeof FilePatternConfigSchema>;
// Complete language configuration
// Complete language configuration (base schema — used by LanguageRegistry.register())
export const LanguageConfigSchema = z.object({
id: z.string().min(1),
displayName: z.string().min(1),
extensions: z.array(z.string()).min(1),
extensions: z.array(z.string()),
filenames: z.array(z.string()).optional(),
treeSitter: TreeSitterConfigSchema.optional(),
concepts: z.array(z.string()),
filePatterns: FilePatternConfigSchema,
@@ -34,6 +35,18 @@ export const LanguageConfigSchema = z.object({
export type LanguageConfig = z.infer<typeof LanguageConfigSchema>;
/**
* Strict schema with refinement: ensures at least one extension or filename
* is provided so the config can actually be detected by the registry.
* Use this for validating new/user-supplied configs (some builtin configs like
* kubernetes/github-actions intentionally lack both and rely on future
* content-based detection).
*/
export const StrictLanguageConfigSchema = LanguageConfigSchema.refine(
(c) => c.extensions.length > 0 || (c.filenames !== undefined && c.filenames.length > 0),
{ message: "LanguageConfig must have at least one extension or filename for detection" }
);
// Framework configuration
export const FrameworkConfigSchema = z.object({
id: z.string().min(1),
@@ -1,11 +1,14 @@
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
import { join } from "node:path";
import type { KnowledgeGraph, AnalysisMeta } from "../types.js";
import { join, isAbsolute, relative, basename } from "node:path";
import type { KnowledgeGraph, AnalysisMeta, ProjectConfig } from "../types.js";
import type { FingerprintStore } from "../fingerprint.js";
import { validateGraph } from "../schema.js";
const UA_DIR = ".understand-anything";
const GRAPH_FILE = "knowledge-graph.json";
const META_FILE = "meta.json";
const FINGERPRINT_FILE = "fingerprints.json";
const CONFIG_FILE = "config.json";
function ensureDir(projectRoot: string): string {
const dir = join(projectRoot, UA_DIR);
@@ -15,9 +18,68 @@ function ensureDir(projectRoot: string): string {
return dir;
}
/**
* Sanitise every node's filePath before writing to disk.
*
* The analysis agent produces absolute paths like:
* /Users/alice/company/src/auth.ts
*
* We convert them to paths relative to projectRoot:
* src/auth.ts
*
* Three cases are handled:
* 1. Path is inside projectRoot → make it relative
* 2. Path is absolute but outside → keep only the filename (last segment)
* 3. Path is already relative → leave it untouched
*
* This means the developer's home directory, username, and company
* directory layout are never written to knowledge-graph.json.
*/
function sanitiseFilePaths(
graph: KnowledgeGraph,
projectRoot: string,
): KnowledgeGraph {
const normalRoot = projectRoot.endsWith("/")
? projectRoot
: projectRoot + "/";
const sanitisedNodes = graph.nodes.map((node) => {
if (typeof node.filePath !== "string") return node;
const fp = node.filePath;
if (!isAbsolute(fp)) {
// Already relative — nothing to do.
return node;
}
if (fp.startsWith(normalRoot) || fp.startsWith(projectRoot)) {
// Inside the project root — make it relative.
return { ...node, filePath: relative(projectRoot, fp) };
}
// Absolute but outside the project root — use only the filename
// so we leak as little as possible.
return { ...node, filePath: basename(fp) };
});
return { ...graph, nodes: sanitisedNodes };
}
export function saveGraph(projectRoot: string, graph: KnowledgeGraph): void {
const dir = ensureDir(projectRoot);
writeFileSync(join(dir, GRAPH_FILE), JSON.stringify(graph, null, 2), "utf-8");
// FIX — sanitise absolute file paths before persisting.
// Without this, absolute paths like /Users/alice/company/src/auth.ts
// are written verbatim into knowledge-graph.json and later served
// by the dashboard server, leaking the developer's directory layout.
const sanitised = sanitiseFilePaths(graph, projectRoot);
writeFileSync(
join(dir, GRAPH_FILE),
JSON.stringify(sanitised, null, 2),
"utf-8",
);
}
export function loadGraph(
@@ -33,7 +95,7 @@ export function loadGraph(
const result = validateGraph(data);
if (!result.success) {
throw new Error(
`Invalid knowledge graph: ${result.errors!.join("; ")}`,
`Invalid knowledge graph: ${result.fatal ?? "unknown error"}`,
);
}
return result.data as KnowledgeGraph;
@@ -52,3 +114,35 @@ export function loadMeta(projectRoot: string): AnalysisMeta | null {
if (!existsSync(filePath)) return null;
return JSON.parse(readFileSync(filePath, "utf-8")) as AnalysisMeta;
}
export function saveFingerprints(projectRoot: string, store: FingerprintStore): void {
const dir = ensureDir(projectRoot);
writeFileSync(join(dir, FINGERPRINT_FILE), JSON.stringify(store, null, 2), "utf-8");
}
export function loadFingerprints(projectRoot: string): FingerprintStore | null {
const filePath = join(projectRoot, UA_DIR, FINGERPRINT_FILE);
if (!existsSync(filePath)) return null;
try {
return JSON.parse(readFileSync(filePath, "utf-8")) as FingerprintStore;
} catch {
return null;
}
}
const DEFAULT_CONFIG: ProjectConfig = { autoUpdate: false };
export function saveConfig(projectRoot: string, config: ProjectConfig): void {
const dir = ensureDir(projectRoot);
writeFileSync(join(dir, CONFIG_FILE), JSON.stringify(config, null, 2), "utf-8");
}
export function loadConfig(projectRoot: string): ProjectConfig {
const filePath = join(projectRoot, UA_DIR, CONFIG_FILE);
if (!existsSync(filePath)) return { ...DEFAULT_CONFIG };
try {
return JSON.parse(readFileSync(filePath, "utf-8")) as ProjectConfig;
} catch {
return { ...DEFAULT_CONFIG };
}
}
@@ -2,8 +2,10 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync, existsSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { saveGraph, loadGraph, saveMeta, loadMeta } from "./index.js";
import { writeFileSync } from "node:fs";
import { saveGraph, loadGraph, saveMeta, loadMeta, saveFingerprints, loadFingerprints, saveConfig, loadConfig } from "./index.js";
import type { KnowledgeGraph, AnalysisMeta } from "../types.js";
import type { FingerprintStore } from "../fingerprint.js";
describe("persistence", () => {
let tempDir: string;
@@ -41,7 +43,7 @@ describe("persistence", () => {
edges: [
{
source: "node-1",
target: "node-2",
target: "node-1",
type: "imports",
direction: "forward",
weight: 0.8,
@@ -92,6 +94,24 @@ describe("persistence", () => {
const loaded = loadGraph(tempDir);
expect(loaded).toBeNull();
});
it("should throw error when loading a fatally invalid graph", () => {
const invalidGraph = { ...sampleGraph, project: null };
saveGraph(tempDir, invalidGraph as unknown as KnowledgeGraph);
expect(() => {
loadGraph(tempDir);
}).toThrow(/Invalid knowledge graph/);
});
it("should skip validation when validate option is false", () => {
const invalidGraph = { ...sampleGraph, version: 123 };
saveGraph(tempDir, invalidGraph as unknown as KnowledgeGraph);
const loaded = loadGraph(tempDir, { validate: false });
expect(loaded).not.toBeNull();
expect(loaded?.version).toBe(123);
});
});
describe("saveMeta / loadMeta", () => {
@@ -115,4 +135,70 @@ describe("persistence", () => {
expect(loaded).toBeNull();
});
});
describe("saveFingerprints / loadFingerprints", () => {
const sampleFingerprints: FingerprintStore = {
version: "1.0.0",
gitCommitHash: "abc123",
generatedAt: "2026-03-14T00:00:00.000Z",
files: {
"src/index.ts": {
filePath: "src/index.ts",
contentHash: "deadbeef",
functions: [],
classes: [],
imports: [],
exports: [],
totalLines: 10,
hasStructuralAnalysis: false,
},
},
};
it("should round-trip fingerprints correctly", () => {
saveFingerprints(tempDir, sampleFingerprints);
const loaded = loadFingerprints(tempDir);
expect(loaded).toEqual(sampleFingerprints);
});
it("should return null when no fingerprints file exists", () => {
const loaded = loadFingerprints(tempDir);
expect(loaded).toBeNull();
});
it("should return null when fingerprints.json is corrupted", () => {
const dir = join(tempDir, ".understand-anything");
// Ensure the directory exists by saving first, then overwrite with garbage
saveFingerprints(tempDir, sampleFingerprints);
writeFileSync(join(dir, "fingerprints.json"), "{{not valid json!!", "utf-8");
const loaded = loadFingerprints(tempDir);
expect(loaded).toBeNull();
});
});
describe("saveConfig / loadConfig", () => {
it("should round-trip config correctly", () => {
saveConfig(tempDir, { autoUpdate: true });
const loaded = loadConfig(tempDir);
expect(loaded).toEqual({ autoUpdate: true });
});
it("should return default config when no file exists", () => {
const loaded = loadConfig(tempDir);
expect(loaded).toEqual({ autoUpdate: false });
});
it("should return default config when config.json is corrupted", () => {
saveConfig(tempDir, { autoUpdate: true });
const dir = join(tempDir, ".understand-anything");
writeFileSync(join(dir, "config.json"), "not json!!", "utf-8");
const loaded = loadConfig(tempDir);
expect(loaded).toEqual({ autoUpdate: false });
});
});
});
@@ -0,0 +1,86 @@
import type { AnalyzerPlugin, StructuralAnalysis, ServiceInfo, StepInfo } from "../../types.js";
/**
* Parses Dockerfiles to extract multi-stage build stages, EXPOSE ports, and instruction steps.
* Associates EXPOSE ports with the correct stage based on FROM directive ordering.
* Does not parse ARG/ENV variable substitution or heredoc syntax.
*/
export class DockerfileParser implements AnalyzerPlugin {
name = "dockerfile-parser";
languages = ["dockerfile"];
analyzeFile(_filePath: string, content: string): StructuralAnalysis {
const services = this.extractStages(content);
const steps = this.extractSteps(content);
return {
functions: [],
classes: [],
imports: [],
exports: [],
services,
steps,
};
}
private extractStages(content: string): ServiceInfo[] {
const stages: ServiceInfo[] = [];
const lines = content.split("\n");
// First pass: find FROM line indices
const fromLines: number[] = [];
for (let i = 0; i < lines.length; i++) {
if (/^FROM\s+/i.test(lines[i])) {
fromLines.push(i);
}
}
// Second pass: for each stage, collect EXPOSE ports within its range and build ServiceInfo
for (let s = 0; s < fromLines.length; s++) {
const stageStartLine = fromLines[s];
const stageEndLine = s + 1 < fromLines.length ? fromLines[s + 1] - 1 : lines.length - 1;
const fromMatch = lines[stageStartLine].match(/^FROM\s+(\S+)(?:\s+[Aa][Ss]\s+(\S+))?/i);
if (!fromMatch) continue;
const image = fromMatch[1];
const name = fromMatch[2] ?? image.split(":")[0].split("/").pop() ?? image;
// Collect EXPOSE ports that appear within this stage's range
const ports: number[] = [];
for (let i = stageStartLine; i <= stageEndLine; i++) {
const exposeMatch = lines[i].match(/^EXPOSE\s+(.+)/i);
if (exposeMatch) {
const portValues = exposeMatch[1].split(/\s+/);
for (const p of portValues) {
const num = parseInt(p, 10);
if (!isNaN(num)) ports.push(num);
}
}
}
stages.push({
name,
image,
ports,
lineRange: [stageStartLine + 1, stageEndLine + 1],
});
}
return stages;
}
private extractSteps(content: string): StepInfo[] {
const steps: StepInfo[] = [];
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
const match = lines[i].match(/^(FROM|RUN|COPY|ADD|WORKDIR|CMD|ENTRYPOINT|ENV|ARG|EXPOSE|VOLUME|USER|HEALTHCHECK)\s/i);
if (match) {
steps.push({
name: `${match[1].toUpperCase()} ${lines[i].slice(match[1].length + 1).trim().slice(0, 60)}`,
lineRange: [i + 1, i + 1],
});
}
}
return steps;
}
}
@@ -0,0 +1,41 @@
import type { AnalyzerPlugin, StructuralAnalysis, DefinitionInfo } from "../../types.js";
/**
* Parses .env files to extract environment variable definitions.
* Handles KEY=value syntax, skipping comments and empty lines.
* Does not handle `export VAR=value` syntax or multi-line values.
*/
export class EnvParser implements AnalyzerPlugin {
name = "env-parser";
languages = ["env"];
analyzeFile(_filePath: string, content: string): StructuralAnalysis {
const definitions = this.extractVariables(content);
return {
functions: [],
classes: [],
imports: [],
exports: [],
definitions,
};
}
private extractVariables(content: string): DefinitionInfo[] {
const definitions: DefinitionInfo[] = [];
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (line.startsWith("#") || line === "") continue;
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=/);
if (match) {
definitions.push({
name: match[1],
kind: "variable",
lineRange: [i + 1, i + 1],
fields: [],
});
}
}
return definitions;
}
}
@@ -0,0 +1,123 @@
import type { AnalyzerPlugin, StructuralAnalysis, DefinitionInfo, EndpointInfo } from "../../types.js";
/**
* Parses GraphQL schema files to extract type, input, enum, interface, union, and scalar definitions.
* Extracts Query, Mutation, and Subscription endpoints as separate endpoint entries.
* Does not handle schema directives, fragments, or inline union members.
*/
export class GraphQLParser implements AnalyzerPlugin {
name = "graphql-parser";
languages = ["graphql"];
analyzeFile(_filePath: string, content: string): StructuralAnalysis {
const definitions = this.extractDefinitions(content);
const endpoints = this.extractEndpoints(content);
return {
functions: [],
classes: [],
imports: [],
exports: [],
definitions,
endpoints,
};
}
private extractDefinitions(content: string): DefinitionInfo[] {
const definitions: DefinitionInfo[] = [];
const lines = content.split("\n");
// Match type, input, enum, interface, union, scalar definitions
const typeRegex = /^(type|input|enum|interface|union|scalar)\s+(\w+)/gm;
let match;
while ((match = typeRegex.exec(content)) !== null) {
const kind = match[1];
const name = match[2];
if (name === "Query" || name === "Mutation" || name === "Subscription") continue;
const startLine = content.slice(0, match.index).split("\n").length;
// Extract fields (for type/input/interface/enum)
const fields = this.extractFields(content, match.index);
// Find closing brace
const afterMatch = content.slice(match.index);
const closeBrace = afterMatch.indexOf("}");
const endLine = closeBrace !== -1
? content.slice(0, match.index + closeBrace + 1).split("\n").length
: startLine;
definitions.push({
name,
kind,
lineRange: [startLine, endLine],
fields,
});
}
return definitions;
}
private extractEndpoints(content: string): EndpointInfo[] {
const endpoints: EndpointInfo[] = [];
// Find Query, Mutation, Subscription blocks and extract their fields
const blockRegex = /^(type)\s+(Query|Mutation|Subscription)\s*\{/gm;
let match;
while ((match = blockRegex.exec(content)) !== null) {
const method = match[2]; // Query, Mutation, Subscription
const startIdx = match.index + match[0].length;
// Find closing brace
let depth = 1;
let i = startIdx;
while (i < content.length && depth > 0) {
if (content[i] === "{") depth++;
if (content[i] === "}") depth--;
i++;
}
const blockContent = content.slice(startIdx, i - 1);
const blockLines = blockContent.split("\n");
const blockStartLine = content.slice(0, startIdx).split("\n").length;
for (let j = 0; j < blockLines.length; j++) {
const fieldMatch = blockLines[j].trim().match(/^(\w+)/);
if (fieldMatch && fieldMatch[1]) {
const lineNum = blockStartLine + j;
endpoints.push({
method,
path: fieldMatch[1],
lineRange: [lineNum, lineNum],
});
}
}
}
return endpoints;
}
private extractFields(content: string, startIdx: number): string[] {
const fields: string[] = [];
const afterType = content.slice(startIdx);
const openBrace = afterType.indexOf("{");
if (openBrace === -1) return fields;
let depth = 1;
let i = openBrace + 1;
while (i < afterType.length && depth > 0) {
if (afterType[i] === "{") depth++;
if (afterType[i] === "}") depth--;
i++;
}
const body = afterType.slice(openBrace + 1, i - 1);
const lines = body.split("\n");
for (const line of lines) {
const fieldMatch = line.trim().match(/^(\w+)/);
if (fieldMatch) {
fields.push(fieldMatch[1]);
}
}
return fields;
}
}
@@ -0,0 +1,44 @@
export { MarkdownParser } from "./markdown-parser.js";
export { YAMLConfigParser } from "./yaml-parser.js";
export { JSONConfigParser } from "./json-parser.js";
export { TOMLParser } from "./toml-parser.js";
export { EnvParser } from "./env-parser.js";
export { DockerfileParser } from "./dockerfile-parser.js";
export { SQLParser } from "./sql-parser.js";
export { GraphQLParser } from "./graphql-parser.js";
export { ProtobufParser } from "./protobuf-parser.js";
export { TerraformParser } from "./terraform-parser.js";
export { MakefileParser } from "./makefile-parser.js";
export { ShellParser } from "./shell-parser.js";
import type { PluginRegistry } from "../registry.js";
import { MarkdownParser } from "./markdown-parser.js";
import { YAMLConfigParser } from "./yaml-parser.js";
import { JSONConfigParser } from "./json-parser.js";
import { TOMLParser } from "./toml-parser.js";
import { EnvParser } from "./env-parser.js";
import { DockerfileParser } from "./dockerfile-parser.js";
import { SQLParser } from "./sql-parser.js";
import { GraphQLParser } from "./graphql-parser.js";
import { ProtobufParser } from "./protobuf-parser.js";
import { TerraformParser } from "./terraform-parser.js";
import { MakefileParser } from "./makefile-parser.js";
import { ShellParser } from "./shell-parser.js";
/**
* Register all built-in non-code parsers with a PluginRegistry.
*/
export function registerAllParsers(registry: PluginRegistry): void {
registry.register(new MarkdownParser());
registry.register(new YAMLConfigParser());
registry.register(new JSONConfigParser());
registry.register(new TOMLParser());
registry.register(new EnvParser());
registry.register(new DockerfileParser());
registry.register(new SQLParser());
registry.register(new GraphQLParser());
registry.register(new ProtobufParser());
registry.register(new TerraformParser());
registry.register(new MakefileParser());
registry.register(new ShellParser());
}
@@ -0,0 +1,70 @@
import type { AnalyzerPlugin, StructuralAnalysis, SectionInfo, ReferenceResolution } from "../../types.js";
/**
* Parses JSON configuration files to extract top-level key sections and $ref references.
* Handles package.json, tsconfig.json, JSON Schema, and OpenAPI spec files.
* Does not descend into nested object structures beyond top-level keys.
*/
export class JSONConfigParser implements AnalyzerPlugin {
name = "json-config-parser";
languages = ["json"];
analyzeFile(_filePath: string, content: string): StructuralAnalysis {
const sections = this.extractSections(content);
return {
functions: [],
classes: [],
imports: [],
exports: [],
sections,
};
}
extractReferences(filePath: string, content: string): ReferenceResolution[] {
const refs: ReferenceResolution[] = [];
// Match $ref values (JSON Schema / OpenAPI)
const refRegex = /"\$ref"\s*:\s*"([^"]+)"/g;
let match;
while ((match = refRegex.exec(content)) !== null) {
const target = match[1];
if (target.startsWith("#")) continue; // Skip internal refs
const line = content.slice(0, match.index).split("\n").length;
refs.push({
source: filePath,
target,
referenceType: "schema",
line,
});
}
return refs;
}
private extractSections(content: string): SectionInfo[] {
const sections: SectionInfo[] = [];
try {
const doc = JSON.parse(content);
if (doc && typeof doc === "object" && !Array.isArray(doc)) {
const lines = content.split("\n");
for (const key of Object.keys(doc)) {
const escapedKey = JSON.stringify(key);
const lineIdx = lines.findIndex((l) => l.includes(escapedKey));
if (lineIdx !== -1) {
sections.push({
name: key,
level: 1,
lineRange: [lineIdx + 1, lineIdx + 1],
});
}
}
// Fix lineRange end
for (let i = 0; i < sections.length; i++) {
const next = sections[i + 1];
sections[i].lineRange[1] = next ? next.lineRange[0] - 1 : lines.length;
}
}
} catch (err) {
console.warn(`[json-parser] Failed to parse JSON: ${err instanceof Error ? err.message : String(err)}`);
}
return sections;
}
}
@@ -0,0 +1,51 @@
import type { AnalyzerPlugin, StructuralAnalysis, StepInfo } from "../../types.js";
/**
* Parses Makefiles to extract build targets and their line ranges.
* Filters out special Make targets (e.g., .PHONY, .DEFAULT, .SUFFIXES) and variable assignments.
* Does not parse target dependencies or recipe commands.
*/
export class MakefileParser implements AnalyzerPlugin {
name = "makefile-parser";
languages = ["makefile"];
analyzeFile(_filePath: string, content: string): StructuralAnalysis {
const steps = this.extractTargets(content);
return {
functions: [],
classes: [],
imports: [],
exports: [],
steps,
};
}
private extractTargets(content: string): StepInfo[] {
const targets: StepInfo[] = [];
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
// Match target: dependencies (not variable assignments or comments)
const match = lines[i].match(/^([a-zA-Z_.][a-zA-Z0-9_.-]*)(?:\s+.*)?:/);
if (match && !lines[i].includes(":=") && !lines[i].includes("?=")) {
const name = match[1];
// Skip special Make targets (.PHONY, .DEFAULT, .SUFFIXES, etc.)
if (name.startsWith(".")) continue;
// Find end of target (next non-indented non-empty line or EOF)
let endLine = i + 1;
while (endLine < lines.length) {
const nextLine = lines[endLine];
if (nextLine === "" || nextLine.startsWith("\t") || nextLine.startsWith(" ")) {
endLine++;
} else {
break;
}
}
targets.push({
name,
lineRange: [i + 1, endLine],
});
}
}
return targets;
}
}
@@ -0,0 +1,61 @@
import type { AnalyzerPlugin, StructuralAnalysis, ReferenceResolution, SectionInfo } from "../../types.js";
/**
* Parses Markdown files to extract heading sections and local file/image references.
* Supports ATX-style headings (# through ######) with line range computation.
* Does not extract code blocks, front matter fields, or external URL references.
*/
export class MarkdownParser implements AnalyzerPlugin {
name = "markdown-parser";
languages = ["markdown"];
analyzeFile(_filePath: string, content: string): StructuralAnalysis {
const sections = this.extractSections(content);
return {
functions: [],
classes: [],
imports: [],
exports: [],
sections,
};
}
extractReferences(filePath: string, content: string): ReferenceResolution[] {
const refs: ReferenceResolution[] = [];
const linkRegex = /!?\[([^\]]*)\]\(([^)]+)\)/g;
let match;
while ((match = linkRegex.exec(content)) !== null) {
const target = match[2];
if (target.startsWith("http")) continue; // Skip external URLs
const line = content.slice(0, match.index).split("\n").length;
refs.push({
source: filePath,
target,
referenceType: match[0].startsWith("!") ? "image" : "file",
line,
});
}
return refs;
}
private extractSections(content: string): SectionInfo[] {
const sections: SectionInfo[] = [];
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
const match = lines[i].match(/^(#{1,6})\s+(.+)/);
if (match) {
sections.push({
name: match[2].trim(),
level: match[1].length,
lineRange: [i + 1, i + 1],
});
}
}
// Fix lineRange end for each section (extends to next heading or EOF)
for (let i = 0; i < sections.length; i++) {
const next = sections[i + 1];
sections[i].lineRange[1] = next ? next.lineRange[0] - 1 : lines.length;
}
return sections;
}
}
@@ -0,0 +1,141 @@
import type { AnalyzerPlugin, StructuralAnalysis, DefinitionInfo, EndpointInfo } from "../../types.js";
/**
* Parses Protocol Buffer (.proto) files to extract message, enum, and service definitions.
* Extracts message fields, enum values, and service RPC method endpoints.
* Does not handle nested message types, oneof fields, or proto2 extensions.
*/
export class ProtobufParser implements AnalyzerPlugin {
name = "protobuf-parser";
languages = ["protobuf"];
analyzeFile(_filePath: string, content: string): StructuralAnalysis {
const definitions = this.extractDefinitions(content);
const endpoints = this.extractServiceMethods(content);
return {
functions: [],
classes: [],
imports: [],
exports: [],
definitions,
endpoints,
};
}
private extractDefinitions(content: string): DefinitionInfo[] {
const definitions: DefinitionInfo[] = [];
// Match message definitions
const messageRegex = /^message\s+(\w+)\s*\{/gm;
let match;
while ((match = messageRegex.exec(content)) !== null) {
const startLine = content.slice(0, match.index).split("\n").length;
const fields = this.extractMessageFields(content, match.index);
const afterMatch = content.slice(match.index);
const closeBrace = this.findClosingBrace(afterMatch);
const endLine = content.slice(0, match.index + closeBrace + 1).split("\n").length;
definitions.push({
name: match[1],
kind: "message",
lineRange: [startLine, endLine],
fields,
});
}
// Match enum definitions
const enumRegex = /^enum\s+(\w+)\s*\{/gm;
while ((match = enumRegex.exec(content)) !== null) {
const startLine = content.slice(0, match.index).split("\n").length;
const fields = this.extractEnumValues(content, match.index);
const afterMatch = content.slice(match.index);
const closeBrace = this.findClosingBrace(afterMatch);
const endLine = content.slice(0, match.index + closeBrace + 1).split("\n").length;
definitions.push({
name: match[1],
kind: "enum",
lineRange: [startLine, endLine],
fields,
});
}
return definitions;
}
private extractServiceMethods(content: string): EndpointInfo[] {
const endpoints: EndpointInfo[] = [];
const serviceRegex = /^service\s+(\w+)\s*\{/gm;
let match;
while ((match = serviceRegex.exec(content)) !== null) {
const serviceName = match[1];
const startIdx = match.index + match[0].length;
const afterService = content.slice(match.index);
const closeBrace = this.findClosingBrace(afterService);
const body = afterService.slice(match[0].length, closeBrace);
const rpcRegex = /rpc\s+(\w+)\s*\(/g;
let rpcMatch;
while ((rpcMatch = rpcRegex.exec(body)) !== null) {
const lineNum = content.slice(0, startIdx + rpcMatch.index).split("\n").length;
endpoints.push({
method: "rpc",
path: `${serviceName}.${rpcMatch[1]}`,
lineRange: [lineNum, lineNum],
});
}
}
return endpoints;
}
private extractMessageFields(content: string, startIdx: number): string[] {
const fields: string[] = [];
const afterMsg = content.slice(startIdx);
const openBrace = afterMsg.indexOf("{");
if (openBrace === -1) return fields;
const closeBrace = this.findClosingBrace(afterMsg);
const body = afterMsg.slice(openBrace + 1, closeBrace);
const fieldRegex = /^\s*(?:repeated\s+|optional\s+|required\s+|map<[^>]+>\s+)?\w+\s+(\w+)\s*=/gm;
let match;
while ((match = fieldRegex.exec(body)) !== null) {
fields.push(match[1]);
}
return fields;
}
private extractEnumValues(content: string, startIdx: number): string[] {
const values: string[] = [];
const afterEnum = content.slice(startIdx);
const openBrace = afterEnum.indexOf("{");
if (openBrace === -1) return values;
const closeBrace = this.findClosingBrace(afterEnum);
const body = afterEnum.slice(openBrace + 1, closeBrace);
const valueRegex = /^\s*(\w+)\s*=/gm;
let match;
while ((match = valueRegex.exec(body)) !== null) {
values.push(match[1]);
}
return values;
}
private findClosingBrace(content: string): number {
let depth = 0;
for (let i = 0; i < content.length; i++) {
if (content[i] === "{") depth++;
if (content[i] === "}") {
depth--;
if (depth === 0) return i;
}
}
if (depth !== 0) {
console.warn(`[protobuf-parser] Unbalanced braces detected (depth=${depth}), results may be incomplete`);
}
return content.length;
}
}
@@ -0,0 +1,76 @@
import type { AnalyzerPlugin, StructuralAnalysis, ReferenceResolution } from "../../types.js";
/**
* Parses shell scripts (.sh, .bash) to extract function definitions and source references.
* Handles both `name() {` and `function name {` styles, including brace on next line.
* Does not extract variable declarations, aliases, or trap handlers.
*/
export class ShellParser implements AnalyzerPlugin {
name = "shell-parser";
languages = ["shell"];
analyzeFile(_filePath: string, content: string): StructuralAnalysis {
const functions = this.extractFunctions(content);
return {
functions,
classes: [],
imports: [],
exports: [],
};
}
extractReferences(filePath: string, content: string): ReferenceResolution[] {
const refs: ReferenceResolution[] = [];
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
// Match source/. commands
const sourceMatch = lines[i].match(/^\s*(?:source|\.)[ \t]+["']?([^"'\s]+)["']?/);
if (sourceMatch) {
refs.push({
source: filePath,
target: sourceMatch[1],
referenceType: "file",
line: i + 1,
});
}
}
return refs;
}
private extractFunctions(content: string): Array<{ name: string; lineRange: [number, number]; params: string[] }> {
const functions: Array<{ name: string; lineRange: [number, number]; params: string[] }> = [];
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
// Match function name() { or function name {
const match = lines[i].match(/^(?:function\s+)?(\w+)\s*\(\s*\)\s*\{?/) ||
lines[i].match(/^function\s+(\w+)\s*\{?/);
if (match) {
const name = match[1];
// Find closing brace (handle brace on same line or next line)
let endLine = i;
if (lines[i].includes("{") || (i + 1 < lines.length && lines[i + 1]?.trim() === "{")) {
const startBraceLine = lines[i].includes("{") ? i : i + 1;
let depth = 0;
for (let j = startBraceLine; j < lines.length; j++) {
for (const ch of lines[j]) {
if (ch === "{") depth++;
if (ch === "}") depth--;
}
if (depth === 0) {
endLine = j;
break;
}
}
}
functions.push({
name,
lineRange: [i + 1, endLine + 1],
params: [],
});
}
}
return functions;
}
}
@@ -0,0 +1,103 @@
import type { AnalyzerPlugin, StructuralAnalysis, DefinitionInfo } from "../../types.js";
/**
* Parses SQL files to extract table, view, and index definitions.
* Handles CREATE TABLE, CREATE VIEW, CREATE INDEX with IF NOT EXISTS and OR REPLACE variants.
* Does not handle stored procedures, triggers, or schema-qualified names (e.g., public.users).
*/
export class SQLParser implements AnalyzerPlugin {
name = "sql-parser";
languages = ["sql"];
analyzeFile(_filePath: string, content: string): StructuralAnalysis {
const definitions = this.extractDefinitions(content);
return {
functions: [],
classes: [],
imports: [],
exports: [],
definitions,
};
}
private extractDefinitions(content: string): DefinitionInfo[] {
const definitions: DefinitionInfo[] = [];
const lines = content.split("\n");
// Match CREATE TABLE statements
const tableRegex = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:`|")?(\w+)(?:`|")?/gi;
let match;
while ((match = tableRegex.exec(content)) !== null) {
const tableName = match[1];
const startLine = content.slice(0, match.index).split("\n").length;
// Extract columns (simplified: look for column names in parenthesized block)
const fields = this.extractColumns(content, match.index);
// Find the end of the CREATE TABLE statement
const afterMatch = content.slice(match.index);
const endParen = afterMatch.indexOf(");");
const endLine = endParen !== -1
? content.slice(0, match.index + endParen + 2).split("\n").length
: startLine + 5;
definitions.push({
name: tableName,
kind: "table",
lineRange: [startLine, endLine],
fields,
});
}
// Match CREATE VIEW
const viewRegex = /CREATE\s+(?:OR\s+REPLACE\s+)?VIEW\s+(?:`|")?(\w+)(?:`|")?/gi;
while ((match = viewRegex.exec(content)) !== null) {
const startLine = content.slice(0, match.index).split("\n").length;
definitions.push({
name: match[1],
kind: "view",
lineRange: [startLine, startLine],
fields: [],
});
}
// Match CREATE INDEX
const indexRegex = /CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:`|")?(\w+)(?:`|")?/gi;
while ((match = indexRegex.exec(content)) !== null) {
const startLine = content.slice(0, match.index).split("\n").length;
definitions.push({
name: match[1],
kind: "index",
lineRange: [startLine, startLine],
fields: [],
});
}
return definitions;
}
private extractColumns(content: string, startIdx: number): string[] {
const fields: string[] = [];
const afterCreate = content.slice(startIdx);
const openParen = afterCreate.indexOf("(");
if (openParen === -1) return fields;
const closeParen = afterCreate.indexOf(");", openParen);
if (closeParen === -1) return fields;
const body = afterCreate.slice(openParen + 1, closeParen);
const lines = body.split(",");
for (const line of lines) {
const trimmed = line.trim();
// Skip constraints
if (/^(PRIMARY|FOREIGN|UNIQUE|CHECK|CONSTRAINT|INDEX|KEY)/i.test(trimmed)) continue;
const colMatch = trimmed.match(/^(?:`|")?(\w+)(?:`|")?\s+/);
if (colMatch) {
fields.push(colMatch[1]);
}
}
return fields;
}
}
@@ -0,0 +1,130 @@
import type { AnalyzerPlugin, StructuralAnalysis, ResourceInfo, DefinitionInfo } from "../../types.js";
/**
* Parses Terraform (.tf) files to extract resource, data, module, variable, and output blocks.
* Handles HCL block syntax with brace-matching for line range computation.
* Does not handle provider blocks, locals, or terraform configuration blocks.
*/
export class TerraformParser implements AnalyzerPlugin {
name = "terraform-parser";
languages = ["terraform"];
analyzeFile(_filePath: string, content: string): StructuralAnalysis {
const resources = this.extractResources(content);
const definitions = this.extractVariablesAndOutputs(content);
return {
functions: [],
classes: [],
imports: [],
exports: [],
resources,
definitions,
};
}
private extractResources(content: string): ResourceInfo[] {
const resources: ResourceInfo[] = [];
// Match resource blocks: resource "type" "name" {
const resourceRegex = /^resource\s+"([^"]+)"\s+"([^"]+)"\s*\{/gm;
let match;
while ((match = resourceRegex.exec(content)) !== null) {
const startLine = content.slice(0, match.index).split("\n").length;
const afterMatch = content.slice(match.index);
const closeBrace = this.findClosingBrace(afterMatch);
const endLine = content.slice(0, match.index + closeBrace + 1).split("\n").length;
resources.push({
name: `${match[1]}.${match[2]}`,
kind: match[1],
lineRange: [startLine, endLine],
});
}
// Match data blocks: data "type" "name" {
const dataRegex = /^data\s+"([^"]+)"\s+"([^"]+)"\s*\{/gm;
while ((match = dataRegex.exec(content)) !== null) {
const startLine = content.slice(0, match.index).split("\n").length;
const afterMatch = content.slice(match.index);
const closeBrace = this.findClosingBrace(afterMatch);
const endLine = content.slice(0, match.index + closeBrace + 1).split("\n").length;
resources.push({
name: `data.${match[1]}.${match[2]}`,
kind: `data.${match[1]}`,
lineRange: [startLine, endLine],
});
}
// Match module blocks: module "name" {
const moduleRegex = /^module\s+"([^"]+)"\s*\{/gm;
while ((match = moduleRegex.exec(content)) !== null) {
const startLine = content.slice(0, match.index).split("\n").length;
const afterMatch = content.slice(match.index);
const closeBrace = this.findClosingBrace(afterMatch);
const endLine = content.slice(0, match.index + closeBrace + 1).split("\n").length;
resources.push({
name: `module.${match[1]}`,
kind: "module",
lineRange: [startLine, endLine],
});
}
return resources;
}
private extractVariablesAndOutputs(content: string): DefinitionInfo[] {
const definitions: DefinitionInfo[] = [];
// Match variable blocks
const varRegex = /^variable\s+"([^"]+)"\s*\{/gm;
let match;
while ((match = varRegex.exec(content)) !== null) {
const startLine = content.slice(0, match.index).split("\n").length;
const afterMatch = content.slice(match.index);
const closeBrace = this.findClosingBrace(afterMatch);
const endLine = content.slice(0, match.index + closeBrace + 1).split("\n").length;
definitions.push({
name: match[1],
kind: "variable",
lineRange: [startLine, endLine],
fields: [],
});
}
// Match output blocks
const outputRegex = /^output\s+"([^"]+)"\s*\{/gm;
while ((match = outputRegex.exec(content)) !== null) {
const startLine = content.slice(0, match.index).split("\n").length;
const afterMatch = content.slice(match.index);
const closeBrace = this.findClosingBrace(afterMatch);
const endLine = content.slice(0, match.index + closeBrace + 1).split("\n").length;
definitions.push({
name: match[1],
kind: "output",
lineRange: [startLine, endLine],
fields: [],
});
}
return definitions;
}
private findClosingBrace(content: string): number {
let depth = 0;
for (let i = 0; i < content.length; i++) {
if (content[i] === "{") depth++;
if (content[i] === "}") {
depth--;
if (depth === 0) return i;
}
}
if (depth !== 0) {
console.warn(`[terraform-parser] Unbalanced braces detected (depth=${depth}), results may be incomplete`);
}
return content.length;
}
}
@@ -0,0 +1,46 @@
import type { AnalyzerPlugin, StructuralAnalysis, SectionInfo } from "../../types.js";
/**
* Parses TOML files to extract section headers ([section] and [[array-of-tables]]).
* Computes section nesting level from dotted key paths (e.g., [tool.poetry] = level 2).
* Does not parse individual key-value pairs within sections.
*/
export class TOMLParser implements AnalyzerPlugin {
name = "toml-parser";
languages = ["toml"];
analyzeFile(_filePath: string, content: string): StructuralAnalysis {
const sections = this.extractSections(content);
return {
functions: [],
classes: [],
imports: [],
exports: [],
sections,
};
}
private extractSections(content: string): SectionInfo[] {
const sections: SectionInfo[] = [];
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
// Match [section] and [[array-of-tables]] headers
const match = lines[i].match(/^\s*\[(\[?)([^\]]+)\]?\]/);
if (match) {
const isArray = match[1] === "[";
const name = match[2].trim();
sections.push({
name: isArray ? `[[${name}]]` : name,
level: name.split(".").length,
lineRange: [i + 1, i + 1],
});
}
}
// Fix lineRange end
for (let i = 0; i < sections.length; i++) {
const next = sections[i + 1];
sections[i].lineRange[1] = next ? next.lineRange[0] - 1 : lines.length;
}
return sections;
}
}
@@ -0,0 +1,72 @@
import type { AnalyzerPlugin, StructuralAnalysis, SectionInfo } from "../../types.js";
import { parse as parseYAML } from "yaml";
/**
* Parses YAML configuration files to extract top-level key sections.
* Uses the `yaml` library for parsing with a regex fallback for malformed input.
* Only extracts top-level keys; does not descend into nested structures.
*/
export class YAMLConfigParser implements AnalyzerPlugin {
name = "yaml-config-parser";
languages = ["yaml"];
analyzeFile(_filePath: string, content: string): StructuralAnalysis {
const sections = this.extractSections(content);
return {
functions: [],
classes: [],
imports: [],
exports: [],
sections,
};
}
private extractSections(content: string): SectionInfo[] {
const sections: SectionInfo[] = [];
try {
const doc = parseYAML(content);
if (doc && typeof doc === "object" && !Array.isArray(doc)) {
const lines = content.split("\n");
for (const key of Object.keys(doc)) {
// Find the line where this top-level key appears
const lineIdx = lines.findIndex((l) => l.match(new RegExp(`^${this.escapeRegex(key)}\\s*:`)));
if (lineIdx !== -1) {
sections.push({
name: key,
level: 1,
lineRange: [lineIdx + 1, lineIdx + 1],
});
}
}
// Fix lineRange end
for (let i = 0; i < sections.length; i++) {
const next = sections[i + 1];
sections[i].lineRange[1] = next ? next.lineRange[0] - 1 : lines.length;
}
}
} catch (err) {
console.warn(`[yaml-parser] YAML parse failed, falling back to regex extraction: ${err instanceof Error ? err.message : String(err)}`);
// If YAML parsing fails, fall back to regex
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
const match = lines[i].match(/^(\w[\w-]*)\s*:/);
if (match) {
sections.push({
name: match[1],
level: 1,
lineRange: [i + 1, i + 1],
});
}
}
for (let i = 0; i < sections.length; i++) {
const next = sections[i + 1];
sections[i].lineRange[1] = next ? next.lineRange[0] - 1 : lines.length;
}
}
return sections;
}
private escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
}
@@ -61,7 +61,7 @@ export class PluginRegistry {
resolveImports(filePath: string, content: string): ImportResolution[] | null {
const plugin = this.getPluginForFile(filePath);
if (!plugin) return null;
if (!plugin || !plugin.resolveImports) return null;
return plugin.resolveImports(filePath, content);
}
@@ -142,6 +142,31 @@ export default class AppController {}
expect(exportNames).toContain("default");
});
it("should handle export with aliases", () => {
const code = `
const originalName = () => true;
export { originalName as renamedExport };
`;
const result = plugin.analyzeFile("test.ts", code);
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("renamedExport");
});
it("should handle arrow functions without parameters", () => {
const code = `
const noParams = () => { return 42; };
const withReturn = () => "hello";
`;
const result = plugin.analyzeFile("test.ts", code);
expect(result.functions).toHaveLength(2);
expect(result.functions[0].name).toBe("noParams");
expect(result.functions[0].params).toEqual([]);
expect(result.functions[1].name).toBe("withReturn");
expect(result.functions[1].params).toEqual([]);
});
it("should extract functions from JavaScript files", () => {
const code = `
function hello() {
@@ -1,12 +1,14 @@
import { z } from "zod";
// Edge types (18 values across 5 categories)
// Edge types (26 values across 6 categories)
export const EdgeTypeSchema = z.enum([
"imports", "exports", "contains", "inherits", "implements", // Structural
"calls", "subscribes", "publishes", "middleware", // Behavioral
"reads_from", "writes_to", "transforms", "validates", // Data flow
"depends_on", "tested_by", "configures", // Dependencies
"related", "similar_to", // Semantic
"deploys", "serves", "provisions", "triggers", // Infrastructure
"migrates", "documents", "routes", "defines_schema", // Schema/Data
]);
// Aliases that LLMs commonly generate instead of canonical node types
@@ -19,6 +21,35 @@ export const NODE_TYPE_ALIASES: Record<string, string> = {
mod: "module",
pkg: "module",
package: "module",
// Non-code aliases
container: "service",
deployment: "service",
pod: "service",
doc: "document",
readme: "document",
docs: "document",
workflow: "pipeline",
job: "pipeline",
ci: "pipeline",
action: "pipeline",
route: "endpoint",
api: "endpoint",
query: "endpoint",
mutation: "endpoint",
setting: "config",
env: "config",
configuration: "config",
infra: "resource",
infrastructure: "resource",
terraform: "resource",
migration: "table",
database: "table",
db: "table",
view: "table",
proto: "schema",
protobuf: "schema",
definition: "schema",
typedef: "schema",
};
// Aliases that LLMs commonly generate instead of canonical edge types
@@ -36,11 +67,253 @@ export const EDGE_TYPE_ALIASES: Record<string, string> = {
contain: "contains",
publish: "publishes",
subscribe: "subscribes",
// Non-code aliases
describes: "documents",
documented_by: "documents",
creates: "provisions",
exposes: "serves",
listens: "serves",
deploys_to: "deploys",
migrates_to: "migrates",
routes_to: "routes",
triggers_on: "triggers",
fires: "triggers",
defines: "defines_schema",
};
// Aliases for complexity values LLMs commonly generate
export const COMPLEXITY_ALIASES: Record<string, string> = {
low: "simple",
easy: "simple",
medium: "moderate",
intermediate: "moderate",
high: "complex",
hard: "complex",
difficult: "complex",
};
// Aliases for direction values LLMs commonly generate
export const DIRECTION_ALIASES: Record<string, string> = {
to: "forward",
outbound: "forward",
from: "backward",
inbound: "backward",
both: "bidirectional",
mutual: "bidirectional",
};
export function sanitizeGraph(data: Record<string, unknown>): Record<string, unknown> {
const result = { ...data };
// Null → empty array for top-level collections
if (data.tour === null || data.tour === undefined) result.tour = [];
if (data.layers === null || data.layers === undefined) result.layers = [];
// Sanitize nodes
if (Array.isArray(data.nodes)) {
result.nodes = (data.nodes as Record<string, unknown>[]).map((node) => {
if (typeof node !== "object" || node === null) return node;
const n = { ...node };
// Null → undefined for optional fields
if (n.filePath === null) delete n.filePath;
if (n.lineRange === null) delete n.lineRange;
if (n.languageNotes === null) delete n.languageNotes;
// Lowercase enum-like strings
if (typeof n.type === "string") n.type = n.type.toLowerCase();
if (typeof n.complexity === "string") n.complexity = n.complexity.toLowerCase();
return n;
});
}
// Sanitize edges
if (Array.isArray(data.edges)) {
result.edges = (data.edges as Record<string, unknown>[]).map((edge) => {
if (typeof edge !== "object" || edge === null) return edge;
const e = { ...edge };
if (e.description === null) delete e.description;
if (typeof e.type === "string") e.type = e.type.toLowerCase();
if (typeof e.direction === "string") e.direction = e.direction.toLowerCase();
return e;
});
}
// Sanitize tour steps
if (Array.isArray(result.tour)) {
result.tour = (result.tour as Record<string, unknown>[]).map((step) => {
if (typeof step !== "object" || step === null) return step;
const s = { ...step };
if (s.languageLesson === null) delete s.languageLesson;
return s;
});
}
return result;
}
export function autoFixGraph(data: Record<string, unknown>): {
data: Record<string, unknown>;
issues: GraphIssue[];
} {
const issues: GraphIssue[] = [];
const result = { ...data };
if (Array.isArray(data.nodes)) {
result.nodes = (data.nodes as Record<string, unknown>[]).map((node, i) => {
if (typeof node !== "object" || node === null) return node;
const n = { ...node };
const name = (n.name as string) || (n.id as string) || `index ${i}`;
// Missing or empty type
if (!n.type || typeof n.type !== "string") {
n.type = "file";
issues.push({
level: "auto-corrected",
category: "missing-field",
message: `nodes[${i}] ("${name}"): missing "type" — defaulted to "file"`,
path: `nodes[${i}].type`,
});
}
// Missing or empty complexity
if (!n.complexity || n.complexity === "") {
n.complexity = "moderate";
issues.push({
level: "auto-corrected",
category: "missing-field",
message: `nodes[${i}] ("${name}"): missing "complexity" — defaulted to "moderate"`,
path: `nodes[${i}].complexity`,
});
} else if (typeof n.complexity === "string" && n.complexity in COMPLEXITY_ALIASES) {
const original = n.complexity;
n.complexity = COMPLEXITY_ALIASES[n.complexity];
issues.push({
level: "auto-corrected",
category: "alias",
message: `nodes[${i}] ("${name}"): complexity "${original}" — mapped to "${n.complexity}"`,
path: `nodes[${i}].complexity`,
});
}
// Missing tags
if (!Array.isArray(n.tags)) {
n.tags = [];
issues.push({
level: "auto-corrected",
category: "missing-field",
message: `nodes[${i}] ("${name}"): missing "tags" — defaulted to []`,
path: `nodes[${i}].tags`,
});
}
// Missing summary
if (!n.summary || typeof n.summary !== "string") {
n.summary = (n.name as string) || "No summary";
issues.push({
level: "auto-corrected",
category: "missing-field",
message: `nodes[${i}] ("${name}"): missing "summary" — defaulted to name`,
path: `nodes[${i}].summary`,
});
}
return n;
});
}
if (Array.isArray(data.edges)) {
result.edges = (data.edges as Record<string, unknown>[]).map((edge, i) => {
if (typeof edge !== "object" || edge === null) return edge;
const e = { ...edge };
// Missing type
if (!e.type || typeof e.type !== "string") {
e.type = "depends_on";
issues.push({
level: "auto-corrected",
category: "missing-field",
message: `edges[${i}]: missing "type" — defaulted to "depends_on"`,
path: `edges[${i}].type`,
});
}
// Missing direction
if (!e.direction || typeof e.direction !== "string") {
e.direction = "forward";
issues.push({
level: "auto-corrected",
category: "missing-field",
message: `edges[${i}]: missing "direction" — defaulted to "forward"`,
path: `edges[${i}].direction`,
});
} else if (e.direction in DIRECTION_ALIASES) {
const original = e.direction;
e.direction = DIRECTION_ALIASES[e.direction as string];
issues.push({
level: "auto-corrected",
category: "alias",
message: `edges[${i}]: direction "${original}" — mapped to "${e.direction}"`,
path: `edges[${i}].direction`,
});
}
// Missing weight
if (e.weight === undefined || e.weight === null) {
e.weight = 0.5;
issues.push({
level: "auto-corrected",
category: "missing-field",
message: `edges[${i}]: missing "weight" — defaulted to 0.5`,
path: `edges[${i}].weight`,
});
} else if (typeof e.weight === "string") {
const parsed = parseFloat(e.weight as string);
if (!isNaN(parsed)) {
const original = e.weight;
e.weight = parsed;
issues.push({
level: "auto-corrected",
category: "type-coercion",
message: `edges[${i}]: weight was string "${original}" — coerced to number`,
path: `edges[${i}].weight`,
});
} else {
const original = e.weight;
e.weight = 0.5;
issues.push({
level: "auto-corrected",
category: "type-coercion",
message: `edges[${i}]: weight "${original}" is not a valid number — defaulted to 0.5`,
path: `edges[${i}].weight`,
});
}
}
// Clamp weight to [0, 1]
if (typeof e.weight === "number" && (e.weight < 0 || e.weight > 1)) {
const original = e.weight;
e.weight = Math.max(0, Math.min(1, e.weight));
issues.push({
level: "auto-corrected",
category: "out-of-range",
message: `edges[${i}]: weight ${original} clamped to ${e.weight}`,
path: `edges[${i}].weight`,
});
}
return e;
});
}
return { data: result, issues };
}
export const GraphNodeSchema = z.object({
id: z.string(),
type: z.enum(["file", "function", "class", "module", "concept"]),
type: z.enum([
"file", "function", "class", "module", "concept",
"config", "document", "service", "table", "endpoint",
"pipeline", "schema", "resource",
]),
name: z.string(),
filePath: z.string().optional(),
lineRange: z.tuple([z.number(), z.number()]).optional(),
@@ -92,10 +365,35 @@ export const KnowledgeGraphSchema = z.object({
tour: z.array(TourStepSchema),
});
export interface GraphIssue {
level: "auto-corrected" | "dropped" | "fatal";
category: string;
message: string;
path?: string;
}
export interface ValidationResult {
success: boolean;
data?: z.infer<typeof KnowledgeGraphSchema>;
/** @deprecated Use issues/fatal instead */
errors?: string[];
issues: GraphIssue[];
fatal?: string;
}
function buildInvalidCollectionIssue(name: string): GraphIssue {
return {
level: "fatal",
category: "invalid-collection",
message: `"${name}" must be an array when present`,
path: name,
};
}
function buildErrors(issues: GraphIssue[], fatal?: string): string[] | undefined {
const messages = issues.map((issue) => issue.message);
if (fatal && !messages.includes(fatal)) messages.unshift(fatal);
return messages.length > 0 ? messages : undefined;
}
export function normalizeGraph(data: unknown): unknown {
@@ -136,16 +434,167 @@ export function normalizeGraph(data: unknown): unknown {
}
export function validateGraph(data: unknown): ValidationResult {
const result = KnowledgeGraphSchema.safeParse(normalizeGraph(data));
if (result.success) {
return { success: true, data: result.data };
// Tier 4: Fatal — not even an object
if (typeof data !== "object" || data === null) {
const fatal = "Invalid input: not an object";
return { success: false, issues: [], fatal, errors: buildErrors([], fatal) };
}
const errors = result.error.issues.map((issue) => {
const path = issue.path.join(".");
return path ? `${path}: ${issue.message}` : issue.message;
});
const raw = data as Record<string, unknown>;
return { success: false, errors };
// Tier 1: Sanitize
const sanitized = sanitizeGraph(raw);
// Existing: Normalize type aliases
const normalized = normalizeGraph(sanitized) as Record<string, unknown>;
// Tier 2: Auto-fix defaults and coercion
const { data: fixed, issues } = autoFixGraph(normalized);
// Tier 4: Fatal — malformed top-level collections
const requiredCollections = ["nodes", "edges", "layers", "tour"] as const;
for (const collection of requiredCollections) {
if (collection in fixed && fixed[collection] !== undefined && !Array.isArray(fixed[collection])) {
const issue = buildInvalidCollectionIssue(collection);
issues.push(issue);
return {
success: false,
errors: buildErrors(issues, issue.message),
issues,
fatal: issue.message,
};
}
}
// Tier 4: Fatal — missing project metadata
const projectResult = ProjectMetaSchema.safeParse(fixed.project);
if (!projectResult.success) {
return {
success: false,
errors: buildErrors(issues, "Missing or invalid project metadata"),
issues,
fatal: "Missing or invalid project metadata",
};
}
// Tier 3: Validate nodes individually, drop broken
const validNodes: z.infer<typeof GraphNodeSchema>[] = [];
if (Array.isArray(fixed.nodes)) {
for (let i = 0; i < fixed.nodes.length; i++) {
const node = fixed.nodes[i] as Record<string, unknown>;
const result = GraphNodeSchema.safeParse(node);
if (result.success) {
validNodes.push(result.data);
} else {
const name = node?.name || node?.id || `index ${i}`;
issues.push({
level: "dropped",
category: "invalid-node",
message: `nodes[${i}] ("${name}"): ${result.error.issues[0]?.message ?? "validation failed"} — removed`,
path: `nodes[${i}]`,
});
}
}
}
// Tier 4: Fatal — no valid nodes
if (validNodes.length === 0) {
return {
success: false,
errors: buildErrors(issues, "No valid nodes found in knowledge graph"),
issues,
fatal: "No valid nodes found in knowledge graph",
};
}
// Tier 3: Validate edges + referential integrity
const nodeIds = new Set(validNodes.map((n) => n.id));
const validEdges: z.infer<typeof GraphEdgeSchema>[] = [];
if (Array.isArray(fixed.edges)) {
for (let i = 0; i < fixed.edges.length; i++) {
const edge = fixed.edges[i] as Record<string, unknown>;
const result = GraphEdgeSchema.safeParse(edge);
if (!result.success) {
issues.push({
level: "dropped",
category: "invalid-edge",
message: `edges[${i}]: ${result.error.issues[0]?.message ?? "validation failed"} — removed`,
path: `edges[${i}]`,
});
continue;
}
if (!nodeIds.has(result.data.source)) {
issues.push({
level: "dropped",
category: "invalid-reference",
message: `edges[${i}]: source "${result.data.source}" does not exist in nodes — removed`,
path: `edges[${i}].source`,
});
continue;
}
if (!nodeIds.has(result.data.target)) {
issues.push({
level: "dropped",
category: "invalid-reference",
message: `edges[${i}]: target "${result.data.target}" does not exist in nodes — removed`,
path: `edges[${i}].target`,
});
continue;
}
validEdges.push(result.data);
}
}
// Validate layers (drop broken, filter dangling nodeIds)
const validLayers: z.infer<typeof LayerSchema>[] = [];
if (Array.isArray(fixed.layers)) {
for (let i = 0; i < (fixed.layers as unknown[]).length; i++) {
const result = LayerSchema.safeParse((fixed.layers as unknown[])[i]);
if (result.success) {
validLayers.push({
...result.data,
nodeIds: result.data.nodeIds.filter((id) => nodeIds.has(id)),
});
} else {
issues.push({
level: "dropped",
category: "invalid-layer",
message: `layers[${i}]: ${result.error.issues[0]?.message ?? "validation failed"} — removed`,
path: `layers[${i}]`,
});
}
}
}
// Validate tour steps (drop broken, filter dangling nodeIds)
const validTour: z.infer<typeof TourStepSchema>[] = [];
if (Array.isArray(fixed.tour)) {
for (let i = 0; i < (fixed.tour as unknown[]).length; i++) {
const result = TourStepSchema.safeParse((fixed.tour as unknown[])[i]);
if (result.success) {
validTour.push({
...result.data,
nodeIds: result.data.nodeIds.filter((id) => nodeIds.has(id)),
});
} else {
issues.push({
level: "dropped",
category: "invalid-tour-step",
message: `tour[${i}]: ${result.error.issues[0]?.message ?? "validation failed"} — removed`,
path: `tour[${i}]`,
});
}
}
}
const graph = {
version: typeof fixed.version === "string" ? fixed.version : "1.0.0",
project: projectResult.data,
nodes: validNodes,
edges: validEdges,
layers: validLayers,
tour: validTour,
};
return { success: true, data: graph, issues, errors: buildErrors(issues) };
}
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import type { KnowledgeGraph, GraphNode, GraphEdge } from "./types.js";
import type { KnowledgeGraph, GraphNode, GraphEdge, EdgeType, NodeType, StructuralAnalysis, AnalyzerPlugin, ReferenceResolution } from "./types.js";
describe("KnowledgeGraph types", () => {
it("should create a valid empty KnowledgeGraph", () => {
@@ -115,3 +115,89 @@ describe("KnowledgeGraph types", () => {
expect(maxWeightEdge.weight).toBe(1);
});
});
describe("Extended types", () => {
it("accepts all 13 node types via NodeType alias", () => {
const nodeTypes: NodeType[] = [
"file", "function", "class", "module", "concept",
"config", "document", "service", "table", "endpoint",
"pipeline", "schema", "resource",
];
expect(nodeTypes).toHaveLength(13);
// NodeType and GraphNode["type"] should be interchangeable
const check: GraphNode["type"] = nodeTypes[0];
expect(check).toBe("file");
});
it("accepts all 26 edge types", () => {
const edgeTypes: EdgeType[] = [
"imports", "exports", "contains", "inherits", "implements",
"calls", "subscribes", "publishes", "middleware",
"reads_from", "writes_to", "transforms", "validates",
"depends_on", "tested_by", "configures",
"related", "similar_to",
"deploys", "serves", "migrates", "documents",
"provisions", "routes", "defines_schema", "triggers",
];
expect(edgeTypes).toHaveLength(26);
});
it("StructuralAnalysis has optional non-code fields", () => {
const analysis: StructuralAnalysis = {
functions: [], classes: [], imports: [], exports: [],
sections: [{ name: "Introduction", level: 1, lineRange: [1, 10] }],
definitions: [{ name: "users", kind: "table", lineRange: [1, 20], fields: ["id", "name"] }],
services: [{ name: "web", image: "node:22", ports: [3000], lineRange: [1, 5] }],
endpoints: [{ method: "GET", path: "/api/users", lineRange: [5, 15] }],
steps: [{ name: "build", lineRange: [1, 5] }],
resources: [{ name: "aws_s3_bucket.main", kind: "aws_s3_bucket", lineRange: [1, 10] }],
};
expect(analysis.sections).toHaveLength(1);
expect(analysis.definitions).toHaveLength(1);
expect(analysis.services).toHaveLength(1);
expect(analysis.services![0].lineRange).toEqual([1, 5]);
expect(analysis.endpoints).toHaveLength(1);
expect(analysis.steps).toHaveLength(1);
expect(analysis.resources).toHaveLength(1);
});
it("ServiceInfo.lineRange is optional for backward compat", () => {
const svcWithout: import("./types.js").ServiceInfo = { name: "web", ports: [3000] };
const svcWith: import("./types.js").ServiceInfo = { name: "db", ports: [5432], lineRange: [10, 20] };
expect(svcWithout.lineRange).toBeUndefined();
expect(svcWith.lineRange).toEqual([10, 20]);
});
it("StructuralAnalysis is backward compatible (non-code fields are optional)", () => {
const analysis: StructuralAnalysis = {
functions: [], classes: [], imports: [], exports: [],
};
expect(analysis.sections).toBeUndefined();
expect(analysis.definitions).toBeUndefined();
expect(analysis.services).toBeUndefined();
});
it("AnalyzerPlugin allows optional resolveImports", () => {
const plugin: AnalyzerPlugin = {
name: "test-plugin",
languages: ["markdown"],
analyzeFile: () => ({ functions: [], classes: [], imports: [], exports: [] }),
// resolveImports is optional — not provided
};
expect(plugin.resolveImports).toBeUndefined();
expect(plugin.analyzeFile).toBeDefined();
});
it("AnalyzerPlugin supports extractReferences", () => {
const refs: ReferenceResolution[] = [
{ source: "README.md", target: "./docs/guide.md", referenceType: "file", line: 5 },
];
const plugin: AnalyzerPlugin = {
name: "test-plugin",
languages: ["markdown"],
analyzeFile: () => ({ functions: [], classes: [], imports: [], exports: [] }),
extractReferences: () => refs,
};
expect(plugin.extractReferences!("README.md", "")).toEqual(refs);
});
});
@@ -1,15 +1,23 @@
// Edge types (18 total in 5 categories: Structural, Behavioral, Data flow, Dependencies, Semantic)
// Node types (13 total: 5 code + 8 non-code)
export type NodeType =
| "file" | "function" | "class" | "module" | "concept"
| "config" | "document" | "service" | "table" | "endpoint"
| "pipeline" | "schema" | "resource";
// Edge types (26 total in 6 categories: Structural, Behavioral, Data flow, Dependencies, Semantic, Infrastructure/Schema)
export type EdgeType =
| "imports" | "exports" | "contains" | "inherits" | "implements" // Structural
| "calls" | "subscribes" | "publishes" | "middleware" // Behavioral
| "reads_from" | "writes_to" | "transforms" | "validates" // Data flow
| "depends_on" | "tested_by" | "configures" // Dependencies
| "related" | "similar_to"; // Semantic
| "related" | "similar_to" // Semantic
| "deploys" | "serves" | "provisions" | "triggers" // Infrastructure
| "migrates" | "documents" | "routes" | "defines_schema"; // Schema/Data
// GraphNode with 5 types: file, function, class, module, concept
// GraphNode with 13 types: 5 code + 8 non-code
export interface GraphNode {
id: string;
type: "file" | "function" | "class" | "module" | "concept";
type: NodeType;
name: string;
filePath?: string;
lineRange?: [number, number];
@@ -66,12 +74,70 @@ export interface KnowledgeGraph {
tour: TourStep[];
}
// Theme configuration (for dashboard customization)
export interface ThemeConfig {
presetId: string;
accentId: string;
}
// AnalysisMeta (for persistence)
export interface AnalysisMeta {
lastAnalyzedAt: string;
gitCommitHash: string;
version: string;
analyzedFiles: number;
theme?: ThemeConfig;
}
// Project config (for auto-update opt-in)
export interface ProjectConfig {
autoUpdate: boolean;
}
// Non-code structural sub-interfaces
export interface SectionInfo {
name: string;
level: number;
lineRange: [number, number];
}
export interface DefinitionInfo {
name: string;
/** Parser-reported definition kind. Known values: "table", "view", "index", "message", "enum", "type", "input", "interface", "union", "scalar", "variable", "output", "resource", "data", "section", "target", "stage" */
kind: string;
lineRange: [number, number];
fields: string[];
}
export interface ServiceInfo {
name: string;
image?: string;
ports: number[];
lineRange?: [number, number];
}
export interface EndpointInfo {
method?: string;
path: string;
lineRange: [number, number];
}
export interface StepInfo {
name: string;
lineRange: [number, number];
}
export interface ResourceInfo {
name: string;
kind: string;
lineRange: [number, number];
}
export interface ReferenceResolution {
source: string;
target: string;
referenceType: string; // "file", "image", "schema", "service"
line?: number;
}
// Plugin interfaces
@@ -80,6 +146,13 @@ export interface StructuralAnalysis {
classes: Array<{ name: string; lineRange: [number, number]; methods: string[]; properties: string[] }>;
imports: Array<{ source: string; specifiers: string[]; lineNumber: number }>;
exports: Array<{ name: string; lineNumber: number }>;
// Non-code structural data (all optional for backward compat)
sections?: SectionInfo[];
definitions?: DefinitionInfo[];
services?: ServiceInfo[];
endpoints?: EndpointInfo[];
steps?: StepInfo[];
resources?: ResourceInfo[];
}
export interface ImportResolution {
@@ -98,6 +171,7 @@ export interface AnalyzerPlugin {
name: string;
languages: string[];
analyzeFile(filePath: string, content: string): StructuralAnalysis;
resolveImports(filePath: string, content: string): ImportResolution[];
resolveImports?(filePath: string, content: string): ImportResolution[];
extractCallGraph?(filePath: string, content: string): CallGraphEntry[];
extractReferences?(filePath: string, content: string): ReferenceResolution[];
}
@@ -1,5 +1,6 @@
import { useEffect, useState, useMemo } from "react";
import { useEffect, useState, useMemo, useCallback } from "react";
import { validateGraph } from "@understand-anything/core/schema";
import type { GraphIssue } from "@understand-anything/core/schema";
import { useDashboardStore } from "./store";
import GraphView from "./components/GraphView";
import CodeViewer from "./components/CodeViewer";
@@ -14,10 +15,58 @@ import LearnPanel from "./components/LearnPanel";
import PersonaSelector from "./components/PersonaSelector";
import ProjectOverview from "./components/ProjectOverview";
import KeyboardShortcutsHelp from "./components/KeyboardShortcutsHelp";
import WarningBanner from "./components/WarningBanner";
import TokenGate from "./components/TokenGate";
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
import type { KeyboardShortcut } from "./hooks/useKeyboardShortcuts";
import { ThemeProvider } from "./themes/index.ts";
import { ThemePicker } from "./components/ThemePicker.tsx";
import type { ThemeConfig } from "./themes/index.ts";
const SESSION_TOKEN_KEY = "understand-anything-token";
/**
* Resolve the access token from the URL query string or sessionStorage.
* If found in the URL, persist to sessionStorage and strip the param from the address bar.
*/
function resolveInitialToken(): string | null {
const params = new URLSearchParams(window.location.search);
const urlToken = params.get("token");
if (urlToken) {
sessionStorage.setItem(SESSION_TOKEN_KEY, urlToken);
// Clean the URL
params.delete("token");
const cleanSearch = params.toString();
const newUrl =
window.location.pathname + (cleanSearch ? `?${cleanSearch}` : "") + window.location.hash;
window.history.replaceState(null, "", newUrl);
return urlToken;
}
return sessionStorage.getItem(SESSION_TOKEN_KEY);
}
/** Build a URL with the token query param appended. */
function tokenUrl(path: string, token: string | null): string {
return token ? `${path}?token=${encodeURIComponent(token)}` : path;
}
function App() {
const [accessToken, setAccessToken] = useState<string | null>(resolveInitialToken);
const handleTokenValid = useCallback((token: string) => {
sessionStorage.setItem(SESSION_TOKEN_KEY, token);
setAccessToken(token);
}, []);
// Show the token gate when no token is available
if (accessToken === null) {
return <TokenGate onTokenValid={handleTokenValid} />;
}
return <Dashboard accessToken={accessToken} />;
}
function Dashboard({ accessToken }: { accessToken: string }) {
const graph = useDashboardStore((s) => s.graph);
const setGraph = useDashboardStore((s) => s.setGraph);
const selectedNodeId = useDashboardStore((s) => s.selectedNodeId);
@@ -28,8 +77,21 @@ function App() {
const setDiffOverlay = useDashboardStore((s) => s.setDiffOverlay);
const pathFinderOpen = useDashboardStore((s) => s.pathFinderOpen);
const togglePathFinder = useDashboardStore((s) => s.togglePathFinder);
const nodeTypeFilters = useDashboardStore((s) => s.nodeTypeFilters);
const toggleNodeTypeFilter = useDashboardStore((s) => s.toggleNodeTypeFilter);
const [loadError, setLoadError] = useState<string | null>(null);
const [graphIssues, setGraphIssues] = useState<GraphIssue[]>([]);
const [showKeyboardHelp, setShowKeyboardHelp] = useState(false);
const [metaTheme, setMetaTheme] = useState<ThemeConfig | null>(null);
useEffect(() => {
fetch(tokenUrl("/meta.json", accessToken))
.then((r) => (r.ok ? r.json() : null))
.then((meta) => {
if (meta?.theme) setMetaTheme(meta.theme);
})
.catch(() => {});
}, []);
// Define keyboard shortcuts
const shortcuts = useMemo<KeyboardShortcut[]>(
@@ -45,7 +107,7 @@ function App() {
// Navigation
{
key: "Escape",
description: "Close panels and modals",
description: "Close panels and modals / go back to overview",
action: () => {
// Read from store at invocation time to avoid stale closures
const state = useDashboardStore.getState();
@@ -59,6 +121,8 @@ function App() {
state.closeCodeViewer();
} else if (state.selectedNodeId) {
state.selectNode(null);
} else if (state.navigationLevel === "layer-detail") {
state.navigateToOverview();
} else if (state.tourActive) {
state.stopTour();
} else {
@@ -102,15 +166,6 @@ function App() {
category: "Tour",
},
// View toggles
{
key: "l",
description: "Toggle layer visualization",
action: () => {
const state = useDashboardStore.getState();
state.toggleLayers();
},
category: "View",
},
{
key: "d",
description: "Toggle diff mode",
@@ -155,16 +210,26 @@ function App() {
useKeyboardShortcuts(shortcuts);
useEffect(() => {
fetch("/knowledge-graph.json")
fetch(tokenUrl("/knowledge-graph.json", accessToken))
.then((res) => res.json())
.then((data: unknown) => {
const result = validateGraph(data);
if (result.success && result.data) {
setGraph(result.data);
setGraphIssues(result.issues);
for (const issue of result.issues) {
if (issue.level === "auto-corrected") {
console.warn(`[graph] auto-corrected: ${issue.message}`);
} else if (issue.level === "dropped") {
console.error(`[graph] dropped: ${issue.message}`);
}
}
} else if (result.fatal) {
console.error("Knowledge graph validation failed:", result.fatal);
setLoadError(`Invalid knowledge graph: ${result.fatal}`);
} else {
const errorMsg = result.errors?.join("; ") ?? "Unknown validation error";
console.error("Knowledge graph validation failed:", errorMsg);
setLoadError(`Invalid knowledge graph: ${errorMsg}`);
console.error("Knowledge graph validation failed: unknown error");
setLoadError("Invalid knowledge graph: unknown validation error");
}
})
.catch((err) => {
@@ -174,7 +239,7 @@ function App() {
}, [setGraph]);
useEffect(() => {
fetch("/diff-overlay.json")
fetch(tokenUrl("/diff-overlay.json", accessToken))
.then((res) => {
if (!res.ok) return null;
return res.json();
@@ -200,16 +265,19 @@ function App() {
}, [setDiffOverlay]);
// Determine sidebar content
// Learn persona always shows LearnPanel; tour active overrides everything
const sidebarContent = tourActive || persona === "junior" ? (
<LearnPanel />
) : selectedNodeId ? (
<NodeInfo />
) : (
<ProjectOverview />
// NodeInfo always takes priority when a node is selected.
// Learn mode adds LearnPanel below it; otherwise ProjectOverview shows when idle.
const isLearnMode = tourActive || persona === "junior";
const sidebarContent = (
<>
{selectedNodeId && <NodeInfo />}
{isLearnMode && <LearnPanel />}
{!selectedNodeId && !isLearnMode && <ProjectOverview />}
</>
);
return (
<ThemeProvider metaTheme={metaTheme}>
<div className="h-screen w-screen flex flex-col bg-root text-text-primary noise-overlay">
{/* Header */}
<header className="flex items-center justify-between px-5 py-3 bg-surface border-b border-border-subtle shrink-0">
@@ -222,6 +290,35 @@ function App() {
</div>
<div className="flex items-center gap-4">
<DiffToggle />
<div className="flex items-center gap-1">
{([
{ key: "code", label: "Code", color: "var(--color-node-file)" },
{ key: "config", label: "Config", color: "var(--color-node-config)" },
{ key: "docs", label: "Docs", color: "var(--color-node-document)" },
{ key: "infra", label: "Infra", color: "var(--color-node-service)" },
{ key: "data", label: "Data", color: "var(--color-node-table)" },
] as const).map((cat) => (
<button
key={cat.key}
onClick={() => toggleNodeTypeFilter(cat.key)}
className={`text-[10px] font-semibold uppercase tracking-wider px-2 py-1 rounded border transition-colors flex items-center gap-1.5 ${
nodeTypeFilters[cat.key] !== false
? "border-border-medium bg-elevated text-text-secondary hover:text-text-primary"
: "border-transparent bg-transparent text-text-muted/40 line-through hover:text-text-muted"
}`}
title={`${nodeTypeFilters[cat.key] !== false ? "Hide" : "Show"} ${cat.label} nodes`}
>
<span
className="w-2 h-2 rounded-full shrink-0"
style={{
backgroundColor: cat.color,
opacity: nodeTypeFilters[cat.key] !== false ? 1 : 0.3,
}}
/>
{cat.label}
</button>
))}
</div>
<LayerLegend />
<FilterPanel />
<ExportMenu />
@@ -245,9 +342,10 @@ function App() {
</svg>
Path
</button>
<ThemePicker />
<button
onClick={() => setShowKeyboardHelp(true)}
className="text-text-muted hover:text-gold transition-colors"
className="text-text-muted hover:text-accent transition-colors"
title="Keyboard shortcuts (Shift + ?)"
>
<svg
@@ -270,6 +368,11 @@ function App() {
{/* Search */}
<SearchBar />
{/* Validation warning banner */}
{graphIssues.length > 0 && !loadError && (
<WarningBanner issues={graphIssues} />
)}
{/* Error banner */}
{loadError && (
<div className="px-5 py-3 bg-red-900/30 border-b border-red-700 text-red-200 text-sm">
@@ -288,13 +391,13 @@ function App() {
</div>
{/* Right sidebar */}
<aside className="w-[360px] shrink-0 bg-surface border-l border-border-subtle overflow-hidden">
<aside className="w-[360px] shrink-0 bg-surface border-l border-border-subtle overflow-auto">
{sidebarContent}
</aside>
{/* Code viewer overlay */}
{codeViewerOpen && (
<div className="absolute bottom-0 left-0 right-0 h-[40vh] bg-surface border-t border-border-subtle animate-slide-up z-20">
<div className="absolute bottom-0 left-0 right-0 h-[25vh] bg-surface border-t border-border-subtle animate-slide-up z-20">
<div className="h-full flex flex-col">
<div className="flex items-center justify-end px-3 py-1 shrink-0">
<button
@@ -326,6 +429,7 @@ function App() {
onClose={togglePathFinder}
/>
</div>
</ThemeProvider>
);
}
@@ -0,0 +1,38 @@
import { useDashboardStore } from "../store";
export default function Breadcrumb() {
const navigationLevel = useDashboardStore((s) => s.navigationLevel);
const activeLayerId = useDashboardStore((s) => s.activeLayerId);
const graph = useDashboardStore((s) => s.graph);
const navigateToOverview = useDashboardStore((s) => s.navigateToOverview);
const activeLayer = graph?.layers.find((l) => l.id === activeLayerId);
return (
<div className="absolute top-4 left-4 z-10 flex items-center gap-2">
{navigationLevel === "overview" && (
<div className="px-4 py-2 rounded-full bg-elevated border border-border-subtle text-xs font-semibold tracking-wider uppercase text-text-secondary shadow-lg">
Project Overview
</div>
)}
{navigationLevel === "layer-detail" && (
<div className="flex items-center gap-1.5 px-4 py-2 rounded-full bg-elevated border border-gold/30 text-xs font-semibold tracking-wider uppercase shadow-lg">
<button
onClick={navigateToOverview}
className="text-gold hover:text-gold-bright transition-colors"
>
Project
</button>
<span className="text-text-muted"></span>
<span className="text-text-primary">
{activeLayer?.name ?? "Layer"}
</span>
<span className="text-text-muted ml-1 text-[10px] normal-case tracking-normal">
(Esc to go back)
</span>
</div>
)}
</div>
);
}
@@ -27,8 +27,8 @@ export default function CodeViewer() {
className="text-[10px] font-semibold uppercase tracking-wider px-2 py-0.5 rounded border"
style={{
color: "var(--color-node-file)",
borderColor: "rgba(74,124,155,0.3)",
backgroundColor: "rgba(74,124,155,0.1)",
borderColor: "color-mix(in srgb, var(--color-node-file) 30%, transparent)",
backgroundColor: "color-mix(in srgb, var(--color-node-file) 10%, transparent)",
}}
>
{node.type}
@@ -56,14 +56,14 @@ export default function CodeViewer() {
<div className="flex-1 overflow-auto p-5">
{/* Summary */}
<div className="mb-4">
<h4 className="text-[11px] font-semibold text-gold uppercase tracking-wider mb-2">Summary</h4>
<h4 className="text-[11px] font-semibold text-accent uppercase tracking-wider mb-2">Summary</h4>
<p className="text-sm text-text-secondary leading-relaxed">{node.summary}</p>
</div>
{/* Language notes callout */}
{node.languageNotes && (
<div className="mb-4 bg-gold/5 border border-gold/20 rounded-lg p-3">
<h4 className="text-[11px] font-semibold text-gold uppercase tracking-wider mb-1.5">Language Notes</h4>
<div className="mb-4 bg-accent/5 border border-accent/20 rounded-lg p-3">
<h4 className="text-[11px] font-semibold text-accent uppercase tracking-wider mb-1.5">Language Notes</h4>
<p className="text-sm text-text-secondary leading-relaxed">{node.languageNotes}</p>
</div>
)}
@@ -71,7 +71,7 @@ export default function CodeViewer() {
{/* Tags */}
{node.tags.length > 0 && (
<div className="mb-4">
<h4 className="text-[11px] font-semibold text-gold uppercase tracking-wider mb-2">Tags</h4>
<h4 className="text-[11px] font-semibold text-accent uppercase tracking-wider mb-2">Tags</h4>
<div className="flex flex-wrap gap-1.5">
{node.tags.map((tag) => (
<span key={tag} className="text-[11px] glass text-text-secondary px-2.5 py-1 rounded-full">
@@ -1,26 +1,44 @@
import { memo } from "react";
import { Handle, Position } from "@xyflow/react";
import type { NodeProps, Node } from "@xyflow/react";
import type { NodeType } from "@understand-anything/core/types";
const typeColors: Record<string, string> = {
// Color maps keyed by NodeType — must be kept in sync with core NodeType union.
const typeColors: Record<NodeType, string> = {
file: "var(--color-node-file)",
function: "var(--color-node-function)",
class: "var(--color-node-class)",
module: "var(--color-node-module)",
concept: "var(--color-node-concept)",
config: "var(--color-node-config)",
document: "var(--color-node-document)",
service: "var(--color-node-service)",
table: "var(--color-node-table)",
endpoint: "var(--color-node-endpoint)",
pipeline: "var(--color-node-pipeline)",
schema: "var(--color-node-schema)",
resource: "var(--color-node-resource)",
};
const typeTextColors: Record<string, string> = {
const typeTextColors: Record<NodeType, string> = {
file: "text-node-file",
function: "text-node-function",
class: "text-node-class",
module: "text-node-module",
concept: "text-node-concept",
config: "text-node-config",
document: "text-node-document",
service: "text-node-service",
table: "text-node-table",
endpoint: "text-node-endpoint",
pipeline: "text-node-pipeline",
schema: "text-node-schema",
resource: "text-node-resource",
};
const complexityColors: Record<string, string> = {
simple: "text-node-function",
moderate: "text-gold-dim",
moderate: "text-accent-dim",
complex: "text-[#c97070]",
};
@@ -36,6 +54,8 @@ export interface CustomNodeData extends Record<string, unknown> {
isDiffChanged: boolean;
isDiffAffected: boolean;
isDiffFaded: boolean;
isNeighbor: boolean;
isSelectionFaded: boolean;
onNodeClick?: (nodeId: string) => void;
incomingCount?: number;
outgoingCount?: number;
@@ -48,23 +68,28 @@ function CustomNodeComponent({
id,
data,
}: NodeProps<CustomFlowNode>) {
const barColor = typeColors[data.nodeType] ?? typeColors.file;
const textColor = typeTextColors[data.nodeType] ?? typeTextColors.file;
const knownType = data.nodeType as NodeType;
const barColor = typeColors[knownType] ?? typeColors.file;
const textColor = typeTextColors[knownType] ?? typeTextColors.file;
const complexityColor = complexityColors[data.complexity] ?? complexityColors.simple;
if (import.meta.env.DEV && !(knownType in typeColors)) {
console.warn(`[CustomNode] Unknown node type "${data.nodeType}" — using "file" colors`);
}
let extraClass = "";
if (data.isSelected) {
extraClass = "ring-2 ring-gold node-glow";
extraClass = "ring-2 ring-accent node-glow";
} else if (data.isTourHighlighted) {
extraClass = "ring-2 ring-gold-dim animate-gold-pulse";
extraClass = "ring-2 ring-accent-dim animate-accent-pulse";
} else if (data.isHighlighted) {
const score = data.searchScore ?? 1;
if (score <= 0.1) {
extraClass = "ring-2 ring-gold-bright";
extraClass = "ring-2 ring-accent-bright";
} else if (score <= 0.3) {
extraClass = "ring-2 ring-gold";
extraClass = "ring-2 ring-accent";
} else {
extraClass = "ring-1 ring-gold-dim/60";
extraClass = "ring-1 ring-accent-dim/60";
}
}
@@ -77,6 +102,13 @@ function CustomNodeComponent({
extraClass += " diff-faded";
}
// Selection-based dimming (when another node is selected, fade unrelated nodes)
if (data.isSelectionFaded) {
extraClass += " opacity-20 pointer-events-auto";
} else if (data.isNeighbor) {
extraClass += " ring-1 ring-gold-dim/50";
}
const name = data.label ?? "unnamed";
const truncatedName =
name.length > 24 ? name.slice(0, 22) + "..." : name;
@@ -3,6 +3,10 @@ import { useDashboardStore } from "../store";
import type { KnowledgeGraph } from "@understand-anything/core/types";
import { filterNodes, filterEdges } from "../utils/filters";
function escapeXml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
@@ -97,23 +101,32 @@ export default function ExportMenu() {
// Create an image and draw to canvas
const img = new Image();
img.onerror = () => {
URL.revokeObjectURL(url);
console.error("PNG export failed: Image failed to load from SVG blob");
alert("Failed to export PNG: could not render graph as image. Try SVG export instead.");
};
img.onload = () => {
const canvas = document.createElement("canvas");
canvas.width = width * 2;
canvas.height = height * 2;
const ctx = canvas.getContext("2d");
if (!ctx) {
URL.revokeObjectURL(url);
alert("Failed to create canvas context");
return;
}
ctx.drawImage(img, 0, 0);
URL.revokeObjectURL(url);
const filename = `${graph?.project.name ?? "knowledge-graph"}-export.png`;
canvas.toBlob((blob) => {
if (blob) {
const filename = `${graph?.project.name ?? "knowledge-graph"}-export.png`;
downloadBlob(blob, filename);
toggleExportMenu();
} else {
console.error("PNG export failed: canvas.toBlob returned null (canvas may be tainted)");
alert("Failed to export PNG: image encoding failed. Try SVG export instead.");
}
}, "image/png");
};
@@ -186,7 +199,7 @@ export default function ExportMenu() {
const h = node.height ?? 80;
svgContent += `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="8" fill="#1a1a1a" stroke="rgba(212,165,116,0.2)" stroke-width="1"/>`;
svgContent += `<text x="${x + w / 2}" y="${y + h / 2}" fill="#d4a574" text-anchor="middle" dominant-baseline="middle" font-size="12">${node.data.label ?? node.id}</text>`;
svgContent += `<text x="${x + w / 2}" y="${y + h / 2}" fill="#d4a574" text-anchor="middle" dominant-baseline="middle" font-size="12">${escapeXml(String(node.data.label ?? node.id))}</text>`;
});
svgContent += `</svg>`;
@@ -1,5 +1,5 @@
import { useEffect, useRef } from "react";
import { useDashboardStore } from "../store";
import { useDashboardStore, ALL_NODE_TYPES, ALL_COMPLEXITIES, ALL_EDGE_CATEGORIES } from "../store";
import type { NodeType, Complexity, EdgeCategory } from "../store";
export default function FilterPanel() {
@@ -13,9 +13,9 @@ export default function FilterPanel() {
const containerRef = useRef<HTMLDivElement>(null);
const allNodeTypes: NodeType[] = ["file", "function", "class", "module", "concept"];
const allComplexities: Complexity[] = ["simple", "moderate", "complex"];
const allEdgeCategories: EdgeCategory[] = ["structural", "behavioral", "data-flow", "dependencies", "semantic"];
const allNodeTypes = ALL_NODE_TYPES;
const allComplexities = ALL_COMPLEXITIES;
const allEdgeCategories = ALL_EDGE_CATEGORIES;
const layers = graph?.layers ?? [];
// Close dropdown on outside click
@@ -193,7 +193,7 @@ export default function FilterPanel() {
className="w-3.5 h-3.5 rounded border-border-subtle bg-elevated checked:bg-gold checked:border-gold focus:ring-0 focus:ring-offset-0 cursor-pointer"
/>
<span className="text-sm text-text-primary capitalize">
{category.replace("-", " ")}
{category.replace(/-/g, " ")}
</span>
</label>
))}
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef } from "react";
import {
ReactFlow,
ReactFlowProvider,
@@ -15,26 +15,52 @@ import "@xyflow/react/dist/style.css";
import CustomNode from "./CustomNode";
import type { CustomFlowNode } from "./CustomNode";
import NodeTooltip from "./NodeTooltip";
import LayerClusterNode from "./LayerClusterNode";
import type { LayerClusterFlowNode } from "./LayerClusterNode";
import PortalNode from "./PortalNode";
import type { PortalFlowNode } from "./PortalNode";
import Breadcrumb from "./Breadcrumb";
import { useDashboardStore } from "../store";
import type { FilterState } from "../store";
import { applyDagreLayout, applyDagreLayoutAsync, NODE_WIDTH, NODE_HEIGHT } from "../utils/layout";
import { filterNodes, filterEdges } from "../utils/filters";
import type { KnowledgeGraph, NodeType } from "@understand-anything/core/types";
import { useTheme } from "../themes/index.ts";
import {
applyDagreLayout,
NODE_WIDTH,
NODE_HEIGHT,
LAYER_CLUSTER_WIDTH,
LAYER_CLUSTER_HEIGHT,
PORTAL_NODE_WIDTH,
PORTAL_NODE_HEIGHT,
} from "../utils/layout";
import {
aggregateLayerEdges,
computePortals,
findCrossLayerFileNodes,
} from "../utils/edgeAggregation";
const LAYER_PADDING = 40;
const nodeTypes = {
custom: CustomNode,
"layer-cluster": LayerClusterNode,
portal: PortalNode,
};
import type { NodeCategory } from "../store";
/**
* Node count above which layout runs in a Web Worker
* to avoid blocking the main thread.
* Maps each NodeType to a filter category. Must be kept in sync with core NodeType.
* Unknown types default to "code" with a development warning.
*/
const ASYNC_LAYOUT_THRESHOLD = 200;
const NODE_TYPE_TO_CATEGORY: Record<NodeType, NodeCategory> = {
file: "code", function: "code", class: "code", module: "code", concept: "code",
config: "config",
document: "docs",
service: "infra", resource: "infra", pipeline: "infra",
table: "data", endpoint: "data", schema: "data",
} as const;
const nodeTypes = { custom: CustomNode };
// ── Helper components that must live inside <ReactFlow> ────────────────
/**
* Inner component that pans/zooms to tour-highlighted nodes.
* Must be rendered inside <ReactFlow> so useReactFlow() works.
*/
/** Pans/zooms to tour-highlighted nodes. */
function TourFitView() {
const tourHighlightedNodeIds = useDashboardStore((s) => s.tourHighlightedNodeIds);
const { fitView } = useReactFlow();
@@ -49,7 +75,6 @@ function TourFitView() {
prevRef.current = tourHighlightedNodeIds;
if (changed) {
// Small delay to ensure nodes are rendered before fitting
requestAnimationFrame(() => {
fitView({
nodes: tourHighlightedNodeIds.map((id) => ({ id })),
@@ -65,10 +90,7 @@ function TourFitView() {
return null;
}
/**
* Centers the graph on the selected node (e.g. from search).
* Must be rendered inside <ReactFlow> so useReactFlow() works.
*/
/** Centers the graph on the selected node (e.g. from search). */
function SelectedNodeFitView() {
const selectedNodeId = useDashboardStore((s) => s.selectedNodeId);
const { fitView } = useReactFlow();
@@ -92,345 +114,419 @@ function SelectedNodeFitView() {
return null;
}
/**
* Build topology-only flow data: nodes and edges without visual-only state
* (selection, tour highlights, search results). This output drives dagre
* layout and should only recompute when the graph structure changes.
*/
function buildTopologyData(
graph: NonNullable<ReturnType<typeof useDashboardStore.getState>["graph"]>,
persona: string,
diffMode: boolean,
changedNodeIds: Set<string>,
affectedNodeIds: Set<string>,
handleNodeSelect: (nodeId: string) => void,
filters: FilterState,
) {
// Step 1: Apply persona filtering
let filteredGraphNodes =
persona === "non-technical"
? graph.nodes.filter(
(n) =>
n.type === "concept" || n.type === "module" || n.type === "file",
)
: graph.nodes;
// ── Overview level: layers as cluster nodes ────────────────────────────
// Step 2: Apply filter panel filters
filteredGraphNodes = filterNodes(filteredGraphNodes, graph.layers ?? [], filters);
function useOverviewGraph() {
const graph = useDashboardStore((s) => s.graph);
const searchResults = useDashboardStore((s) => s.searchResults);
const drillIntoLayer = useDashboardStore((s) => s.drillIntoLayer);
const filteredNodeIds = new Set(filteredGraphNodes.map((n) => n.id));
return useMemo(() => {
if (!graph) return { nodes: [] as Node[], edges: [] as Edge[] };
// Step 3: Filter edges based on visible nodes and edge categories
let filteredGraphEdges = graph.edges.filter(
(e) => filteredNodeIds.has(e.source) && filteredNodeIds.has(e.target),
);
filteredGraphEdges = filterEdges(filteredGraphEdges, filteredNodeIds, filters);
const layers = graph.layers ?? [];
if (layers.length === 0) return { nodes: [] as Node[], edges: [] as Edge[] };
// Compute connection counts for each node
const incomingCounts = new Map<string, number>();
const outgoingCounts = new Map<string, number>();
for (const edge of filteredGraphEdges) {
outgoingCounts.set(edge.source, (outgoingCounts.get(edge.source) ?? 0) + 1);
incomingCounts.set(edge.target, (incomingCounts.get(edge.target) ?? 0) + 1);
}
const flowNodes: CustomFlowNode[] = filteredGraphNodes.map((node) => ({
id: node.id,
type: "custom" as const,
position: { x: 0, y: 0 },
data: {
label: node.name ?? node.filePath?.split("/").pop() ?? node.id,
nodeType: node.type,
summary: node.summary,
complexity: node.complexity,
isHighlighted: false,
searchScore: undefined,
isSelected: false,
isTourHighlighted: false,
isDiffChanged: diffMode && changedNodeIds.has(node.id),
isDiffAffected: diffMode && affectedNodeIds.has(node.id),
isDiffFaded: diffMode && !changedNodeIds.has(node.id) && !affectedNodeIds.has(node.id),
onNodeClick: handleNodeSelect,
incomingCount: incomingCounts.get(node.id) ?? 0,
outgoingCount: outgoingCounts.get(node.id) ?? 0,
tags: node.tags ?? [],
},
}));
const diffNodeIds = diffMode ? new Set([...changedNodeIds, ...affectedNodeIds]) : new Set<string>();
const flowEdges: Edge[] = filteredGraphEdges.map((edge, i) => {
const sourceInDiff = diffMode && diffNodeIds.has(edge.source);
const targetInDiff = diffMode && diffNodeIds.has(edge.target);
const isImpacted = diffMode && (sourceInDiff || targetInDiff);
return {
id: `e-${i}`,
source: edge.source,
target: edge.target,
label: edge.type,
animated: edge.type === "calls" || isImpacted,
style: isImpacted
? {
stroke: sourceInDiff && targetInDiff
? "rgba(224, 82, 82, 0.7)"
: "rgba(212, 160, 48, 0.5)",
strokeWidth: 2.5,
}
: diffMode
? { stroke: "rgba(212,165,116,0.08)", strokeWidth: 1 }
: { stroke: "rgba(212,165,116,0.3)", strokeWidth: 1.5 },
labelStyle: diffMode && !isImpacted
? { fill: "rgba(163,151,135,0.3)", fontSize: 10 }
: { fill: "#a39787", fontSize: 10 },
};
});
return { flowNodes, flowEdges };
}
/**
* Lightweight overlay of visual-only state onto already-positioned nodes.
* This is O(n) object spreads — cheap even for thousands of nodes — and
* avoids triggering a dagre relayout when selection/highlight/search changes.
*/
function applyVisualState(
nodes: (CustomFlowNode | Node)[],
selectedNodeId: string | null,
tourHighlightedNodeIds: string[],
searchResults: Array<{ nodeId: string; score: number }>,
): (CustomFlowNode | Node)[] {
const searchMap = new Map(searchResults.map((r) => [r.nodeId, r.score]));
const tourSet = new Set(tourHighlightedNodeIds);
return nodes.map((node) => {
// Skip group nodes (layer containers) — they have no CustomNodeData
if (node.type === "group") return node;
const searchScore = searchMap.get(node.id);
const isHighlighted = searchScore !== undefined;
const isSelected = selectedNodeId === node.id;
const isTourHighlighted = tourSet.has(node.id);
const data = node.data as CustomFlowNode["data"];
// Skip creating a new object if nothing visual changed
if (
data.isHighlighted === isHighlighted &&
data.searchScore === searchScore &&
data.isSelected === isSelected &&
data.isTourHighlighted === isTourHighlighted
) {
return node;
// Build search match counts per layer
const searchMatchByLayer = new Map<string, number>();
if (searchResults.length > 0) {
const nodeToLayer = new Map<string, string>();
for (const layer of layers) {
for (const nid of layer.nodeIds) {
nodeToLayer.set(nid, layer.id);
}
}
for (const result of searchResults) {
const lid = nodeToLayer.get(result.nodeId);
if (lid) {
searchMatchByLayer.set(lid, (searchMatchByLayer.get(lid) ?? 0) + 1);
}
}
}
return {
...node,
data: {
...data,
isHighlighted,
searchScore,
isSelected,
isTourHighlighted,
},
};
});
}
// Create cluster nodes
const clusterNodes: LayerClusterFlowNode[] = layers.map((layer, i) => {
const memberNodes = graph.nodes.filter((n) => layer.nodeIds.includes(n.id));
const complexCounts = { simple: 0, moderate: 0, complex: 0 };
for (const n of memberNodes) {
complexCounts[n.complexity]++;
}
const aggregateComplexity =
complexCounts.complex > memberNodes.length * 0.3
? "complex"
: complexCounts.moderate > memberNodes.length * 0.3
? "moderate"
: "simple";
function applyLayerGroups(
laidNodes: CustomFlowNode[],
edges: Edge[],
layers: Array<{ id: string; name: string; nodeIds: string[] }>,
showLayers: boolean,
): { initialNodes: (CustomFlowNode | Node)[]; initialEdges: Edge[] } {
if (!showLayers || layers.length === 0) {
return { initialNodes: laidNodes, initialEdges: edges };
}
const nodeToLayer = new Map<string, string>();
for (const layer of layers) {
for (const nodeId of layer.nodeIds) {
nodeToLayer.set(nodeId, layer.id);
}
}
const groupNodes: Node[] = [];
const adjustedNodes: (CustomFlowNode | Node)[] = [];
for (const layer of layers) {
const memberNodes = laidNodes.filter((n) => layer.nodeIds.includes(n.id));
if (memberNodes.length === 0) continue;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const node of memberNodes) {
minX = Math.min(minX, node.position.x);
minY = Math.min(minY, node.position.y);
maxX = Math.max(maxX, node.position.x + NODE_WIDTH);
maxY = Math.max(maxY, node.position.y + NODE_HEIGHT);
}
const groupX = minX - LAYER_PADDING;
const groupY = minY - LAYER_PADDING - 24;
const groupWidth = maxX - minX + LAYER_PADDING * 2;
const groupHeight = maxY - minY + LAYER_PADDING * 2 + 24;
groupNodes.push({
id: layer.id,
type: "group",
position: { x: groupX, y: groupY },
data: { label: layer.name },
style: {
width: groupWidth,
height: groupHeight,
backgroundColor: "rgba(212,165,116,0.05)",
borderRadius: 12,
border: "2px dashed rgba(212,165,116,0.25)",
padding: 8,
fontSize: 13,
fontWeight: 600,
color: "#d4a574",
},
return {
id: layer.id,
type: "layer-cluster" as const,
position: { x: 0, y: 0 },
data: {
layerId: layer.id,
layerName: layer.name,
layerDescription: layer.description,
fileCount: layer.nodeIds.length,
aggregateComplexity,
layerColorIndex: i,
searchMatchCount: searchMatchByLayer.get(layer.id),
onDrillIn: drillIntoLayer,
},
};
});
for (const node of memberNodes) {
adjustedNodes.push({
...node,
parentId: layer.id,
extent: "parent" as const,
position: {
x: node.position.x - groupX,
y: node.position.y - groupY,
},
});
}
}
// Aggregate edges between layers
const aggregated = aggregateLayerEdges(graph);
const flowEdges: Edge[] = aggregated.map((agg, i) => ({
id: `le-${i}`,
source: agg.sourceLayerId,
target: agg.targetLayerId,
label: `${agg.count}`,
style: {
stroke: "rgba(212,165,116,0.4)",
strokeWidth: Math.min(1 + Math.log2(agg.count + 1), 5),
},
labelStyle: { fill: "#a39787", fontSize: 11, fontWeight: 600 },
}));
for (const node of laidNodes) {
if (!nodeToLayer.has(node.id)) {
adjustedNodes.push(node);
const dims = new Map<string, { width: number; height: number }>();
for (const n of clusterNodes) {
dims.set(n.id, { width: LAYER_CLUSTER_WIDTH, height: LAYER_CLUSTER_HEIGHT });
}
}
return {
initialNodes: [...groupNodes, ...adjustedNodes],
initialEdges: edges,
};
const laid = applyDagreLayout(clusterNodes as unknown as Node[], flowEdges, "TB", dims);
return { nodes: laid.nodes, edges: laid.edges };
}, [graph, searchResults, drillIntoLayer]);
}
function GraphViewInner() {
// ── Layer detail level: topology (dagre) + visual overlay ───────────────
/**
* Topology memo: computes node positions via dagre. Only recomputes when
* the graph structure, active layer, persona, diff, or focus changes.
* Does NOT depend on selectedNodeId, searchResults, or tourHighlightedNodeIds.
*/
function useLayerDetailTopology() {
const graph = useDashboardStore((s) => s.graph);
const selectedNodeId = useDashboardStore((s) => s.selectedNodeId);
const searchResults = useDashboardStore((s) => s.searchResults);
const activeLayerId = useDashboardStore((s) => s.activeLayerId);
const selectNode = useDashboardStore((s) => s.selectNode);
const openCodeViewer = useDashboardStore((s) => s.openCodeViewer);
const showLayers = useDashboardStore((s) => s.showLayers);
const tourHighlightedNodeIds = useDashboardStore((s) => s.tourHighlightedNodeIds);
const persona = useDashboardStore((s) => s.persona);
const diffMode = useDashboardStore((s) => s.diffMode);
const changedNodeIds = useDashboardStore((s) => s.changedNodeIds);
const affectedNodeIds = useDashboardStore((s) => s.affectedNodeIds);
const filters = useDashboardStore((s) => s.filters);
const setReactFlowInstance = useDashboardStore((s) => s.setReactFlowInstance);
const [layouting, setLayouting] = useState(false);
const focusNodeId = useDashboardStore((s) => s.focusNodeId);
const nodeTypeFilters = useDashboardStore((s) => s.nodeTypeFilters);
const drillIntoLayer = useDashboardStore((s) => s.drillIntoLayer);
const handleNodeSelect = useCallback(
(nodeId: string) => {
selectNode(nodeId);
openCodeViewer(nodeId);
},
[selectNode, openCodeViewer],
[selectNode],
);
// ── Topology memo: only recomputes when graph structure changes ──
// Does NOT depend on selectedNodeId, tourHighlightedNodeIds, or searchResults.
const { topoNodes, topoEdges, needsAsyncLayout } = useMemo(() => {
if (!graph) {
return { topoNodes: [] as CustomFlowNode[], topoEdges: [] as Edge[], needsAsyncLayout: false };
}
const { flowNodes, flowEdges } = buildTopologyData(
graph, persona, diffMode, changedNodeIds, affectedNodeIds,
handleNodeSelect, filters,
);
return { topoNodes: flowNodes, topoEdges: flowEdges, needsAsyncLayout: flowNodes.length > ASYNC_LAYOUT_THRESHOLD };
}, [graph, persona, handleNodeSelect, diffMode, changedNodeIds, affectedNodeIds, filters]);
return useMemo(() => {
if (!graph || !activeLayerId)
return { nodes: [] as CustomFlowNode[], edges: [] as Edge[], portalNodes: [] as PortalFlowNode[], portalEdges: [] as Edge[], filteredEdges: [] as KnowledgeGraph["edges"] };
// ── Laid-out nodes from the last completed layout pass ──
// Stored in a ref so layout results persist across visual-state changes.
const laidOutRef = useRef<{ initialNodes: (CustomFlowNode | Node)[]; initialEdges: Edge[] } | null>(null);
const activeLayer = graph.layers.find((l) => l.id === activeLayerId);
if (!activeLayer) return { nodes: [] as CustomFlowNode[], edges: [] as Edge[], portalNodes: [] as PortalFlowNode[], portalEdges: [] as Edge[], filteredEdges: [] as KnowledgeGraph["edges"] };
// ── Sync layout: for small graphs, run dagre on the main thread ──
const syncResult = useMemo(() => {
if (!graph || needsAsyncLayout || topoNodes.length === 0) return null;
const laid = applyDagreLayout(topoNodes, topoEdges);
const layers = graph.layers ?? [];
return applyLayerGroups(laid.nodes as CustomFlowNode[], laid.edges, layers, showLayers);
}, [graph, topoNodes, topoEdges, needsAsyncLayout, showLayers]);
const layerNodeIds = new Set(activeLayer.nodeIds);
// Keep laidOutRef in sync with sync layout results
if (syncResult) {
laidOutRef.current = syncResult;
}
// All top-level (file-level) node types that should appear in the graph.
// This includes the 8 new non-code types plus the original "file" type.
const fileLevelTypes = new Set([
"file", "config", "document", "service", "table",
"endpoint", "pipeline", "schema", "resource",
]);
// ── Visual memo: cheap overlay of selection/highlight/search state ──
const visualNodes = useMemo(() => {
const base = laidOutRef.current;
if (!base) return [] as (CustomFlowNode | Node)[];
return applyVisualState(base.initialNodes, selectedNodeId, tourHighlightedNodeIds, searchResults);
}, [laidOutRef.current, selectedNodeId, tourHighlightedNodeIds, searchResults]);
// Non-technical persona: show module, concept, and file-level types (hide function/class)
// Junior/experienced persona: show everything including function/class
let filteredGraphNodes = persona === "non-technical"
? graph.nodes.filter(
(n) => layerNodeIds.has(n.id) && (n.type === "concept" || n.type === "module" || fileLevelTypes.has(n.type)),
)
: graph.nodes.filter((n) => layerNodeIds.has(n.id) && (fileLevelTypes.has(n.type) || n.type === "module" || n.type === "concept" || n.type === "function" || n.type === "class"));
const [nodes, setNodes, onNodesChange] = useNodesState(visualNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(laidOutRef.current?.initialEdges ?? []);
// ── Push sync layout + visual state to ReactFlow ──
useEffect(() => {
if (syncResult) {
const withVisual = applyVisualState(syncResult.initialNodes, selectedNodeId, tourHighlightedNodeIds, searchResults);
setNodes(withVisual);
setEdges(syncResult.initialEdges);
}
}, [syncResult, selectedNodeId, tourHighlightedNodeIds, searchResults, setNodes, setEdges]);
// ── Push visual-only changes (no relayout) ──
useEffect(() => {
if (laidOutRef.current && !layouting) {
const withVisual = applyVisualState(laidOutRef.current.initialNodes, selectedNodeId, tourHighlightedNodeIds, searchResults);
setNodes(withVisual);
}
}, [selectedNodeId, tourHighlightedNodeIds, searchResults, setNodes, layouting]);
// ── Async layout: for large graphs, run dagre in a Web Worker ──
useEffect(() => {
if (!graph || !needsAsyncLayout || topoNodes.length === 0) return;
let cancelled = false;
setLayouting(true);
applyDagreLayoutAsync(topoNodes, topoEdges).then((laid) => {
if (cancelled) return;
const layers = graph.layers ?? [];
const result = applyLayerGroups(laid.nodes as CustomFlowNode[], laid.edges, layers, showLayers);
laidOutRef.current = result;
const withVisual = applyVisualState(result.initialNodes, selectedNodeId, tourHighlightedNodeIds, searchResults);
setNodes(withVisual);
setEdges(result.initialEdges);
setLayouting(false);
}).catch(() => {
if (cancelled) return;
setLayouting(false);
// Apply node type category filters
filteredGraphNodes = filteredGraphNodes.filter((n) => {
const category = NODE_TYPE_TO_CATEGORY[n.type as NodeType];
if (!category) {
if (import.meta.env.DEV) {
console.warn(`[GraphView] Unknown node type "${n.type}" — defaulting to "code" category`);
}
}
const effectiveCategory = category ?? "code";
return nodeTypeFilters[effectiveCategory] !== false;
});
return () => { cancelled = true; setLayouting(false); };
}, [graph, topoNodes, topoEdges, needsAsyncLayout, showLayers, setNodes, setEdges]);
let filteredNodeIds = new Set(filteredGraphNodes.map((n) => n.id));
let filteredGraphEdges = graph.edges.filter(
(e) => filteredNodeIds.has(e.source) && filteredNodeIds.has(e.target),
);
// Focus mode: 1-hop neighborhood within the layer
if (focusNodeId && filteredNodeIds.has(focusNodeId)) {
const focusNeighborIds = new Set<string>([focusNodeId]);
for (const edge of filteredGraphEdges) {
if (edge.source === focusNodeId) focusNeighborIds.add(edge.target);
if (edge.target === focusNodeId) focusNeighborIds.add(edge.source);
}
filteredGraphNodes = filteredGraphNodes.filter((n) =>
focusNeighborIds.has(n.id),
);
filteredNodeIds = new Set(filteredGraphNodes.map((n) => n.id));
filteredGraphEdges = filteredGraphEdges.filter(
(e) => filteredNodeIds.has(e.source) && filteredNodeIds.has(e.target),
);
}
const diffNodeIds = diffMode
? new Set([...changedNodeIds, ...affectedNodeIds])
: new Set<string>();
const flowNodes: CustomFlowNode[] = filteredGraphNodes.map((node) => ({
id: node.id,
type: "custom" as const,
position: { x: 0, y: 0 },
data: {
label: node.name ?? node.filePath?.split("/").pop() ?? node.id,
nodeType: node.type,
summary: node.summary,
complexity: node.complexity,
isHighlighted: false,
searchScore: undefined,
isSelected: false,
isTourHighlighted: false,
isDiffChanged: diffMode && changedNodeIds.has(node.id),
isDiffAffected: diffMode && affectedNodeIds.has(node.id),
isDiffFaded: diffMode && !changedNodeIds.has(node.id) && !affectedNodeIds.has(node.id),
isNeighbor: false,
isSelectionFaded: false,
onNodeClick: handleNodeSelect,
},
}));
const flowEdges: Edge[] = filteredGraphEdges.map((edge, i) => {
const sourceInDiff = diffMode && diffNodeIds.has(edge.source);
const targetInDiff = diffMode && diffNodeIds.has(edge.target);
const isImpacted = diffMode && (sourceInDiff || targetInDiff);
let edgeStyle: React.CSSProperties;
let edgeLabelStyle: React.CSSProperties;
let edgeAnimated: boolean;
if (isImpacted) {
edgeStyle = {
stroke: sourceInDiff && targetInDiff ? "rgba(224, 82, 82, 0.7)" : "rgba(212, 160, 48, 0.5)",
strokeWidth: 2.5,
};
edgeLabelStyle = { fill: "#a39787", fontSize: 10 };
edgeAnimated = true;
} else if (diffMode) {
edgeStyle = { stroke: "rgba(212,165,116,0.08)", strokeWidth: 1 };
edgeLabelStyle = { fill: "rgba(163,151,135,0.3)", fontSize: 10 };
edgeAnimated = false;
} else {
edgeStyle = { stroke: "rgba(212,165,116,0.3)", strokeWidth: 1.5 };
edgeLabelStyle = { fill: "#a39787", fontSize: 10 };
edgeAnimated = edge.type === "calls";
}
return {
id: `e-${i}`,
source: edge.source,
target: edge.target,
label: edge.type,
animated: edgeAnimated,
style: edgeStyle,
labelStyle: edgeLabelStyle,
};
});
// Portal nodes for connected external layers
const portals = computePortals(graph, activeLayerId);
const layerIndexMap = new Map(graph.layers.map((l, i) => [l.id, i]));
const portalNodes: PortalFlowNode[] = portals.map((portal) => ({
id: `portal:${portal.layerId}`,
type: "portal" as const,
position: { x: 0, y: 0 },
data: {
targetLayerId: portal.layerId,
targetLayerName: portal.layerName,
connectionCount: portal.connectionCount,
layerColorIndex: layerIndexMap.get(portal.layerId) ?? 0,
onNavigate: drillIntoLayer,
},
}));
const portalEdges: Edge[] = [];
let portalEdgeIdx = flowEdges.length;
for (const portal of portals) {
const crossFiles = findCrossLayerFileNodes(graph, activeLayerId, portal.layerId);
for (const fileId of crossFiles) {
if (filteredNodeIds.has(fileId)) {
portalEdges.push({
id: `e-${portalEdgeIdx++}`,
source: fileId,
target: `portal:${portal.layerId}`,
style: { stroke: "rgba(212,165,116,0.2)", strokeWidth: 1, strokeDasharray: "4 4" },
animated: false,
});
}
}
}
const allFlowNodes: Node[] = [
...(flowNodes as unknown as Node[]),
...(portalNodes as unknown as Node[]),
];
const allFlowEdges = [...flowEdges, ...portalEdges];
const dims = new Map<string, { width: number; height: number }>();
for (const n of flowNodes) {
dims.set(n.id, { width: NODE_WIDTH, height: NODE_HEIGHT });
}
for (const n of portalNodes) {
dims.set(n.id, { width: PORTAL_NODE_WIDTH, height: PORTAL_NODE_HEIGHT });
}
const laid = applyDagreLayout(allFlowNodes, allFlowEdges, "TB", dims);
return { nodes: laid.nodes, edges: laid.edges, portalNodes, portalEdges, filteredEdges: filteredGraphEdges };
}, [graph, activeLayerId, persona, handleNodeSelect, diffMode, changedNodeIds, affectedNodeIds, focusNodeId, nodeTypeFilters, drillIntoLayer]);
}
/**
* Visual overlay: cheap O(n) pass that applies selection, search, and tour
* state onto already-positioned nodes. Avoids triggering dagre relayout.
*/
function useLayerDetailGraph() {
const selectedNodeId = useDashboardStore((s) => s.selectedNodeId);
const searchResults = useDashboardStore((s) => s.searchResults);
const tourHighlightedNodeIds = useDashboardStore((s) => s.tourHighlightedNodeIds);
const topo = useLayerDetailTopology();
const nodes = useMemo(() => {
const searchMap = new Map(searchResults.map((r) => [r.nodeId, r.score]));
const tourSet = new Set(tourHighlightedNodeIds);
// Build neighbor set for selection highlighting
const neighborNodeIds = new Set<string>();
if (selectedNodeId) {
for (const edge of topo.filteredEdges) {
if (edge.source === selectedNodeId) neighborNodeIds.add(edge.target);
if (edge.target === selectedNodeId) neighborNodeIds.add(edge.source);
}
neighborNodeIds.add(selectedNodeId);
}
return topo.nodes.map((node) => {
// Skip portal nodes — they have no CustomNodeData
if (node.type === "portal") return node;
const searchScore = searchMap.get(node.id);
const isHighlighted = searchScore !== undefined;
const isSelected = selectedNodeId === node.id;
const isTourHighlighted = tourSet.has(node.id);
const hasSelection = !!selectedNodeId;
const isNeighbor = hasSelection && neighborNodeIds.has(node.id) && !isSelected;
const isSelectionFaded = hasSelection && !neighborNodeIds.has(node.id);
const data = node.data as CustomFlowNode["data"];
// Skip creating a new object if nothing visual changed
if (
data.isHighlighted === isHighlighted &&
data.searchScore === searchScore &&
data.isSelected === isSelected &&
data.isTourHighlighted === isTourHighlighted &&
data.isNeighbor === isNeighbor &&
data.isSelectionFaded === isSelectionFaded
) {
return node;
}
return { ...node, data: { ...data, isHighlighted, searchScore, isSelected, isTourHighlighted, isNeighbor, isSelectionFaded } };
});
}, [topo.nodes, topo.filteredEdges, selectedNodeId, searchResults, tourHighlightedNodeIds]);
const edges = useMemo(() => {
if (!selectedNodeId) return topo.edges;
// Apply selection-based edge styling on top of topology edges
return topo.edges.map((edge) => {
const isSelectedEdge = edge.source === selectedNodeId || edge.target === selectedNodeId;
// Don't restyle diff-impacted or portal edges
if ((edge.style as Record<string, unknown>)?.strokeDasharray) return edge;
if (isSelectedEdge) {
return { ...edge, animated: true, style: { stroke: "rgba(212,165,116,0.8)", strokeWidth: 2.5 }, labelStyle: { fill: "#d4a574", fontSize: 11, fontWeight: 600 } };
}
// Fade unrelated edges
return { ...edge, animated: false, style: { stroke: "rgba(212,165,116,0.08)", strokeWidth: 1 }, labelStyle: { fill: "rgba(163,151,135,0.2)", fontSize: 10 } };
});
}, [topo.edges, selectedNodeId]);
return { nodes, edges };
}
// ── Main inner component (must be inside ReactFlowProvider) ────────────
function GraphViewInner() {
const graph = useDashboardStore((s) => s.graph);
const navigationLevel = useDashboardStore((s) => s.navigationLevel);
const activeLayerId = useDashboardStore((s) => s.activeLayerId);
const selectNode = useDashboardStore((s) => s.selectNode);
const openCodeViewer = useDashboardStore((s) => s.openCodeViewer);
const drillIntoLayer = useDashboardStore((s) => s.drillIntoLayer);
const focusNodeId = useDashboardStore((s) => s.focusNodeId);
const setFocusNode = useDashboardStore((s) => s.setFocusNode);
const setReactFlowInstance = useDashboardStore((s) => s.setReactFlowInstance);
const { preset } = useTheme();
const overviewGraph = useOverviewGraph();
const detailGraph = useLayerDetailGraph();
const { nodes: initialNodes, edges: initialEdges } =
navigationLevel === "overview" ? overviewGraph : detailGraph;
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
const { fitView } = useReactFlow();
useEffect(() => {
setNodes(initialNodes);
}, [initialNodes, setNodes]);
useEffect(() => {
setEdges(initialEdges);
}, [initialEdges, setEdges]);
// Fit view on level/layer transitions
useEffect(() => {
const timer = setTimeout(() => {
fitView({ duration: 400, padding: 0.2 });
}, 50);
return () => clearTimeout(timer);
}, [navigationLevel, activeLayerId, fitView]);
const onNodeClick = useCallback(
(_: React.MouseEvent, node: { id: string }) => {
// Ignore clicks on group nodes
const isGroupNode = graph?.layers?.some((l) => l.id === node.id);
if (isGroupNode) return;
selectNode(node.id);
openCodeViewer(node.id);
if (navigationLevel === "overview") {
drillIntoLayer(node.id);
} else if (node.id.startsWith("portal:")) {
const targetLayerId = node.id.replace("portal:", "");
drillIntoLayer(targetLayerId);
} else {
selectNode(node.id);
openCodeViewer(node.id);
}
},
[selectNode, openCodeViewer, graph],
[navigationLevel, drillIntoLayer, selectNode, openCodeViewer],
);
const onPaneClick = useCallback(() => {
@@ -447,14 +543,16 @@ function GraphViewInner() {
return (
<div className="h-full w-full relative">
{layouting && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-root/80 rounded-lg">
<div className="text-center">
<div className="inline-block w-8 h-8 border-2 border-gold border-t-transparent rounded-full animate-spin mb-3" />
<p className="text-text-secondary text-sm">
Laying out {topoNodes.length.toLocaleString()} nodes...
</p>
</div>
<Breadcrumb />
{focusNodeId && navigationLevel === "layer-detail" && (
<div className="absolute top-14 left-1/2 -translate-x-1/2 z-10">
<button
onClick={() => setFocusNode(null)}
className="px-4 py-2 rounded-full bg-elevated border border-gold/30 text-gold text-xs font-semibold tracking-wider uppercase hover:bg-gold/10 transition-colors flex items-center gap-2 shadow-lg"
>
<span>Showing neighborhood</span>
<span className="text-text-muted">&times;</span>
</button>
</div>
)}
<ReactFlow
@@ -475,35 +573,18 @@ function GraphViewInner() {
fitViewOptions={{ minZoom: 0.01, padding: 0.1 }}
minZoom={0.01}
maxZoom={2}
colorMode="dark"
colorMode={preset.isDark ? "dark" : "light"}
>
<Background variant={BackgroundVariant.Dots} color="rgba(212,165,116,0.15)" gap={20} size={1} />
<Background variant={BackgroundVariant.Dots} color="var(--color-edge-dot)" gap={20} size={1} />
<Controls />
<MiniMap
nodeColor="#1a1a1a"
maskColor="rgba(10,10,10,0.7)"
nodeColor="var(--color-elevated)"
maskColor="var(--glass-bg)"
className="!bg-surface !border !border-border-subtle"
/>
<TourFitView />
<SelectedNodeFitView />
</ReactFlow>
{/* Node tooltips */}
{nodes
.filter((n) => n.type === "custom")
.map((node) => {
const data = node.data as CustomFlowNode["data"];
return (
<NodeTooltip
key={node.id}
nodeId={node.id}
data={data}
incomingCount={data.incomingCount ?? 0}
outgoingCount={data.outgoingCount ?? 0}
tags={data.tags}
/>
);
})}
</div>
);
}
@@ -62,7 +62,7 @@ export default function KeyboardShortcutsHelp({
<div className="p-6 space-y-6">
{Object.entries(groupedShortcuts).map(([category, categoryShortcuts]) => (
<div key={category}>
<h3 className="text-sm font-semibold text-gold uppercase tracking-wider mb-3">
<h3 className="text-sm font-semibold text-accent uppercase tracking-wider mb-3">
{category}
</h3>
<div className="space-y-2">
@@ -0,0 +1,104 @@
import { memo } from "react";
import { Handle, Position } from "@xyflow/react";
import type { NodeProps, Node } from "@xyflow/react";
import { getLayerColor } from "./LayerLegend";
const complexityColors: Record<string, string> = {
simple: "text-node-function",
moderate: "text-gold-dim",
complex: "text-[#c97070]",
};
export interface LayerClusterData extends Record<string, unknown> {
layerId: string;
layerName: string;
layerDescription: string;
fileCount: number;
aggregateComplexity: string;
layerColorIndex: number;
searchMatchCount?: number;
onDrillIn: (layerId: string) => void;
}
export type LayerClusterFlowNode = Node<LayerClusterData, "layer-cluster">;
function LayerClusterNode({
data,
}: NodeProps<LayerClusterFlowNode>) {
const color = getLayerColor(data.layerColorIndex);
const complexityColor =
complexityColors[data.aggregateComplexity] ?? complexityColors.simple;
return (
<div
className="relative rounded-xl bg-elevated border border-border-subtle overflow-hidden cursor-pointer transition-all duration-200 hover:border-gold/40 hover:shadow-lg group"
style={{
width: 300,
boxShadow: "0 4px 16px rgba(0,0,0,0.4)",
}}
onClick={() => data.onDrillIn(data.layerId)}
>
{/* Left color bar */}
<div
className="absolute left-0 top-0 bottom-0 w-1.5 rounded-l-xl"
style={{ backgroundColor: color.label }}
/>
<Handle
type="target"
position={Position.Top}
className="!bg-text-muted !w-2 !h-2"
/>
<div className="pl-5 pr-4 py-4">
{/* Header row */}
<div className="flex items-center justify-between mb-2">
<span
className="text-[10px] font-semibold uppercase tracking-wider"
style={{ color: color.label }}
>
Layer
</span>
<div className="flex items-center gap-2">
{data.searchMatchCount != null && data.searchMatchCount > 0 && (
<span className="text-[10px] font-mono bg-gold/20 text-gold px-1.5 py-0.5 rounded">
{data.searchMatchCount} match{data.searchMatchCount !== 1 ? "es" : ""}
</span>
)}
<span className={`text-[10px] font-mono ${complexityColor}`}>
{data.aggregateComplexity}
</span>
</div>
</div>
{/* Layer name */}
<div className="text-lg font-serif text-text-primary mb-1">
{data.layerName}
</div>
{/* Description */}
<div className="text-[11px] text-text-secondary line-clamp-2 leading-tight mb-3">
{data.layerDescription}
</div>
{/* Footer */}
<div className="flex items-center justify-between">
<span className="text-[11px] text-text-muted">
{data.fileCount} file{data.fileCount !== 1 ? "s" : ""}
</span>
<span className="text-[10px] text-text-muted opacity-0 group-hover:opacity-100 transition-opacity">
Click to explore
</span>
</div>
</div>
<Handle
type="source"
position={Position.Bottom}
className="!bg-text-muted !w-2 !h-2"
/>
</div>
);
}
export default memo(LayerClusterNode);
@@ -1,86 +1,70 @@
import { useDashboardStore } from "../store";
const LAYER_COLORS = [
"rgba(59, 130, 246, 0.08)", // blue
"rgba(16, 185, 129, 0.08)", // green
"rgba(245, 158, 11, 0.08)", // amber
"rgba(139, 92, 246, 0.08)", // violet
"rgba(236, 72, 153, 0.08)", // pink
"rgba(6, 182, 212, 0.08)", // cyan
"rgba(249, 115, 22, 0.08)", // orange
"rgba(168, 162, 158, 0.08)", // stone
// Shared layer color palette — used by LayerLegend, LayerClusterNode, PortalNode, and GraphView
export const LAYER_PALETTE = [
{ bg: "rgba(74, 124, 155, 0.12)", border: "rgba(74, 124, 155, 0.4)", label: "#4a7c9b" }, // blue (API)
{ bg: "rgba(90, 158, 111, 0.12)", border: "rgba(90, 158, 111, 0.4)", label: "#5a9e6f" }, // green (Data)
{ bg: "rgba(139, 111, 176, 0.12)", border: "rgba(139, 111, 176, 0.4)", label: "#8b6fb0" }, // purple (Service)
{ bg: "rgba(201, 160, 108, 0.12)", border: "rgba(201, 160, 108, 0.4)", label: "#c9a06c" }, // gold (Config)
{ bg: "rgba(176, 122, 138, 0.12)", border: "rgba(176, 122, 138, 0.4)", label: "#b07a8a" }, // pink (UI)
{ bg: "rgba(74, 155, 140, 0.12)", border: "rgba(74, 155, 140, 0.4)", label: "#4a9b8c" }, // teal (Middleware)
{ bg: "rgba(120, 130, 145, 0.12)", border: "rgba(120, 130, 145, 0.4)", label: "#788291" }, // slate (Test)
];
export const LAYER_BORDER_COLORS = [
"rgba(59, 130, 246, 0.5)", // blue
"rgba(16, 185, 129, 0.5)", // green
"rgba(245, 158, 11, 0.5)", // amber
"rgba(139, 92, 246, 0.5)", // violet
"rgba(236, 72, 153, 0.5)", // pink
"rgba(6, 182, 212, 0.5)", // cyan
"rgba(249, 115, 22, 0.5)", // orange
"rgba(168, 162, 158, 0.5)", // stone
];
export { LAYER_COLORS };
export function getLayerColor(index: number): string {
return LAYER_COLORS[index % LAYER_COLORS.length];
}
export function getLayerBorderColor(index: number): string {
return LAYER_BORDER_COLORS[index % LAYER_BORDER_COLORS.length];
export function getLayerColor(index: number) {
return LAYER_PALETTE[index % LAYER_PALETTE.length];
}
export default function LayerLegend() {
const graph = useDashboardStore((s) => s.graph);
const showLayers = useDashboardStore((s) => s.showLayers);
const toggleLayers = useDashboardStore((s) => s.toggleLayers);
const navigationLevel = useDashboardStore((s) => s.navigationLevel);
const activeLayerId = useDashboardStore((s) => s.activeLayerId);
const layers = graph?.layers ?? [];
const hasLayers = layers.length > 0;
if (!hasLayers) return null;
const activeLayer = layers.find((l) => l.id === activeLayerId);
return (
<div className="flex items-center gap-2">
<button
onClick={toggleLayers}
disabled={!hasLayers}
className={`px-2 py-0.5 rounded text-[11px] font-medium transition-colors ${
showLayers && hasLayers
? "bg-gold/20 text-gold"
: hasLayers
? "bg-elevated text-text-secondary hover:bg-surface"
: "bg-elevated text-text-muted cursor-not-allowed"
}`}
title={
hasLayers
? showLayers
? "Hide layer grouping"
: "Show layer grouping"
: "No layers in graph"
}
>
Layers {showLayers && hasLayers ? "ON" : "OFF"}
</button>
<span className="text-[11px] font-medium text-text-secondary">
{navigationLevel === "overview"
? `${layers.length} layers`
: activeLayer?.name ?? "Layer"}
</span>
{showLayers && hasLayers && (
<div className="flex items-center gap-3">
{layers.map((layer, i) => (
<div className="flex items-center gap-3">
{layers.map((layer, i) => {
const color = getLayerColor(i);
const isActive = navigationLevel === "layer-detail" && layer.id === activeLayerId;
return (
<div key={layer.id} className="flex items-center gap-1">
<span
className="inline-block w-2 h-2 rounded-full"
style={{ backgroundColor: getLayerBorderColor(i) }}
style={{
backgroundColor: color.label,
opacity: navigationLevel === "layer-detail" && !isActive ? 0.3 : 1,
}}
/>
<span className="text-text-secondary text-[11px]">
<span
className={`text-[11px] ${
isActive ? "text-text-primary font-medium" : "text-text-secondary"
}`}
style={{
opacity: navigationLevel === "layer-detail" && !isActive ? 0.4 : 1,
}}
>
{layer.name}
<span className="text-text-muted ml-0.5">
({layer.nodeIds.length})
</span>
</span>
</div>
))}
</div>
)}
);
})}
</div>
</div>
);
}
@@ -47,13 +47,13 @@ export default function LearnPanel() {
<button
onClick={startTour}
className="w-full mb-4 bg-gold/10 border border-gold/30 text-gold text-sm font-medium py-2.5 px-4 rounded-lg hover:bg-gold/20 transition-colors"
className="w-full mb-4 bg-accent/10 border border-accent/30 text-accent text-sm font-medium py-2.5 px-4 rounded-lg hover:bg-accent/20 transition-colors"
>
Start Tour
</button>
<div className="space-y-2">
<h3 className="text-[11px] font-semibold text-gold uppercase tracking-wider mb-2">
<h3 className="text-[11px] font-semibold text-accent uppercase tracking-wider mb-2">
Steps
</h3>
{tourSteps.map((step, i) => (
@@ -61,7 +61,7 @@ export default function LearnPanel() {
key={step.order}
className="flex items-start gap-2 text-xs bg-elevated rounded-lg px-3 py-2 border border-border-subtle"
>
<span className="text-gold font-mono shrink-0 mt-0.5">
<span className="text-accent font-mono shrink-0 mt-0.5">
{i + 1}.
</span>
<span className="text-text-secondary">{step.title}</span>
@@ -86,7 +86,7 @@ export default function LearnPanel() {
{/* Header with progress counter and exit */}
<div className="flex items-center justify-between px-3 py-2 border-b border-border-subtle shrink-0">
<div className="flex items-center gap-2">
<h3 className="text-[11px] font-semibold text-gold uppercase tracking-wider">
<h3 className="text-[11px] font-semibold text-accent uppercase tracking-wider">
Tour
</h3>
<span className="text-xs text-text-muted">
@@ -104,7 +104,7 @@ export default function LearnPanel() {
{/* Progress bar */}
<div className="h-1 bg-elevated shrink-0">
<div
className="h-full bg-gold transition-all duration-300"
className="h-full bg-accent transition-all duration-300"
style={{ width: `${progressPct}%` }}
/>
</div>
@@ -122,16 +122,16 @@ export default function LearnPanel() {
<p className="mb-1.5 last:mb-0">{children}</p>
),
strong: ({ children }) => (
<strong className="font-semibold text-white">{children}</strong>
<strong className="font-semibold text-text-primary">{children}</strong>
),
code: ({ className, children }) => {
const isBlock = className?.includes("language-");
return isBlock ? (
<code className="block bg-gray-900 rounded px-2 py-1.5 mb-1.5 overflow-x-auto text-[11px] leading-relaxed">
<code className="block bg-elevated rounded px-2 py-1.5 mb-1.5 overflow-x-auto text-[11px] leading-relaxed">
{children}
</code>
) : (
<code className="bg-gray-900 rounded px-1 py-0.5 text-[11px]">
<code className="bg-elevated rounded px-1 py-0.5 text-[11px]">
{children}
</code>
);
@@ -154,8 +154,8 @@ export default function LearnPanel() {
{/* Language lesson */}
{step.languageLesson && (
<div className="bg-gold/5 border border-gold/20 rounded p-3 mb-4">
<h4 className="text-[11px] font-semibold text-gold uppercase tracking-wider mb-1.5">
<div className="bg-accent/5 border border-accent/20 rounded p-3 mb-4">
<h4 className="text-[11px] font-semibold text-accent uppercase tracking-wider mb-1.5">
Language Lesson
</h4>
<p className="text-sm text-text-secondary leading-relaxed">
@@ -167,7 +167,7 @@ export default function LearnPanel() {
{/* Referenced component pills */}
{step.nodeIds.length > 0 && (
<div className="mb-4">
<h4 className="text-[11px] font-semibold text-gold uppercase tracking-wider mb-2">
<h4 className="text-[11px] font-semibold text-accent uppercase tracking-wider mb-2">
Referenced Components
</h4>
<div className="flex flex-wrap gap-1.5">
@@ -198,7 +198,7 @@ export default function LearnPanel() {
onClick={() => setTourStep(i)}
className={`w-2 h-2 rounded-full transition-colors ${
i === currentTourStep
? "bg-gold"
? "bg-accent"
: "bg-elevated hover:bg-surface"
}`}
aria-label={`Go to step ${i + 1}`}
@@ -217,7 +217,7 @@ export default function LearnPanel() {
</button>
<button
onClick={isLast ? stopTour : nextTourStep}
className="flex-1 text-xs bg-gold/10 border border-gold/30 text-gold py-1.5 rounded-lg hover:bg-gold/20 transition-colors"
className="flex-1 text-xs bg-accent/10 border border-accent/30 text-accent py-1.5 rounded-lg hover:bg-accent/20 transition-colors"
>
{isLast ? "Finish" : "Next"}
</button>
@@ -1,27 +1,96 @@
import { useState } from "react";
import { useDashboardStore } from "../store";
import type { NodeType, EdgeType } from "@understand-anything/core/types";
const typeBadgeColors: Record<string, string> = {
// Badge color classes keyed by NodeType — must be kept in sync with core NodeType union.
const typeBadgeColors: Record<NodeType, string> = {
file: "text-node-file border border-node-file/30 bg-node-file/10",
function: "text-node-function border border-node-function/30 bg-node-function/10",
class: "text-node-class border border-node-class/30 bg-node-class/10",
module: "text-node-module border border-node-module/30 bg-node-module/10",
concept: "text-node-concept border border-node-concept/30 bg-node-concept/10",
config: "text-node-config border border-node-config/30 bg-node-config/10",
document: "text-node-document border border-node-document/30 bg-node-document/10",
service: "text-node-service border border-node-service/30 bg-node-service/10",
table: "text-node-table border border-node-table/30 bg-node-table/10",
endpoint: "text-node-endpoint border border-node-endpoint/30 bg-node-endpoint/10",
pipeline: "text-node-pipeline border border-node-pipeline/30 bg-node-pipeline/10",
schema: "text-node-schema border border-node-schema/30 bg-node-schema/10",
resource: "text-node-resource border border-node-resource/30 bg-node-resource/10",
};
const complexityBadgeColors: Record<string, string> = {
simple: "text-node-function border border-node-function/30 bg-node-function/10",
moderate: "text-gold-dim border border-gold-dim/30 bg-gold-dim/10",
moderate: "text-accent-dim border border-accent-dim/30 bg-accent-dim/10",
complex: "text-[#c97070] border border-[#c97070]/30 bg-[#c97070]/10",
};
/**
* Human-readable directional labels for all 26 edge types.
* Must be kept in sync with core EdgeType.
*/
const EDGE_LABELS: Record<EdgeType, { forward: string; backward: string }> = {
imports: { forward: "imports", backward: "imported by" },
exports: { forward: "exports to", backward: "exported by" },
contains: { forward: "contains", backward: "contained in" },
inherits: { forward: "inherits from", backward: "inherited by" },
implements: { forward: "implements", backward: "implemented by" },
calls: { forward: "calls", backward: "called by" },
subscribes: { forward: "subscribes to", backward: "subscribed by" },
publishes: { forward: "publishes to", backward: "consumed by" },
middleware: { forward: "middleware for", backward: "uses middleware" },
reads_from: { forward: "reads from", backward: "read by" },
writes_to: { forward: "writes to", backward: "written by" },
transforms: { forward: "transforms", backward: "transformed by" },
validates: { forward: "validates", backward: "validated by" },
depends_on: { forward: "depends on", backward: "depended on by" },
tested_by: { forward: "tested by", backward: "tests" },
configures: { forward: "configures", backward: "configured by" },
related: { forward: "related to", backward: "related to" },
similar_to: { forward: "similar to", backward: "similar to" },
deploys: { forward: "deploys", backward: "deployed by" },
serves: { forward: "serves", backward: "served by" },
migrates: { forward: "migrates", backward: "migrated by" },
documents: { forward: "documents", backward: "documented by" },
provisions: { forward: "provisions", backward: "provisioned by" },
routes: { forward: "routes to", backward: "routed from" },
defines_schema: { forward: "defines schema for", backward: "schema defined by" },
triggers: { forward: "triggers", backward: "triggered by" },
};
/**
* Returns a human-readable directional label for an edge type.
* Falls back to formatted type name for unknown edge types.
*/
function getDirectionalLabel(edgeType: string, isSource: boolean): string {
const labels = (EDGE_LABELS as Record<string, { forward: string; backward: string }>)[edgeType];
if (!labels) {
// Fallback for unknown edge types
const formatted = edgeType.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
return isSource ? formatted : `${formatted} (reverse)`;
}
return isSource ? labels.forward : labels.backward;
}
export default function NodeInfo() {
const graph = useDashboardStore((s) => s.graph);
const selectedNodeId = useDashboardStore((s) => s.selectedNodeId);
const nodeHistory = useDashboardStore((s) => s.nodeHistory);
const goBackNode = useDashboardStore((s) => s.goBackNode);
const [languageExpanded, setLanguageExpanded] = useState(true);
const navigateToNode = useDashboardStore((s) => s.navigateToNode);
const navigateToHistoryIndex = useDashboardStore((s) => s.navigateToHistoryIndex);
const setFocusNode = useDashboardStore((s) => s.setFocusNode);
const focusNodeId = useDashboardStore((s) => s.focusNodeId);
const node = graph?.nodes.find((n) => n.id === selectedNodeId) ?? null;
// Resolve history node names for the breadcrumb trail
const historyNodes = nodeHistory.map((id) => {
const n = graph?.nodes.find((gn) => gn.id === id);
return { id, name: n?.name ?? id };
});
if (!node) {
return (
<div className="h-full w-full flex items-center justify-center bg-surface">
@@ -30,16 +99,70 @@ export default function NodeInfo() {
);
}
const connections = (graph?.edges ?? []).filter(
const allEdges = graph?.edges ?? [];
const connections = allEdges.filter(
(e) => e.source === node.id || e.target === node.id,
);
const typeBadge = typeBadgeColors[node.type] ?? typeBadgeColors.file;
// Separate child nodes (contained IN this file) from other connections
const childEdges = connections.filter(
(e) => e.type === "contains" && e.source === node.id,
);
const otherConnections = connections.filter(
(e) => !(e.type === "contains" && e.source === node.id),
);
// Resolve child nodes
const childNodes = childEdges
.map((e) => graph?.nodes.find((n) => n.id === e.target))
.filter(Boolean);
const knownType = node.type as NodeType;
const typeBadge = typeBadgeColors[knownType] ?? typeBadgeColors.file;
const complexityBadge =
complexityBadgeColors[node.complexity] ?? complexityBadgeColors.simple;
if (import.meta.env.DEV && !(knownType in typeBadgeColors)) {
console.warn(`[NodeInfo] Unknown node type "${node.type}" — using "file" badge colors`);
}
return (
<div className="h-full w-full overflow-auto p-5 animate-fade-slide-in">
{/* Navigation history trail */}
{historyNodes.length > 0 && (
<div className="mb-3 flex items-center gap-1 flex-wrap">
<button
onClick={goBackNode}
className="text-[10px] font-semibold text-gold hover:text-gold-bright transition-colors flex items-center gap-1"
>
<span></span>
<span>Back</span>
</button>
<span className="text-text-muted text-[10px]"></span>
{historyNodes.slice(-3).map((h, i, arr) => (
<span key={`${h.id}-${i}`} className="flex items-center gap-1">
<button
onClick={() => {
const fullIdx = historyNodes.length - arr.length + i;
navigateToHistoryIndex(fullIdx);
}}
className="text-[10px] text-text-muted hover:text-gold transition-colors truncate max-w-[80px]"
title={h.name}
>
{h.name}
</button>
{i < arr.length - 1 && (
<span className="text-text-muted text-[10px]"></span>
)}
</span>
))}
<span className="text-text-muted text-[10px]"></span>
<span className="text-[10px] text-text-primary font-medium truncate max-w-[80px]">
{node.name}
</span>
</div>
)}
<div className="flex items-center gap-2 mb-3">
<span
className={`text-[10px] font-semibold uppercase tracking-wider px-2 py-0.5 rounded ${typeBadge}`}
@@ -53,7 +176,19 @@ export default function NodeInfo() {
</span>
</div>
<h2 className="text-lg font-serif text-text-primary mb-2">{node.name}</h2>
<div className="flex items-center justify-between mb-2">
<h2 className="text-lg font-serif text-text-primary">{node.name}</h2>
<button
onClick={() => setFocusNode(focusNodeId === node.id ? null : node.id)}
className={`text-[10px] font-semibold uppercase tracking-wider px-2.5 py-1 rounded transition-colors ${
focusNodeId === node.id
? "bg-gold/20 text-gold border border-gold/40"
: "text-text-muted border border-border-subtle hover:text-gold hover:border-gold/30"
}`}
>
{focusNodeId === node.id ? "Unfocus" : "Focus"}
</button>
</div>
<p className="text-sm text-text-secondary mb-4 leading-relaxed">
{node.summary}
@@ -75,7 +210,7 @@ export default function NodeInfo() {
<div className="mb-4">
<button
onClick={() => setLanguageExpanded(!languageExpanded)}
className="flex items-center gap-1.5 text-xs font-semibold text-gold uppercase tracking-wider mb-2 hover:text-gold-bright transition-colors"
className="flex items-center gap-1.5 text-xs font-semibold text-accent uppercase tracking-wider mb-2 hover:text-accent-bright transition-colors"
>
<svg
className={`w-3 h-3 transition-transform ${languageExpanded ? "rotate-90" : ""}`}
@@ -88,7 +223,7 @@ export default function NodeInfo() {
Language Concepts
</button>
{languageExpanded && (
<div className="bg-gold/5 border border-gold/20 rounded-lg p-3">
<div className="bg-accent/5 border border-accent/20 rounded-lg p-3">
<p className="text-sm text-text-secondary leading-relaxed">
{node.languageNotes}
</p>
@@ -99,7 +234,7 @@ export default function NodeInfo() {
{node.tags.length > 0 && (
<div className="mb-4">
<h3 className="text-[11px] font-semibold text-gold uppercase tracking-wider mb-2">
<h3 className="text-[11px] font-semibold text-accent uppercase tracking-wider mb-2">
Tags
</h3>
<div className="flex flex-wrap gap-1.5">
@@ -115,25 +250,68 @@ export default function NodeInfo() {
</div>
)}
{connections.length > 0 && (
{/* Child classes/functions within this file */}
{childNodes.length > 0 && (
<div className="mb-4">
<h3 className="text-[11px] font-semibold text-gold uppercase tracking-wider mb-2">
Defined in this file ({childNodes.length})
</h3>
<div className="space-y-1">
{childNodes.map((child) => {
if (!child) return null;
const childTypeBadge = typeBadgeColors[child.type as NodeType] ?? typeBadgeColors.file;
const childComplexity = complexityBadgeColors[child.complexity] ?? complexityBadgeColors.simple;
return (
<div
key={child.id}
className="text-xs bg-elevated rounded-lg px-3 py-2 border border-border-subtle cursor-pointer hover:border-gold/40 hover:bg-gold/5 transition-colors"
onClick={() => navigateToNode(child.id)}
>
<div className="flex items-center gap-2">
<span className={`text-[9px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${childTypeBadge}`}>
{child.type}
</span>
<span className="text-text-primary truncate">{child.name}</span>
<span className={`text-[9px] ml-auto ${childComplexity} px-1 py-0.5 rounded`}>
{child.complexity}
</span>
</div>
{child.summary && (
<p className="text-[11px] text-text-muted mt-1 line-clamp-1 pl-1">
{child.summary}
</p>
)}
</div>
);
})}
</div>
</div>
)}
{/* Other connections (excluding "contains" children) */}
{otherConnections.length > 0 && (
<div>
<h3 className="text-[11px] font-semibold text-gold uppercase tracking-wider mb-2">
Connections ({connections.length})
Connections ({otherConnections.length})
</h3>
<div className="space-y-1.5">
{connections.map((edge, i) => {
{otherConnections.map((edge, i) => {
const isSource = edge.source === node.id;
const otherId = isSource ? edge.target : edge.source;
const otherNode = graph?.nodes.find((n) => n.id === otherId);
const dirLabel = getDirectionalLabel(edge.type, isSource);
const arrow = isSource ? "\u2192" : "\u2190";
return (
<div
key={i}
className="text-xs bg-elevated rounded-lg px-3 py-2 border border-border-subtle flex items-center gap-2"
className="text-xs bg-elevated rounded-lg px-3 py-2 border border-border-subtle flex items-center gap-2 cursor-pointer hover:border-gold/40 hover:bg-gold/5 transition-colors"
onClick={() => {
navigateToNode(otherId);
}}
>
<span className="text-gold font-mono">{arrow}</span>
<span className="text-text-muted">{edge.type}</span>
<span className="text-text-muted">{dirLabel}</span>
<span className="text-text-primary truncate">
{otherNode?.name ?? otherId}
</span>
@@ -20,15 +20,16 @@ export default function NodeTooltip({
const [visible, setVisible] = useState(false);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
setPosition({ x: e.clientX, y: e.clientY });
const handleMouseMove = (e: Event) => {
const me = e as globalThis.MouseEvent;
setPosition({ x: me.clientX, y: me.clientY });
};
const showTooltip = () => setVisible(true);
const hideTooltip = () => setVisible(false);
// Find the node element
const nodeElement = document.querySelector(`[data-id="${nodeId}"]`);
// Find the node element via data-id (React Flow convention)
const nodeElement = document.querySelector(`[data-id="${CSS.escape(nodeId)}"]`);
if (nodeElement) {
nodeElement.addEventListener("mouseenter", showTooltip);
nodeElement.addEventListener("mouseleave", hideTooltip);
@@ -32,7 +32,7 @@ export default function PersonaSelector() {
title={p.description}
className={`px-2.5 py-1 rounded text-[11px] font-medium transition-colors ${
persona === p.id
? "bg-gold/20 text-gold"
? "bg-accent/20 text-accent"
: "text-text-muted hover:text-text-secondary hover:bg-surface"
}`}
>
@@ -0,0 +1,64 @@
import { memo } from "react";
import { Handle, Position } from "@xyflow/react";
import type { NodeProps, Node } from "@xyflow/react";
import { getLayerColor } from "./LayerLegend";
export interface PortalNodeData extends Record<string, unknown> {
targetLayerId: string;
targetLayerName: string;
connectionCount: number;
layerColorIndex: number;
onNavigate: (layerId: string) => void;
}
export type PortalFlowNode = Node<PortalNodeData, "portal">;
function PortalNode({
data,
}: NodeProps<PortalFlowNode>) {
const color = getLayerColor(data.layerColorIndex);
return (
<div
className="relative rounded-lg bg-elevated/60 overflow-hidden cursor-pointer transition-all duration-200 hover:bg-elevated/80"
style={{
width: 220,
border: `2px dashed ${color.border}`,
boxShadow: "0 2px 8px rgba(0,0,0,0.2)",
}}
onClick={() => data.onNavigate(data.targetLayerId)}
>
<Handle
type="target"
position={Position.Top}
className="!bg-text-muted !w-2 !h-2"
/>
<div className="px-3 py-2.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0">
<span
className="inline-block w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: color.label }}
/>
<span className="text-sm text-text-primary truncate">
{data.targetLayerName}
</span>
</div>
<span className="text-text-muted ml-2 shrink-0"></span>
</div>
<div className="text-[10px] text-text-muted mt-1 pl-4">
{data.connectionCount} connection{data.connectionCount !== 1 ? "s" : ""}
</div>
</div>
<Handle
type="source"
position={Position.Bottom}
className="!bg-text-muted !w-2 !h-2"
/>
</div>
);
}
export default memo(PortalNode);
@@ -45,6 +45,16 @@ export default function ProjectOverview() {
const avgConnections = nodes.length > 0 ? (edges.length * 2 / nodes.length).toFixed(1) : "0";
// Category breakdowns
const categoryBreakdown = [
{ label: "Code", color: "var(--color-node-file)", count: (typeCounts["file"] ?? 0) + (typeCounts["function"] ?? 0) + (typeCounts["class"] ?? 0) },
{ label: "Config", color: "var(--color-node-config)", count: typeCounts["config"] ?? 0 },
{ label: "Docs", color: "var(--color-node-document)", count: typeCounts["document"] ?? 0 },
{ label: "Infra", color: "var(--color-node-service)", count: (typeCounts["service"] ?? 0) + (typeCounts["resource"] ?? 0) + (typeCounts["pipeline"] ?? 0) },
{ label: "Data", color: "var(--color-node-table)", count: (typeCounts["table"] ?? 0) + (typeCounts["endpoint"] ?? 0) + (typeCounts["schema"] ?? 0) },
];
const hasNonCodeNodes = categoryBreakdown.some((c) => c.label !== "Code" && c.count > 0);
return (
<div className="h-full w-full overflow-auto p-5 animate-fade-slide-in">
{/* Project name */}
@@ -54,27 +64,46 @@ export default function ProjectOverview() {
{/* Stats grid */}
<div className="grid grid-cols-2 gap-3 mb-6">
<div className="bg-elevated rounded-lg p-3 border border-border-subtle">
<div className="text-2xl font-mono font-medium text-gold">{nodes.length}</div>
<div className="text-2xl font-mono font-medium text-accent">{nodes.length}</div>
<div className="text-[11px] text-text-muted uppercase tracking-wider mt-1">Nodes</div>
</div>
<div className="bg-elevated rounded-lg p-3 border border-border-subtle">
<div className="text-2xl font-mono font-medium text-gold">{edges.length}</div>
<div className="text-2xl font-mono font-medium text-accent">{edges.length}</div>
<div className="text-[11px] text-text-muted uppercase tracking-wider mt-1">Edges</div>
</div>
<div className="bg-elevated rounded-lg p-3 border border-border-subtle">
<div className="text-2xl font-mono font-medium text-gold">{layers.length}</div>
<div className="text-2xl font-mono font-medium text-accent">{layers.length}</div>
<div className="text-[11px] text-text-muted uppercase tracking-wider mt-1">Layers</div>
</div>
<div className="bg-elevated rounded-lg p-3 border border-border-subtle">
<div className="text-2xl font-mono font-medium text-gold">{Object.keys(typeCounts).length}</div>
<div className="text-2xl font-mono font-medium text-accent">{Object.keys(typeCounts).length}</div>
<div className="text-[11px] text-text-muted uppercase tracking-wider mt-1">Types</div>
</div>
</div>
{/* File Types breakdown */}
{hasNonCodeNodes && (
<div className="mb-5">
<h3 className="text-[11px] font-semibold text-accent uppercase tracking-wider mb-2">File Types</h3>
<div className="space-y-1.5">
{categoryBreakdown.filter((c) => c.count > 0).map((cat) => (
<div key={cat.label} className="flex items-center gap-2">
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: cat.color }}
/>
<span className="text-xs text-text-secondary flex-1">{cat.label}</span>
<span className="text-xs font-mono text-text-muted">{cat.count}</span>
</div>
))}
</div>
</div>
)}
{/* Languages */}
{project.languages.length > 0 && (
<div className="mb-5">
<h3 className="text-[11px] font-semibold text-gold uppercase tracking-wider mb-2">Languages</h3>
<h3 className="text-[11px] font-semibold text-accent uppercase tracking-wider mb-2">Languages</h3>
<div className="flex flex-wrap gap-1.5">
{project.languages.map((lang) => (
<span key={lang} className="text-[11px] glass text-text-secondary px-2.5 py-1 rounded-full">
@@ -88,7 +117,7 @@ export default function ProjectOverview() {
{/* Frameworks */}
{project.frameworks.length > 0 && (
<div className="mb-5">
<h3 className="text-[11px] font-semibold text-gold uppercase tracking-wider mb-2">Frameworks</h3>
<h3 className="text-[11px] font-semibold text-accent uppercase tracking-wider mb-2">Frameworks</h3>
<div className="flex flex-wrap gap-1.5">
{project.frameworks.map((fw) => (
<span key={fw} className="text-[11px] glass text-text-secondary px-2.5 py-1 rounded-full">
@@ -101,7 +130,7 @@ export default function ProjectOverview() {
{/* Node Type Breakdown */}
<div className="mb-5">
<h3 className="text-[11px] font-semibold text-gold uppercase tracking-wider mb-3">Node Type Distribution</h3>
<h3 className="text-[11px] font-semibold text-accent uppercase tracking-wider mb-3">Node Type Distribution</h3>
<div className="space-y-2">
{Object.entries(typeCounts)
.sort((a, b) => b[1] - a[1])
@@ -115,7 +144,7 @@ export default function ProjectOverview() {
</div>
<div className="w-full h-1.5 bg-elevated rounded-full overflow-hidden">
<div
className="h-full bg-gold/50 rounded-full transition-all duration-500"
className="h-full bg-accent/50 rounded-full transition-all duration-500"
style={{ width: `${percentage}%` }}
/>
</div>
@@ -128,7 +157,7 @@ export default function ProjectOverview() {
{/* Complexity Breakdown */}
{Object.values(complexityCounts).some((c) => c > 0) && (
<div className="mb-5">
<h3 className="text-[11px] font-semibold text-gold uppercase tracking-wider mb-3">Complexity Distribution</h3>
<h3 className="text-[11px] font-semibold text-accent uppercase tracking-wider mb-3">Complexity Distribution</h3>
<div className="grid grid-cols-3 gap-2">
<div className="bg-elevated rounded-lg p-2 border border-border-subtle text-center">
<div className="text-lg font-mono font-medium text-green-400">{complexityCounts.simple}</div>
@@ -149,14 +178,14 @@ export default function ProjectOverview() {
{/* Top Connected Nodes */}
{topNodes.length > 0 && (
<div className="mb-5">
<h3 className="text-[11px] font-semibold text-gold uppercase tracking-wider mb-3">Most Connected Nodes</h3>
<h3 className="text-[11px] font-semibold text-accent uppercase tracking-wider mb-3">Most Connected Nodes</h3>
<div className="space-y-2">
{topNodes.map((node, idx) => (
<div
key={node.id}
className="flex items-center gap-2 text-xs bg-elevated rounded-lg p-2 border border-border-subtle"
>
<div className="w-5 h-5 shrink-0 rounded-full bg-gold/20 flex items-center justify-center text-[10px] font-bold text-gold">
<div className="w-5 h-5 shrink-0 rounded-full bg-accent/20 flex items-center justify-center text-[10px] font-bold text-accent">
{idx + 1}
</div>
<span className="flex-1 text-text-primary truncate">{node.name}</span>
@@ -171,7 +200,7 @@ export default function ProjectOverview() {
<div className="mb-5 bg-elevated rounded-lg p-3 border border-border-subtle">
<div className="flex items-center justify-between">
<span className="text-xs text-text-secondary">Avg Connections per Node</span>
<span className="text-lg font-mono font-medium text-gold">{avgConnections}</span>
<span className="text-lg font-mono font-medium text-accent">{avgConnections}</span>
</div>
</div>
@@ -184,7 +213,7 @@ export default function ProjectOverview() {
{hasTour && (
<button
onClick={startTour}
className="w-full bg-gold/10 border border-gold/30 text-gold text-sm font-medium py-2.5 px-4 rounded-lg hover:bg-gold/20 transition-all duration-200"
className="w-full bg-accent/10 border border-accent/30 text-accent text-sm font-medium py-2.5 px-4 rounded-lg hover:bg-accent/20 transition-all duration-200"
>
Start Guided Tour
</button>
@@ -14,7 +14,7 @@ export default function SearchBar() {
const searchResults = useDashboardStore((s) => s.searchResults);
const graph = useDashboardStore((s) => s.graph);
const setSearchQuery = useDashboardStore((s) => s.setSearchQuery);
const selectNode = useDashboardStore((s) => s.selectNode);
const navigateToNodeInLayer = useDashboardStore((s) => s.navigateToNodeInLayer);
const searchMode = useDashboardStore((s) => s.searchMode);
const setSearchMode = useDashboardStore((s) => s.setSearchMode);
@@ -40,10 +40,10 @@ export default function SearchBar() {
const handleResultClick = useCallback(
(nodeId: string) => {
selectNode(nodeId);
navigateToNodeInLayer(nodeId);
setDropdownOpen(false);
},
[selectNode],
[navigateToNodeInLayer],
);
// Close dropdown on Escape
@@ -72,7 +72,7 @@ export default function SearchBar() {
const showDropdown = dropdownOpen && searchQuery.trim() && topResults.length > 0;
return (
<div ref={containerRef} className="relative z-10">
<div ref={containerRef} className="relative z-30">
<div className="flex items-center gap-2 px-4 py-2 bg-surface border-b border-border-subtle">
<svg
className="w-4 h-4 text-text-muted shrink-0"
@@ -94,14 +94,14 @@ export default function SearchBar() {
onChange={handleInputChange}
onFocus={() => setDropdownOpen(true)}
placeholder="Search nodes by name, summary, or tags..."
className="flex-1 bg-elevated text-text-primary text-sm rounded-lg px-3 py-1.5 border border-border-subtle focus:outline-none focus:border-gold/50 placeholder-text-muted"
className="flex-1 bg-elevated text-text-primary text-sm rounded-lg px-3 py-1.5 border border-border-subtle focus:outline-none focus:border-accent/50 placeholder-text-muted"
/>
<div className="flex items-center gap-1 bg-elevated rounded-lg p-0.5 shrink-0">
<button
onClick={() => setSearchMode("fuzzy")}
className={`text-[10px] px-1.5 py-0.5 rounded transition-colors ${
searchMode === "fuzzy"
? "bg-gold/20 text-gold"
? "bg-accent/20 text-accent"
: "text-text-muted hover:text-text-secondary"
}`}
>
@@ -111,7 +111,7 @@ export default function SearchBar() {
onClick={() => setSearchMode("semantic")}
className={`text-[10px] px-1.5 py-0.5 rounded transition-colors ${
searchMode === "semantic"
? "bg-gold/20 text-gold"
? "bg-accent/20 text-accent"
: "text-text-muted hover:text-text-secondary"
}`}
>
@@ -159,7 +159,7 @@ export default function SearchBar() {
<div className="flex items-center gap-1.5 shrink-0">
<div className="w-16 h-1.5 bg-elevated rounded-full overflow-hidden">
<div
className="h-full bg-gold rounded-full"
className="h-full bg-accent rounded-full"
style={{ width: `${relevance}%` }}
/>
</div>
@@ -0,0 +1,143 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTheme, PRESETS } from "../themes/index.ts";
export function ThemePicker() {
const { config, preset, setPreset, setAccent } = useTheme();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
// Close on outside click
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
// Close on Escape
useEffect(() => {
if (!open) return;
function handleKey(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}, [open]);
const handlePreset = useCallback(
(id: string) => {
setPreset(id as Parameters<typeof setPreset>[0]);
},
[setPreset],
);
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen((v) => !v)}
className="flex items-center gap-1.5 px-2 py-1 rounded text-xs text-text-secondary hover:text-text-primary transition-colors"
title="Change theme"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 2a7 7 0 0 0 0 14 4 4 0 0 1 0 8 10 10 0 0 0 0-20z" />
<circle cx="8" cy="10" r="1.5" fill="currentColor" />
<circle cx="12" cy="7" r="1.5" fill="currentColor" />
<circle cx="16" cy="10" r="1.5" fill="currentColor" />
</svg>
<span className="hidden sm:inline">Theme</span>
</button>
{open && (
<div className="absolute right-0 top-full mt-2 w-64 rounded-lg glass-heavy shadow-xl z-50 p-3 space-y-3">
{/* Presets */}
<div>
<div className="text-[10px] font-semibold text-text-muted uppercase tracking-wider mb-2">
Theme
</div>
<div className="space-y-1">
{PRESETS.map((p) => (
<button
key={p.id}
onClick={() => handlePreset(p.id)}
className={`w-full flex items-center gap-2.5 px-2.5 py-1.5 rounded text-xs transition-colors ${
p.id === config.presetId
? "bg-accent/15 text-accent"
: "text-text-secondary hover:text-text-primary hover:bg-elevated"
}`}
>
{/* Color preview dots */}
<div className="flex gap-1">
<span
className="w-3 h-3 rounded-full border border-border-subtle"
style={{ backgroundColor: p.colors.root }}
/>
<span
className="w-3 h-3 rounded-full border border-border-subtle"
style={{ backgroundColor: p.colors.surface }}
/>
<span
className="w-3 h-3 rounded-full border border-border-subtle"
style={{
backgroundColor:
p.accentSwatches.find((s) => s.id === p.defaultAccentId)?.accent ??
p.accentSwatches[0].accent,
}}
/>
</div>
<span>{p.name}</span>
{p.id === config.presetId && (
<svg
className="ml-auto w-3.5 h-3.5 text-accent"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
>
<polyline points="20 6 9 17 4 12" />
</svg>
)}
</button>
))}
</div>
</div>
{/* Accent swatches */}
<div>
<div className="text-[10px] font-semibold text-text-muted uppercase tracking-wider mb-2">
Accent Color
</div>
<div className="flex gap-2 flex-wrap">
{preset.accentSwatches.map((swatch) => (
<button
key={swatch.id}
onClick={() => setAccent(swatch.id)}
className={`w-6 h-6 rounded-full transition-transform hover:scale-110 ${
swatch.id === config.accentId
? "ring-2 ring-text-primary ring-offset-1 ring-offset-root"
: ""
}`}
style={{ backgroundColor: swatch.accent }}
title={swatch.name}
/>
))}
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,79 @@
import { useState } from "react";
interface TokenGateProps {
onTokenValid: (token: string) => void;
}
export default function TokenGate({ onTokenValid }: TokenGateProps) {
const [input, setInput] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const token = input.trim();
if (!token) return;
setLoading(true);
setError(null);
try {
const res = await fetch(`/knowledge-graph.json?token=${encodeURIComponent(token)}`);
if (res.ok) {
onTokenValid(token);
} else if (res.status === 403) {
setError("Invalid token. Please check and try again.");
} else {
setError(`Unexpected response (${res.status}). Is the dashboard server running?`);
}
} catch (err) {
setError(
`Could not reach the server: ${err instanceof Error ? err.message : String(err)}`
);
} finally {
setLoading(false);
}
};
return (
<div className="h-screen w-screen flex items-center justify-center bg-root noise-overlay">
<div className="w-full max-w-md px-8 py-10 bg-surface border border-border-subtle rounded-lg shadow-2xl">
{/* Heading */}
<h1 className="font-serif text-2xl text-text-primary tracking-wide text-center mb-2">
Access Token Required
</h1>
<p className="text-text-muted text-sm text-center mb-8">
Paste the access token from your terminal. Look for the{" "}
<span role="img" aria-label="key">&#x1F511;</span> line.
</p>
{/* Form */}
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<input
type="text"
value={input}
onChange={(e) => {
setInput(e.target.value);
if (error) setError(null);
}}
placeholder="Paste token here..."
autoFocus
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded text-text-primary placeholder:text-text-muted/50 font-mono text-sm focus:outline-none focus:border-accent transition-colors"
/>
{error && (
<p className="text-red-400 text-sm">{error}</p>
)}
<button
type="submit"
disabled={loading || !input.trim()}
className="w-full py-3 bg-accent text-root font-semibold rounded transition-all hover:brightness-110 disabled:opacity-40 disabled:cursor-not-allowed"
>
{loading ? "Validating..." : "Continue"}
</button>
</form>
</div>
</div>
);
}
@@ -0,0 +1,193 @@
import { useState, useCallback } from "react";
import type { GraphIssue } from "@understand-anything/core/schema";
interface WarningBannerProps {
issues: GraphIssue[];
}
function buildCopyText(issues: GraphIssue[]): string {
const lines = [
"The following issues were found in your knowledge-graph.json.",
"These are LLM generation errors — not a system bug.",
"You can ask your agent to fix these specific issues in the knowledge-graph.json file:",
"",
];
// Auto-corrected first, then dropped
const sorted = [...issues].sort((a, b) => {
const order: Record<string, number> = { "auto-corrected": 0, dropped: 1, fatal: 2 };
return (order[a.level] ?? 2) - (order[b.level] ?? 2);
});
for (const issue of sorted) {
const label =
issue.level === "auto-corrected"
? "Auto-corrected"
: issue.level === "dropped"
? "Dropped"
: "Fatal";
lines.push(`[${label}] ${issue.message}`);
}
return lines.join("\n");
}
export default function WarningBanner({ issues }: WarningBannerProps) {
const [expanded, setExpanded] = useState(false);
const [copied, setCopied] = useState(false);
const autoCorrected = issues.filter((i) => i.level === "auto-corrected");
const dropped = issues.filter((i) => i.level === "dropped");
// Build summary text — only mention counts > 0
const parts: string[] = [];
if (autoCorrected.length > 0) {
parts.push(`${autoCorrected.length} auto-correction${autoCorrected.length !== 1 ? "s" : ""}`);
}
if (dropped.length > 0) {
parts.push(`${dropped.length} dropped item${dropped.length !== 1 ? "s" : ""}`);
}
const summary = `Knowledge graph loaded with ${parts.join(" and ")}`;
const handleCopy = useCallback(async () => {
const text = buildCopyText(issues);
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
console.warn("Clipboard write failed — copy text manually from the expanded issue list");
}
}, [issues]);
if (issues.length === 0) return null;
return (
<div className="bg-amber-900/20 border-b border-amber-700 text-amber-200 text-sm">
{/* Collapsed summary row */}
<button
type="button"
aria-expanded={expanded}
onClick={() => setExpanded((prev) => !prev)}
className="w-full flex items-center gap-2 px-5 py-3 text-left hover:bg-amber-900/10 transition-colors"
>
{/* Chevron icon */}
<svg
className={`w-4 h-4 shrink-0 text-amber-400 transition-transform duration-200 ${
expanded ? "rotate-90" : ""
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
{/* Warning icon */}
<svg
className="w-4 h-4 shrink-0 text-amber-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
<span className="flex-1">{summary}</span>
<span className="text-amber-400/60 text-xs shrink-0">
{expanded ? "click to collapse" : "click to expand"}
</span>
</button>
{/* Expanded detail panel */}
{expanded && (
<div className="px-5 pb-4">
{/* Issue list */}
<div className="space-y-1 mb-3">
{/* Auto-corrected issues */}
{autoCorrected.length > 0 && (
<div>
<h4 className="text-xs font-semibold uppercase tracking-wider text-amber-400 mb-1">
Auto-corrected ({autoCorrected.length})
</h4>
{autoCorrected.map((issue, i) => (
<div key={`ac-${i}`} className="flex items-start gap-2 py-0.5 pl-2 text-amber-200/80">
<span className="text-amber-400 shrink-0 mt-0.5">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</span>
<span className="text-xs">{issue.message}</span>
</div>
))}
</div>
)}
{/* Dropped issues */}
{dropped.length > 0 && (
<div className={autoCorrected.length > 0 ? "mt-2" : ""}>
<h4 className="text-xs font-semibold uppercase tracking-wider text-orange-400 mb-1">
Dropped ({dropped.length})
</h4>
{dropped.map((issue, i) => (
<div key={`dr-${i}`} className="flex items-start gap-2 py-0.5 pl-2 text-orange-300/80">
<span className="text-orange-400 shrink-0 mt-0.5">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</span>
<span className="text-xs">{issue.message}</span>
</div>
))}
</div>
)}
</div>
{/* Footer with copy button and actionable message */}
<div className="flex items-center justify-between pt-2 border-t border-amber-700/50">
<p className="text-xs text-amber-200/60">
Copy these issues and ask your agent to fix them in knowledge-graph.json
</p>
<button
type="button"
onClick={handleCopy}
className="flex items-center gap-1.5 px-3 py-1 rounded text-xs font-medium bg-amber-800/40 text-amber-200 hover:bg-amber-800/60 transition-colors shrink-0 ml-4"
>
{copied ? (
<>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Copied!
</>
) : (
<>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
Copy Issues
</>
)}
</button>
</div>
</div>
)}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More