mirror of
https://github.com/Egonex-AI/Understand-Anything.git
synced 2026-06-22 10:58:03 +08:00
merge: resolve SKILL.md conflict — keep both --auto-update and --review flags
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,16 +11,20 @@
|
||||
git clone https://github.com/Lum1104/Understand-Anything.git ~/.antigravity/understand-anything
|
||||
```
|
||||
|
||||
2. **Create the skills symlink:**
|
||||
2. **Create the skills symlinks:**
|
||||
```bash
|
||||
mkdir -p ~/.gemini/antigravity/skills
|
||||
ln -s ~/.antigravity/understand-anything/understand-anything-plugin/skills ~/.gemini/antigravity/skills/understand-anything
|
||||
# Universal plugin root symlink — lets the dashboard skill find packages/dashboard/
|
||||
# Skip if already exists (e.g. another platform was installed first)
|
||||
[ -e ~/.understand-anything-plugin ] || [ -L ~/.understand-anything-plugin ] || ln -s ~/.antigravity/understand-anything/understand-anything-plugin ~/.understand-anything-plugin
|
||||
```
|
||||
|
||||
**Windows (PowerShell):**
|
||||
```powershell
|
||||
New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\.gemini\antigravity\skills"
|
||||
cmd /c mklink /J "$env:USERPROFILE\.gemini\antigravity\skills\understand-anything" "$env:USERPROFILE\.antigravity\understand-anything\understand-anything-plugin\skills"
|
||||
cmd /c mklink /J "$env:USERPROFILE\.understand-anything-plugin" "$env:USERPROFILE\.antigravity\understand-anything\understand-anything-plugin"
|
||||
```
|
||||
|
||||
3. **Restart the chat or IDE** so Antigravity can discover the skills.
|
||||
@@ -52,5 +56,6 @@ Skills update instantly through the symlink.
|
||||
|
||||
```bash
|
||||
rm ~/.gemini/antigravity/skills/understand-anything
|
||||
rm ~/.understand-anything-plugin
|
||||
rm -rf ~/.antigravity/understand-anything
|
||||
```
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
{
|
||||
"name": "understand-anything",
|
||||
"description": "Multi-agent codebase analysis with interactive dashboard, guided tours, and skill commands",
|
||||
"version": "1.1.1",
|
||||
"version": "1.2.2",
|
||||
"source": "./understand-anything-plugin"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "understand-anything",
|
||||
"description": "AI-powered codebase understanding — analyze, visualize, and explain any project",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.2",
|
||||
"author": {
|
||||
"name": "Lum1104"
|
||||
},
|
||||
@@ -15,4 +15,4 @@
|
||||
"onboarding",
|
||||
"dashboard"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+23
-7
@@ -11,16 +11,29 @@
|
||||
git clone https://github.com/Lum1104/Understand-Anything.git ~/.codex/understand-anything
|
||||
```
|
||||
|
||||
2. **Create the skills symlink:**
|
||||
2. **Create the skills symlinks:**
|
||||
```bash
|
||||
mkdir -p ~/.agents/skills
|
||||
ln -s ~/.codex/understand-anything/understand-anything-plugin/skills ~/.agents/skills/understand-anything
|
||||
# Note: if OpenCode's Understand-Anything is already installed, these symlinks
|
||||
# already exist and the ln commands will safely fail — that is fine, the
|
||||
# existing symlinks work for Codex too.
|
||||
for skill in understand understand-chat understand-dashboard understand-diff understand-explain understand-onboard; do
|
||||
ln -sf ~/.codex/understand-anything/understand-anything-plugin/skills/$skill ~/.agents/skills/$skill
|
||||
done
|
||||
# Universal plugin root symlink — lets the dashboard skill find packages/dashboard/
|
||||
# Skip if already exists (e.g. another platform was installed first)
|
||||
[ -e ~/.understand-anything-plugin ] || [ -L ~/.understand-anything-plugin ] || ln -s ~/.codex/understand-anything/understand-anything-plugin ~/.understand-anything-plugin
|
||||
```
|
||||
|
||||
**Windows (PowerShell):**
|
||||
```powershell
|
||||
New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\.agents\skills"
|
||||
cmd /c mklink /J "$env:USERPROFILE\.agents\skills\understand-anything" "$env:USERPROFILE\.codex\understand-anything\understand-anything-plugin\skills"
|
||||
$skills = @("understand","understand-chat","understand-dashboard","understand-diff","understand-explain","understand-onboard")
|
||||
foreach ($skill in $skills) {
|
||||
cmd /c mklink /J "$env:USERPROFILE\.agents\skills\$skill" "$env:USERPROFILE\.codex\understand-anything\understand-anything-plugin\skills\$skill"
|
||||
}
|
||||
# Universal plugin root symlink
|
||||
cmd /c mklink /J "$env:USERPROFILE\.understand-anything-plugin" "$env:USERPROFILE\.codex\understand-anything\understand-anything-plugin"
|
||||
```
|
||||
|
||||
3. **Restart Codex** to discover the skills.
|
||||
@@ -28,10 +41,10 @@
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
ls -la ~/.agents/skills/understand-anything
|
||||
ls -la ~/.agents/skills/ | grep understand
|
||||
```
|
||||
|
||||
You should see a symlink pointing to the skills directory.
|
||||
You should see symlinks for each skill pointing into the cloned repository.
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -45,11 +58,14 @@ Skills activate automatically when relevant. You can also invoke directly:
|
||||
cd ~/.codex/understand-anything && git pull
|
||||
```
|
||||
|
||||
Skills update instantly through the symlink.
|
||||
Skills update instantly through the symlinks.
|
||||
|
||||
## Uninstalling
|
||||
|
||||
```bash
|
||||
rm ~/.agents/skills/understand-anything
|
||||
for skill in understand understand-chat understand-dashboard understand-diff understand-explain understand-onboard; do
|
||||
rm -f ~/.agents/skills/$skill
|
||||
done
|
||||
rm ~/.understand-anything-plugin
|
||||
rm -rf ~/.codex/understand-anything
|
||||
```
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "understand-anything",
|
||||
"displayName": "Understand Anything",
|
||||
"description": "AI-powered codebase understanding — analyze, visualize, and explain any project",
|
||||
"version": "1.0.5",
|
||||
"version": "1.2.2",
|
||||
"author": {
|
||||
"name": "Lum1104"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# Installing Understand-Anything for Gemini CLI
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Git
|
||||
- [Gemini CLI](https://github.com/google-gemini/gemini-cli) installed
|
||||
|
||||
## Installation
|
||||
|
||||
1. **Clone the repository:**
|
||||
```bash
|
||||
git clone https://github.com/Lum1104/Understand-Anything.git ~/.gemini/understand-anything
|
||||
```
|
||||
|
||||
2. **Create the skills symlinks:**
|
||||
```bash
|
||||
mkdir -p ~/.agents/skills
|
||||
# Note: if another platform's Understand-Anything is already installed, these symlinks
|
||||
# already exist and the ln commands will safely fail — that is fine, the
|
||||
# existing symlinks work for Gemini CLI too.
|
||||
for skill in understand understand-chat understand-dashboard understand-diff understand-explain understand-onboard; do
|
||||
ln -sf ~/.gemini/understand-anything/understand-anything-plugin/skills/$skill ~/.agents/skills/$skill
|
||||
done
|
||||
# Universal plugin root symlink — lets the dashboard skill find packages/dashboard/
|
||||
# Skip if already exists (e.g. another platform was installed first)
|
||||
[ -e ~/.understand-anything-plugin ] || [ -L ~/.understand-anything-plugin ] || ln -s ~/.gemini/understand-anything/understand-anything-plugin ~/.understand-anything-plugin
|
||||
```
|
||||
|
||||
**Windows (PowerShell):**
|
||||
```powershell
|
||||
New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\.agents\skills"
|
||||
$skills = @("understand","understand-chat","understand-dashboard","understand-diff","understand-explain","understand-onboard")
|
||||
foreach ($skill in $skills) {
|
||||
cmd /c mklink /J "$env:USERPROFILE\.agents\skills\$skill" "$env:USERPROFILE\.gemini\understand-anything\understand-anything-plugin\skills\$skill"
|
||||
}
|
||||
# Universal plugin root symlink
|
||||
cmd /c mklink /J "$env:USERPROFILE\.understand-anything-plugin" "$env:USERPROFILE\.gemini\understand-anything\understand-anything-plugin"
|
||||
```
|
||||
|
||||
3. **Restart Gemini CLI** to discover the skills.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
ls -la ~/.agents/skills/ | grep understand
|
||||
```
|
||||
|
||||
You should see symlinks for each skill pointing into the cloned repository.
|
||||
|
||||
## Usage
|
||||
|
||||
Skills activate automatically when relevant. You can also invoke directly:
|
||||
- "Analyze this codebase and build a knowledge graph"
|
||||
- "Help me understand this project's architecture"
|
||||
|
||||
## Updating
|
||||
|
||||
```bash
|
||||
cd ~/.gemini/understand-anything && git pull
|
||||
```
|
||||
|
||||
Skills update instantly through the symlinks.
|
||||
|
||||
## Uninstalling
|
||||
|
||||
```bash
|
||||
for skill in understand understand-chat understand-dashboard understand-diff understand-explain understand-onboard; do
|
||||
rm -f ~/.agents/skills/$skill
|
||||
done
|
||||
rm ~/.understand-anything-plugin
|
||||
rm -rf ~/.gemini/understand-anything
|
||||
```
|
||||
@@ -7,3 +7,5 @@ dist
|
||||
.env.*
|
||||
coverage/
|
||||
*.log
|
||||
.claude/
|
||||
.worktrees/
|
||||
|
||||
@@ -11,16 +11,20 @@
|
||||
git clone https://github.com/Lum1104/Understand-Anything.git ~/.openclaw/understand-anything
|
||||
```
|
||||
|
||||
2. **Create the skills symlink:**
|
||||
2. **Create the skills symlinks:**
|
||||
```bash
|
||||
mkdir -p ~/.openclaw/skills
|
||||
ln -s ~/.openclaw/understand-anything/understand-anything-plugin/skills ~/.openclaw/skills/understand-anything
|
||||
# Universal plugin root symlink — lets the dashboard skill find packages/dashboard/
|
||||
# Skip if already exists (e.g. another platform was installed first)
|
||||
[ -e ~/.understand-anything-plugin ] || [ -L ~/.understand-anything-plugin ] || ln -s ~/.openclaw/understand-anything/understand-anything-plugin ~/.understand-anything-plugin
|
||||
```
|
||||
|
||||
**Windows (PowerShell):**
|
||||
```powershell
|
||||
New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\.openclaw\skills"
|
||||
cmd /c mklink /J "$env:USERPROFILE\.openclaw\skills\understand-anything" "$env:USERPROFILE\.openclaw\understand-anything\understand-anything-plugin\skills"
|
||||
cmd /c mklink /J "$env:USERPROFILE\.understand-anything-plugin" "$env:USERPROFILE\.openclaw\understand-anything\understand-anything-plugin"
|
||||
```
|
||||
|
||||
3. **Restart OpenClaw** to discover the skills.
|
||||
@@ -41,5 +45,6 @@ cd ~/.openclaw/understand-anything && git pull
|
||||
|
||||
```bash
|
||||
rm ~/.openclaw/skills/understand-anything
|
||||
rm ~/.understand-anything-plugin
|
||||
rm -rf ~/.openclaw/understand-anything
|
||||
```
|
||||
|
||||
+53
-22
@@ -2,55 +2,86 @@
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [OpenCode.ai](https://opencode.ai) installed
|
||||
- Git
|
||||
- [OpenCode](https://opencode.ai) installed
|
||||
|
||||
## Installation
|
||||
|
||||
Add understand-anything to the `plugin` array in your `opencode.json` (global or project-level):
|
||||
1. **Clone the repository:**
|
||||
```bash
|
||||
git clone https://github.com/Lum1104/Understand-Anything.git ~/.opencode/understand-anything
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"plugin": ["understand-anything@git+https://github.com/Lum1104/Understand-Anything.git"]
|
||||
}
|
||||
```
|
||||
2. **Create the skills symlinks:**
|
||||
```bash
|
||||
mkdir -p ~/.agents/skills
|
||||
# Note: if Codex's Understand-Anything is already installed, these symlinks
|
||||
# already exist and the ln commands will safely fail — that is fine, the
|
||||
# existing symlinks work for OpenCode too.
|
||||
for skill in understand understand-chat understand-dashboard understand-diff understand-explain understand-onboard; do
|
||||
ln -sf ~/.opencode/understand-anything/understand-anything-plugin/skills/$skill ~/.agents/skills/$skill
|
||||
done
|
||||
# Universal plugin root symlink — lets the dashboard skill find packages/dashboard/
|
||||
# Skip if already exists (e.g. another platform was installed first)
|
||||
[ -e ~/.understand-anything-plugin ] || [ -L ~/.understand-anything-plugin ] || ln -s ~/.opencode/understand-anything/understand-anything-plugin ~/.understand-anything-plugin
|
||||
```
|
||||
|
||||
Restart OpenCode. The plugin auto-installs and registers all skills.
|
||||
**Windows (PowerShell):**
|
||||
```powershell
|
||||
New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\.agents\skills"
|
||||
$skills = @("understand","understand-chat","understand-dashboard","understand-diff","understand-explain","understand-onboard")
|
||||
foreach ($skill in $skills) {
|
||||
cmd /c mklink /J "$env:USERPROFILE\.agents\skills\$skill" "$env:USERPROFILE\.opencode\understand-anything\understand-anything-plugin\skills\$skill"
|
||||
}
|
||||
# Universal plugin root symlink
|
||||
cmd /c mklink /J "$env:USERPROFILE\.understand-anything-plugin" "$env:USERPROFILE\.opencode\understand-anything\understand-anything-plugin"
|
||||
```
|
||||
|
||||
3. **Restart OpenCode** to discover the skills.
|
||||
|
||||
## Verify
|
||||
|
||||
Ask: "List available skills" — you should see understand, understand-chat, understand-dashboard, etc.
|
||||
```bash
|
||||
ls -la ~/.agents/skills/ | grep understand
|
||||
```
|
||||
|
||||
You should see symlinks for each skill pointing into the cloned repository.
|
||||
|
||||
## Usage
|
||||
|
||||
Skills activate automatically when relevant. You can also invoke directly:
|
||||
|
||||
```
|
||||
use skill tool to load understand-anything/understand
|
||||
use skill tool to load understand
|
||||
```
|
||||
|
||||
Or just ask: "Analyze this codebase and build a knowledge graph"
|
||||
|
||||
## Updating
|
||||
|
||||
Restart OpenCode — the plugin re-installs from git automatically.
|
||||
|
||||
To pin a specific version:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugin": ["understand-anything@git+https://github.com/Lum1104/Understand-Anything.git#v1.1.1"]
|
||||
}
|
||||
```bash
|
||||
cd ~/.opencode/understand-anything && git pull
|
||||
```
|
||||
|
||||
Skills update instantly through the symlinks.
|
||||
|
||||
## Uninstalling
|
||||
|
||||
Remove the plugin line from `opencode.json` and restart.
|
||||
```bash
|
||||
for skill in understand understand-chat understand-dashboard understand-diff understand-explain understand-onboard; do
|
||||
rm -f ~/.agents/skills/$skill
|
||||
done
|
||||
rm ~/.understand-anything-plugin
|
||||
rm -rf ~/.opencode/understand-anything
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Skills not found
|
||||
|
||||
1. Verify the plugin line in your `opencode.json`
|
||||
2. Check that `~/.cache/opencode/node_modules/understand-anything` exists after restart
|
||||
3. Use the `skill` tool to list discovered skills
|
||||
1. Check that the symlinks exist: `ls -la ~/.agents/skills/ | grep understand`
|
||||
2. Verify the clone succeeded: `ls ~/.opencode/understand-anything/understand-anything-plugin/skills/`
|
||||
3. Restart OpenCode
|
||||
|
||||
### Tool mapping
|
||||
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
/**
|
||||
* Understand Anything plugin for OpenCode.ai
|
||||
*
|
||||
* Auto-registers the skills directory so OpenCode discovers all
|
||||
* understand-anything skills without manual symlinks or config edits.
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export const UnderstandAnythingPlugin = async ({ client, directory }) => {
|
||||
const skillsDir = path.resolve(__dirname, '../../understand-anything-plugin/skills');
|
||||
|
||||
return {
|
||||
config: async (config) => {
|
||||
config.skills = config.skills || {};
|
||||
config.skills.paths = config.skills.paths || [];
|
||||
if (!config.skills.paths.includes(skillsDir)) {
|
||||
config.skills.paths.push(skillsDir);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
# Installing Understand-Anything for Pi Agent
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Git
|
||||
- [Pi Agent](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent) installed
|
||||
|
||||
## Installation
|
||||
|
||||
1. **Clone the repository:**
|
||||
```bash
|
||||
git clone https://github.com/Lum1104/Understand-Anything.git ~/.pi/understand-anything
|
||||
```
|
||||
|
||||
2. **Create the skills symlinks:**
|
||||
```bash
|
||||
mkdir -p ~/.agents/skills
|
||||
# Note: if another platform's Understand-Anything is already installed, these symlinks
|
||||
# already exist and the ln commands will safely fail — that is fine, the
|
||||
# existing symlinks work for Pi Agent too.
|
||||
for skill in understand understand-chat understand-dashboard understand-diff understand-explain understand-onboard; do
|
||||
ln -sf ~/.pi/understand-anything/understand-anything-plugin/skills/$skill ~/.agents/skills/$skill
|
||||
done
|
||||
# Universal plugin root symlink — lets the dashboard skill find packages/dashboard/
|
||||
# Skip if already exists (e.g. another platform was installed first)
|
||||
[ -e ~/.understand-anything-plugin ] || [ -L ~/.understand-anything-plugin ] || ln -s ~/.pi/understand-anything/understand-anything-plugin ~/.understand-anything-plugin
|
||||
```
|
||||
|
||||
**Windows (PowerShell):**
|
||||
```powershell
|
||||
New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\.agents\skills"
|
||||
$skills = @("understand","understand-chat","understand-dashboard","understand-diff","understand-explain","understand-onboard")
|
||||
foreach ($skill in $skills) {
|
||||
cmd /c mklink /J "$env:USERPROFILE\.agents\skills\$skill" "$env:USERPROFILE\.pi\understand-anything\understand-anything-plugin\skills\$skill"
|
||||
}
|
||||
# Universal plugin root symlink
|
||||
cmd /c mklink /J "$env:USERPROFILE\.understand-anything-plugin" "$env:USERPROFILE\.pi\understand-anything\understand-anything-plugin"
|
||||
```
|
||||
|
||||
3. **Restart Pi Agent** to discover the skills.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
ls -la ~/.agents/skills/ | grep understand
|
||||
```
|
||||
|
||||
You should see symlinks for each skill pointing into the cloned repository.
|
||||
|
||||
## Usage
|
||||
|
||||
Skills activate automatically when relevant. You can also invoke directly:
|
||||
- "Analyze this codebase and build a knowledge graph"
|
||||
- "Help me understand this project's architecture"
|
||||
|
||||
## Updating
|
||||
|
||||
```bash
|
||||
cd ~/.pi/understand-anything && git pull
|
||||
```
|
||||
|
||||
Skills update instantly through the symlinks.
|
||||
|
||||
## Uninstalling
|
||||
|
||||
```bash
|
||||
for skill in understand understand-chat understand-dashboard understand-diff understand-explain understand-onboard; do
|
||||
rm -f ~/.agents/skills/$skill
|
||||
done
|
||||
rm ~/.understand-anything-plugin
|
||||
rm -rf ~/.pi/understand-anything
|
||||
```
|
||||
@@ -51,7 +51,45 @@ An open-source tool combining LLM intelligence + static analysis to produce inte
|
||||
- **tree-sitter**: Uses `web-tree-sitter` (WASM) instead of native `tree-sitter` — native bindings fail on darwin/arm64 + Node 24
|
||||
- **Dashboard imports**: Dashboard must only import from core's browser-safe subpath exports (`./search`, `./types`, `./schema`), never the main entry point which pulls in Node.js modules
|
||||
|
||||
## Scripts
|
||||
- `scripts/generate-large-graph.mjs` — Generates a fake knowledge graph for performance testing (e.g. large-graph layout). Writes to `.understand-anything/knowledge-graph.json`. Usage: `node scripts/generate-large-graph.mjs [nodeCount]` (default: 3000 nodes). Not part of the production pipeline.
|
||||
|
||||
## Versioning
|
||||
When pushing to remote, bump the version in **both** of these files (keep them in sync):
|
||||
When pushing to remote, bump the version in **all four** of these files (keep them in sync):
|
||||
- `understand-anything-plugin/package.json` → `"version"` field
|
||||
- `.claude-plugin/marketplace.json` → `plugins[0].version` field
|
||||
- `.claude-plugin/plugin.json` → `"version"` field
|
||||
- `.cursor-plugin/plugin.json` → `"version"` field
|
||||
|
||||
## Testing Local Plugin Changes
|
||||
|
||||
Claude Code caches installed plugins at `~/.claude/plugins/cache/understand-anything/understand-anything/<version>/`. Symlinks don't work because Claude's Search/Glob tools can't follow them. To test local changes:
|
||||
|
||||
1. **Build the packages:**
|
||||
```bash
|
||||
pnpm --filter @understand-anything/core build
|
||||
pnpm --filter @understand-anything/skill build
|
||||
```
|
||||
|
||||
2. **Find the installed version** (must match what the marketplace currently serves):
|
||||
```bash
|
||||
ls ~/.claude/plugins/cache/understand-anything/understand-anything/
|
||||
```
|
||||
|
||||
3. **Copy your local plugin into the cache**, replacing `<VERSION>` with the version from step 2:
|
||||
```bash
|
||||
rm -rf ~/.claude/plugins/cache/understand-anything/understand-anything/<VERSION>
|
||||
cp -R ./understand-anything-plugin ~/.claude/plugins/cache/understand-anything/understand-anything/<VERSION>
|
||||
```
|
||||
|
||||
4. **Start a fresh Claude Code session** (existing sessions cache the old prompts in context).
|
||||
|
||||
5. **Run `/understand --full`** in the target project to verify.
|
||||
|
||||
**Re-sync after further changes:**
|
||||
```bash
|
||||
pnpm --filter @understand-anything/core build && \
|
||||
cp -R ./understand-anything-plugin/* ~/.claude/plugins/cache/understand-anything/understand-anything/<VERSION>/
|
||||
```
|
||||
|
||||
**To revert to upstream:** Uninstall and reinstall the plugin from the marketplace — it repopulates the cache from the upstream repo.
|
||||
|
||||
+270
@@ -0,0 +1,270 @@
|
||||
# Contributing to Understand Anything
|
||||
|
||||
Thank you for your interest in contributing to Understand Anything! This document provides guidelines and instructions for contributing to the project.
|
||||
|
||||
## 🌟 Ways to Contribute
|
||||
|
||||
- **Bug Reports**: Found a bug? Open an issue with detailed reproduction steps
|
||||
- **Feature Requests**: Have an idea? Share it in the issues section
|
||||
- **Documentation**: Improve or translate documentation
|
||||
- **Code**: Fix bugs, add features, or improve performance
|
||||
- **Testing**: Write tests to improve code coverage
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js >= 22 (developed on v24)
|
||||
- pnpm >= 10 (pinned via `packageManager` field in root `package.json`)
|
||||
- Git for version control
|
||||
|
||||
### Setup
|
||||
|
||||
1. **Fork and Clone**
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/Understand-Anything.git
|
||||
cd Understand-Anything
|
||||
```
|
||||
|
||||
2. **Install Dependencies**
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
3. **Build Core Package**
|
||||
```bash
|
||||
pnpm --filter @understand-anything/core build
|
||||
```
|
||||
|
||||
4. **Run Tests**
|
||||
```bash
|
||||
pnpm --filter @understand-anything/core test
|
||||
pnpm --filter @understand-anything/skill test
|
||||
```
|
||||
|
||||
5. **Start Dashboard (Optional)**
|
||||
```bash
|
||||
pnpm dev:dashboard
|
||||
```
|
||||
|
||||
## 📝 Development Workflow
|
||||
|
||||
### 1. Create a Branch
|
||||
|
||||
Create a descriptive branch name:
|
||||
```bash
|
||||
git checkout -b feat/my-feature # For new features
|
||||
git checkout -b fix/bug-description # For bug fixes
|
||||
git checkout -b docs/update-readme # For documentation
|
||||
```
|
||||
|
||||
### 2. Make Changes
|
||||
|
||||
- Write clean, readable code
|
||||
- Follow existing code style and conventions
|
||||
- Add tests for new functionality
|
||||
- Update documentation as needed
|
||||
|
||||
### 3. Test Your Changes
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
pnpm --filter @understand-anything/core test
|
||||
pnpm --filter @understand-anything/skill test
|
||||
|
||||
# Run linter
|
||||
pnpm lint
|
||||
|
||||
# Build packages
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### 4. Commit Your Changes
|
||||
|
||||
Write clear, descriptive commit messages:
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "feat: add keyboard shortcuts to dashboard"
|
||||
```
|
||||
|
||||
**Commit Message Convention:**
|
||||
- `feat:` - New feature
|
||||
- `fix:` - Bug fix
|
||||
- `docs:` - Documentation changes
|
||||
- `style:` - Code style changes (formatting, etc.)
|
||||
- `refactor:` - Code refactoring
|
||||
- `test:` - Adding or updating tests
|
||||
- `chore:` - Maintenance tasks
|
||||
|
||||
### 5. Push and Create Pull Request
|
||||
|
||||
```bash
|
||||
git push origin your-branch-name
|
||||
```
|
||||
|
||||
Then open a Pull Request on GitHub with:
|
||||
- Clear title describing the change
|
||||
- Detailed description of what changed and why
|
||||
- Link to related issues (if any)
|
||||
- Screenshots (for UI changes)
|
||||
|
||||
## 🧪 Testing Guidelines
|
||||
|
||||
### Writing Tests
|
||||
|
||||
- Use Vitest for testing
|
||||
- Place tests in `__tests__` directories or `*.test.ts` files
|
||||
- Aim for high test coverage for new features
|
||||
- Test edge cases and error conditions
|
||||
|
||||
Example test structure:
|
||||
```typescript
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('MyFeature', () => {
|
||||
it('should do something', () => {
|
||||
// Arrange
|
||||
const input = 'test';
|
||||
|
||||
// Act
|
||||
const result = myFunction(input);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe('expected');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
pnpm test
|
||||
|
||||
# Run tests for specific package
|
||||
pnpm --filter @understand-anything/core test
|
||||
|
||||
# Run tests in watch mode
|
||||
pnpm --filter @understand-anything/core test --watch
|
||||
```
|
||||
|
||||
## 📚 Code Style Guidelines
|
||||
|
||||
### TypeScript
|
||||
|
||||
- Use TypeScript strict mode
|
||||
- Define explicit types for function parameters and return values
|
||||
- Avoid `any` type - use `unknown` if type is truly unknown
|
||||
- Use interfaces for object shapes
|
||||
- Use type aliases for unions and complex types
|
||||
|
||||
### Formatting
|
||||
|
||||
- The project uses ESLint for code quality
|
||||
- Consistent indentation (2 spaces)
|
||||
- Use meaningful variable and function names
|
||||
- Keep functions small and focused
|
||||
|
||||
### React/Dashboard
|
||||
|
||||
- Use functional components with hooks
|
||||
- Keep components focused and single-purpose
|
||||
- Use Zustand for state management
|
||||
- Follow the existing component structure
|
||||
|
||||
### File Organization
|
||||
|
||||
```
|
||||
understand-anything-plugin/
|
||||
├── packages/
|
||||
│ ├── core/ # Core analysis engine
|
||||
│ │ ├── src/
|
||||
│ │ └── package.json
|
||||
│ └── dashboard/ # React dashboard
|
||||
│ ├── src/
|
||||
│ │ ├── components/
|
||||
│ │ ├── utils/
|
||||
│ │ └── store.ts
|
||||
│ └── package.json
|
||||
├── src/ # Plugin skills implementation
|
||||
├── agents/ # AI agent prompts
|
||||
└── skills/ # Skill definitions
|
||||
```
|
||||
|
||||
## 🌍 Translation Guidelines
|
||||
|
||||
### Adding a New Language
|
||||
|
||||
1. Create `README.{language-code}.md` (e.g., `README.fr-FR.md`)
|
||||
2. Translate all sections while maintaining formatting
|
||||
3. Update main `README.md` to include language link
|
||||
4. Keep technical terms in English where appropriate
|
||||
5. Ensure all links still work
|
||||
|
||||
Example:
|
||||
```markdown
|
||||
<a href="README.md">English</a> | <a href="README.fr-FR.md">Français</a>
|
||||
```
|
||||
|
||||
## 🐛 Bug Reports
|
||||
|
||||
When reporting bugs, include:
|
||||
|
||||
- **Description**: Clear description of the issue
|
||||
- **Steps to Reproduce**: Detailed steps to reproduce the bug
|
||||
- **Expected Behavior**: What you expected to happen
|
||||
- **Actual Behavior**: What actually happened
|
||||
- **Environment**: OS, Node version, pnpm version
|
||||
- **Screenshots**: If applicable
|
||||
- **Error Messages**: Full error output
|
||||
|
||||
## 💡 Feature Requests
|
||||
|
||||
When requesting features:
|
||||
|
||||
- **Use Case**: Describe the problem you're trying to solve
|
||||
- **Proposed Solution**: How you envision the feature working
|
||||
- **Alternatives**: Other solutions you've considered
|
||||
- **Additional Context**: Any other relevant information
|
||||
|
||||
## 📋 Pull Request Checklist
|
||||
|
||||
Before submitting a PR, ensure:
|
||||
|
||||
- [ ] Code follows the project's style guidelines
|
||||
- [ ] All tests pass (`pnpm test`)
|
||||
- [ ] New code has test coverage
|
||||
- [ ] Documentation is updated (if needed)
|
||||
- [ ] Commit messages follow convention
|
||||
- [ ] PR description clearly explains changes
|
||||
- [ ] No console.log or debug code left behind
|
||||
- [ ] Branch is up to date with main
|
||||
|
||||
## 🤝 Code Review Process
|
||||
|
||||
1. **Automated Checks**: CI runs tests and linting
|
||||
2. **Maintainer Review**: Project maintainers review the code
|
||||
3. **Feedback**: Address any requested changes
|
||||
4. **Approval**: Once approved, PR will be merged
|
||||
5. **Cleanup**: Delete your branch after merge
|
||||
|
||||
## 📞 Getting Help
|
||||
|
||||
- **Issues**: For bugs and feature requests
|
||||
- **Discussions**: For questions and general discussion
|
||||
- **Documentation**: Check existing docs first
|
||||
|
||||
## 📄 License
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under the MIT License.
|
||||
|
||||
## 🙏 Recognition
|
||||
|
||||
Contributors will be recognized in:
|
||||
- GitHub contributors list
|
||||
- Release notes (for significant contributions)
|
||||
- Special mentions for exceptional contributions
|
||||
|
||||
---
|
||||
|
||||
**Thank you for contributing to Understand Anything! Your contributions help make code understanding accessible to everyone.** 🚀
|
||||
+21
-7
@@ -5,7 +5,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> | <a href="README.zh-CN.md">中文</a> | <a href="README.ja-JP.md">日本語</a>
|
||||
<a href="README.md">English</a> | <a href="README.zh-CN.md">中文</a> | <a href="README.ja-JP.md">日本語</a> | <a href="README.tr-TR.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -122,11 +122,9 @@ Fetch and follow instructions from https://raw.githubusercontent.com/Lum1104/Und
|
||||
|
||||
### OpenCode
|
||||
|
||||
`opencode.json` に以下を追加:
|
||||
```json
|
||||
{
|
||||
"plugin": ["understand-anything@git+https://github.com/Lum1104/Understand-Anything.git"]
|
||||
}
|
||||
OpenCodeに以下を伝えてください:
|
||||
```
|
||||
Fetch and follow instructions from https://raw.githubusercontent.com/Lum1104/Understand-Anything/refs/heads/main/.opencode/INSTALL.md
|
||||
```
|
||||
|
||||
### OpenClaw
|
||||
@@ -147,16 +145,32 @@ Antigravityに以下を伝えてください:
|
||||
Fetch and follow instructions from https://raw.githubusercontent.com/Lum1104/Understand-Anything/refs/heads/main/.antigravity/INSTALL.md
|
||||
```
|
||||
|
||||
### Gemini CLI
|
||||
|
||||
Gemini CLIに以下を伝えてください:
|
||||
```text
|
||||
Fetch and follow instructions from https://raw.githubusercontent.com/Lum1104/Understand-Anything/refs/heads/main/.gemini/INSTALL.md
|
||||
```
|
||||
|
||||
### Pi Agent
|
||||
|
||||
Pi Agentに以下を伝えてください:
|
||||
```text
|
||||
Fetch and follow instructions from https://raw.githubusercontent.com/Lum1104/Understand-Anything/refs/heads/main/.pi/INSTALL.md
|
||||
```
|
||||
|
||||
### プラットフォーム互換性
|
||||
|
||||
| プラットフォーム | ステータス | インストール方法 |
|
||||
|----------|--------|----------------|
|
||||
| Claude Code | ✅ ネイティブ | プラグインマーケットプレイス |
|
||||
| Codex | ✅ サポート | AI駆動インストール |
|
||||
| OpenCode | ✅ サポート | プラグイン設定 |
|
||||
| OpenCode | ✅ サポート | AI駆動インストール |
|
||||
| OpenClaw | ✅ サポート | AI駆動インストール |
|
||||
| Cursor | ✅ サポート | 自動検出 |
|
||||
| Antigravity | ✅ サポート | AI駆動インストール |
|
||||
| Gemini CLI | ✅ サポート | AI駆動インストール |
|
||||
| Pi Agent | ✅ サポート | AI駆動インストール |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> | <a href="README.zh-CN.md">中文</a> | <a href="README.ja-JP.md">日本語</a>
|
||||
<a href="README.md">English</a> | <a href="README.zh-CN.md">中文</a> | <a href="README.ja-JP.md">日本語</a> | <a href="README.tr-TR.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -122,11 +122,9 @@ Fetch and follow instructions from https://raw.githubusercontent.com/Lum1104/Und
|
||||
|
||||
### OpenCode
|
||||
|
||||
Add to your `opencode.json`:
|
||||
```json
|
||||
{
|
||||
"plugin": ["understand-anything@git+https://github.com/Lum1104/Understand-Anything.git"]
|
||||
}
|
||||
Tell OpenCode:
|
||||
```
|
||||
Fetch and follow instructions from https://raw.githubusercontent.com/Lum1104/Understand-Anything/refs/heads/main/.opencode/INSTALL.md
|
||||
```
|
||||
|
||||
### OpenClaw
|
||||
@@ -147,16 +145,32 @@ Tell Antigravity:
|
||||
Fetch and follow instructions from https://raw.githubusercontent.com/Lum1104/Understand-Anything/refs/heads/main/.antigravity/INSTALL.md
|
||||
```
|
||||
|
||||
### Gemini CLI
|
||||
|
||||
Tell Gemini CLI:
|
||||
```text
|
||||
Fetch and follow instructions from https://raw.githubusercontent.com/Lum1104/Understand-Anything/refs/heads/main/.gemini/INSTALL.md
|
||||
```
|
||||
|
||||
### Pi Agent
|
||||
|
||||
Tell Pi Agent:
|
||||
```text
|
||||
Fetch and follow instructions from https://raw.githubusercontent.com/Lum1104/Understand-Anything/refs/heads/main/.pi/INSTALL.md
|
||||
```
|
||||
|
||||
### Platform Compatibility
|
||||
|
||||
| Platform | Status | Install Method |
|
||||
|----------|--------|----------------|
|
||||
| Claude Code | ✅ Native | Plugin marketplace |
|
||||
| Codex | ✅ Supported | AI-driven install |
|
||||
| OpenCode | ✅ Supported | Plugin config |
|
||||
| OpenCode | ✅ Supported | AI-driven install |
|
||||
| OpenClaw | ✅ Supported | AI-driven install |
|
||||
| Cursor | ✅ Supported | Auto-discovery |
|
||||
| Antigravity | ✅ Supported | AI-driven install |
|
||||
| Gemini CLI | ✅ Supported | AI-driven install |
|
||||
| Pi Agent | ✅ Supported | AI-driven install |
|
||||
|
||||
---
|
||||
|
||||
@@ -223,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
|
||||
|
||||
|
||||
+304
@@ -0,0 +1,304 @@
|
||||
<h1 align="center">Understand Anything</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>Herhangi bir kod tabanını keşfedebileceğin, arayabileceğin ve hakkında sorular sorabileceğin interaktif bir bilgi grafiğine dönüştür.</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> | <a href="README.zh-CN.md">中文</a> | <a href="README.ja-JP.md">日本語</a> | <a href="README.tr-TR.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#-hızlı-başlangıç"><img src="https://img.shields.io/badge/Hızlı_Başlangıç-blue?style=for-the-badge" alt="Hızlı Başlangıç" /></a>
|
||||
<a href="https://github.com/Lum1104/Understand-Anything/blob/main/LICENSE"><img src="https://img.shields.io/badge/Lisans-MIT-yellow?style=for-the-badge" alt="Lisans: MIT" /></a>
|
||||
<a href="https://docs.anthropic.com/en/docs/claude-code"><img src="https://img.shields.io/badge/Claude_Code-Plugin-8A2BE2?style=for-the-badge" alt="Claude Code Eklentisi" /></a>
|
||||
<a href="https://lum1104.github.io/Understand-Anything"><img src="https://img.shields.io/badge/Ana_Sayfa-d4a574?style=for-the-badge" alt="Ana Sayfa" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/hero.jpg" alt="Understand Anything — Herhangi bir kod tabanını interaktif bir bilgi grafiğine dönüştür" width="800" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
> [!TIP]
|
||||
> **Topluluğa çok teşekkürler!** Understand-Anything'e gösterilen destek inanılmaz oldu. Bu araç sana karmaşıklığı anlamak için birkaç dakika kazandırıyorsa, istediğim tek şey buydu. 🚀
|
||||
|
||||
**Yeni bir ekibe katıldın. Kod tabanı 200.000 satır kod. Nereden başlayacaksın bile bilemiyorsun?**
|
||||
|
||||
Understand Anything, projenizi çok-ajan hattıyla analiz eden, her dosya, fonksiyon, sınıf ve bağımlılığın bilgi grafiğini oluşturan ve hepsini görsel olarak keşfetmen için interaktif bir kontrol paneli sunan bir [Claude Code](https://docs.anthropic.com/en/docs/claude-code) eklentisidir. Kodu körü körüne okumayı bırak. Büyük resmi görmeye başla.
|
||||
|
||||
---
|
||||
|
||||
## 🤔 Neden?
|
||||
|
||||
Kod okumak zor. Bütün bir kod tabanını anlamak daha da zor. Dokümantasyon her zaman güncel değil, işe alıştırma haftalar alıyor ve her yeni özellik arkeoloji gibi hissettiriyor.
|
||||
|
||||
Understand Anything bunu **LLM zekası** ile **statik analizi** birleştirerek çözüyor ve projenin canlı, keşfedilebilir bir haritasını üretiyor — her şey için sade Türkçe açıklamalarla.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Kimler için?
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="33%" valign="top">
|
||||
<h3>👩💻 Junior Geliştiriciler</h3>
|
||||
<p>Tanımadığın kodda boğulmayı bırak. Her fonksiyon ve sınıfın sade Türkçe açıklandığı, mimariyi adım adım anlatan rehberli turlar al.</p>
|
||||
</td>
|
||||
<td width="33%" valign="top">
|
||||
<h3>📋 Ürün Yöneticileri ve Tasarımcılar</h3>
|
||||
<p>Kod okumadan sistemin gerçekte nasıl çalıştığını nihayet anla. "Kimlik doğrulama nasıl çalışır?" gibi sorular sor ve gerçek kod tabanına dayalı net cevaplar al.</p>
|
||||
</td>
|
||||
<td width="33%" valign="top">
|
||||
<h3>🤖 AI Destekli Geliştiriciler</h3>
|
||||
<p>AI araçlarına projen hakkında derin bağlam ver. Kod incelemeden önce <code>/understand-diff</code>, herhangi bir modüle dalmak için <code>/understand-explain</code> veya mimari hakkında akıl yürütmek için <code>/understand-chat</code> kullan.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Hızlı Başlangıç
|
||||
|
||||
### 1. Eklentiyi yükle
|
||||
|
||||
```bash
|
||||
/plugin marketplace add Lum1104/Understand-Anything
|
||||
/plugin install understand-anything
|
||||
```
|
||||
|
||||
### 2. Kod tabanını analiz et
|
||||
|
||||
```bash
|
||||
/understand
|
||||
```
|
||||
|
||||
Çok-ajan hattı projenizi tarar, her dosya, fonksiyon, sınıf ve bağımlılığı çıkarır, ardından `.understand-anything/knowledge-graph.json` dosyasına kaydedilen bir bilgi grafiği oluşturur.
|
||||
|
||||
### 3. Kontrol panelini keşfet
|
||||
|
||||
```bash
|
||||
/understand-dashboard
|
||||
```
|
||||
|
||||
Kod tabanın bir grafik olarak görselleştirilmiş, mimari katmana göre renklendirilmiş, aranabilir ve tıklanabilir interaktif bir web kontrol paneli açılır. Kodunu, ilişkilerini ve sade Türkçe açıklamasını görmek için herhangi bir düğüm seç.
|
||||
|
||||
### 4. Öğrenmeye devam et
|
||||
|
||||
```bash
|
||||
# Kod tabanı hakkında her şeyi sor
|
||||
/understand-chat Ödeme akışı nasıl çalışır?
|
||||
|
||||
# Mevcut değişikliklerinin etkisini analiz et
|
||||
/understand-diff
|
||||
|
||||
# Belirli bir dosya veya fonksiyona derinlemesine dal
|
||||
/understand-explain src/auth/login.ts
|
||||
|
||||
# Yeni ekip üyeleri için bir işe alıştırma rehberi oluştur
|
||||
/understand-onboard
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Çoklu Platform Kurulumu
|
||||
|
||||
Understand-Anything birden fazla AI kodlama platformunda çalışır.
|
||||
|
||||
### Claude Code (Yerli)
|
||||
|
||||
```bash
|
||||
/plugin marketplace add Lum1104/Understand-Anything
|
||||
/plugin install understand-anything
|
||||
```
|
||||
|
||||
### Codex
|
||||
|
||||
Codex'e söyle:
|
||||
```
|
||||
Fetch and follow instructions from https://raw.githubusercontent.com/Lum1104/Understand-Anything/refs/heads/main/.codex/INSTALL.md
|
||||
```
|
||||
|
||||
### OpenCode
|
||||
|
||||
OpenCode'a söyle:
|
||||
```
|
||||
Fetch and follow instructions from https://raw.githubusercontent.com/Lum1104/Understand-Anything/refs/heads/main/.opencode/INSTALL.md
|
||||
```
|
||||
|
||||
### OpenClaw
|
||||
|
||||
OpenClaw'a söyle:
|
||||
```
|
||||
Fetch and follow instructions from https://raw.githubusercontent.com/Lum1104/Understand-Anything/refs/heads/main/.openclaw/INSTALL.md
|
||||
```
|
||||
|
||||
### Cursor
|
||||
|
||||
Bu depo klonlandığında Cursor, eklentiyi `.cursor-plugin/plugin.json` aracılığıyla otomatik olarak keşfeder. Manuel kurulum gerekmez — sadece klonla ve Cursor'da aç.
|
||||
|
||||
### Antigravity
|
||||
|
||||
Antigravity'e söyle:
|
||||
```text
|
||||
Fetch and follow instructions from https://raw.githubusercontent.com/Lum1104/Understand-Anything/refs/heads/main/.antigravity/INSTALL.md
|
||||
```
|
||||
|
||||
### Gemini CLI
|
||||
|
||||
Gemini CLI'a söyle:
|
||||
```text
|
||||
Fetch and follow instructions from https://raw.githubusercontent.com/Lum1104/Understand-Anything/refs/heads/main/.gemini/INSTALL.md
|
||||
```
|
||||
|
||||
### Pi Agent
|
||||
|
||||
Pi Agent'a söyle:
|
||||
```text
|
||||
Fetch and follow instructions from https://raw.githubusercontent.com/Lum1104/Understand-Anything/refs/heads/main/.pi/INSTALL.md
|
||||
```
|
||||
|
||||
### Platform Uyumluluğu
|
||||
|
||||
| Platform | Durum | Kurulum Yöntemi |
|
||||
|----------|--------|----------------|
|
||||
| Claude Code | ✅ Yerli | Eklenti pazarı |
|
||||
| Codex | ✅ Destekleniyor | AI güdümlü kurulum |
|
||||
| OpenCode | ✅ Destekleniyor | AI güdümlü kurulum |
|
||||
| OpenClaw | ✅ Destekleniyor | AI güdümlü kurulum |
|
||||
| Cursor | ✅ Destekleniyor | Otomatik keşif |
|
||||
| Antigravity | ✅ Destekleniyor | AI güdümlü kurulum |
|
||||
| Gemini CLI | ✅ Destekleniyor | AI güdümlü kurulum |
|
||||
| Pi Agent | ✅ Destekleniyor | AI güdümlü kurulum |
|
||||
|
||||
---
|
||||
|
||||
## ✨ Özellikler
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/overview.png" alt="Kontrol Paneli Ekran Görüntüsü" width="800" />
|
||||
</p>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<h3>🗺️ İnteraktif Bilgi Grafiği</h3>
|
||||
<p>Dosyalar, fonksiyonlar, sınıflar ve ilişkileri React Flow ile görselleştirildi. Kodunu ve bağlantılarını görmek için herhangi bir düğüme tıkla.</p>
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<h3>💬 Sade Türkçe Özetler</h3>
|
||||
<p>Her düğüm bir LLM tarafından açıklanır, böylece herkes — teknik olsun ya da olmasın — ne yaptığını ve neden var olduğunu anlayabilir.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<h3>🧭 Rehberli Turlar</h3>
|
||||
<p>Bağımlılığa göre sıralanmış, mimarinin otomatik oluşturulmuş gözden geçirmeleri. Kod tabanını doğru sırayla öğren.</p>
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<h3>🔍 Bulanık ve Anlamsal Arama</h3>
|
||||
<p>İsme veya anlamına göre her şeyi bul. "Kimlik doğrulamayı hangi parçalar yönetiyor?" ara ve grafik boyunca ilgili sonuçları al.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<h3>📊 Diff Etki Analizi</h3>
|
||||
<p>Değişikliklerinin sistemin hangi bölümlerini etkilediğini commit etmeden önce gör. Kod tabanı boyunca dalgalanma etkilerini anla.</p>
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<h3>🎭 Kişiye Uyarlanabilir UI</h3>
|
||||
<p>Kontrol paneli, kim olduğuna göre ayrıntı seviyesini ayarlar — junior geliştirici, ürün yöneticisi veya güçlü kullanıcı.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<h3>🏗️ Katman Görselleştirmesi</h3>
|
||||
<p>Mimari katmana göre otomatik gruplama — API, Servis, Veri, UI, Yardımcı — renk kodlu efsaneyle.</p>
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<h3>📚 Dil Kavramları</h3>
|
||||
<p>12 programlama deseni (generikler, kapanışlar, dekoratörler, vb.) göründükleri her yerde bağlam içinde açıklanır.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Kaputun Altında
|
||||
|
||||
### Çok-Ajan Hattı
|
||||
|
||||
`/understand` komutu 5 özel ajan düzenler:
|
||||
|
||||
| Ajan | Rol |
|
||||
|-------|------|
|
||||
| `project-scanner` | Dosyaları keşfet, dilleri ve çerçeveleri tespit et |
|
||||
| `file-analyzer` | Fonksiyonları, sınıfları, içe aktarmaları çıkar; grafik düğümleri ve kenarları üret |
|
||||
| `architecture-analyzer` | Mimari katmanları tanımla |
|
||||
| `tour-builder` | Rehberli öğrenme turları oluştur |
|
||||
| `graph-reviewer` | Grafik bütünlüğünü ve referans bütünlüğünü doğrula |
|
||||
|
||||
Dosya analizörleri paralel çalışır (en fazla 3 eşzamanlı). Artımlı güncellemeleri destekler — yalnızca son çalıştırmadan bu yana değişen dosyaları yeniden analiz eder.
|
||||
|
||||
### Proje Yapısı
|
||||
|
||||
```
|
||||
understand-anything-plugin/
|
||||
.claude-plugin/ — Eklenti manifestosu
|
||||
agents/ — Özel AI ajanları
|
||||
skills/ — Yetenek tanımları (/understand, /understand-chat, vb.)
|
||||
src/ — TypeScript kaynağı (context-builder, diff-analyzer, vb.)
|
||||
packages/
|
||||
core/ — Analiz motoru (tipler, kalıcılık, tree-sitter, arama, şema, turlar)
|
||||
dashboard/ — React + TypeScript web kontrol paneli
|
||||
```
|
||||
|
||||
### Teknoloji Yığını
|
||||
|
||||
TypeScript, pnpm workspaces, React 18, Vite, TailwindCSS v4, React Flow, Zustand, web-tree-sitter, Fuse.js, Zod, Dagre
|
||||
|
||||
### Geliştirme Komutları
|
||||
|
||||
| Komut | Açıklama |
|
||||
|---------|-------------|
|
||||
| `pnpm install` | Tüm bağımlılıkları yükle |
|
||||
| `pnpm --filter @understand-anything/core build` | Core paketini derle |
|
||||
| `pnpm --filter @understand-anything/core test` | Core testlerini çalıştır |
|
||||
| `pnpm --filter @understand-anything/skill build` | Eklenti paketini derle |
|
||||
| `pnpm --filter @understand-anything/skill test` | Eklenti testlerini çalıştır |
|
||||
| `pnpm --filter @understand-anything/dashboard build` | Kontrol panelini derle |
|
||||
| `pnpm dev:dashboard` | Kontrol paneli geliştirme sunucusunu başlat |
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Katkıda Bulunma
|
||||
|
||||
Katkılar memnuniyetle karşılanır! Başlamak için:
|
||||
|
||||
1. Depoyu fork'la
|
||||
2. Bir özellik dalı oluştur (`git checkout -b feature/benim-ozellligim`)
|
||||
3. Testleri çalıştır (`pnpm --filter @understand-anything/core test`)
|
||||
4. Değişikliklerini commit et ve bir pull request aç
|
||||
|
||||
Büyük değişiklikler için lütfen önce bir issue aç ki yaklaşımı tartışalım.
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<strong>Kodu körü körüne okumayı bırak. Her şeyi anlamaya başla.</strong>
|
||||
</p>
|
||||
|
||||
## Star Geçmişi
|
||||
|
||||
<a href="https://www.star-history.com/?repos=Lum1104%2FUnderstand-Anything&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/image?repos=Lum1104/Understand-Anything&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/image?repos=Lum1104/Understand-Anything&type=date&legend=top-left" />
|
||||
<img alt="Star Geçmişi Grafiği" src="https://api.star-history.com/image?repos=Lum1104/Understand-Anything&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
<p align="center">
|
||||
MIT Lisansı © <a href="https://github.com/Lum1104">Lum1104</a>
|
||||
</p>
|
||||
+21
-7
@@ -4,7 +4,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> | <a href="README.zh-CN.md">中文</a> | <a href="README.ja-JP.md">日本語</a>
|
||||
<a href="README.md">English</a> | <a href="README.zh-CN.md">中文</a> | <a href="README.ja-JP.md">日本語</a> | <a href="README.tr-TR.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -121,11 +121,9 @@ Fetch and follow instructions from https://raw.githubusercontent.com/Lum1104/Und
|
||||
|
||||
### OpenCode
|
||||
|
||||
添加到你的 `opencode.json` 文件:
|
||||
```json
|
||||
{
|
||||
"plugin": ["understand-anything@git+https://github.com/Lum1104/Understand-Anything.git"]
|
||||
}
|
||||
告诉 OpenCode:
|
||||
```
|
||||
Fetch and follow instructions from https://raw.githubusercontent.com/Lum1104/Understand-Anything/refs/heads/main/.opencode/INSTALL.md
|
||||
```
|
||||
|
||||
### OpenClaw
|
||||
@@ -146,16 +144,32 @@ Fetch and follow instructions from https://raw.githubusercontent.com/Lum1104/Und
|
||||
Fetch and follow instructions from https://raw.githubusercontent.com/Lum1104/Understand-Anything/refs/heads/main/.antigravity/INSTALL.md
|
||||
```
|
||||
|
||||
### Gemini CLI
|
||||
|
||||
告诉 Gemini CLI:
|
||||
```text
|
||||
Fetch and follow instructions from https://raw.githubusercontent.com/Lum1104/Understand-Anything/refs/heads/main/.gemini/INSTALL.md
|
||||
```
|
||||
|
||||
### Pi Agent
|
||||
|
||||
告诉 Pi Agent:
|
||||
```text
|
||||
Fetch and follow instructions from https://raw.githubusercontent.com/Lum1104/Understand-Anything/refs/heads/main/.pi/INSTALL.md
|
||||
```
|
||||
|
||||
### 多平台兼容
|
||||
|
||||
| 平台 | 状态 | 安装方式 |
|
||||
|----------|--------|----------------|
|
||||
| Claude Code | ✅ Native | 插件市场 |
|
||||
| Codex | ✅ 支持 | AI驱动安装 |
|
||||
| OpenCode | ✅ 支持 | 插件配置 |
|
||||
| OpenCode | ✅ 支持 | AI驱动安装 |
|
||||
| OpenClaw | ✅ 支持 | AI驱动安装 |
|
||||
| Cursor | ✅ 支持 | 自动发现 |
|
||||
| Antigravity | ✅ 支持 | AI驱动安装 |
|
||||
| Gemini CLI | ✅ 支持 | AI驱动安装 |
|
||||
| Pi Agent | ✅ 支持 | AI驱动安装 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
# Language-Agnostic Support Design
|
||||
|
||||
**Date:** 2026-03-21
|
||||
**Status:** Approved
|
||||
**Issue:** Make Understand-Anything codebase-aware and language-agnostic instead of TypeScript-heavy
|
||||
|
||||
## Problem
|
||||
|
||||
The tool's agent prompts, tree-sitter plugin, and language lesson system are heavily biased toward TypeScript/JavaScript. Non-TS codebases get degraded analysis because:
|
||||
|
||||
1. Agent prompts use TS-specific examples and concepts (e.g., "barrel files", "type guards", "generics")
|
||||
2. Tree-sitter plugin only ships TS/JS grammar support — structural analysis silently fails for other languages
|
||||
3. Language lesson detection hardcodes TS-specific concept patterns and display names
|
||||
|
||||
The architecture (PluginRegistry, GraphBuilder, dashboard, search) is already language-neutral. The bias is in shipped content, not the framework.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Scope:** All three layers — prompts, tree-sitter plugins, language framework
|
||||
- **Languages (v1):** TypeScript, JavaScript, Python, Go, Java, Rust, C/C++, C#, Ruby, PHP, Swift, Kotlin
|
||||
- **Architecture:** Config-first with code escape hatch (hybrid)
|
||||
- **Prompt strategy:** Base prompt + per-language markdown snippet files in a `languages/` folder
|
||||
- **Config location:** Prompt snippets in `skills/understand/languages/`, tree-sitter configs in `packages/core/src/languages/`
|
||||
- **Multi-language projects:** Per-file language analysis + project-level multi-language summary
|
||||
- **Language detection:** Auto-detect from file extensions only (no manual override for v1)
|
||||
|
||||
## Design
|
||||
|
||||
### 1. LanguageConfig Type & Registry
|
||||
|
||||
#### LanguageConfig Interface
|
||||
|
||||
```typescript
|
||||
// packages/core/src/languages/types.ts
|
||||
interface LanguageConfig {
|
||||
id: string; // e.g., "python"
|
||||
displayName: string; // e.g., "Python"
|
||||
extensions: string[]; // e.g., [".py", ".pyi"]
|
||||
treeSitter: {
|
||||
grammarPackage: string; // npm package name
|
||||
nodeTypes: {
|
||||
function: string[]; // e.g., ["function_definition"]
|
||||
class: string[]; // e.g., ["class_definition"]
|
||||
import: string[]; // e.g., ["import_statement", "import_from_statement"]
|
||||
export: string[]; // e.g., ["export_statement"] or [] for languages without exports
|
||||
typeAnnotation: string[]; // e.g., ["type"] for Python type hints
|
||||
};
|
||||
};
|
||||
concepts: string[]; // e.g., ["decorators", "list comprehensions", "generators"]
|
||||
filePatterns?: Record<string, string>; // special files, e.g., {"config": "pyproject.toml"}
|
||||
customAnalyzer?: (node: SyntaxNode) => AnalysisResult; // escape hatch for unusual AST shapes
|
||||
}
|
||||
```
|
||||
|
||||
#### Language Registry
|
||||
|
||||
```typescript
|
||||
// packages/core/src/languages/registry.ts
|
||||
class LanguageRegistry {
|
||||
private configs: Map<string, LanguageConfig>;
|
||||
|
||||
register(config: LanguageConfig): void;
|
||||
getByExtension(ext: string): LanguageConfig | null;
|
||||
getById(id: string): LanguageConfig;
|
||||
getAll(): LanguageConfig[];
|
||||
}
|
||||
```
|
||||
|
||||
#### File Structure
|
||||
|
||||
```
|
||||
packages/core/src/languages/
|
||||
├── types.ts
|
||||
├── registry.ts
|
||||
├── index.ts
|
||||
├── configs/
|
||||
│ ├── typescript.ts
|
||||
│ ├── javascript.ts
|
||||
│ ├── python.ts
|
||||
│ ├── go.ts
|
||||
│ ├── java.ts
|
||||
│ ├── rust.ts
|
||||
│ ├── cpp.ts
|
||||
│ ├── csharp.ts
|
||||
│ ├── ruby.ts
|
||||
│ ├── php.ts
|
||||
│ ├── swift.ts
|
||||
│ └── kotlin.ts
|
||||
```
|
||||
|
||||
All built-in configs auto-registered on import.
|
||||
|
||||
### 2. GenericTreeSitterPlugin
|
||||
|
||||
Replaces the current TS-only `TreeSitterPlugin` with a config-driven version.
|
||||
|
||||
```typescript
|
||||
// packages/core/src/plugins/generic-tree-sitter-plugin.ts
|
||||
class GenericTreeSitterPlugin implements AnalyzerPlugin {
|
||||
private registry: LanguageRegistry;
|
||||
|
||||
canAnalyze(filePath: string): boolean {
|
||||
return this.registry.getByExtension(path.extname(filePath)) !== null;
|
||||
}
|
||||
|
||||
async analyzeFile(filePath: string, content: string): Promise<FileAnalysis> {
|
||||
const config = this.registry.getByExtension(path.extname(filePath));
|
||||
|
||||
// Custom analyzer escape hatch
|
||||
if (config.customAnalyzer) {
|
||||
return config.customAnalyzer(tree.rootNode);
|
||||
}
|
||||
|
||||
// Generic extraction driven by config.treeSitter.nodeTypes
|
||||
const functions = this.extractNodes(tree, config.treeSitter.nodeTypes.function);
|
||||
const classes = this.extractNodes(tree, config.treeSitter.nodeTypes.class);
|
||||
const imports = this.extractNodes(tree, config.treeSitter.nodeTypes.import);
|
||||
const exports = this.extractNodes(tree, config.treeSitter.nodeTypes.export);
|
||||
// ...
|
||||
}
|
||||
|
||||
private extractNodes(tree: Tree, nodeTypes: string[]): NodeInfo[] {
|
||||
// Walk AST, collect all nodes matching any of the given types
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Migration
|
||||
|
||||
- Current `TreeSitterPlugin` deleted, replaced by `GenericTreeSitterPlugin` + TS/JS configs
|
||||
- `PluginRegistry` unchanged
|
||||
- Existing tests updated to use new plugin
|
||||
|
||||
#### WASM Grammar Loading
|
||||
|
||||
- Each grammar loaded lazily on first use and cached
|
||||
- WASM files bundled in `packages/core/src/languages/grammars/` or fetched from tree-sitter's official WASM builds
|
||||
|
||||
### 3. Language-Aware Prompts
|
||||
|
||||
#### File Structure
|
||||
|
||||
```
|
||||
skills/understand/
|
||||
├── file-analyzer-prompt.md # Base prompt (language-neutral)
|
||||
├── tour-builder-prompt.md
|
||||
├── project-scanner-prompt.md
|
||||
├── languages/
|
||||
│ ├── typescript.md
|
||||
│ ├── javascript.md
|
||||
│ ├── python.md
|
||||
│ ├── go.md
|
||||
│ ├── java.md
|
||||
│ ├── rust.md
|
||||
│ ├── cpp.md
|
||||
│ ├── csharp.md
|
||||
│ ├── ruby.md
|
||||
│ ├── php.md
|
||||
│ ├── swift.md
|
||||
│ └── kotlin.md
|
||||
```
|
||||
|
||||
#### Base Prompt Changes
|
||||
|
||||
All TS-specific examples removed from base prompts. Replaced with injection point:
|
||||
|
||||
```markdown
|
||||
## Language-Specific Guidance
|
||||
|
||||
{{LANGUAGE_CONTEXT}}
|
||||
```
|
||||
|
||||
#### Language Markdown Format
|
||||
|
||||
Each language file contains:
|
||||
|
||||
```markdown
|
||||
# Python
|
||||
|
||||
## Key Concepts
|
||||
- Decorators, comprehensions, generators, context managers, type hints, dunder methods
|
||||
|
||||
## Import Patterns
|
||||
- `import module`, `from module import name`, relative imports
|
||||
|
||||
## Notable File Patterns
|
||||
- `__init__.py` (package initializer), `conftest.py` (pytest), `pyproject.toml` (config)
|
||||
|
||||
## Example Summary Style
|
||||
> "FastAPI route handler that accepts a Pydantic model, validates input..."
|
||||
```
|
||||
|
||||
#### Injection Logic
|
||||
|
||||
1. Project scanner detects languages present in the codebase
|
||||
2. File-analyzer: inject matching language `.md` for that file's language
|
||||
3. Tour-builder: inject all detected languages' `.md` files
|
||||
4. Project-scanner: inject all detected languages' key concepts for project-level summary
|
||||
|
||||
#### Multi-Language Projects
|
||||
|
||||
Project-scanner prompt gets a combined section listing all detected languages with their key concepts.
|
||||
|
||||
### 4. Language Lesson Updates
|
||||
|
||||
- Delete `LANGUAGE_DISPLAY_NAMES` — use `LanguageRegistry.getById(id).displayName`
|
||||
- Delete hardcoded concept patterns — use `LanguageConfig.concepts` from registry
|
||||
- Language lesson generation becomes config-driven
|
||||
|
||||
### 5. Testing Strategy
|
||||
|
||||
#### Unit Tests
|
||||
|
||||
1. **LanguageConfig validation** — Each config has all required fields, non-empty nodeTypes
|
||||
2. **LanguageRegistry** — Registration, lookup by extension/id, duplicate handling
|
||||
3. **GenericTreeSitterPlugin per language** — Small fixture file per language verifying function/class/import extraction
|
||||
4. **Language lesson generation** — Concepts sourced from config
|
||||
|
||||
#### Integration Tests
|
||||
|
||||
5. **Multi-language project** — Mixed TS + Python fixture, verify graph contains nodes from both languages
|
||||
6. **Prompt injection** — Correct language `.md` injected based on detected language
|
||||
|
||||
#### Migration Tests
|
||||
|
||||
- Current tree-sitter-plugin tests rewritten for GenericTreeSitterPlugin with TS config
|
||||
- Must produce identical results to validate non-breaking migration
|
||||
|
||||
### 6. Error Handling & Graceful Degradation
|
||||
|
||||
#### Key Principle
|
||||
|
||||
**Every file always gets analyzed.** Tree-sitter is an enhancement, not a gate. The LLM is the primary analyzer; structural analysis enriches it.
|
||||
|
||||
#### Unknown Language
|
||||
|
||||
- Tree-sitter skipped (returns `null`)
|
||||
- LLM analysis still runs — file gets summary, tags, graph node
|
||||
- Debug log: `"No language config for .xyz, skipping structural analysis"`
|
||||
|
||||
#### Missing WASM Grammar
|
||||
|
||||
- Warning logged, that language degrades to LLM-only
|
||||
- Other languages unaffected
|
||||
|
||||
#### Malformed Language Config
|
||||
|
||||
- Validated at registration time via Zod schema
|
||||
- Invalid config throws at startup — fail fast
|
||||
File diff suppressed because it is too large
Load Diff
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 0–1 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 (C1–C5). 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,000–5,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 (~400–800 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 330–362)
|
||||
|
||||
### Step 1: Open SKILL.md and locate Phase 6
|
||||
|
||||
Read the file and find "## Phase 6 — REVIEW" (line 297). Identify steps 3–6 (lines 330–362) which currently always dispatch the LLM graph-reviewer subagent.
|
||||
|
||||
### Step 2: Replace Phase 6 steps 3–6 with conditional reviewer logic
|
||||
|
||||
Replace lines 330–362 (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 297–380 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 4–6 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 188–196)
|
||||
|
||||
### 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 257–270)
|
||||
- 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 18–35). 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 104–117)
|
||||
- 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 104–117:
|
||||
```
|
||||
**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 163–167) 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 49–53):
|
||||
```
|
||||
**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 119–134):
|
||||
```
|
||||
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 0–7 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 1–4 are independent of Tasks 5–7. 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,297 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
import { writeFileSync, mkdirSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
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"];
|
||||
const edgeTypes = [
|
||||
"imports", "exports", "contains", "inherits", "implements",
|
||||
"calls", "subscribes", "publishes", "middleware",
|
||||
"reads_from", "writes_to", "transforms", "validates",
|
||||
"depends_on", "tested_by", "configures",
|
||||
"related", "similar_to",
|
||||
];
|
||||
const complexities = ["simple", "moderate", "complex"];
|
||||
const languages = ["TypeScript", "JavaScript", "Python", "Go", "Rust"];
|
||||
const frameworks = ["React", "Express", "FastAPI", "Gin", "Actix"];
|
||||
|
||||
function pick(arr) {
|
||||
return arr[Math.floor(Math.random() * arr.length)];
|
||||
}
|
||||
|
||||
function generateNodes(count) {
|
||||
const nodes = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const type = pick(nodeTypes);
|
||||
const name = `${type}_${i}`;
|
||||
nodes.push({
|
||||
id: `node-${i}`,
|
||||
type,
|
||||
name,
|
||||
filePath: type === "file" ? `src/${name}.ts` : undefined,
|
||||
summary: `Auto-generated ${type} node #${i} for performance testing.`,
|
||||
tags: [type, `group-${i % 20}`],
|
||||
complexity: pick(complexities),
|
||||
});
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function generateEdges(nodes, edgeCount) {
|
||||
const edges = [];
|
||||
const seen = new Set();
|
||||
const n = nodes.length;
|
||||
|
||||
for (let i = 0; i < edgeCount; i++) {
|
||||
let src, tgt;
|
||||
// Forward-only edges to avoid cycles (dagre blows the stack on large cyclic graphs)
|
||||
do {
|
||||
src = Math.floor(Math.random() * (n - 1));
|
||||
const offset = Math.floor(Math.random() * Math.min(50, n - src - 1)) + 1;
|
||||
tgt = src + offset;
|
||||
} while (tgt >= n || src === tgt || seen.has(`${src}-${tgt}`));
|
||||
|
||||
seen.add(`${src}-${tgt}`);
|
||||
edges.push({
|
||||
source: nodes[src].id,
|
||||
target: nodes[tgt].id,
|
||||
type: pick(edgeTypes),
|
||||
direction: "forward",
|
||||
weight: Math.round(Math.random() * 100) / 100,
|
||||
});
|
||||
}
|
||||
return edges;
|
||||
}
|
||||
|
||||
function generateLayers(nodes) {
|
||||
const layers = [];
|
||||
const layerNames = [
|
||||
"Presentation", "Application", "Domain", "Infrastructure",
|
||||
"API Gateway", "Data Access", "Utilities", "Testing",
|
||||
];
|
||||
|
||||
for (let i = 0; i < layerNames.length; i++) {
|
||||
const start = Math.floor((i / layerNames.length) * nodes.length);
|
||||
const end = Math.floor(((i + 1) / layerNames.length) * nodes.length);
|
||||
layers.push({
|
||||
id: `layer-${i}`,
|
||||
name: layerNames[i],
|
||||
description: `${layerNames[i]} layer (auto-generated)`,
|
||||
nodeIds: nodes.slice(start, end).map((n) => n.id),
|
||||
});
|
||||
}
|
||||
return layers;
|
||||
}
|
||||
|
||||
function generateTour(nodes) {
|
||||
const steps = [];
|
||||
const stepCount = Math.min(8, Math.floor(nodes.length / 100));
|
||||
for (let i = 0; i < stepCount; i++) {
|
||||
const idx = Math.floor((i / stepCount) * nodes.length);
|
||||
steps.push({
|
||||
order: i + 1,
|
||||
title: `Step ${i + 1}: Explore ${nodes[idx].name}`,
|
||||
description: `This tour step highlights node **${nodes[idx].name}** and its surrounding context.`,
|
||||
nodeIds: [nodes[idx].id, nodes[Math.min(idx + 1, nodes.length - 1)].id],
|
||||
});
|
||||
}
|
||||
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);
|
||||
const edgeCount = Math.floor(NODE_COUNT * EDGE_RATIO);
|
||||
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 ${MESSY ? "robustness" : "performance"} testing.`,
|
||||
analyzedAt: new Date().toISOString(),
|
||||
gitCommitHash: "0000000000000000000000000000000000000000",
|
||||
},
|
||||
nodes,
|
||||
edges,
|
||||
layers: MESSY && Math.random() < 0.5 ? null : layers,
|
||||
tour: MESSY && Math.random() < 0.5 ? null : tour,
|
||||
};
|
||||
|
||||
const outDir = resolve(process.cwd(), ".understand-anything");
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
const outPath = resolve(outDir, "knowledge-graph.json");
|
||||
writeFileSync(outPath, JSON.stringify(graph, null, 2));
|
||||
|
||||
console.log(`Generated knowledge graph${MESSY ? " (messy mode)" : ""}:`);
|
||||
console.log(` Nodes: ${nodes.length}`);
|
||||
console.log(` Edges: ${edges.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}`);
|
||||
@@ -36,7 +36,7 @@ The JSON has this top-level shape:
|
||||
| Type | ID Convention | Description |
|
||||
|---|---|---|
|
||||
| `file` | `file:<relative-path>` | Source file |
|
||||
| `function` | `func:<relative-path>:<name>` | Function or method |
|
||||
| `function` | `function:<relative-path>:<name>` | Function or method |
|
||||
| `class` | `class:<relative-path>:<name>` | Class, interface, or type |
|
||||
| `module` | `module:<name>` | Logical module or package |
|
||||
| `concept` | `concept:<name>` | Abstract concept or pattern |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@understand-anything/skill",
|
||||
"version": "1.1.1",
|
||||
"version": "1.2.2",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
@@ -16,4 +16,4 @@
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,10 @@
|
||||
"./schema": {
|
||||
"types": "./dist/schema.d.ts",
|
||||
"default": "./dist/schema.js"
|
||||
},
|
||||
"./languages": {
|
||||
"types": "./dist/languages/index.d.ts",
|
||||
"default": "./dist/languages/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { FrameworkRegistry } from "../languages/framework-registry.js";
|
||||
import { djangoConfig } from "../languages/frameworks/django.js";
|
||||
import { reactConfig } from "../languages/frameworks/react.js";
|
||||
|
||||
describe("FrameworkRegistry", () => {
|
||||
it("registers and retrieves a framework config by id", () => {
|
||||
const registry = new FrameworkRegistry();
|
||||
registry.register(djangoConfig);
|
||||
expect(registry.getById("django")?.displayName).toBe("Django");
|
||||
});
|
||||
|
||||
it("retrieves frameworks for a language", () => {
|
||||
const registry = new FrameworkRegistry();
|
||||
registry.register(djangoConfig);
|
||||
registry.register(reactConfig);
|
||||
const pythonFrameworks = registry.getForLanguage("python");
|
||||
expect(pythonFrameworks).toHaveLength(1);
|
||||
expect(pythonFrameworks[0].id).toBe("django");
|
||||
});
|
||||
|
||||
it("returns empty array for unknown language", () => {
|
||||
const registry = new FrameworkRegistry();
|
||||
registry.register(djangoConfig);
|
||||
expect(registry.getForLanguage("haskell")).toEqual([]);
|
||||
});
|
||||
|
||||
describe("detectFrameworks", () => {
|
||||
it("detects Django from requirements.txt", () => {
|
||||
const registry = new FrameworkRegistry();
|
||||
registry.register(djangoConfig);
|
||||
const detected = registry.detectFrameworks({
|
||||
"requirements.txt": "django==4.2\ncelery==5.3\n",
|
||||
});
|
||||
expect(detected).toHaveLength(1);
|
||||
expect(detected[0].id).toBe("django");
|
||||
});
|
||||
|
||||
it("detects React from package.json", () => {
|
||||
const registry = new FrameworkRegistry();
|
||||
registry.register(reactConfig);
|
||||
const detected = registry.detectFrameworks({
|
||||
"package.json": '{"dependencies": {"react": "^18.2.0", "react-dom": "^18.2.0"}}',
|
||||
});
|
||||
expect(detected).toHaveLength(1);
|
||||
expect(detected[0].id).toBe("react");
|
||||
});
|
||||
|
||||
it("detection is case-insensitive", () => {
|
||||
const registry = new FrameworkRegistry();
|
||||
registry.register(djangoConfig);
|
||||
const detected = registry.detectFrameworks({
|
||||
"requirements.txt": "Django==4.2\n",
|
||||
});
|
||||
expect(detected).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns empty array when no frameworks match", () => {
|
||||
const registry = new FrameworkRegistry();
|
||||
registry.register(djangoConfig);
|
||||
const detected = registry.detectFrameworks({
|
||||
"requirements.txt": "requests==2.31\n",
|
||||
});
|
||||
expect(detected).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array for empty manifests", () => {
|
||||
const registry = new FrameworkRegistry();
|
||||
registry.register(djangoConfig);
|
||||
expect(registry.detectFrameworks({})).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not duplicate detected frameworks", () => {
|
||||
const registry = new FrameworkRegistry();
|
||||
registry.register(djangoConfig);
|
||||
const detected = registry.detectFrameworks({
|
||||
"requirements.txt": "django==4.2\ndjango==4.2\n",
|
||||
"pyproject.toml": '[project]\ndependencies = ["django>=4.0"]',
|
||||
});
|
||||
expect(detected).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("returns frameworks for all listed languages (cross-language)", () => {
|
||||
const registry = FrameworkRegistry.createDefault();
|
||||
// React lists both typescript and javascript
|
||||
const tsFrameworks = registry.getForLanguage("typescript");
|
||||
const jsFrameworks = registry.getForLanguage("javascript");
|
||||
expect(tsFrameworks.some((f) => f.id === "react")).toBe(true);
|
||||
expect(jsFrameworks.some((f) => f.id === "react")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not duplicate on re-registration", () => {
|
||||
const registry = new FrameworkRegistry();
|
||||
registry.register(djangoConfig);
|
||||
registry.register(djangoConfig);
|
||||
expect(registry.getForLanguage("python")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("getForLanguage returns a copy, not the internal array", () => {
|
||||
const registry = new FrameworkRegistry();
|
||||
registry.register(djangoConfig);
|
||||
const result = registry.getForLanguage("python");
|
||||
result.push(reactConfig);
|
||||
expect(registry.getForLanguage("python")).toHaveLength(1);
|
||||
});
|
||||
|
||||
describe("createDefault", () => {
|
||||
it("registers all 10 built-in framework configs", () => {
|
||||
const registry = FrameworkRegistry.createDefault();
|
||||
expect(registry.getAllFrameworks()).toHaveLength(10);
|
||||
});
|
||||
|
||||
it("includes frameworks for multiple languages", () => {
|
||||
const registry = FrameworkRegistry.createDefault();
|
||||
expect(registry.getForLanguage("python").length).toBeGreaterThanOrEqual(3);
|
||||
expect(registry.getForLanguage("typescript").length).toBeGreaterThanOrEqual(2);
|
||||
expect(registry.getForLanguage("java").length).toBeGreaterThanOrEqual(1);
|
||||
expect(registry.getForLanguage("ruby").length).toBeGreaterThanOrEqual(1);
|
||||
expect(registry.getForLanguage("go").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,9 +5,10 @@ import {
|
||||
detectLanguageConcepts,
|
||||
} from "../analyzer/language-lesson.js";
|
||||
import type { GraphNode, GraphEdge } from "../types.js";
|
||||
import { typescriptConfig } from "../languages/configs/typescript.js";
|
||||
|
||||
const sampleNode: GraphNode = {
|
||||
id: "func:auth:verifyToken",
|
||||
id: "function:auth:verifyToken",
|
||||
type: "function",
|
||||
name: "verifyToken",
|
||||
filePath: "src/auth/verify.ts",
|
||||
@@ -19,7 +20,7 @@ const sampleNode: GraphNode = {
|
||||
|
||||
const sampleEdges: GraphEdge[] = [
|
||||
{
|
||||
source: "func:auth:verifyToken",
|
||||
source: "function:auth:verifyToken",
|
||||
target: "file:src/config.ts",
|
||||
type: "reads_from",
|
||||
direction: "forward",
|
||||
@@ -27,7 +28,7 @@ const sampleEdges: GraphEdge[] = [
|
||||
},
|
||||
{
|
||||
source: "file:src/middleware.ts",
|
||||
target: "func:auth:verifyToken",
|
||||
target: "function:auth:verifyToken",
|
||||
type: "calls",
|
||||
direction: "forward",
|
||||
weight: 0.8,
|
||||
@@ -51,6 +52,7 @@ describe("language-lesson", () => {
|
||||
sampleNode,
|
||||
sampleEdges,
|
||||
"typescript",
|
||||
typescriptConfig,
|
||||
);
|
||||
expect(prompt).toContain("TypeScript");
|
||||
});
|
||||
@@ -126,7 +128,7 @@ describe("language-lesson", () => {
|
||||
|
||||
it("detects middleware pattern", () => {
|
||||
const middlewareNode: GraphNode = {
|
||||
id: "func:middleware:auth",
|
||||
id: "function:middleware:auth",
|
||||
type: "function",
|
||||
name: "authMiddleware",
|
||||
filePath: "src/middleware/auth.ts",
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { LanguageRegistry } from "../languages/language-registry.js";
|
||||
import { typescriptConfig } from "../languages/configs/typescript.js";
|
||||
import { pythonConfig } from "../languages/configs/python.js";
|
||||
|
||||
describe("LanguageRegistry", () => {
|
||||
it("registers and retrieves a language config by id", () => {
|
||||
const registry = new LanguageRegistry();
|
||||
registry.register(typescriptConfig);
|
||||
expect(registry.getById("typescript")).toEqual(typescriptConfig);
|
||||
});
|
||||
|
||||
it("retrieves config by file extension", () => {
|
||||
const registry = new LanguageRegistry();
|
||||
registry.register(typescriptConfig);
|
||||
expect(registry.getByExtension(".ts")?.id).toBe("typescript");
|
||||
expect(registry.getByExtension(".tsx")?.id).toBe("typescript");
|
||||
});
|
||||
|
||||
it("retrieves config for a file path", () => {
|
||||
const registry = new LanguageRegistry();
|
||||
registry.register(typescriptConfig);
|
||||
registry.register(pythonConfig);
|
||||
expect(registry.getForFile("src/index.ts")?.id).toBe("typescript");
|
||||
expect(registry.getForFile("app/models.py")?.id).toBe("python");
|
||||
});
|
||||
|
||||
it("returns null for unknown extensions", () => {
|
||||
const registry = new LanguageRegistry();
|
||||
registry.register(typescriptConfig);
|
||||
expect(registry.getByExtension(".xyz")).toBeNull();
|
||||
expect(registry.getForFile("file.unknown")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for files without extensions", () => {
|
||||
const registry = new LanguageRegistry();
|
||||
expect(registry.getForFile("Makefile")).toBeNull();
|
||||
});
|
||||
|
||||
it("lists all registered languages", () => {
|
||||
const registry = new LanguageRegistry();
|
||||
registry.register(typescriptConfig);
|
||||
registry.register(pythonConfig);
|
||||
const all = registry.getAllLanguages();
|
||||
expect(all).toHaveLength(2);
|
||||
expect(all.map(c => c.id)).toContain("typescript");
|
||||
expect(all.map(c => c.id)).toContain("python");
|
||||
});
|
||||
|
||||
describe("createDefault", () => {
|
||||
it("registers all 12 built-in language configs", () => {
|
||||
const registry = LanguageRegistry.createDefault();
|
||||
const all = registry.getAllLanguages();
|
||||
expect(all.length).toBe(12);
|
||||
});
|
||||
|
||||
it("maps all expected extensions", () => {
|
||||
const registry = LanguageRegistry.createDefault();
|
||||
expect(registry.getByExtension(".ts")?.id).toBe("typescript");
|
||||
expect(registry.getByExtension(".py")?.id).toBe("python");
|
||||
expect(registry.getByExtension(".go")?.id).toBe("go");
|
||||
expect(registry.getByExtension(".rs")?.id).toBe("rust");
|
||||
expect(registry.getByExtension(".java")?.id).toBe("java");
|
||||
expect(registry.getByExtension(".rb")?.id).toBe("ruby");
|
||||
expect(registry.getByExtension(".php")?.id).toBe("php");
|
||||
expect(registry.getByExtension(".swift")?.id).toBe("swift");
|
||||
expect(registry.getByExtension(".kt")?.id).toBe("kotlin");
|
||||
expect(registry.getByExtension(".cs")?.id).toBe("csharp");
|
||||
expect(registry.getByExtension(".cpp")?.id).toBe("cpp");
|
||||
expect(registry.getByExtension(".js")?.id).toBe("javascript");
|
||||
});
|
||||
|
||||
it("has no duplicate extension mappings across configs", () => {
|
||||
const registry = LanguageRegistry.createDefault();
|
||||
const all = registry.getAllLanguages();
|
||||
const allExtensions: string[] = [];
|
||||
for (const config of all) {
|
||||
allExtensions.push(...config.extensions);
|
||||
}
|
||||
const unique = new Set(allExtensions);
|
||||
expect(unique.size).toBe(allExtensions.length);
|
||||
});
|
||||
|
||||
it("every config has at least one concept", () => {
|
||||
const registry = LanguageRegistry.createDefault();
|
||||
for (const config of registry.getAllLanguages()) {
|
||||
expect(config.concepts.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,12 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { validateGraph } from "../schema.js";
|
||||
import {
|
||||
validateGraph,
|
||||
normalizeGraph,
|
||||
sanitizeGraph,
|
||||
autoFixGraph,
|
||||
NODE_TYPE_ALIASES,
|
||||
EDGE_TYPE_ALIASES,
|
||||
} from "../schema.js";
|
||||
import type { KnowledgeGraph } from "../types.js";
|
||||
|
||||
const validGraph: KnowledgeGraph = {
|
||||
@@ -27,7 +34,7 @@ const validGraph: KnowledgeGraph = {
|
||||
edges: [
|
||||
{
|
||||
source: "node-1",
|
||||
target: "node-2",
|
||||
target: "node-1",
|
||||
type: "imports",
|
||||
direction: "forward",
|
||||
weight: 0.8,
|
||||
@@ -57,56 +64,601 @@ 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(true);
|
||||
expect(result.issues).toContainEqual(
|
||||
expect.objectContaining({ level: "auto-corrected", category: "out-of-range" })
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes "func" node type to "function"', () => {
|
||||
const graph = structuredClone(validGraph);
|
||||
(graph.nodes[0] as any).type = "func";
|
||||
|
||||
const result = validateGraph(graph);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data!.nodes[0].type).toBe("function");
|
||||
});
|
||||
|
||||
it('normalizes "fn" node type to "function"', () => {
|
||||
const graph = structuredClone(validGraph);
|
||||
(graph.nodes[0] as any).type = "fn";
|
||||
|
||||
const result = validateGraph(graph);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data!.nodes[0].type).toBe("function");
|
||||
});
|
||||
|
||||
it('normalizes "method" node type to "function"', () => {
|
||||
const graph = structuredClone(validGraph);
|
||||
(graph.nodes[0] as any).type = "method";
|
||||
|
||||
const result = validateGraph(graph);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data!.nodes[0].type).toBe("function");
|
||||
});
|
||||
|
||||
it('normalizes "interface" node type to "class"', () => {
|
||||
const graph = structuredClone(validGraph);
|
||||
(graph.nodes[0] as any).type = "interface";
|
||||
|
||||
const result = validateGraph(graph);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data!.nodes[0].type).toBe("class");
|
||||
});
|
||||
|
||||
it('normalizes "struct" node type to "class"', () => {
|
||||
const graph = structuredClone(validGraph);
|
||||
(graph.nodes[0] as any).type = "struct";
|
||||
|
||||
const result = validateGraph(graph);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data!.nodes[0].type).toBe("class");
|
||||
});
|
||||
|
||||
it("normalizes multiple aliased node types in one graph", () => {
|
||||
const graph = structuredClone(validGraph);
|
||||
(graph.nodes[0] as any).type = "func";
|
||||
graph.nodes.push({
|
||||
id: "node-2",
|
||||
type: "file" as any,
|
||||
name: "utils.ts",
|
||||
filePath: "src/utils.ts",
|
||||
lineRange: [1, 30],
|
||||
summary: "Utility helpers",
|
||||
tags: ["utils"],
|
||||
complexity: "simple",
|
||||
});
|
||||
(graph.nodes[1] as any).type = "pkg";
|
||||
graph.nodes.push({
|
||||
id: "node-3",
|
||||
type: "file" as any,
|
||||
name: "MyClass.ts",
|
||||
filePath: "src/MyClass.ts",
|
||||
lineRange: [1, 80],
|
||||
summary: "A class",
|
||||
tags: ["class"],
|
||||
complexity: "moderate",
|
||||
});
|
||||
(graph.nodes[2] as any).type = "struct";
|
||||
|
||||
const result = validateGraph(graph);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data!.nodes[0].type).toBe("function");
|
||||
expect(result.data!.nodes[1].type).toBe("module");
|
||||
expect(result.data!.nodes[2].type).toBe("class");
|
||||
});
|
||||
|
||||
it('normalizes "extends" edge type to "inherits"', () => {
|
||||
const graph = structuredClone(validGraph);
|
||||
(graph.edges[0] as any).type = "extends";
|
||||
|
||||
const result = validateGraph(graph);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data!.edges[0].type).toBe("inherits");
|
||||
});
|
||||
|
||||
it('normalizes "invokes" edge type to "calls"', () => {
|
||||
const graph = structuredClone(validGraph);
|
||||
(graph.edges[0] as any).type = "invokes";
|
||||
|
||||
const result = validateGraph(graph);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data!.edges[0].type).toBe("calls");
|
||||
});
|
||||
|
||||
it('normalizes "relates_to" edge type to "related"', () => {
|
||||
const graph = structuredClone(validGraph);
|
||||
(graph.edges[0] as any).type = "relates_to";
|
||||
|
||||
const result = validateGraph(graph);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data!.edges[0].type).toBe("related");
|
||||
});
|
||||
|
||||
it('normalizes "uses" edge type to "depends_on"', () => {
|
||||
const graph = structuredClone(validGraph);
|
||||
(graph.edges[0] as any).type = "uses";
|
||||
|
||||
const result = validateGraph(graph);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data!.edges[0].type).toBe("depends_on");
|
||||
});
|
||||
|
||||
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(true);
|
||||
expect(result.data!.edges.length).toBe(0);
|
||||
expect(result.issues).toContainEqual(
|
||||
expect.objectContaining({ level: "dropped" })
|
||||
);
|
||||
});
|
||||
|
||||
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(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)", () => {
|
||||
for (const [alias, target] of Object.entries(NODE_TYPE_ALIASES)) {
|
||||
expect(
|
||||
NODE_TYPE_ALIASES,
|
||||
`chain detected: ${alias} → ${target} → ${NODE_TYPE_ALIASES[target]}`,
|
||||
).not.toHaveProperty(target);
|
||||
}
|
||||
});
|
||||
|
||||
it("EDGE_TYPE_ALIASES values are never alias keys (no chains)", () => {
|
||||
for (const [alias, target] of Object.entries(EDGE_TYPE_ALIASES)) {
|
||||
expect(
|
||||
EDGE_TYPE_ALIASES,
|
||||
`chain detected: ${alias} → ${target} → ${EDGE_TYPE_ALIASES[target]}`,
|
||||
).not.toHaveProperty(target);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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.errors).toBeDefined();
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,14 +74,14 @@ describe("GraphBuilder", () => {
|
||||
expect(fileNode!.type).toBe("file");
|
||||
expect(fileNode!.summary).toBe("Handles data processing");
|
||||
|
||||
const funcNode = graph.nodes.find((n) => n.id === "func:src/service.ts:processData");
|
||||
const funcNode = graph.nodes.find((n) => n.id === "function:src/service.ts:processData");
|
||||
expect(funcNode).toBeDefined();
|
||||
expect(funcNode!.type).toBe("function");
|
||||
expect(funcNode!.name).toBe("processData");
|
||||
expect(funcNode!.lineRange).toEqual([10, 25]);
|
||||
expect(funcNode!.summary).toBe("Processes raw input data");
|
||||
|
||||
const validateNode = graph.nodes.find((n) => n.id === "func:src/service.ts:validate");
|
||||
const validateNode = graph.nodes.find((n) => n.id === "function:src/service.ts:validate");
|
||||
expect(validateNode).toBeDefined();
|
||||
expect(validateNode!.summary).toBe("Validates data format");
|
||||
|
||||
@@ -120,7 +120,7 @@ describe("GraphBuilder", () => {
|
||||
|
||||
expect(containsEdges[0]).toMatchObject({
|
||||
source: "file:src/widget.ts",
|
||||
target: "func:src/widget.ts:helper",
|
||||
target: "function:src/widget.ts:helper",
|
||||
type: "contains",
|
||||
direction: "forward",
|
||||
weight: 1,
|
||||
@@ -170,8 +170,8 @@ describe("GraphBuilder", () => {
|
||||
const callEdges = graph.edges.filter((e) => e.type === "calls");
|
||||
expect(callEdges).toHaveLength(1);
|
||||
expect(callEdges[0]).toMatchObject({
|
||||
source: "func:src/index.ts:main",
|
||||
target: "func:src/utils.ts:helper",
|
||||
source: "function:src/index.ts:main",
|
||||
target: "function:src/utils.ts:helper",
|
||||
type: "calls",
|
||||
direction: "forward",
|
||||
});
|
||||
|
||||
@@ -117,7 +117,7 @@ export class GraphBuilder {
|
||||
|
||||
// Create function nodes with "contains" edges
|
||||
for (const fn of analysis.functions) {
|
||||
const funcId = `func:${filePath}:${fn.name}`;
|
||||
const funcId = `function:${filePath}:${fn.name}`;
|
||||
this.nodes.push({
|
||||
id: funcId,
|
||||
type: "function",
|
||||
@@ -179,8 +179,8 @@ export class GraphBuilder {
|
||||
calleeFunc: string,
|
||||
): void {
|
||||
this.edges.push({
|
||||
source: `func:${callerFile}:${callerFunc}`,
|
||||
target: `func:${calleeFile}:${calleeFunc}`,
|
||||
source: `function:${callerFile}:${callerFunc}`,
|
||||
target: `function:${calleeFile}:${calleeFunc}`,
|
||||
type: "calls",
|
||||
direction: "forward",
|
||||
weight: 0.8,
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import type { GraphNode, GraphEdge } from "../types.js";
|
||||
import type { LanguageConfig } from "../languages/types.js";
|
||||
|
||||
export interface LanguageLessonResult {
|
||||
languageNotes: string;
|
||||
concepts: Array<{ name: string; explanation: string }>;
|
||||
}
|
||||
|
||||
const CONCEPT_PATTERNS: Record<string, string[]> = {
|
||||
/**
|
||||
* Base concept patterns that apply across all languages.
|
||||
* These are merged with language-specific concepts from LanguageConfig.
|
||||
*/
|
||||
const BASE_CONCEPT_PATTERNS: Record<string, string[]> = {
|
||||
"async/await": ["async", "await", "promise", "asynchronous"],
|
||||
"middleware pattern": ["middleware", "interceptor", "pipe"],
|
||||
"generics": ["generic", "type parameter", "template"],
|
||||
@@ -36,12 +41,35 @@ const CONCEPT_PATTERNS: Record<string, string[]> = {
|
||||
"concurrency": ["goroutine", "channel", "thread", "worker", "mutex"],
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the full concept patterns map by merging base patterns with
|
||||
* language-specific concepts from a LanguageConfig (if provided).
|
||||
*/
|
||||
function buildConceptPatterns(
|
||||
langConfig?: LanguageConfig | null,
|
||||
): Record<string, string[]> {
|
||||
const patterns = { ...BASE_CONCEPT_PATTERNS };
|
||||
|
||||
if (langConfig?.concepts) {
|
||||
for (const concept of langConfig.concepts) {
|
||||
if (!patterns[concept]) {
|
||||
// Use the concept name itself as a keyword for detection
|
||||
patterns[concept] = [concept.toLowerCase()];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return patterns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects language concepts present in a graph node based on its tags, summary, and languageNotes.
|
||||
* When a LanguageConfig is provided, language-specific concepts are also detected.
|
||||
*/
|
||||
export function detectLanguageConcepts(
|
||||
node: GraphNode,
|
||||
language: string,
|
||||
langConfig?: LanguageConfig | null,
|
||||
): string[] {
|
||||
const text = [
|
||||
...node.tags,
|
||||
@@ -49,9 +77,10 @@ export function detectLanguageConcepts(
|
||||
node.languageNotes?.toLowerCase() ?? "",
|
||||
].join(" ");
|
||||
|
||||
const patterns = buildConceptPatterns(langConfig);
|
||||
const detected: string[] = [];
|
||||
|
||||
for (const [concept, keywords] of Object.entries(CONCEPT_PATTERNS)) {
|
||||
for (const [concept, keywords] of Object.entries(patterns)) {
|
||||
const found = keywords.some((keyword) =>
|
||||
text.toLowerCase().includes(keyword.toLowerCase()),
|
||||
);
|
||||
@@ -63,11 +92,19 @@ export function detectLanguageConcepts(
|
||||
return detected;
|
||||
}
|
||||
|
||||
const LANGUAGE_DISPLAY_NAMES: Record<string, string> = {
|
||||
typescript: "TypeScript",
|
||||
javascript: "JavaScript",
|
||||
coffeescript: "CoffeeScript",
|
||||
};
|
||||
/**
|
||||
* Get the display name for a language.
|
||||
* Uses LanguageConfig if provided, otherwise falls back to capitalization.
|
||||
*/
|
||||
export function getLanguageDisplayName(
|
||||
language: string,
|
||||
langConfig?: LanguageConfig | null,
|
||||
): string {
|
||||
if (langConfig?.displayName) {
|
||||
return langConfig.displayName;
|
||||
}
|
||||
return language.charAt(0).toUpperCase() + language.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a prompt that asks an LLM to produce a language-specific lesson for a given node.
|
||||
@@ -76,12 +113,11 @@ export function buildLanguageLessonPrompt(
|
||||
node: GraphNode,
|
||||
edges: GraphEdge[],
|
||||
language: string,
|
||||
langConfig?: LanguageConfig | null,
|
||||
): string {
|
||||
const capitalizedLanguage =
|
||||
LANGUAGE_DISPLAY_NAMES[language.toLowerCase()] ??
|
||||
language.charAt(0).toUpperCase() + language.slice(1);
|
||||
const capitalizedLanguage = getLanguageDisplayName(language, langConfig);
|
||||
|
||||
const concepts = detectLanguageConcepts(node, language);
|
||||
const concepts = detectLanguageConcepts(node, language, langConfig);
|
||||
|
||||
const relationships = edges
|
||||
.map((edge) => {
|
||||
|
||||
@@ -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 {
|
||||
@@ -36,6 +45,20 @@ export {
|
||||
type LanguageLessonResult,
|
||||
} from "./analyzer/language-lesson.js";
|
||||
export { PluginRegistry } from "./plugins/registry.js";
|
||||
export {
|
||||
LanguageRegistry,
|
||||
FrameworkRegistry,
|
||||
builtinLanguageConfigs,
|
||||
builtinFrameworkConfigs,
|
||||
LanguageConfigSchema,
|
||||
FrameworkConfigSchema,
|
||||
} from "./languages/index.js";
|
||||
export type {
|
||||
LanguageConfig,
|
||||
FrameworkConfig,
|
||||
TreeSitterConfig,
|
||||
FilePatternConfig,
|
||||
} from "./languages/index.js";
|
||||
export {
|
||||
parsePluginConfig,
|
||||
serializePluginConfig,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { LanguageConfig } from "../types.js";
|
||||
|
||||
export const cppConfig = {
|
||||
id: "cpp",
|
||||
displayName: "C/C++",
|
||||
extensions: [".cpp", ".cc", ".cxx", ".c", ".h", ".hpp", ".hxx"],
|
||||
concepts: [
|
||||
"templates",
|
||||
"RAII",
|
||||
"smart pointers",
|
||||
"move semantics",
|
||||
"operator overloading",
|
||||
"virtual functions",
|
||||
"namespaces",
|
||||
"constexpr",
|
||||
"lambda expressions",
|
||||
"STL containers",
|
||||
],
|
||||
filePatterns: {
|
||||
entryPoints: ["main.cpp", "main.c", "src/main.cpp"],
|
||||
barrels: [],
|
||||
tests: ["*_test.cpp", "*_test.cc", "test_*.cpp"],
|
||||
config: ["CMakeLists.txt", "Makefile", "meson.build"],
|
||||
},
|
||||
} satisfies LanguageConfig;
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { LanguageConfig } from "../types.js";
|
||||
|
||||
export const csharpConfig = {
|
||||
id: "csharp",
|
||||
displayName: "C#",
|
||||
extensions: [".cs"],
|
||||
concepts: [
|
||||
"LINQ",
|
||||
"async/await",
|
||||
"generics",
|
||||
"properties",
|
||||
"delegates and events",
|
||||
"attributes",
|
||||
"nullable reference types",
|
||||
"pattern matching",
|
||||
"records",
|
||||
"dependency injection",
|
||||
],
|
||||
filePatterns: {
|
||||
entryPoints: ["Program.cs", "**/Program.cs"],
|
||||
barrels: [],
|
||||
tests: ["*Tests.cs", "*Test.cs"],
|
||||
config: ["*.csproj", "*.sln"],
|
||||
},
|
||||
} satisfies LanguageConfig;
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { LanguageConfig } from "../types.js";
|
||||
|
||||
export const goConfig = {
|
||||
id: "go",
|
||||
displayName: "Go",
|
||||
extensions: [".go"],
|
||||
concepts: [
|
||||
"goroutines",
|
||||
"channels",
|
||||
"interfaces",
|
||||
"struct embedding",
|
||||
"error handling patterns",
|
||||
"defer/panic/recover",
|
||||
"slices",
|
||||
"pointers",
|
||||
"concurrency patterns",
|
||||
],
|
||||
filePatterns: {
|
||||
entryPoints: ["main.go", "cmd/*/main.go"],
|
||||
barrels: [],
|
||||
tests: ["*_test.go"],
|
||||
config: ["go.mod", "go.sum"],
|
||||
},
|
||||
} satisfies LanguageConfig;
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { LanguageConfig } from "../types.js";
|
||||
import { typescriptConfig } from "./typescript.js";
|
||||
import { javascriptConfig } from "./javascript.js";
|
||||
import { pythonConfig } from "./python.js";
|
||||
import { goConfig } from "./go.js";
|
||||
import { rustConfig } from "./rust.js";
|
||||
import { javaConfig } from "./java.js";
|
||||
import { rubyConfig } from "./ruby.js";
|
||||
import { phpConfig } from "./php.js";
|
||||
import { swiftConfig } from "./swift.js";
|
||||
import { kotlinConfig } from "./kotlin.js";
|
||||
import { cppConfig } from "./cpp.js";
|
||||
import { csharpConfig } from "./csharp.js";
|
||||
|
||||
export const builtinLanguageConfigs: LanguageConfig[] = [
|
||||
typescriptConfig,
|
||||
javascriptConfig,
|
||||
pythonConfig,
|
||||
goConfig,
|
||||
rustConfig,
|
||||
javaConfig,
|
||||
rubyConfig,
|
||||
phpConfig,
|
||||
swiftConfig,
|
||||
kotlinConfig,
|
||||
cppConfig,
|
||||
csharpConfig,
|
||||
];
|
||||
|
||||
export {
|
||||
typescriptConfig,
|
||||
javascriptConfig,
|
||||
pythonConfig,
|
||||
goConfig,
|
||||
rustConfig,
|
||||
javaConfig,
|
||||
rubyConfig,
|
||||
phpConfig,
|
||||
swiftConfig,
|
||||
kotlinConfig,
|
||||
cppConfig,
|
||||
csharpConfig,
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { LanguageConfig } from "../types.js";
|
||||
|
||||
export const javaConfig = {
|
||||
id: "java",
|
||||
displayName: "Java",
|
||||
extensions: [".java"],
|
||||
concepts: [
|
||||
"generics",
|
||||
"annotations",
|
||||
"interfaces",
|
||||
"abstract classes",
|
||||
"streams API",
|
||||
"lambdas",
|
||||
"sealed classes",
|
||||
"records",
|
||||
"dependency injection",
|
||||
"checked exceptions",
|
||||
],
|
||||
filePatterns: {
|
||||
entryPoints: [
|
||||
"**/Application.java",
|
||||
"**/Main.java",
|
||||
"src/main/java/**/App.java",
|
||||
],
|
||||
barrels: [],
|
||||
tests: ["*Test.java", "*Tests.java", "*IT.java"],
|
||||
config: ["pom.xml", "build.gradle", "build.gradle.kts"],
|
||||
},
|
||||
} satisfies LanguageConfig;
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { LanguageConfig } from "../types.js";
|
||||
|
||||
export const javascriptConfig = {
|
||||
id: "javascript",
|
||||
displayName: "JavaScript",
|
||||
extensions: [".js", ".jsx", ".mjs", ".cjs"],
|
||||
treeSitter: {
|
||||
wasmPackage: "tree-sitter-javascript",
|
||||
wasmFile: "tree-sitter-javascript.wasm",
|
||||
},
|
||||
concepts: [
|
||||
"closures",
|
||||
"prototypes",
|
||||
"promises",
|
||||
"async/await",
|
||||
"event loop",
|
||||
"destructuring",
|
||||
"spread operator",
|
||||
"proxies",
|
||||
"generators",
|
||||
"modules (ESM/CJS)",
|
||||
],
|
||||
filePatterns: {
|
||||
entryPoints: ["index.js", "src/index.js", "main.js"],
|
||||
barrels: ["index.js"],
|
||||
tests: ["*.test.js", "*.spec.js"],
|
||||
config: ["package.json", "jsconfig.json"],
|
||||
},
|
||||
} satisfies LanguageConfig;
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { LanguageConfig } from "../types.js";
|
||||
|
||||
export const kotlinConfig = {
|
||||
id: "kotlin",
|
||||
displayName: "Kotlin",
|
||||
extensions: [".kt", ".kts"],
|
||||
concepts: [
|
||||
"coroutines",
|
||||
"data classes",
|
||||
"sealed classes",
|
||||
"extension functions",
|
||||
"null safety",
|
||||
"delegation",
|
||||
"DSL builders",
|
||||
"inline functions",
|
||||
"companion objects",
|
||||
"flow",
|
||||
],
|
||||
filePatterns: {
|
||||
entryPoints: ["**/Application.kt", "**/Main.kt"],
|
||||
barrels: [],
|
||||
tests: ["*Test.kt", "*Tests.kt"],
|
||||
config: ["build.gradle.kts", "build.gradle"],
|
||||
},
|
||||
} satisfies LanguageConfig;
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { LanguageConfig } from "../types.js";
|
||||
|
||||
export const phpConfig = {
|
||||
id: "php",
|
||||
displayName: "PHP",
|
||||
extensions: [".php"],
|
||||
concepts: [
|
||||
"namespaces",
|
||||
"traits",
|
||||
"type declarations",
|
||||
"attributes",
|
||||
"enums",
|
||||
"fibers",
|
||||
"closures",
|
||||
"magic methods",
|
||||
"dependency injection",
|
||||
"middleware",
|
||||
],
|
||||
filePatterns: {
|
||||
entryPoints: ["index.php", "public/index.php", "artisan"],
|
||||
barrels: [],
|
||||
tests: ["*Test.php", "tests/**/*.php"],
|
||||
config: ["composer.json", "php.ini"],
|
||||
},
|
||||
} satisfies LanguageConfig;
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { LanguageConfig } from "../types.js";
|
||||
|
||||
export const pythonConfig = {
|
||||
id: "python",
|
||||
displayName: "Python",
|
||||
extensions: [".py", ".pyi"],
|
||||
concepts: [
|
||||
"decorators",
|
||||
"list comprehensions",
|
||||
"generators",
|
||||
"context managers",
|
||||
"type hints",
|
||||
"dunder methods",
|
||||
"metaclasses",
|
||||
"dataclasses",
|
||||
"async/await",
|
||||
"descriptors",
|
||||
"protocols",
|
||||
],
|
||||
filePatterns: {
|
||||
entryPoints: [
|
||||
"main.py",
|
||||
"manage.py",
|
||||
"app.py",
|
||||
"wsgi.py",
|
||||
"asgi.py",
|
||||
"run.py",
|
||||
"__main__.py",
|
||||
],
|
||||
barrels: ["__init__.py"],
|
||||
tests: ["test_*.py", "*_test.py", "conftest.py"],
|
||||
config: [
|
||||
"pyproject.toml",
|
||||
"setup.py",
|
||||
"setup.cfg",
|
||||
"requirements.txt",
|
||||
"Pipfile",
|
||||
],
|
||||
},
|
||||
} satisfies LanguageConfig;
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { LanguageConfig } from "../types.js";
|
||||
|
||||
export const rubyConfig = {
|
||||
id: "ruby",
|
||||
displayName: "Ruby",
|
||||
extensions: [".rb", ".rake"],
|
||||
concepts: [
|
||||
"blocks and procs",
|
||||
"mixins",
|
||||
"metaprogramming",
|
||||
"duck typing",
|
||||
"DSLs",
|
||||
"monkey patching",
|
||||
"symbols",
|
||||
"method_missing",
|
||||
"open classes",
|
||||
],
|
||||
filePatterns: {
|
||||
entryPoints: ["config.ru", "app.rb"],
|
||||
barrels: [],
|
||||
tests: ["*_test.rb", "*_spec.rb", "spec_helper.rb"],
|
||||
config: ["Gemfile", "Rakefile"],
|
||||
},
|
||||
} satisfies LanguageConfig;
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { LanguageConfig } from "../types.js";
|
||||
|
||||
export const rustConfig = {
|
||||
id: "rust",
|
||||
displayName: "Rust",
|
||||
extensions: [".rs"],
|
||||
concepts: [
|
||||
"ownership",
|
||||
"borrowing",
|
||||
"lifetimes",
|
||||
"traits",
|
||||
"pattern matching",
|
||||
"enums with data",
|
||||
"error handling (Result/Option)",
|
||||
"macros",
|
||||
"async/await",
|
||||
"unsafe blocks",
|
||||
"generics",
|
||||
"closures",
|
||||
],
|
||||
filePatterns: {
|
||||
entryPoints: ["src/main.rs", "src/lib.rs"],
|
||||
barrels: ["mod.rs", "lib.rs"],
|
||||
tests: ["tests/*.rs"],
|
||||
config: ["Cargo.toml"],
|
||||
},
|
||||
} satisfies LanguageConfig;
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { LanguageConfig } from "../types.js";
|
||||
|
||||
export const swiftConfig = {
|
||||
id: "swift",
|
||||
displayName: "Swift",
|
||||
extensions: [".swift"],
|
||||
concepts: [
|
||||
"optionals",
|
||||
"protocols",
|
||||
"extensions",
|
||||
"generics",
|
||||
"closures",
|
||||
"property wrappers",
|
||||
"result builders",
|
||||
"actors",
|
||||
"structured concurrency",
|
||||
"value types vs reference types",
|
||||
],
|
||||
filePatterns: {
|
||||
entryPoints: ["Sources/*/main.swift", "App.swift", "AppDelegate.swift"],
|
||||
barrels: [],
|
||||
tests: ["*Tests.swift", "Tests/**/*.swift"],
|
||||
config: ["Package.swift"],
|
||||
},
|
||||
} satisfies LanguageConfig;
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { LanguageConfig } from "../types.js";
|
||||
|
||||
export const typescriptConfig = {
|
||||
id: "typescript",
|
||||
displayName: "TypeScript",
|
||||
extensions: [".ts", ".tsx"],
|
||||
treeSitter: {
|
||||
wasmPackage: "tree-sitter-typescript",
|
||||
wasmFile: "tree-sitter-typescript.wasm",
|
||||
},
|
||||
concepts: [
|
||||
"generics",
|
||||
"type guards",
|
||||
"discriminated unions",
|
||||
"utility types",
|
||||
"decorators",
|
||||
"enums",
|
||||
"interfaces",
|
||||
"type inference",
|
||||
"mapped types",
|
||||
"conditional types",
|
||||
"template literal types",
|
||||
],
|
||||
filePatterns: {
|
||||
entryPoints: ["src/index.ts", "src/main.ts", "src/App.tsx", "index.ts"],
|
||||
barrels: ["index.ts"],
|
||||
tests: ["*.test.ts", "*.spec.ts", "*.test.tsx"],
|
||||
config: ["tsconfig.json"],
|
||||
},
|
||||
} satisfies LanguageConfig;
|
||||
@@ -0,0 +1,86 @@
|
||||
import { FrameworkConfigSchema } from "./types.js";
|
||||
import type { FrameworkConfig } from "./types.js";
|
||||
import { builtinFrameworkConfigs } from "./frameworks/index.js";
|
||||
|
||||
/**
|
||||
* Registry for framework configurations. Provides detection of frameworks
|
||||
* from manifest file contents and lookup by id or language.
|
||||
*/
|
||||
export class FrameworkRegistry {
|
||||
private byId = new Map<string, FrameworkConfig>();
|
||||
private byLanguage = new Map<string, FrameworkConfig[]>();
|
||||
|
||||
register(config: FrameworkConfig): void {
|
||||
const parsed = FrameworkConfigSchema.parse(config);
|
||||
|
||||
// Prevent duplicate registration
|
||||
if (this.byId.has(parsed.id)) return;
|
||||
|
||||
this.byId.set(parsed.id, parsed);
|
||||
|
||||
for (const lang of parsed.languages) {
|
||||
const existing = this.byLanguage.get(lang) ?? [];
|
||||
existing.push(parsed);
|
||||
this.byLanguage.set(lang, existing);
|
||||
}
|
||||
}
|
||||
|
||||
getById(id: string): FrameworkConfig | null {
|
||||
return this.byId.get(id) ?? null;
|
||||
}
|
||||
|
||||
getForLanguage(langId: string): FrameworkConfig[] {
|
||||
return [...(this.byLanguage.get(langId) ?? [])];
|
||||
}
|
||||
|
||||
getAllFrameworks(): FrameworkConfig[] {
|
||||
return [...this.byId.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect frameworks from manifest file contents.
|
||||
* @param manifests - Map of filename to file content (e.g., { "requirements.txt": "django==4.2\n..." })
|
||||
* @returns Array of detected FrameworkConfig objects
|
||||
*/
|
||||
detectFrameworks(manifests: Record<string, string>): FrameworkConfig[] {
|
||||
const detected = new Set<string>();
|
||||
const results: FrameworkConfig[] = [];
|
||||
|
||||
for (const config of this.byId.values()) {
|
||||
if (detected.has(config.id)) continue;
|
||||
|
||||
for (const manifestFile of config.manifestFiles) {
|
||||
// Match manifest entries by filename (basename match)
|
||||
const content = Object.entries(manifests).find(
|
||||
([key]) => key === manifestFile || key.endsWith(`/${manifestFile}`),
|
||||
)?.[1];
|
||||
|
||||
if (!content) continue;
|
||||
|
||||
const contentLower = content.toLowerCase();
|
||||
const found = config.detectionKeywords.some((keyword) =>
|
||||
contentLower.includes(keyword.toLowerCase()),
|
||||
);
|
||||
|
||||
if (found) {
|
||||
detected.add(config.id);
|
||||
results.push(config);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a registry pre-populated with all built-in framework configs.
|
||||
*/
|
||||
static createDefault(): FrameworkRegistry {
|
||||
const registry = new FrameworkRegistry();
|
||||
for (const config of builtinFrameworkConfigs) {
|
||||
registry.register(config);
|
||||
}
|
||||
return registry;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { FrameworkConfig } from "../types.js";
|
||||
|
||||
export const djangoConfig = {
|
||||
id: "django",
|
||||
displayName: "Django",
|
||||
languages: ["python"],
|
||||
detectionKeywords: [
|
||||
"django",
|
||||
"djangorestframework",
|
||||
"django-rest-framework",
|
||||
"django-cors-headers",
|
||||
"django-filter",
|
||||
],
|
||||
manifestFiles: [
|
||||
"requirements.txt",
|
||||
"pyproject.toml",
|
||||
"setup.py",
|
||||
"setup.cfg",
|
||||
"Pipfile",
|
||||
],
|
||||
promptSnippetPath: "./frameworks/django.md",
|
||||
entryPoints: ["manage.py", "wsgi.py", "asgi.py"],
|
||||
layerHints: {
|
||||
views: "api",
|
||||
models: "data",
|
||||
serializers: "api",
|
||||
urls: "api",
|
||||
templates: "ui",
|
||||
migrations: "data",
|
||||
management: "config",
|
||||
signals: "service",
|
||||
admin: "config",
|
||||
forms: "ui",
|
||||
templatetags: "utility",
|
||||
},
|
||||
} satisfies FrameworkConfig;
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { FrameworkConfig } from "../types.js";
|
||||
|
||||
export const expressConfig = {
|
||||
id: "express",
|
||||
displayName: "Express",
|
||||
languages: ["javascript", "typescript"],
|
||||
detectionKeywords: ["\"express\":", "express-validator", "express-session"],
|
||||
manifestFiles: ["package.json"],
|
||||
promptSnippetPath: "./frameworks/express.md",
|
||||
entryPoints: [
|
||||
"src/index.js",
|
||||
"src/app.js",
|
||||
"server.js",
|
||||
"app.js",
|
||||
"src/index.ts",
|
||||
"src/app.ts",
|
||||
],
|
||||
layerHints: {
|
||||
routes: "api",
|
||||
controllers: "service",
|
||||
models: "data",
|
||||
middleware: "middleware",
|
||||
services: "service",
|
||||
db: "data",
|
||||
},
|
||||
} satisfies FrameworkConfig;
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { FrameworkConfig } from "../types.js";
|
||||
|
||||
export const fastapiConfig = {
|
||||
id: "fastapi",
|
||||
displayName: "FastAPI",
|
||||
languages: ["python"],
|
||||
detectionKeywords: ["fastapi", "uvicorn", "starlette"],
|
||||
manifestFiles: [
|
||||
"requirements.txt",
|
||||
"pyproject.toml",
|
||||
"setup.py",
|
||||
"setup.cfg",
|
||||
"Pipfile",
|
||||
],
|
||||
promptSnippetPath: "./frameworks/fastapi.md",
|
||||
entryPoints: ["main.py", "app.py"],
|
||||
layerHints: {
|
||||
routers: "api",
|
||||
schemas: "types",
|
||||
models: "data",
|
||||
dependencies: "service",
|
||||
crud: "service",
|
||||
api: "api",
|
||||
},
|
||||
} satisfies FrameworkConfig;
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { FrameworkConfig } from "../types.js";
|
||||
|
||||
export const flaskConfig = {
|
||||
id: "flask",
|
||||
displayName: "Flask",
|
||||
languages: ["python"],
|
||||
detectionKeywords: [
|
||||
"flask",
|
||||
"flask-restful",
|
||||
"flask-sqlalchemy",
|
||||
"flask-marshmallow",
|
||||
"flask-wtf",
|
||||
],
|
||||
manifestFiles: [
|
||||
"requirements.txt",
|
||||
"pyproject.toml",
|
||||
"setup.py",
|
||||
"setup.cfg",
|
||||
"Pipfile",
|
||||
],
|
||||
promptSnippetPath: "./frameworks/flask.md",
|
||||
entryPoints: ["app.py", "run.py", "wsgi.py"],
|
||||
layerHints: {
|
||||
blueprints: "api",
|
||||
views: "api",
|
||||
models: "data",
|
||||
forms: "ui",
|
||||
templates: "ui",
|
||||
extensions: "config",
|
||||
},
|
||||
} satisfies FrameworkConfig;
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { FrameworkConfig } from "../types.js";
|
||||
|
||||
export const ginConfig = {
|
||||
id: "gin",
|
||||
displayName: "Gin",
|
||||
languages: ["go"],
|
||||
detectionKeywords: ["github.com/gin-gonic/gin"],
|
||||
manifestFiles: ["go.mod"],
|
||||
promptSnippetPath: "./frameworks/gin.md",
|
||||
entryPoints: ["main.go", "cmd/server/main.go"],
|
||||
layerHints: {
|
||||
handlers: "api",
|
||||
routes: "api",
|
||||
models: "data",
|
||||
middleware: "middleware",
|
||||
services: "service",
|
||||
repository: "data",
|
||||
},
|
||||
} satisfies FrameworkConfig;
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { FrameworkConfig } from "../types.js";
|
||||
|
||||
import { djangoConfig } from "./django.js";
|
||||
import { fastapiConfig } from "./fastapi.js";
|
||||
import { flaskConfig } from "./flask.js";
|
||||
import { reactConfig } from "./react.js";
|
||||
import { nextjsConfig } from "./nextjs.js";
|
||||
import { expressConfig } from "./express.js";
|
||||
import { vueConfig } from "./vue.js";
|
||||
import { springConfig } from "./spring.js";
|
||||
import { railsConfig } from "./rails.js";
|
||||
import { ginConfig } from "./gin.js";
|
||||
|
||||
export const builtinFrameworkConfigs: FrameworkConfig[] = [
|
||||
djangoConfig,
|
||||
fastapiConfig,
|
||||
flaskConfig,
|
||||
reactConfig,
|
||||
nextjsConfig,
|
||||
expressConfig,
|
||||
vueConfig,
|
||||
springConfig,
|
||||
railsConfig,
|
||||
ginConfig,
|
||||
];
|
||||
|
||||
export {
|
||||
djangoConfig,
|
||||
fastapiConfig,
|
||||
flaskConfig,
|
||||
reactConfig,
|
||||
nextjsConfig,
|
||||
expressConfig,
|
||||
vueConfig,
|
||||
springConfig,
|
||||
railsConfig,
|
||||
ginConfig,
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { FrameworkConfig } from "../types.js";
|
||||
|
||||
export const nextjsConfig = {
|
||||
id: "nextjs",
|
||||
displayName: "Next.js",
|
||||
languages: ["typescript", "javascript"],
|
||||
detectionKeywords: ["\"next\":", "@next/font", "@next/image"],
|
||||
manifestFiles: ["package.json"],
|
||||
promptSnippetPath: "./frameworks/nextjs.md",
|
||||
entryPoints: [
|
||||
"src/app/layout.tsx",
|
||||
"pages/_app.tsx",
|
||||
"src/pages/_app.tsx",
|
||||
],
|
||||
layerHints: {
|
||||
app: "ui",
|
||||
pages: "ui",
|
||||
api: "api",
|
||||
components: "ui",
|
||||
lib: "service",
|
||||
middleware: "middleware",
|
||||
},
|
||||
} satisfies FrameworkConfig;
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { FrameworkConfig } from "../types.js";
|
||||
|
||||
export const railsConfig = {
|
||||
id: "rails",
|
||||
displayName: "Ruby on Rails",
|
||||
languages: ["ruby"],
|
||||
detectionKeywords: [
|
||||
"rails",
|
||||
"railties",
|
||||
"actionpack",
|
||||
"activerecord",
|
||||
"actionview",
|
||||
],
|
||||
manifestFiles: ["Gemfile"],
|
||||
promptSnippetPath: "./frameworks/rails.md",
|
||||
entryPoints: ["config.ru", "bin/rails"],
|
||||
layerHints: {
|
||||
controllers: "api",
|
||||
models: "data",
|
||||
views: "ui",
|
||||
helpers: "utility",
|
||||
mailers: "service",
|
||||
jobs: "service",
|
||||
channels: "service",
|
||||
middleware: "middleware",
|
||||
lib: "service",
|
||||
},
|
||||
} satisfies FrameworkConfig;
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { FrameworkConfig } from "../types.js";
|
||||
|
||||
export const reactConfig = {
|
||||
id: "react",
|
||||
displayName: "React",
|
||||
languages: ["typescript", "javascript"],
|
||||
detectionKeywords: ["react", "react-dom", "@types/react"],
|
||||
manifestFiles: ["package.json"],
|
||||
promptSnippetPath: "./frameworks/react.md",
|
||||
entryPoints: ["src/App.tsx", "src/App.jsx", "src/index.tsx", "src/main.tsx"],
|
||||
layerHints: {
|
||||
components: "ui",
|
||||
hooks: "service",
|
||||
pages: "ui",
|
||||
contexts: "service",
|
||||
utils: "utility",
|
||||
lib: "service",
|
||||
},
|
||||
} satisfies FrameworkConfig;
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { FrameworkConfig } from "../types.js";
|
||||
|
||||
export const springConfig = {
|
||||
id: "spring",
|
||||
displayName: "Spring Boot",
|
||||
languages: ["java", "kotlin"],
|
||||
detectionKeywords: [
|
||||
"spring-boot",
|
||||
"spring-boot-starter",
|
||||
"spring-web",
|
||||
"spring-data",
|
||||
"org.springframework",
|
||||
],
|
||||
manifestFiles: ["pom.xml", "build.gradle", "build.gradle.kts"],
|
||||
promptSnippetPath: "./frameworks/spring.md",
|
||||
entryPoints: ["**/Application.java", "**/App.java"],
|
||||
layerHints: {
|
||||
controller: "api",
|
||||
service: "service",
|
||||
repository: "data",
|
||||
model: "data",
|
||||
entity: "data",
|
||||
config: "config",
|
||||
dto: "types",
|
||||
security: "middleware",
|
||||
},
|
||||
} satisfies FrameworkConfig;
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { FrameworkConfig } from "../types.js";
|
||||
|
||||
export const vueConfig = {
|
||||
id: "vue",
|
||||
displayName: "Vue",
|
||||
languages: ["typescript", "javascript"],
|
||||
detectionKeywords: ["vue", "@vue/cli-service", "nuxt", "vite-plugin-vue"],
|
||||
manifestFiles: ["package.json"],
|
||||
promptSnippetPath: "./frameworks/vue.md",
|
||||
entryPoints: ["src/main.ts", "src/App.vue", "src/main.js"],
|
||||
layerHints: {
|
||||
components: "ui",
|
||||
views: "ui",
|
||||
store: "service",
|
||||
composables: "service",
|
||||
router: "config",
|
||||
plugins: "config",
|
||||
},
|
||||
} satisfies FrameworkConfig;
|
||||
@@ -0,0 +1,22 @@
|
||||
// Types
|
||||
export type {
|
||||
LanguageConfig,
|
||||
TreeSitterConfig,
|
||||
FilePatternConfig,
|
||||
FrameworkConfig,
|
||||
} from "./types.js";
|
||||
|
||||
export {
|
||||
LanguageConfigSchema,
|
||||
TreeSitterConfigSchema,
|
||||
FilePatternConfigSchema,
|
||||
FrameworkConfigSchema,
|
||||
} from "./types.js";
|
||||
|
||||
// Registries
|
||||
export { LanguageRegistry } from "./language-registry.js";
|
||||
export { FrameworkRegistry } from "./framework-registry.js";
|
||||
|
||||
// Built-in configs
|
||||
export { builtinLanguageConfigs } from "./configs/index.js";
|
||||
export { builtinFrameworkConfigs } from "./frameworks/index.js";
|
||||
@@ -0,0 +1,53 @@
|
||||
import { LanguageConfigSchema } from "./types.js";
|
||||
import type { LanguageConfig } from "./types.js";
|
||||
import { builtinLanguageConfigs } from "./configs/index.js";
|
||||
|
||||
/**
|
||||
* Registry for language configurations. Maps language ids and file extensions
|
||||
* to their corresponding LanguageConfig objects.
|
||||
*/
|
||||
export class LanguageRegistry {
|
||||
private byId = new Map<string, LanguageConfig>();
|
||||
private byExtension = new Map<string, LanguageConfig>();
|
||||
|
||||
register(config: LanguageConfig): void {
|
||||
const parsed = LanguageConfigSchema.parse(config);
|
||||
this.byId.set(parsed.id, parsed);
|
||||
for (const ext of parsed.extensions) {
|
||||
// Normalize: strip leading dot if present for lookup consistency
|
||||
const key = ext.startsWith(".") ? ext : `.${ext}`;
|
||||
this.byExtension.set(key, parsed);
|
||||
}
|
||||
}
|
||||
|
||||
getById(id: string): LanguageConfig | null {
|
||||
return this.byId.get(id) ?? null;
|
||||
}
|
||||
|
||||
getByExtension(ext: string): LanguageConfig | null {
|
||||
const key = (ext.startsWith(".") ? ext : `.${ext}`).toLowerCase();
|
||||
return this.byExtension.get(key) ?? null;
|
||||
}
|
||||
|
||||
getForFile(filePath: string): LanguageConfig | null {
|
||||
const lastDot = filePath.lastIndexOf(".");
|
||||
if (lastDot === -1) return null;
|
||||
const ext = filePath.slice(lastDot).toLowerCase();
|
||||
return this.getByExtension(ext);
|
||||
}
|
||||
|
||||
getAllLanguages(): LanguageConfig[] {
|
||||
return [...this.byId.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a registry pre-populated with all built-in language configs.
|
||||
*/
|
||||
static createDefault(): LanguageRegistry {
|
||||
const registry = new LanguageRegistry();
|
||||
for (const config of builtinLanguageConfigs) {
|
||||
registry.register(config);
|
||||
}
|
||||
return registry;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// Tree-sitter grammar configuration for a language.
|
||||
// Only wasmPackage and wasmFile are needed for grammar loading.
|
||||
// The extraction logic in tree-sitter-plugin.ts is currently TS/JS-specific;
|
||||
// when grammars for other languages are added, language-specific extractors
|
||||
// should be registered via the customAnalyzer escape hatch.
|
||||
export const TreeSitterConfigSchema = z.object({
|
||||
wasmPackage: z.string(),
|
||||
wasmFile: z.string(),
|
||||
});
|
||||
|
||||
export type TreeSitterConfig = z.infer<typeof TreeSitterConfigSchema>;
|
||||
|
||||
// File pattern conventions for a language
|
||||
export const FilePatternConfigSchema = z.object({
|
||||
entryPoints: z.array(z.string()),
|
||||
barrels: z.array(z.string()),
|
||||
tests: z.array(z.string()),
|
||||
config: z.array(z.string()),
|
||||
});
|
||||
|
||||
export type FilePatternConfig = z.infer<typeof FilePatternConfigSchema>;
|
||||
|
||||
// Complete language configuration
|
||||
export const LanguageConfigSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
displayName: z.string().min(1),
|
||||
extensions: z.array(z.string()).min(1),
|
||||
treeSitter: TreeSitterConfigSchema.optional(),
|
||||
concepts: z.array(z.string()),
|
||||
filePatterns: FilePatternConfigSchema,
|
||||
});
|
||||
|
||||
export type LanguageConfig = z.infer<typeof LanguageConfigSchema>;
|
||||
|
||||
// Framework configuration
|
||||
export const FrameworkConfigSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
displayName: z.string().min(1),
|
||||
languages: z.array(z.string().min(1)).min(1),
|
||||
detectionKeywords: z.array(z.string()).min(1),
|
||||
manifestFiles: z.array(z.string()).min(1),
|
||||
promptSnippetPath: z.string().min(1),
|
||||
entryPoints: z.array(z.string()).optional(),
|
||||
layerHints: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
|
||||
export type FrameworkConfig = z.infer<typeof FrameworkConfigSchema>;
|
||||
@@ -36,7 +36,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;
|
||||
|
||||
@@ -41,7 +41,7 @@ describe("persistence", () => {
|
||||
edges: [
|
||||
{
|
||||
source: "node-1",
|
||||
target: "node-2",
|
||||
target: "node-1",
|
||||
type: "imports",
|
||||
direction: "forward",
|
||||
weight: 0.8,
|
||||
|
||||
@@ -1,30 +1,21 @@
|
||||
import type { AnalyzerPlugin, StructuralAnalysis, ImportResolution } from "../types.js";
|
||||
|
||||
const EXTENSION_TO_LANGUAGE: Record<string, string> = {
|
||||
ts: "typescript",
|
||||
tsx: "typescript",
|
||||
js: "javascript",
|
||||
jsx: "javascript",
|
||||
py: "python",
|
||||
go: "go",
|
||||
rs: "rust",
|
||||
rb: "ruby",
|
||||
java: "java",
|
||||
kt: "kotlin",
|
||||
cs: "csharp",
|
||||
cpp: "cpp",
|
||||
c: "c",
|
||||
swift: "swift",
|
||||
php: "php",
|
||||
};
|
||||
import { LanguageRegistry } from "../languages/language-registry.js";
|
||||
|
||||
/**
|
||||
* Registry for analyzer plugins. Maps languages to plugins and provides
|
||||
* a unified interface for analyzing files across languages.
|
||||
*
|
||||
* Uses LanguageRegistry for extension-to-language mapping instead of
|
||||
* a hardcoded lookup table.
|
||||
*/
|
||||
export class PluginRegistry {
|
||||
private plugins: AnalyzerPlugin[] = [];
|
||||
private languageMap = new Map<string, AnalyzerPlugin>();
|
||||
private languageRegistry: LanguageRegistry;
|
||||
|
||||
constructor(languageRegistry?: LanguageRegistry) {
|
||||
this.languageRegistry = languageRegistry ?? LanguageRegistry.createDefault();
|
||||
}
|
||||
|
||||
register(plugin: AnalyzerPlugin): void {
|
||||
this.plugins.push(plugin);
|
||||
@@ -50,11 +41,16 @@ export class PluginRegistry {
|
||||
}
|
||||
|
||||
getPluginForFile(filePath: string): AnalyzerPlugin | null {
|
||||
const ext = filePath.split(".").pop()?.toLowerCase();
|
||||
if (!ext) return null;
|
||||
const language = EXTENSION_TO_LANGUAGE[ext];
|
||||
if (!language) return null;
|
||||
return this.getPluginForLanguage(language);
|
||||
const langConfig = this.languageRegistry.getForFile(filePath);
|
||||
if (!langConfig) return null;
|
||||
return this.getPluginForLanguage(langConfig.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the language id for a file path using the language registry.
|
||||
*/
|
||||
getLanguageForFile(filePath: string): string | null {
|
||||
return this.languageRegistry.getForFile(filePath)?.id ?? null;
|
||||
}
|
||||
|
||||
analyzeFile(filePath: string, content: string): StructuralAnalysis | null {
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
ImportResolution,
|
||||
CallGraphEntry,
|
||||
} from "../types.js";
|
||||
import type { LanguageConfig } from "../languages/types.js";
|
||||
|
||||
// web-tree-sitter uses CJS internally; we need createRequire for .wasm resolution
|
||||
const require = createRequire(import.meta.url);
|
||||
@@ -14,23 +15,6 @@ type TreeSitterParser = import("web-tree-sitter").Parser;
|
||||
type TreeSitterLanguage = import("web-tree-sitter").Language;
|
||||
type TreeSitterNode = import("web-tree-sitter").Node;
|
||||
|
||||
function languageKeyFromPath(filePath: string): string {
|
||||
const ext = extname(filePath).toLowerCase();
|
||||
switch (ext) {
|
||||
case ".ts":
|
||||
return "typescript";
|
||||
case ".tsx":
|
||||
return "tsx";
|
||||
case ".js":
|
||||
case ".mjs":
|
||||
case ".cjs":
|
||||
case ".jsx":
|
||||
return "javascript";
|
||||
default:
|
||||
throw new Error(`Unsupported file extension: ${ext}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively traverse an AST tree, calling the visitor for each node.
|
||||
*/
|
||||
@@ -155,17 +139,77 @@ function extractImportSpecifiers(
|
||||
return specifiers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Config-driven tree-sitter plugin.
|
||||
*
|
||||
* Accepts LanguageConfig objects to determine which languages to support
|
||||
* and how to load their WASM grammars. Currently provides deep structural
|
||||
* analysis for TypeScript/JavaScript; other languages with tree-sitter configs
|
||||
* get basic function/class/import extraction.
|
||||
*
|
||||
* Languages without tree-sitter configs are gracefully skipped (the LLM
|
||||
* agent handles analysis for those).
|
||||
*/
|
||||
export class TreeSitterPlugin implements AnalyzerPlugin {
|
||||
readonly name = "tree-sitter";
|
||||
readonly languages = ["typescript", "javascript"];
|
||||
readonly languages: string[];
|
||||
|
||||
private configs: LanguageConfig[];
|
||||
|
||||
// Pre-loaded parser constructor and languages (set by init())
|
||||
private _ParserClass:
|
||||
| (new () => TreeSitterParser)
|
||||
| null = null;
|
||||
private _languages = new Map<string, TreeSitterLanguage>();
|
||||
private _extensionToLang = new Map<string, string>();
|
||||
private _initialized = false;
|
||||
|
||||
/**
|
||||
* Create a TreeSitterPlugin with the given language configs.
|
||||
* Only configs that have a `treeSitter` field will be loaded.
|
||||
* If no configs are provided, defaults to TypeScript and JavaScript.
|
||||
*/
|
||||
constructor(configs?: LanguageConfig[]) {
|
||||
if (configs) {
|
||||
this.configs = configs.filter((c) => c.treeSitter);
|
||||
} else {
|
||||
// Default: TS/JS for backward compatibility
|
||||
this.configs = [];
|
||||
}
|
||||
|
||||
// Derive supported languages and extension map from configs
|
||||
const langs: string[] = [];
|
||||
for (const config of this.configs) {
|
||||
langs.push(config.id);
|
||||
for (const ext of config.extensions) {
|
||||
const key = ext.startsWith(".") ? ext : `.${ext}`;
|
||||
this._extensionToLang.set(key, config.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for backward compat when no configs provided
|
||||
if (langs.length === 0) {
|
||||
langs.push("typescript", "javascript");
|
||||
this._extensionToLang.set(".ts", "typescript");
|
||||
this._extensionToLang.set(".tsx", "typescript");
|
||||
this._extensionToLang.set(".js", "javascript");
|
||||
this._extensionToLang.set(".mjs", "javascript");
|
||||
this._extensionToLang.set(".cjs", "javascript");
|
||||
this._extensionToLang.set(".jsx", "javascript");
|
||||
}
|
||||
|
||||
this.languages = langs;
|
||||
}
|
||||
|
||||
private languageKeyFromPath(filePath: string): string | null {
|
||||
const ext = extname(filePath).toLowerCase();
|
||||
|
||||
// Special case: .tsx needs its own grammar
|
||||
if (ext === ".tsx") return "tsx";
|
||||
|
||||
return this._extensionToLang.get(ext) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the plugin by loading the WASM module and all language grammars.
|
||||
* Must be called (and awaited) before any synchronous methods.
|
||||
@@ -180,26 +224,68 @@ export class TreeSitterPlugin implements AnalyzerPlugin {
|
||||
await ParserCls.init();
|
||||
this._ParserClass = ParserCls as unknown as new () => TreeSitterParser;
|
||||
|
||||
// Pre-load all supported language grammars
|
||||
const tsWasm = require.resolve(
|
||||
"tree-sitter-typescript/tree-sitter-typescript.wasm",
|
||||
);
|
||||
const tsxWasm = require.resolve(
|
||||
"tree-sitter-typescript/tree-sitter-tsx.wasm",
|
||||
);
|
||||
const jsWasm = require.resolve(
|
||||
"tree-sitter-javascript/tree-sitter-javascript.wasm",
|
||||
);
|
||||
if (this.configs.length > 0) {
|
||||
// Load grammars from configs
|
||||
const loadPromises: Promise<void>[] = [];
|
||||
|
||||
const [tsLang, tsxLang, jsLang] = await Promise.all([
|
||||
LanguageCls.load(tsWasm),
|
||||
LanguageCls.load(tsxWasm),
|
||||
LanguageCls.load(jsWasm),
|
||||
]);
|
||||
for (const config of this.configs) {
|
||||
if (!config.treeSitter) continue;
|
||||
|
||||
const loadGrammar = async () => {
|
||||
try {
|
||||
const wasmPath = require.resolve(
|
||||
`${config.treeSitter!.wasmPackage}/${config.treeSitter!.wasmFile}`,
|
||||
);
|
||||
const lang = await LanguageCls.load(wasmPath);
|
||||
this._languages.set(config.id, lang);
|
||||
|
||||
// Special handling for TypeScript: also load TSX grammar
|
||||
if (config.id === "typescript") {
|
||||
try {
|
||||
const tsxWasm = require.resolve(
|
||||
`${config.treeSitter!.wasmPackage}/tree-sitter-tsx.wasm`,
|
||||
);
|
||||
const tsxLang = await LanguageCls.load(tsxWasm);
|
||||
this._languages.set("tsx", tsxLang);
|
||||
} catch {
|
||||
// TSX grammar not available; .tsx files will fall back to TS grammar
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Grammar not available — this language will be skipped gracefully
|
||||
console.debug?.(
|
||||
`tree-sitter: Could not load grammar for ${config.id}, skipping structural analysis`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
loadPromises.push(loadGrammar());
|
||||
}
|
||||
|
||||
await Promise.all(loadPromises);
|
||||
} else {
|
||||
// Legacy fallback: load TS/JS grammars directly
|
||||
const tsWasm = require.resolve(
|
||||
"tree-sitter-typescript/tree-sitter-typescript.wasm",
|
||||
);
|
||||
const tsxWasm = require.resolve(
|
||||
"tree-sitter-typescript/tree-sitter-tsx.wasm",
|
||||
);
|
||||
const jsWasm = require.resolve(
|
||||
"tree-sitter-javascript/tree-sitter-javascript.wasm",
|
||||
);
|
||||
|
||||
const [tsLang, tsxLang, jsLang] = await Promise.all([
|
||||
LanguageCls.load(tsWasm),
|
||||
LanguageCls.load(tsxWasm),
|
||||
LanguageCls.load(jsWasm),
|
||||
]);
|
||||
|
||||
this._languages.set("typescript", tsLang);
|
||||
this._languages.set("tsx", tsxLang);
|
||||
this._languages.set("javascript", jsLang);
|
||||
}
|
||||
|
||||
this._languages.set("typescript", tsLang);
|
||||
this._languages.set("tsx", tsxLang);
|
||||
this._languages.set("javascript", jsLang);
|
||||
this._initialized = true;
|
||||
}
|
||||
|
||||
@@ -207,16 +293,18 @@ export class TreeSitterPlugin implements AnalyzerPlugin {
|
||||
* Create a parser set to the appropriate language for the given file.
|
||||
* This is synchronous because all languages are pre-loaded during init().
|
||||
*/
|
||||
private getParser(filePath: string): TreeSitterParser {
|
||||
private getParser(filePath: string): TreeSitterParser | null {
|
||||
if (!this._initialized || !this._ParserClass) {
|
||||
throw new Error(
|
||||
"TreeSitterPlugin.init() must be called before use",
|
||||
);
|
||||
}
|
||||
const langKey = languageKeyFromPath(filePath);
|
||||
const langKey = this.languageKeyFromPath(filePath);
|
||||
if (!langKey) return null;
|
||||
const lang = this._languages.get(langKey);
|
||||
if (!lang) {
|
||||
throw new Error(`Language not loaded: ${langKey}`);
|
||||
// Language grammar not loaded — graceful degradation
|
||||
return null;
|
||||
}
|
||||
const parser = new this._ParserClass();
|
||||
parser.setLanguage(lang);
|
||||
@@ -228,6 +316,10 @@ export class TreeSitterPlugin implements AnalyzerPlugin {
|
||||
content: string,
|
||||
): StructuralAnalysis {
|
||||
const parser = this.getParser(filePath);
|
||||
if (!parser) {
|
||||
return { functions: [], classes: [], imports: [], exports: [] };
|
||||
}
|
||||
|
||||
const tree = parser.parse(content);
|
||||
if (!tree) {
|
||||
parser.delete();
|
||||
@@ -290,6 +382,8 @@ export class TreeSitterPlugin implements AnalyzerPlugin {
|
||||
content: string,
|
||||
): CallGraphEntry[] {
|
||||
const parser = this.getParser(filePath);
|
||||
if (!parser) return [];
|
||||
|
||||
const tree = parser.parse(content);
|
||||
if (!tree) {
|
||||
parser.delete();
|
||||
|
||||
@@ -9,6 +9,261 @@ export const EdgeTypeSchema = z.enum([
|
||||
"related", "similar_to", // Semantic
|
||||
]);
|
||||
|
||||
// Aliases that LLMs commonly generate instead of canonical node types
|
||||
export const NODE_TYPE_ALIASES: Record<string, string> = {
|
||||
func: "function",
|
||||
fn: "function",
|
||||
method: "function",
|
||||
interface: "class",
|
||||
struct: "class",
|
||||
mod: "module",
|
||||
pkg: "module",
|
||||
package: "module",
|
||||
};
|
||||
|
||||
// Aliases that LLMs commonly generate instead of canonical edge types
|
||||
export const EDGE_TYPE_ALIASES: Record<string, string> = {
|
||||
extends: "inherits",
|
||||
invokes: "calls",
|
||||
invoke: "calls",
|
||||
uses: "depends_on",
|
||||
requires: "depends_on",
|
||||
relates_to: "related",
|
||||
related_to: "related",
|
||||
similar: "similar_to",
|
||||
import: "imports",
|
||||
export: "exports",
|
||||
contain: "contains",
|
||||
publish: "publishes",
|
||||
subscribe: "subscribes",
|
||||
};
|
||||
|
||||
// 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"]),
|
||||
@@ -63,23 +318,236 @@ 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 {
|
||||
if (typeof data !== "object" || data === null) return data;
|
||||
|
||||
const d = data as Record<string, unknown>;
|
||||
const result = { ...d };
|
||||
|
||||
if (Array.isArray(d.nodes)) {
|
||||
result.nodes = (d.nodes as any[]).map((node) => {
|
||||
if (
|
||||
typeof node === "object" &&
|
||||
node !== null &&
|
||||
typeof node.type === "string" &&
|
||||
node.type in NODE_TYPE_ALIASES
|
||||
) {
|
||||
return { ...node, type: NODE_TYPE_ALIASES[node.type] };
|
||||
}
|
||||
return node;
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(d.edges)) {
|
||||
result.edges = (d.edges as any[]).map((edge) => {
|
||||
if (
|
||||
typeof edge === "object" &&
|
||||
edge !== null &&
|
||||
typeof edge.type === "string" &&
|
||||
edge.type in EDGE_TYPE_ALIASES
|
||||
) {
|
||||
return { ...edge, type: EDGE_TYPE_ALIASES[edge.type] };
|
||||
}
|
||||
return edge;
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function validateGraph(data: unknown): ValidationResult {
|
||||
const result = KnowledgeGraphSchema.safeParse(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) };
|
||||
}
|
||||
|
||||
@@ -66,12 +66,19 @@ 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)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useMemo } 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";
|
||||
@@ -10,6 +11,13 @@ import DiffToggle from "./components/DiffToggle";
|
||||
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 { 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";
|
||||
|
||||
function App() {
|
||||
const graph = useDashboardStore((s) => s.graph);
|
||||
@@ -21,6 +29,108 @@ function App() {
|
||||
const closeCodeViewer = useDashboardStore((s) => s.closeCodeViewer);
|
||||
const setDiffOverlay = useDashboardStore((s) => s.setDiffOverlay);
|
||||
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("/meta.json")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((meta) => {
|
||||
if (meta?.theme) setMetaTheme(meta.theme);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Define keyboard shortcuts
|
||||
const shortcuts = useMemo<KeyboardShortcut[]>(
|
||||
() => [
|
||||
// Help
|
||||
{
|
||||
key: "?",
|
||||
shiftKey: true,
|
||||
description: "Show keyboard shortcuts",
|
||||
action: () => setShowKeyboardHelp((prev) => !prev),
|
||||
category: "General",
|
||||
},
|
||||
// Navigation
|
||||
{
|
||||
key: "Escape",
|
||||
description: "Close panels and modals",
|
||||
action: () => {
|
||||
// Read from store at invocation time to avoid stale closures
|
||||
const state = useDashboardStore.getState();
|
||||
if (state.codeViewerOpen) {
|
||||
state.closeCodeViewer();
|
||||
} else if (state.selectedNodeId) {
|
||||
state.selectNode(null);
|
||||
} else if (state.tourActive) {
|
||||
state.stopTour();
|
||||
} else {
|
||||
setShowKeyboardHelp(false);
|
||||
}
|
||||
},
|
||||
category: "Navigation",
|
||||
},
|
||||
{
|
||||
key: "/",
|
||||
description: "Focus search bar",
|
||||
action: () => {
|
||||
const searchInput = document.querySelector<HTMLInputElement>(
|
||||
'input[placeholder*="Search"]'
|
||||
);
|
||||
searchInput?.focus();
|
||||
},
|
||||
category: "Navigation",
|
||||
},
|
||||
// Tour controls
|
||||
{
|
||||
key: "ArrowRight",
|
||||
description: "Next tour step",
|
||||
action: () => {
|
||||
const state = useDashboardStore.getState();
|
||||
if (state.tourActive) {
|
||||
state.nextTourStep();
|
||||
}
|
||||
},
|
||||
category: "Tour",
|
||||
},
|
||||
{
|
||||
key: "ArrowLeft",
|
||||
description: "Previous tour step",
|
||||
action: () => {
|
||||
const state = useDashboardStore.getState();
|
||||
if (state.tourActive) {
|
||||
state.prevTourStep();
|
||||
}
|
||||
},
|
||||
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",
|
||||
action: () => {
|
||||
const state = useDashboardStore.getState();
|
||||
state.toggleDiffMode();
|
||||
},
|
||||
category: "View",
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
// Register keyboard shortcuts
|
||||
useKeyboardShortcuts(shortcuts);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/knowledge-graph.json")
|
||||
@@ -29,10 +139,20 @@ function App() {
|
||||
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) => {
|
||||
@@ -78,6 +198,7 @@ function App() {
|
||||
);
|
||||
|
||||
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">
|
||||
@@ -91,12 +212,37 @@ function App() {
|
||||
<div className="flex items-center gap-4">
|
||||
<DiffToggle />
|
||||
<LayerLegend />
|
||||
<ThemePicker />
|
||||
<button
|
||||
onClick={() => setShowKeyboardHelp(true)}
|
||||
className="text-text-muted hover:text-accent transition-colors"
|
||||
title="Keyboard shortcuts (Shift + ?)"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 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">
|
||||
@@ -107,8 +253,11 @@ function App() {
|
||||
{/* Main content: Graph + Sidebar */}
|
||||
<div className="flex-1 flex min-h-0 relative">
|
||||
{/* Graph area */}
|
||||
<div className="flex-1 min-w-0 min-h-0">
|
||||
<div className="flex-1 min-w-0 min-h-0 relative">
|
||||
<GraphView />
|
||||
<div className="absolute top-3 right-3 text-sm text-text-muted/60 pointer-events-none select-none">
|
||||
Press <kbd className="kbd">?</kbd> for keyboard shortcuts
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right sidebar */}
|
||||
@@ -135,7 +284,16 @@ function App() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Keyboard shortcuts help modal */}
|
||||
{showKeyboardHelp && (
|
||||
<KeyboardShortcutsHelp
|
||||
shortcuts={shortcuts}
|
||||
onClose={() => setShowKeyboardHelp(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
import { memo } from "react";
|
||||
import { Handle, Position } from "@xyflow/react";
|
||||
import type { NodeProps, Node } from "@xyflow/react";
|
||||
|
||||
@@ -19,7 +20,7 @@ const typeTextColors: Record<string, string> = {
|
||||
|
||||
const complexityColors: Record<string, string> = {
|
||||
simple: "text-node-function",
|
||||
moderate: "text-gold-dim",
|
||||
moderate: "text-accent-dim",
|
||||
complex: "text-[#c97070]",
|
||||
};
|
||||
|
||||
@@ -40,7 +41,7 @@ export interface CustomNodeData extends Record<string, unknown> {
|
||||
|
||||
export type CustomFlowNode = Node<CustomNodeData, "custom">;
|
||||
|
||||
export default function CustomNode({
|
||||
function CustomNodeComponent({
|
||||
id,
|
||||
data,
|
||||
}: NodeProps<CustomFlowNode>) {
|
||||
@@ -50,17 +51,17 @@ export default function CustomNode({
|
||||
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,8 +80,7 @@ export default function CustomNode({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative rounded-lg bg-elevated border border-border-subtle ${extraClass} min-w-[180px] max-w-[220px] overflow-hidden transition-all duration-200 cursor-pointer`}
|
||||
style={{ boxShadow: '0 2px 8px rgba(0,0,0,0.3)' }}
|
||||
className={`relative rounded-lg bg-elevated border border-border-subtle ${extraClass} min-w-[180px] max-w-[220px] overflow-hidden transition-[box-shadow,outline,opacity,filter] duration-200 cursor-pointer shadow-[0_2px_8px_rgba(0,0,0,0.3)]`}
|
||||
onClick={() => data.onNodeClick?.(id)}
|
||||
>
|
||||
{/* Left color bar */}
|
||||
@@ -122,3 +122,6 @@ export default function CustomNode({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CustomNode = memo(CustomNodeComponent);
|
||||
export default CustomNode;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ReactFlow,
|
||||
ReactFlowProvider,
|
||||
@@ -16,11 +16,17 @@ import "@xyflow/react/dist/style.css";
|
||||
import CustomNode from "./CustomNode";
|
||||
import type { CustomFlowNode } from "./CustomNode";
|
||||
import { useDashboardStore } from "../store";
|
||||
import { applyDagreLayout, NODE_WIDTH, NODE_HEIGHT } from "../utils/layout";
|
||||
// Layer colors are hardcoded to gold-tinted values in the group node styles
|
||||
import { useTheme } from "../themes/index.ts";
|
||||
import { applyDagreLayout, applyDagreLayoutAsync, NODE_WIDTH, NODE_HEIGHT } from "../utils/layout";
|
||||
|
||||
const LAYER_PADDING = 40;
|
||||
|
||||
/**
|
||||
* Node count above which layout runs in a Web Worker
|
||||
* to avoid blocking the main thread.
|
||||
*/
|
||||
const ASYNC_LAYOUT_THRESHOLD = 200;
|
||||
|
||||
const nodeTypes = { custom: CustomNode };
|
||||
|
||||
/**
|
||||
@@ -84,6 +90,214 @@ 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,
|
||||
) {
|
||||
const filteredGraphNodes =
|
||||
persona === "non-technical"
|
||||
? graph.nodes.filter(
|
||||
(n) =>
|
||||
n.type === "concept" || n.type === "module" || n.type === "file",
|
||||
)
|
||||
: graph.nodes;
|
||||
|
||||
const filteredNodeIds = new Set(filteredGraphNodes.map((n) => n.id));
|
||||
const filteredGraphEdges =
|
||||
persona === "non-technical"
|
||||
? graph.edges.filter(
|
||||
(e) => filteredNodeIds.has(e.source) && filteredNodeIds.has(e.target),
|
||||
)
|
||||
: graph.edges;
|
||||
|
||||
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,
|
||||
},
|
||||
}));
|
||||
|
||||
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
|
||||
? "var(--color-diff-changed)"
|
||||
: "var(--color-diff-affected)",
|
||||
strokeWidth: 2.5,
|
||||
}
|
||||
: diffMode
|
||||
? { stroke: "var(--color-edge-dim)", strokeWidth: 1 }
|
||||
: { stroke: "var(--color-edge)", strokeWidth: 1.5 },
|
||||
labelStyle: diffMode && !isImpacted
|
||||
? { fill: "var(--color-text-muted)", fontSize: 10 }
|
||||
: { fill: "var(--color-text-secondary)", 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;
|
||||
}
|
||||
|
||||
return {
|
||||
...node,
|
||||
data: {
|
||||
...data,
|
||||
isHighlighted,
|
||||
searchScore,
|
||||
isSelected,
|
||||
isTourHighlighted,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
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: "var(--color-accent-overlay-bg)",
|
||||
borderRadius: 12,
|
||||
border: "2px dashed var(--color-accent-overlay-border)",
|
||||
padding: 8,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "var(--color-accent)",
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of laidNodes) {
|
||||
if (!nodeToLayer.has(node.id)) {
|
||||
adjustedNodes.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
initialNodes: [...groupNodes, ...adjustedNodes],
|
||||
initialEdges: edges,
|
||||
};
|
||||
}
|
||||
|
||||
function GraphViewInner() {
|
||||
const graph = useDashboardStore((s) => s.graph);
|
||||
const selectedNodeId = useDashboardStore((s) => s.selectedNodeId);
|
||||
@@ -96,6 +310,9 @@ function GraphViewInner() {
|
||||
const diffMode = useDashboardStore((s) => s.diffMode);
|
||||
const changedNodeIds = useDashboardStore((s) => s.changedNodeIds);
|
||||
const affectedNodeIds = useDashboardStore((s) => s.affectedNodeIds);
|
||||
const { preset } = useTheme();
|
||||
|
||||
const [layouting, setLayouting] = useState(false);
|
||||
|
||||
const handleNodeSelect = useCallback(
|
||||
(nodeId: string) => {
|
||||
@@ -105,190 +322,86 @@ function GraphViewInner() {
|
||||
[selectNode, openCodeViewer],
|
||||
);
|
||||
|
||||
const { initialNodes, initialEdges } = useMemo(() => {
|
||||
if (!graph)
|
||||
return {
|
||||
initialNodes: [] as (CustomFlowNode | Node)[],
|
||||
initialEdges: [] as Edge[],
|
||||
};
|
||||
// ── 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,
|
||||
);
|
||||
return { topoNodes: flowNodes, topoEdges: flowEdges, needsAsyncLayout: flowNodes.length > ASYNC_LAYOUT_THRESHOLD };
|
||||
}, [graph, persona, handleNodeSelect, diffMode, changedNodeIds, affectedNodeIds]);
|
||||
|
||||
// Filter nodes and edges based on persona
|
||||
const filteredGraphNodes =
|
||||
persona === "non-technical"
|
||||
? graph.nodes.filter(
|
||||
(n) =>
|
||||
n.type === "concept" || n.type === "module" || n.type === "file",
|
||||
)
|
||||
: graph.nodes;
|
||||
|
||||
const filteredNodeIds = new Set(filteredGraphNodes.map((n) => n.id));
|
||||
const filteredGraphEdges =
|
||||
persona === "non-technical"
|
||||
? graph.edges.filter(
|
||||
(e) => filteredNodeIds.has(e.source) && filteredNodeIds.has(e.target),
|
||||
)
|
||||
: graph.edges;
|
||||
|
||||
const flowNodes: CustomFlowNode[] = filteredGraphNodes.map((node) => {
|
||||
const matchResult = searchResults.find((r) => r.nodeId === node.id);
|
||||
return {
|
||||
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: !!matchResult,
|
||||
searchScore: matchResult?.score,
|
||||
isSelected: selectedNodeId === node.id,
|
||||
isTourHighlighted: tourHighlightedNodeIds.includes(node.id),
|
||||
isDiffChanged: diffMode && changedNodeIds.has(node.id),
|
||||
isDiffAffected: diffMode && affectedNodeIds.has(node.id),
|
||||
isDiffFaded: diffMode && !changedNodeIds.has(node.id) && !affectedNodeIds.has(node.id),
|
||||
onNodeClick: handleNodeSelect,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
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 },
|
||||
};
|
||||
});
|
||||
|
||||
// Run dagre layout on all nodes (without groups)
|
||||
const laid = applyDagreLayout(flowNodes, flowEdges);
|
||||
const laidNodes = laid.nodes as CustomFlowNode[];
|
||||
// ── 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);
|
||||
|
||||
// ── 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 ?? [];
|
||||
if (!showLayers || layers.length === 0) {
|
||||
return { initialNodes: laidNodes, initialEdges: laid.edges };
|
||||
}
|
||||
return applyLayerGroups(laid.nodes as CustomFlowNode[], laid.edges, layers, showLayers);
|
||||
}, [graph, topoNodes, topoEdges, needsAsyncLayout, showLayers]);
|
||||
|
||||
// Build a map of nodeId -> layer for quick lookup
|
||||
const nodeToLayer = new Map<string, string>();
|
||||
for (const layer of layers) {
|
||||
for (const nodeId of layer.nodeIds) {
|
||||
nodeToLayer.set(nodeId, layer.id);
|
||||
}
|
||||
}
|
||||
// Keep laidOutRef in sync with sync layout results
|
||||
if (syncResult) {
|
||||
laidOutRef.current = syncResult;
|
||||
}
|
||||
|
||||
// Create group nodes and adjust member positions
|
||||
const groupNodes: Node[] = [];
|
||||
const adjustedNodes: (CustomFlowNode | Node)[] = [];
|
||||
// ── 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]);
|
||||
|
||||
for (let layerIdx = 0; layerIdx < layers.length; layerIdx++) {
|
||||
const layer = layers[layerIdx];
|
||||
const memberNodes = laidNodes.filter((n) =>
|
||||
layer.nodeIds.includes(n.id),
|
||||
);
|
||||
|
||||
if (memberNodes.length === 0) continue;
|
||||
|
||||
// Compute bounding box around member nodes
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
|
||||
for (const node of memberNodes) {
|
||||
const x = node.position.x;
|
||||
const y = node.position.y;
|
||||
minX = Math.min(minX, x);
|
||||
minY = Math.min(minY, y);
|
||||
maxX = Math.max(maxX, x + NODE_WIDTH);
|
||||
maxY = Math.max(maxY, y + NODE_HEIGHT);
|
||||
}
|
||||
|
||||
// Group node position = top-left with padding
|
||||
const groupX = minX - LAYER_PADDING;
|
||||
const groupY = minY - LAYER_PADDING - 24; // extra space for label
|
||||
const groupWidth = maxX - minX + LAYER_PADDING * 2;
|
||||
const groupHeight = maxY - minY + LAYER_PADDING * 2 + 24;
|
||||
|
||||
// Create the group node
|
||||
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",
|
||||
},
|
||||
});
|
||||
|
||||
// Adjust member node positions to be relative to the group
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add nodes that are not in any layer (keep original positions)
|
||||
for (const node of laidNodes) {
|
||||
if (!nodeToLayer.has(node.id)) {
|
||||
adjustedNodes.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
// Group nodes must come before their children in the array
|
||||
const allNodes: (CustomFlowNode | Node)[] = [
|
||||
...groupNodes,
|
||||
...adjustedNodes,
|
||||
];
|
||||
|
||||
return { initialNodes: allNodes, initialEdges: laid.edges };
|
||||
}, [graph, searchResults, selectedNodeId, showLayers, tourHighlightedNodeIds, persona, handleNodeSelect, diffMode, changedNodeIds, affectedNodeIds]);
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(visualNodes);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(laidOutRef.current?.initialEdges ?? []);
|
||||
|
||||
// ── Push sync layout + visual state to ReactFlow ──
|
||||
useEffect(() => {
|
||||
setNodes(initialNodes);
|
||||
}, [initialNodes, setNodes]);
|
||||
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(() => {
|
||||
setEdges(initialEdges);
|
||||
}, [initialEdges, setEdges]);
|
||||
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);
|
||||
});
|
||||
|
||||
return () => { cancelled = true; setLayouting(false); };
|
||||
}, [graph, topoNodes, topoEdges, needsAsyncLayout, showLayers, setNodes, setEdges]);
|
||||
|
||||
const onNodeClick = useCallback(
|
||||
(_: React.MouseEvent, node: { id: string }) => {
|
||||
@@ -314,7 +427,17 @@ function GraphViewInner() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<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-accent border-t-transparent rounded-full animate-spin mb-3" />
|
||||
<p className="text-text-secondary text-sm">
|
||||
Laying out {topoNodes.length.toLocaleString()} nodes...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
@@ -323,17 +446,22 @@ function GraphViewInner() {
|
||||
onNodeClick={onNodeClick}
|
||||
onPaneClick={onPaneClick}
|
||||
nodeTypes={nodeTypes}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
edgesFocusable={false}
|
||||
edgesReconnectable={false}
|
||||
elementsSelectable={false}
|
||||
fitView
|
||||
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 />
|
||||
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
import type { KeyboardShortcut } from "../hooks/useKeyboardShortcuts";
|
||||
import { formatShortcutKey } from "../hooks/useKeyboardShortcuts";
|
||||
|
||||
interface KeyboardShortcutsHelpProps {
|
||||
shortcuts: KeyboardShortcut[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function KeyboardShortcutsHelp({
|
||||
shortcuts,
|
||||
onClose,
|
||||
}: KeyboardShortcutsHelpProps) {
|
||||
// Group shortcuts by category
|
||||
const groupedShortcuts = shortcuts.reduce((acc, shortcut) => {
|
||||
if (!acc[shortcut.category]) {
|
||||
acc[shortcut.category] = [];
|
||||
}
|
||||
acc[shortcut.category].push(shortcut);
|
||||
return acc;
|
||||
}, {} as Record<string, KeyboardShortcut[]>);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="glass rounded-lg shadow-2xl max-w-2xl w-full max-h-[80vh] overflow-auto m-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 glass-heavy border-b border-border-subtle px-6 py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-serif text-text-primary">
|
||||
Keyboard Shortcuts
|
||||
</h2>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Press <kbd className="kbd">?</kbd> anytime to toggle this help
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-text-muted hover:text-text-primary transition-colors"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Shortcuts list */}
|
||||
<div className="p-6 space-y-6">
|
||||
{Object.entries(groupedShortcuts).map(([category, categoryShortcuts]) => (
|
||||
<div key={category}>
|
||||
<h3 className="text-sm font-semibold text-accent uppercase tracking-wider mb-3">
|
||||
{category}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{categoryShortcuts.map((shortcut, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between py-2 px-3 rounded hover:bg-elevated transition-colors"
|
||||
>
|
||||
<span className="text-sm text-text-secondary">
|
||||
{shortcut.description}
|
||||
</span>
|
||||
<kbd className="kbd">{formatShortcutKey(shortcut)}</kbd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="sticky bottom-0 glass-heavy border-t border-border-subtle px-6 py-3 text-center">
|
||||
<p className="text-xs text-text-muted">
|
||||
Press <kbd className="kbd">ESC</kbd> to close
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -47,7 +47,7 @@ export default function LayerLegend() {
|
||||
disabled={!hasLayers}
|
||||
className={`px-2 py-0.5 rounded text-[11px] font-medium transition-colors ${
|
||||
showLayers && hasLayers
|
||||
? "bg-gold/20 text-gold"
|
||||
? "bg-accent/20 text-accent"
|
||||
: hasLayers
|
||||
? "bg-elevated text-text-secondary hover:bg-surface"
|
||||
: "bg-elevated text-text-muted cursor-not-allowed"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -11,7 +11,7 @@ const typeBadgeColors: Record<string, string> = {
|
||||
|
||||
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",
|
||||
};
|
||||
|
||||
@@ -75,7 +75,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 +88,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 +99,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">
|
||||
@@ -117,7 +117,7 @@ export default function NodeInfo() {
|
||||
|
||||
{connections.length > 0 && (
|
||||
<div>
|
||||
<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">
|
||||
Connections ({connections.length})
|
||||
</h3>
|
||||
<div className="space-y-1.5">
|
||||
@@ -132,7 +132,7 @@ export default function NodeInfo() {
|
||||
key={i}
|
||||
className="text-xs bg-elevated rounded-lg px-3 py-2 border border-border-subtle flex items-center gap-2"
|
||||
>
|
||||
<span className="text-gold font-mono">{arrow}</span>
|
||||
<span className="text-accent font-mono">{arrow}</span>
|
||||
<span className="text-text-muted">{edge.type}</span>
|
||||
<span className="text-text-primary truncate">
|
||||
{otherNode?.name ?? otherId}
|
||||
|
||||
@@ -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"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -30,19 +30,19 @@ 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>
|
||||
@@ -50,7 +50,7 @@ export default function ProjectOverview() {
|
||||
{/* 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">
|
||||
@@ -64,7 +64,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">
|
||||
@@ -84,7 +84,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>
|
||||
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
export interface KeyboardShortcut {
|
||||
key: string;
|
||||
ctrlKey?: boolean;
|
||||
shiftKey?: boolean;
|
||||
altKey?: boolean;
|
||||
metaKey?: boolean;
|
||||
description: string;
|
||||
action: () => void;
|
||||
category: string;
|
||||
}
|
||||
|
||||
export function useKeyboardShortcuts(
|
||||
shortcuts: KeyboardShortcut[],
|
||||
enabled = true
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Prevent shortcuts from firing when typing in input fields
|
||||
const target = event.target as HTMLElement;
|
||||
const tagName = target.tagName.toLowerCase();
|
||||
if (tagName === 'input' || tagName === 'textarea' || target.isContentEditable) {
|
||||
if (event.key !== 'Escape') return;
|
||||
}
|
||||
|
||||
for (const shortcut of shortcuts) {
|
||||
const keyMatches = event.key.toLowerCase() === shortcut.key.toLowerCase();
|
||||
const ctrlMatches = shortcut.ctrlKey ? event.ctrlKey : !event.ctrlKey;
|
||||
const shiftMatches = shortcut.shiftKey ? event.shiftKey : !event.shiftKey;
|
||||
const altMatches = shortcut.altKey ? event.altKey : !event.altKey;
|
||||
const metaMatches = shortcut.metaKey ? event.metaKey : !event.metaKey;
|
||||
|
||||
if (keyMatches && ctrlMatches && shiftMatches && altMatches && metaMatches) {
|
||||
// Prevent default for shortcuts that might conflict with browser
|
||||
if (event.ctrlKey || event.metaKey || event.altKey) {
|
||||
event.preventDefault();
|
||||
}
|
||||
shortcut.action();
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [shortcuts, enabled]);
|
||||
}
|
||||
|
||||
export function formatShortcutKey(shortcut: KeyboardShortcut): string {
|
||||
const keys: string[] = [];
|
||||
|
||||
// Use userAgentData with fallback to navigator.platform
|
||||
const isMac = (navigator as Navigator & { userAgentData?: { platform: string } }).userAgentData?.platform
|
||||
? (navigator as Navigator & { userAgentData: { platform: string } }).userAgentData.platform === 'macOS'
|
||||
: navigator.platform.includes("Mac");
|
||||
|
||||
if (shortcut.ctrlKey || shortcut.metaKey) {
|
||||
keys.push(isMac ? "⌘" : "Ctrl");
|
||||
}
|
||||
// Don't show ⇧ for keys that inherently require Shift (e.g. ?, !, @)
|
||||
const isShiftedPunctuation = shortcut.key.length === 1 && /[^a-zA-Z0-9]/.test(shortcut.key);
|
||||
if (shortcut.shiftKey && !isShiftedPunctuation) keys.push("⇧");
|
||||
if (shortcut.altKey) keys.push(isMac ? "⌥" : "Alt");
|
||||
|
||||
keys.push(isShiftedPunctuation ? shortcut.key : shortcut.key.toUpperCase());
|
||||
|
||||
return keys.join(" + ");
|
||||
}
|
||||
@@ -1,46 +1,77 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
/* Dark luxury color palette */
|
||||
/* Base */
|
||||
--color-root: #0a0a0a;
|
||||
--color-surface: #111111;
|
||||
--color-elevated: #1a1a1a;
|
||||
--color-panel: #141414;
|
||||
|
||||
/* Gold accent spectrum */
|
||||
--color-gold: #d4a574;
|
||||
--color-gold-dim: #c9a96e;
|
||||
--color-gold-bright: #e8c49a;
|
||||
/* Accent */
|
||||
--color-accent: #d4a574;
|
||||
--color-accent-dim: #c9a96e;
|
||||
--color-accent-bright: #e8c49a;
|
||||
|
||||
/* Text hierarchy */
|
||||
/* Text */
|
||||
--color-text-primary: #f5f0eb;
|
||||
--color-text-secondary: #a39787;
|
||||
--color-text-muted: #6b5f53;
|
||||
|
||||
/* Border tokens */
|
||||
/* Borders */
|
||||
--color-border-subtle: rgba(212, 165, 116, 0.12);
|
||||
--color-border-medium: rgba(212, 165, 116, 0.25);
|
||||
|
||||
/* Node type colors (muted, refined) */
|
||||
/* Node types */
|
||||
--color-node-file: #4a7c9b;
|
||||
--color-node-function: #5a9e6f;
|
||||
--color-node-class: #8b6fb0;
|
||||
--color-node-module: #c9a06c;
|
||||
--color-node-concept: #b07a8a;
|
||||
|
||||
/* Diff overlay colors */
|
||||
/* Diff */
|
||||
--color-diff-changed: #e05252;
|
||||
--color-diff-affected: #d4a030;
|
||||
--color-diff-changed-dim: rgba(224, 82, 82, 0.25);
|
||||
--color-diff-affected-dim: rgba(212, 160, 48, 0.25);
|
||||
|
||||
/* Fonts */
|
||||
/* Glass */
|
||||
--glass-bg: rgba(20, 20, 20, 0.8);
|
||||
--glass-bg-heavy: rgba(20, 20, 20, 0.95);
|
||||
--glass-border: rgba(212, 165, 116, 0.1);
|
||||
--glass-border-heavy: rgba(212, 165, 116, 0.15);
|
||||
|
||||
/* Scrollbar */
|
||||
--scrollbar-thumb: rgba(212, 165, 116, 0.2);
|
||||
--scrollbar-thumb-hover: rgba(212, 165, 116, 0.35);
|
||||
|
||||
/* Glow */
|
||||
--glow-accent: rgba(212, 165, 116, 0.15);
|
||||
--glow-accent-strong: rgba(212, 165, 116, 0.4);
|
||||
--glow-accent-pulse: rgba(212, 165, 116, 0.6);
|
||||
|
||||
/* Edges */
|
||||
--color-edge: rgba(212, 165, 116, 0.3);
|
||||
--color-edge-dim: rgba(212, 165, 116, 0.08);
|
||||
--color-edge-dot: rgba(212, 165, 116, 0.15);
|
||||
|
||||
/* Accent overlays */
|
||||
--color-accent-overlay-bg: rgba(212, 165, 116, 0.05);
|
||||
--color-accent-overlay-border: rgba(212, 165, 116, 0.25);
|
||||
|
||||
/* Kbd */
|
||||
--kbd-bg: rgba(212, 165, 116, 0.1);
|
||||
|
||||
/* Typography */
|
||||
--font-serif: 'DM Serif Display', Georgia, serif;
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
--font-sans: 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* Base styles */
|
||||
html {
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background-color: var(--color-root);
|
||||
@@ -65,12 +96,37 @@ body {
|
||||
|
||||
/* Glass utility */
|
||||
.glass {
|
||||
background: rgba(20, 20, 20, 0.8);
|
||||
border: 1px solid rgba(212, 165, 116, 0.1);
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.glass-heavy {
|
||||
background: var(--glass-bg-heavy);
|
||||
border: 1px solid var(--glass-border-heavy);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
/* Keyboard shortcut key styling */
|
||||
.kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
padding: 0 0.5rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-accent);
|
||||
background: var(--kbd-bg);
|
||||
border: 1px solid var(--color-border-medium);
|
||||
border-radius: 0.25rem;
|
||||
box-shadow: 0 1px 0 var(--scrollbar-thumb);
|
||||
}
|
||||
|
||||
/* Animation keyframes */
|
||||
@keyframes fadeSlideIn {
|
||||
from {
|
||||
@@ -92,12 +148,12 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes goldPulse {
|
||||
@keyframes accentPulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 0 rgba(212, 165, 116, 0.4);
|
||||
box-shadow: 0 0 8px var(--glow-accent-strong);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 20px 4px rgba(212, 165, 116, 0.15);
|
||||
box-shadow: 0 0 20px var(--glow-accent-pulse);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,13 +166,13 @@ body {
|
||||
animation: slideUp 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-gold-pulse {
|
||||
animation: goldPulse 2s ease-in-out infinite;
|
||||
.animate-accent-pulse {
|
||||
animation: accentPulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Node selection glow */
|
||||
.node-glow {
|
||||
box-shadow: 0 0 20px rgba(212, 165, 116, 0.15);
|
||||
box-shadow: 0 0 20px var(--glow-accent);
|
||||
}
|
||||
|
||||
/* Diff overlay glow effects */
|
||||
@@ -144,14 +200,37 @@ body {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(212, 165, 116, 0.2);
|
||||
border-radius: 3px;
|
||||
background: var(--scrollbar-thumb);
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(212, 165, 116, 0.35);
|
||||
background: var(--scrollbar-thumb-hover);
|
||||
}
|
||||
|
||||
/* Override React Flow dark theme */
|
||||
.react-flow__background {
|
||||
background-color: var(--color-root) !important;
|
||||
}
|
||||
|
||||
/* Light theme overrides */
|
||||
[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
[data-theme="light"] .diff-faded {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
[data-theme="light"] ::-webkit-scrollbar-track {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
[data-theme="light"] .warning-banner {
|
||||
background: rgba(180, 130, 30, 0.1);
|
||||
border-color: rgba(180, 130, 30, 0.3);
|
||||
color: #92600a;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import type { PresetId, ThemeConfig, ThemePreset } from "./types.ts";
|
||||
import { DEFAULT_THEME_CONFIG } from "./types.ts";
|
||||
import { getPreset } from "./presets.ts";
|
||||
import { applyTheme } from "./theme-engine.ts";
|
||||
|
||||
const STORAGE_KEY = "ua-theme";
|
||||
|
||||
interface ThemeContextValue {
|
||||
config: ThemeConfig;
|
||||
preset: ThemePreset;
|
||||
setPreset: (presetId: PresetId) => void;
|
||||
setAccent: (accentId: string) => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
function loadFromLocalStorage(): ThemeConfig | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed.presetId === "string" && typeof parsed.accentId === "string") {
|
||||
return parsed as ThemeConfig;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveToLocalStorage(config: ThemeConfig): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
|
||||
} catch {
|
||||
// Storage full or unavailable — ignore
|
||||
}
|
||||
}
|
||||
|
||||
function resolveInitialTheme(metaTheme?: ThemeConfig | null): ThemeConfig {
|
||||
return loadFromLocalStorage() ?? metaTheme ?? DEFAULT_THEME_CONFIG;
|
||||
}
|
||||
|
||||
interface ThemeProviderProps {
|
||||
metaTheme?: ThemeConfig | null;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ThemeProvider({ metaTheme, children }: ThemeProviderProps) {
|
||||
const [config, setConfig] = useState<ThemeConfig>(() => resolveInitialTheme(metaTheme));
|
||||
const initialized = useRef(false);
|
||||
|
||||
// Apply theme on mount and config changes
|
||||
useEffect(() => {
|
||||
applyTheme(config);
|
||||
if (initialized.current) {
|
||||
saveToLocalStorage(config);
|
||||
}
|
||||
initialized.current = true;
|
||||
}, [config]);
|
||||
|
||||
// Update if metaTheme arrives later (async fetch) and no localStorage preference exists
|
||||
useEffect(() => {
|
||||
if (metaTheme && !loadFromLocalStorage()) {
|
||||
setConfig(metaTheme);
|
||||
}
|
||||
}, [metaTheme]);
|
||||
|
||||
const setPreset = useCallback((presetId: PresetId) => {
|
||||
setConfig((_prev) => {
|
||||
const newPreset = getPreset(presetId);
|
||||
return { presetId, accentId: newPreset.defaultAccentId };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setAccent = useCallback((accentId: string) => {
|
||||
setConfig((prev) => ({ ...prev, accentId }));
|
||||
}, []);
|
||||
|
||||
const preset = getPreset(config.presetId);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ config, preset, setPreset, setAccent }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme(): ThemeContextValue {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { ThemeProvider, useTheme } from "./ThemeContext.tsx";
|
||||
export { PRESETS, getPreset, getAccent } from "./presets.ts";
|
||||
export { applyTheme } from "./theme-engine.ts";
|
||||
export type { PresetId, ThemeConfig, ThemePreset, AccentSwatch } from "./types.ts";
|
||||
export { DEFAULT_THEME_CONFIG } from "./types.ts";
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { AccentSwatch, ThemePreset } from "./types.ts";
|
||||
|
||||
const DARK_ACCENT_SWATCHES: AccentSwatch[] = [
|
||||
{ id: "gold", name: "Gold", accent: "#d4a574", accentDim: "#c9a96e", accentBright: "#e8c49a" },
|
||||
{ id: "ocean", name: "Ocean", accent: "#5ba4cf", accentDim: "#4e93ba", accentBright: "#7abce0" },
|
||||
{ id: "emerald", name: "Emerald", accent: "#5ea67a", accentDim: "#4e9468", accentBright: "#78c492" },
|
||||
{ id: "rose", name: "Rose", accent: "#cf7a8a", accentDim: "#b96e7e", accentBright: "#e094a4" },
|
||||
{ id: "purple", name: "Purple", accent: "#9b7abf", accentDim: "#876bb0", accentBright: "#b494d4" },
|
||||
{ id: "amber", name: "Amber", accent: "#c9963a", accentDim: "#b5862e", accentBright: "#ddb05c" },
|
||||
{ id: "teal", name: "Teal", accent: "#4aab9a", accentDim: "#3d9686", accentBright: "#68c4b4" },
|
||||
{ id: "silver", name: "Silver", accent: "#a0a8b0", accentDim: "#8e959c", accentBright: "#b8bfc6" },
|
||||
];
|
||||
|
||||
const LIGHT_ACCENT_SWATCHES: AccentSwatch[] = [
|
||||
{ id: "indigo", name: "Indigo", accent: "#4a6fa5", accentDim: "#3d5f8f", accentBright: "#6088bf" },
|
||||
{ id: "ocean", name: "Ocean", accent: "#3a8ab5", accentDim: "#2e7aa0", accentBright: "#55a0cc" },
|
||||
{ id: "emerald", name: "Emerald", accent: "#3a8a5c", accentDim: "#2e7a4e", accentBright: "#55a878" },
|
||||
{ id: "rose", name: "Rose", accent: "#a5566a", accentDim: "#8f4a5c", accentBright: "#bf6e82" },
|
||||
{ id: "purple", name: "Purple", accent: "#6b5a9e", accentDim: "#5c4d8a", accentBright: "#8474b5" },
|
||||
{ id: "amber", name: "Amber", accent: "#9e7a30", accentDim: "#8a6a28", accentBright: "#b5923e" },
|
||||
{ id: "teal", name: "Teal", accent: "#2e8a7a", accentDim: "#267a6c", accentBright: "#45a595" },
|
||||
{ id: "slate", name: "Slate", accent: "#5a6570", accentDim: "#4e5860", accentBright: "#6e7a85" },
|
||||
];
|
||||
|
||||
export const PRESETS: ThemePreset[] = [
|
||||
{
|
||||
id: "dark-gold",
|
||||
name: "Dark Gold",
|
||||
isDark: true,
|
||||
defaultAccentId: "gold",
|
||||
accentSwatches: DARK_ACCENT_SWATCHES,
|
||||
colors: {
|
||||
root: "#0a0a0a",
|
||||
surface: "#111111",
|
||||
elevated: "#1a1a1a",
|
||||
panel: "#141414",
|
||||
"text-primary": "#f5f0eb",
|
||||
"text-secondary": "#a39787",
|
||||
"text-muted": "#6b5f53",
|
||||
"node-file": "#4a7c9b",
|
||||
"node-function": "#5a9e6f",
|
||||
"node-class": "#8b6fb0",
|
||||
"node-module": "#c9a06c",
|
||||
"node-concept": "#b07a8a",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "dark-ocean",
|
||||
name: "Dark Ocean",
|
||||
isDark: true,
|
||||
defaultAccentId: "ocean",
|
||||
accentSwatches: DARK_ACCENT_SWATCHES,
|
||||
colors: {
|
||||
root: "#0a0e14",
|
||||
surface: "#111820",
|
||||
elevated: "#1a222c",
|
||||
panel: "#141c24",
|
||||
"text-primary": "#e8edf2",
|
||||
"text-secondary": "#87939f",
|
||||
"text-muted": "#536b7a",
|
||||
"node-file": "#4a7c9b",
|
||||
"node-function": "#5a9e6f",
|
||||
"node-class": "#8b6fb0",
|
||||
"node-module": "#c9a06c",
|
||||
"node-concept": "#b07a8a",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "dark-forest",
|
||||
name: "Dark Forest",
|
||||
isDark: true,
|
||||
defaultAccentId: "emerald",
|
||||
accentSwatches: DARK_ACCENT_SWATCHES,
|
||||
colors: {
|
||||
root: "#0a100a",
|
||||
surface: "#111811",
|
||||
elevated: "#1a241a",
|
||||
panel: "#141c14",
|
||||
"text-primary": "#ebf0eb",
|
||||
"text-secondary": "#87a38f",
|
||||
"text-muted": "#536b5a",
|
||||
"node-file": "#4a7c9b",
|
||||
"node-function": "#5a9e6f",
|
||||
"node-class": "#8b6fb0",
|
||||
"node-module": "#c9a06c",
|
||||
"node-concept": "#b07a8a",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "dark-rose",
|
||||
name: "Dark Rose",
|
||||
isDark: true,
|
||||
defaultAccentId: "rose",
|
||||
accentSwatches: DARK_ACCENT_SWATCHES,
|
||||
colors: {
|
||||
root: "#100a0a",
|
||||
surface: "#181111",
|
||||
elevated: "#221a1a",
|
||||
panel: "#1c1414",
|
||||
"text-primary": "#f2e8ea",
|
||||
"text-secondary": "#9f8790",
|
||||
"text-muted": "#6b535a",
|
||||
"node-file": "#4a7c9b",
|
||||
"node-function": "#5a9e6f",
|
||||
"node-class": "#8b6fb0",
|
||||
"node-module": "#c9a06c",
|
||||
"node-concept": "#b07a8a",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "light-minimal",
|
||||
name: "Light Minimal",
|
||||
isDark: false,
|
||||
defaultAccentId: "indigo",
|
||||
accentSwatches: LIGHT_ACCENT_SWATCHES,
|
||||
colors: {
|
||||
root: "#f5f3f0",
|
||||
surface: "#eae7e3",
|
||||
elevated: "#ffffff",
|
||||
panel: "#f0ede9",
|
||||
"text-primary": "#1a1a1a",
|
||||
"text-secondary": "#6b6b6b",
|
||||
"text-muted": "#a0a0a0",
|
||||
"node-file": "#3a6a87",
|
||||
"node-function": "#488a5b",
|
||||
"node-class": "#755d99",
|
||||
"node-module": "#a88a56",
|
||||
"node-concept": "#966674",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function getPreset(id: string): ThemePreset {
|
||||
return PRESETS.find((p) => p.id === id) ?? PRESETS[0];
|
||||
}
|
||||
|
||||
export function getAccent(preset: ThemePreset, accentId: string): AccentSwatch {
|
||||
return (
|
||||
preset.accentSwatches.find((s) => s.id === accentId) ??
|
||||
preset.accentSwatches.find((s) => s.id === preset.defaultAccentId) ??
|
||||
preset.accentSwatches[0]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { ThemeConfig } from "./types.ts";
|
||||
import { getAccent, getPreset } from "./presets.ts";
|
||||
|
||||
export function hexToRgb(hex: string): string {
|
||||
const h = hex.replace("#", "");
|
||||
const n = parseInt(h, 16);
|
||||
return `${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}`;
|
||||
}
|
||||
|
||||
function deriveFromAccent(accentHex: string, isDark: boolean): Record<string, string> {
|
||||
const rgb = hexToRgb(accentHex);
|
||||
return {
|
||||
"color-border-subtle": `rgba(${rgb}, ${isDark ? 0.12 : 0.1})`,
|
||||
"color-border-medium": `rgba(${rgb}, ${isDark ? 0.25 : 0.18})`,
|
||||
"glass-bg": isDark ? "rgba(20, 20, 20, 0.8)" : "rgba(255, 255, 255, 0.8)",
|
||||
"glass-bg-heavy": isDark ? "rgba(20, 20, 20, 0.95)" : "rgba(255, 255, 255, 0.95)",
|
||||
"glass-border": `rgba(${rgb}, ${isDark ? 0.1 : 0.08})`,
|
||||
"glass-border-heavy": `rgba(${rgb}, ${isDark ? 0.15 : 0.12})`,
|
||||
"scrollbar-thumb": `rgba(${rgb}, 0.2)`,
|
||||
"scrollbar-thumb-hover": `rgba(${rgb}, 0.35)`,
|
||||
"glow-accent": `rgba(${rgb}, 0.15)`,
|
||||
"glow-accent-strong": `rgba(${rgb}, 0.4)`,
|
||||
"glow-accent-pulse": `rgba(${rgb}, 0.6)`,
|
||||
"color-edge": `rgba(${rgb}, 0.3)`,
|
||||
"color-edge-dim": `rgba(${rgb}, 0.08)`,
|
||||
"color-edge-dot": `rgba(${rgb}, 0.15)`,
|
||||
"color-accent-overlay-bg": `rgba(${rgb}, 0.05)`,
|
||||
"color-accent-overlay-border": `rgba(${rgb}, 0.25)`,
|
||||
"kbd-bg": `rgba(${rgb}, 0.1)`,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyTheme(config: ThemeConfig): void {
|
||||
const preset = getPreset(config.presetId);
|
||||
const accent = getAccent(preset, config.accentId);
|
||||
const style = document.documentElement.style;
|
||||
|
||||
// 1. Apply base preset colors
|
||||
for (const [key, value] of Object.entries(preset.colors)) {
|
||||
style.setProperty(`--color-${key}`, value);
|
||||
}
|
||||
|
||||
// 2. Apply accent colors from swatch
|
||||
style.setProperty("--color-accent", accent.accent);
|
||||
style.setProperty("--color-accent-dim", accent.accentDim);
|
||||
style.setProperty("--color-accent-bright", accent.accentBright);
|
||||
|
||||
// 3. Apply derived values
|
||||
const derived = deriveFromAccent(accent.accent, preset.isDark);
|
||||
for (const [key, value] of Object.entries(derived)) {
|
||||
style.setProperty(`--${key}`, value);
|
||||
}
|
||||
|
||||
// 4. Set data-theme for CSS-only selectors
|
||||
document.documentElement.setAttribute("data-theme", preset.isDark ? "dark" : "light");
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export type PresetId =
|
||||
| "dark-gold"
|
||||
| "dark-ocean"
|
||||
| "dark-forest"
|
||||
| "dark-rose"
|
||||
| "light-minimal";
|
||||
|
||||
export interface AccentSwatch {
|
||||
id: string;
|
||||
name: string;
|
||||
accent: string;
|
||||
accentDim: string;
|
||||
accentBright: string;
|
||||
}
|
||||
|
||||
export interface ThemePreset {
|
||||
id: PresetId;
|
||||
name: string;
|
||||
isDark: boolean;
|
||||
colors: Record<string, string>;
|
||||
accentSwatches: AccentSwatch[];
|
||||
defaultAccentId: string;
|
||||
}
|
||||
|
||||
export interface ThemeConfig {
|
||||
presetId: PresetId;
|
||||
accentId: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_THEME_CONFIG: ThemeConfig = {
|
||||
presetId: "dark-gold",
|
||||
accentId: "gold",
|
||||
};
|
||||
@@ -1,9 +1,13 @@
|
||||
import dagre from "@dagrejs/dagre";
|
||||
import type { Node, Edge } from "@xyflow/react";
|
||||
import type { LayoutMessage, LayoutResult } from "./layout.worker";
|
||||
|
||||
export const NODE_WIDTH = 280;
|
||||
export const NODE_HEIGHT = 120;
|
||||
|
||||
/**
|
||||
* Synchronous dagre layout — used for small graphs.
|
||||
*/
|
||||
export function applyDagreLayout(
|
||||
nodes: Node[],
|
||||
edges: Edge[],
|
||||
@@ -43,3 +47,79 @@ export function applyDagreLayout(
|
||||
|
||||
return { nodes: layoutedNodes, edges };
|
||||
}
|
||||
|
||||
let _worker: Worker | null = null;
|
||||
let _nextRequestId = 0;
|
||||
let _latestRequestId = -1;
|
||||
const _pending = new Map<
|
||||
number,
|
||||
{
|
||||
nodes: Node[];
|
||||
edges: Edge[];
|
||||
resolve: (v: { nodes: Node[]; edges: Edge[] }) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
}
|
||||
>();
|
||||
|
||||
function getWorker(): Worker {
|
||||
if (!_worker) {
|
||||
_worker = new Worker(
|
||||
new URL("./layout.worker.ts", import.meta.url),
|
||||
{ type: "module" },
|
||||
);
|
||||
|
||||
_worker.onmessage = (e: MessageEvent<LayoutResult>) => {
|
||||
const { requestId, positions } = e.data;
|
||||
const entry = _pending.get(requestId);
|
||||
_pending.delete(requestId);
|
||||
|
||||
// S1: Discard stale results — only honour the latest request.
|
||||
if (!entry || requestId !== _latestRequestId) return;
|
||||
|
||||
const layoutedNodes = entry.nodes.map((node) => ({
|
||||
...node,
|
||||
position: positions[node.id] ?? { x: 0, y: 0 },
|
||||
}));
|
||||
|
||||
entry.resolve({ nodes: layoutedNodes, edges: entry.edges });
|
||||
};
|
||||
|
||||
_worker.onerror = (err: ErrorEvent) => {
|
||||
for (const [, entry] of _pending) {
|
||||
entry.reject(err);
|
||||
}
|
||||
_pending.clear();
|
||||
};
|
||||
}
|
||||
return _worker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Async dagre layout via Web Worker — used for large graphs.
|
||||
* Keeps the main thread responsive while dagre computes positions.
|
||||
*
|
||||
* Uses request-ID correlation so concurrent calls never cross-wire,
|
||||
* and only the latest request's result is honoured (stale ones are discarded).
|
||||
*/
|
||||
export function applyDagreLayoutAsync(
|
||||
nodes: Node[],
|
||||
edges: Edge[],
|
||||
direction: "TB" | "LR" = "TB",
|
||||
): Promise<{ nodes: Node[]; edges: Edge[] }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const worker = getWorker();
|
||||
const requestId = _nextRequestId++;
|
||||
_latestRequestId = requestId;
|
||||
|
||||
_pending.set(requestId, { nodes, edges, resolve, reject });
|
||||
|
||||
const msg: LayoutMessage = {
|
||||
requestId,
|
||||
nodes: nodes.map((n) => ({ id: n.id, width: NODE_WIDTH, height: NODE_HEIGHT })),
|
||||
edges: edges.map((e) => ({ source: e.source, target: e.target })),
|
||||
direction,
|
||||
};
|
||||
|
||||
worker.postMessage(msg);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import dagre from "@dagrejs/dagre";
|
||||
|
||||
export interface LayoutMessage {
|
||||
requestId: number;
|
||||
nodes: Array<{ id: string; width: number; height: number }>;
|
||||
edges: Array<{ source: string; target: string }>;
|
||||
direction: "TB" | "LR";
|
||||
}
|
||||
|
||||
export interface LayoutResult {
|
||||
requestId: number;
|
||||
positions: Record<string, { x: number; y: number }>;
|
||||
}
|
||||
|
||||
self.onmessage = (e: MessageEvent<LayoutMessage>) => {
|
||||
const { requestId, nodes, edges, direction } = e.data;
|
||||
|
||||
const g = new dagre.graphlib.Graph();
|
||||
g.setDefaultEdgeLabel(() => ({}));
|
||||
g.setGraph({
|
||||
rankdir: direction,
|
||||
nodesep: 60,
|
||||
ranksep: 80,
|
||||
marginx: 20,
|
||||
marginy: 20,
|
||||
});
|
||||
|
||||
for (const node of nodes) {
|
||||
g.setNode(node.id, { width: node.width, height: node.height });
|
||||
}
|
||||
|
||||
for (const edge of edges) {
|
||||
g.setEdge(edge.source, edge.target);
|
||||
}
|
||||
|
||||
dagre.layout(g);
|
||||
|
||||
const positions: Record<string, { x: number; y: number }> = {};
|
||||
for (const node of nodes) {
|
||||
const pos = g.node(node.id);
|
||||
positions[node.id] = pos
|
||||
? { x: pos.x - node.width / 2, y: pos.y - node.height / 2 }
|
||||
: { x: 0, y: 0 };
|
||||
}
|
||||
|
||||
self.postMessage({ requestId, positions } satisfies LayoutResult);
|
||||
};
|
||||
@@ -14,7 +14,7 @@ The knowledge graph JSON has this structure:
|
||||
- `project` — {name, description, languages, frameworks, analyzedAt, gitCommitHash}
|
||||
- `nodes[]` — each has {id, type, name, filePath, summary, tags[], complexity, languageNotes?}
|
||||
- Node types: file, function, class, module, concept
|
||||
- IDs: `file:path`, `func:path:name`, `class:path:name`
|
||||
- IDs: `file:path`, `function:path:name`, `class:path:name`
|
||||
- `edges[]` — each has {source, target, type, direction, weight}
|
||||
- Key types: imports, contains, calls, depends_on
|
||||
- `layers[]` — each has {id, name, description, nodeIds[]}
|
||||
|
||||
@@ -19,13 +19,31 @@ Start the Understand Anything dashboard to visualize the knowledge graph for the
|
||||
No knowledge graph found. Run /understand first to analyze this project.
|
||||
```
|
||||
|
||||
3. Find the dashboard code. The dashboard is at `packages/dashboard/` relative to this plugin's root directory. Use the Bash tool to resolve the path:
|
||||
3. Find the dashboard code. The dashboard is at `packages/dashboard/` relative to this plugin's root directory. Check these paths in order and use the first that exists:
|
||||
- `~/.understand-anything-plugin/packages/dashboard/` (universal symlink, all installs)
|
||||
- `${CLAUDE_PLUGIN_ROOT}/packages/dashboard/` (Claude Code plugin)
|
||||
- Two levels up from this skill file's real path: `../../packages/dashboard/` (self-relative fallback)
|
||||
|
||||
Use the Bash tool to resolve:
|
||||
```bash
|
||||
PLUGIN_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
SKILL_REAL=$(realpath ~/.agents/skills/understand-dashboard 2>/dev/null || readlink -f ~/.agents/skills/understand-dashboard 2>/dev/null || echo "")
|
||||
SELF_RELATIVE=$([ -n "$SKILL_REAL" ] && cd "$SKILL_REAL/../.." 2>/dev/null && pwd || echo "")
|
||||
|
||||
PLUGIN_ROOT=""
|
||||
for candidate in \
|
||||
"$HOME/.understand-anything-plugin" \
|
||||
"${CLAUDE_PLUGIN_ROOT}" \
|
||||
"$SELF_RELATIVE"; do
|
||||
if [ -n "$candidate" ] && [ -d "$candidate/packages/dashboard" ]; then
|
||||
PLUGIN_ROOT="$candidate"; break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$PLUGIN_ROOT" ]; then
|
||||
echo "Error: Cannot find the understand-anything plugin root. Make sure you followed the installation instructions and that ~/.understand-anything-plugin exists."
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
Or locate it by checking these paths in order:
|
||||
- `${CLAUDE_PLUGIN_ROOT}/packages/dashboard/`
|
||||
- The parent directory of this skill file, then `../../packages/dashboard/`
|
||||
|
||||
4. Install dependencies and build if needed:
|
||||
```bash
|
||||
|
||||
@@ -13,7 +13,7 @@ The knowledge graph JSON has this structure:
|
||||
- `project` — {name, description, languages, frameworks, analyzedAt, gitCommitHash}
|
||||
- `nodes[]` — each has {id, type, name, filePath, summary, tags[], complexity, languageNotes?}
|
||||
- Node types: file, function, class, module, concept
|
||||
- IDs: `file:path`, `func:path:name`, `class:path:name`
|
||||
- IDs: `file:path`, `function:path:name`, `class:path:name`
|
||||
- `edges[]` — each has {source, target, type, direction, weight}
|
||||
- Key types: imports, contains, calls, depends_on
|
||||
- `layers[]` — each has {id, name, description, nodeIds[]}
|
||||
|
||||
@@ -14,7 +14,7 @@ The knowledge graph JSON has this structure:
|
||||
- `project` — {name, description, languages, frameworks, analyzedAt, gitCommitHash}
|
||||
- `nodes[]` — each has {id, type, name, filePath, summary, tags[], complexity, languageNotes?}
|
||||
- Node types: file, function, class, module, concept
|
||||
- IDs: `file:path`, `func:path:name`, `class:path:name`
|
||||
- IDs: `file:path`, `function:path:name`, `class:path:name`
|
||||
- `edges[]` — each has {source, target, type, direction, weight}
|
||||
- Key types: imports, contains, calls, depends_on
|
||||
- `layers[]` — each has {id, name, description, nodeIds[]}
|
||||
|
||||
@@ -13,7 +13,7 @@ The knowledge graph JSON has this structure:
|
||||
- `project` — {name, description, languages, frameworks, analyzedAt, gitCommitHash}
|
||||
- `nodes[]` — each has {id, type, name, filePath, summary, tags[], complexity, languageNotes?}
|
||||
- Node types: file, function, class, module, concept
|
||||
- IDs: `file:path`, `func:path:name`, `class:path:name`
|
||||
- IDs: `file:path`, `function:path:name`, `class:path:name`
|
||||
- `edges[]` — each has {source, target, type, direction, weight}
|
||||
- Key types: imports, contains, calls, depends_on
|
||||
- `layers[]` — each has {id, name, description, nodeIds[]}
|
||||
|
||||
@@ -14,6 +14,7 @@ Analyze the current codebase and produce a `knowledge-graph.json` file in `.unde
|
||||
- `--full` — Force a full rebuild, ignoring any existing graph
|
||||
- `--auto-update` — Enable automatic graph updates on commit (writes `autoUpdate: true` to `.understand-anything/config.json`)
|
||||
- `--no-auto-update` — Disable automatic graph updates (writes `autoUpdate: false` to `.understand-anything/config.json`)
|
||||
- `--review` — Run full LLM graph-reviewer instead of inline deterministic validation
|
||||
- A directory path — Scope analysis to a specific subdirectory
|
||||
|
||||
---
|
||||
@@ -27,9 +28,10 @@ Determine whether to run a full analysis or incremental update.
|
||||
```bash
|
||||
git rev-parse HEAD
|
||||
```
|
||||
3. Create the intermediate output directory:
|
||||
3. Create the intermediate and temp output directories:
|
||||
```bash
|
||||
mkdir -p $PROJECT_ROOT/.understand-anything/intermediate
|
||||
mkdir -p $PROJECT_ROOT/.understand-anything/tmp
|
||||
```
|
||||
3.5. **Auto-update configuration:**
|
||||
- If `--auto-update` is in `$ARGUMENTS`: write `{"autoUpdate": true}` to `$PROJECT_ROOT/.understand-anything/config.json`
|
||||
@@ -44,9 +46,12 @@ Determine whether to run a full analysis or incremental update.
|
||||
|---|---|
|
||||
| `--full` flag in `$ARGUMENTS` | Full analysis (all phases) |
|
||||
| No existing graph or meta | Full analysis (all phases) |
|
||||
| Existing graph + unchanged commit hash | Report "Graph is up to date" and STOP |
|
||||
| `--review` flag + existing graph + unchanged commit hash | Skip to Phase 6 (review-only — reuse existing assembled graph) |
|
||||
| Existing graph + unchanged commit hash | Ask the user: "The graph is up to date at this commit. Would you like to: **(a)** run a full rebuild (`--full`), **(b)** run the LLM graph reviewer (`--review`), or **(c)** do nothing?" Then follow their choice. If they pick (c), STOP. |
|
||||
| Existing graph + changed files | Incremental update (re-analyze changed files only) |
|
||||
|
||||
**Review-only path:** Copy the existing `knowledge-graph.json` to `$PROJECT_ROOT/.understand-anything/intermediate/assembled-graph.json`, then jump directly to Phase 6 step 3.
|
||||
|
||||
For incremental updates, get the changed file list:
|
||||
```bash
|
||||
git diff <lastCommitHash>..HEAD --name-only
|
||||
@@ -61,7 +66,7 @@ Determine whether to run a full analysis or incremental update.
|
||||
find $PROJECT_ROOT -maxdepth 2 -type f -not -path '*/node_modules/*' -not -path '*/.git/*' -not -path '*/dist/*' | head -100
|
||||
```
|
||||
Store as `$DIR_TREE`.
|
||||
- Detect the project entry point by checking for common patterns: `src/index.ts`, `src/main.ts`, `src/App.tsx`, `main.py`, `main.go`, `src/main.rs`, `index.js`. Store first match as `$ENTRY_POINT`.
|
||||
- Detect the project entry point by checking for common patterns (in order): `src/index.ts`, `src/main.ts`, `src/App.tsx`, `index.js`, `main.py`, `manage.py`, `app.py`, `wsgi.py`, `asgi.py`, `run.py`, `__main__.py`, `main.go`, `cmd/*/main.go`, `src/main.rs`, `src/lib.rs`, `src/main/java/**/Application.java`, `Program.cs`, `config.ru`, `index.php`. Store first match as `$ENTRY_POINT`.
|
||||
|
||||
---
|
||||
|
||||
@@ -94,6 +99,9 @@ After the subagent completes, read `$PROJECT_ROOT/.understand-anything/intermedi
|
||||
- Languages, frameworks
|
||||
- File list with line counts
|
||||
- Complexity estimate
|
||||
- Import map (`importMap`): pre-resolved project-internal imports per file
|
||||
|
||||
Store `importMap` in memory as `$IMPORT_MAP` for use in Phase 2 batch construction.
|
||||
|
||||
**Gate check:** If >200 files, inform the user and suggest scoping with a subdirectory argument. Proceed only if user confirms or add guidance that this may take a while.
|
||||
|
||||
@@ -103,23 +111,21 @@ After the subagent completes, read `$PROJECT_ROOT/.understand-anything/intermedi
|
||||
|
||||
### Full analysis path
|
||||
|
||||
Batch the file list from Phase 1 into groups of **5-10 files each** (aim for balanced batch sizes).
|
||||
Batch the file list from Phase 1 into groups of **20-30 files each** (aim for ~25 files per batch for balanced sizes).
|
||||
|
||||
For each batch, dispatch a subagent using the prompt template at `./file-analyzer-prompt.md`. Run up to **3 subagents concurrently** using parallel dispatch. Read the template once, then for each batch pass the full template content as the subagent's prompt, appending the following additional context:
|
||||
For each batch, dispatch a subagent using the prompt template at `./file-analyzer-prompt.md`. Run up to **5 subagents concurrently** using parallel dispatch. Pass the template 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>`
|
||||
>
|
||||
> Framework-specific guidance:
|
||||
> - If React/Next.js: files in `app/` or `pages/` are routes, `components/` are UI, `lib/` or `utils/` are utilities
|
||||
> - If Express/Fastify: files in `routes/` are API endpoints, `middleware/` is middleware, `models/` or `db/` is data
|
||||
> - If Python Django: `views.py` are controllers, `models.py` is data, `urls.py` is routing, `templates/` is UI
|
||||
> - If Go: `cmd/` is entry points, `internal/` is private packages, `pkg/` is public packages
|
||||
>
|
||||
> Use this context to produce more accurate summaries and better classify file roles.
|
||||
|
||||
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:
|
||||
|
||||
@@ -130,8 +136,10 @@ Fill in batch-specific parameters below and dispatch:
|
||||
> 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>`
|
||||
> 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)
|
||||
@@ -144,7 +152,7 @@ After ALL batches complete, read each `batch-<N>.json` file and merge:
|
||||
|
||||
### Incremental update path
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
After batches complete, merge with the existing graph:
|
||||
1. Remove old nodes whose `filePath` matches any changed file
|
||||
@@ -165,7 +173,12 @@ Merge all file-analyzer results into a single set of nodes and edges. Then perfo
|
||||
|
||||
## Phase 4 — ARCHITECTURE
|
||||
|
||||
Dispatch a subagent using the prompt template at `./architecture-analyzer-prompt.md`. Read the template file and pass the full content as the subagent's prompt, appending the following additional context:
|
||||
**Build the combined prompt template:**
|
||||
1. Read the base template at `./architecture-analyzer-prompt.md`.
|
||||
2. **Language context injection:** For each language detected in Phase 1 (e.g., `python`), read the file at `./languages/<language-id>.md` (e.g., `./languages/python.md`) and append its content after the base template under a `## Language Context` header. If the file does not exist for a detected language, skip it silently and continue. These files are in the `languages/` subdirectory next to this SKILL.md file.
|
||||
3. **Framework addendum injection:** For each framework detected in Phase 1 (e.g., `Django`), read the file at `./frameworks/<framework-id-lowercase>.md` (e.g., `./frameworks/django.md`) and append its full content after the language context. If the file does not exist for a detected framework, skip it silently and continue. These files are in the `frameworks/` subdirectory next to this SKILL.md file.
|
||||
|
||||
Pass the combined content as the subagent's prompt, appending the following additional context:
|
||||
|
||||
> **Additional context from main session:**
|
||||
>
|
||||
@@ -176,13 +189,7 @@ Dispatch a subagent using the prompt template at `./architecture-analyzer-prompt
|
||||
> $DIR_TREE
|
||||
> ```
|
||||
>
|
||||
> Framework-specific layer hints:
|
||||
> - If React/Next.js: `app/` or `pages/` → UI Layer, `api/` → API Layer, `lib/` → Service Layer, `components/` → UI Layer
|
||||
> - If Express: `routes/` → API Layer, `controllers/` → Service Layer, `models/` → Data Layer, `middleware/` → Middleware Layer
|
||||
> - If Python Django: `views/` → API Layer, `models/` → Data Layer, `templates/` → UI Layer, `management/` → CLI Layer
|
||||
> - If Go: `cmd/` → Entry Points, `internal/` → Service Layer, `pkg/` → Shared Library, `api/` → API Layer
|
||||
>
|
||||
> Use the directory tree and framework hints to inform layer assignments. Directory structure is strong evidence for layer boundaries.
|
||||
> Use the directory tree, language context, and framework addendums (appended above) to inform layer assignments. Directory structure is strong evidence for layer boundaries.
|
||||
|
||||
Pass these parameters in the dispatch prompt:
|
||||
|
||||
@@ -193,7 +200,7 @@ Pass these parameters in the dispatch prompt:
|
||||
>
|
||||
> File nodes:
|
||||
> ```json
|
||||
> [list of {id, name, filePath, summary, tags} for all file-type nodes]
|
||||
> [list of {id, name, filePath, summary, tags} for all file-type nodes — omit complexity, languageNotes]
|
||||
> ```
|
||||
>
|
||||
> Import edges:
|
||||
@@ -260,19 +267,19 @@ Pass these parameters in the dispatch prompt:
|
||||
> Project: `<projectName>` — `<projectDescription>`
|
||||
> Languages: `<languages>`
|
||||
>
|
||||
> Nodes (summarized):
|
||||
> Nodes (file nodes only):
|
||||
> ```json
|
||||
> [list of {id, name, filePath, summary, type} for key nodes]
|
||||
> [list of {id, name, filePath, summary, type} for file-type nodes ONLY — do NOT include function or class nodes]
|
||||
> ```
|
||||
>
|
||||
> Layers:
|
||||
> ```json
|
||||
> [layers from Phase 4]
|
||||
> [list of {id, name, description} for each layer — omit nodeIds]
|
||||
> ```
|
||||
>
|
||||
> Key edges:
|
||||
> Edges (imports and calls only):
|
||||
> ```json
|
||||
> [imports and calls edges]
|
||||
> [list of edges where type is "imports" or "calls" only — exclude all other edge types]
|
||||
> ```
|
||||
|
||||
After the subagent completes, read `$PROJECT_ROOT/.understand-anything/intermediate/tour.json` and normalize it into a final `tour` array. Apply these steps **in order**:
|
||||
@@ -333,7 +340,95 @@ Assemble the full KnowledgeGraph JSON object:
|
||||
|
||||
2. Write the assembled graph to `$PROJECT_ROOT/.understand-anything/intermediate/assembled-graph.json`.
|
||||
|
||||
3. 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:
|
||||
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.cjs`:
|
||||
|
||||
```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 = [];
|
||||
if (!Array.isArray(graph.nodes)) { issues.push('graph.nodes is missing or not an array'); graph.nodes = []; }
|
||||
if (!Array.isArray(graph.edges)) { issues.push('graph.edges is missing or not an array'); graph.edges = []; }
|
||||
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();
|
||||
if (!Array.isArray(graph.layers)) { if (graph.layers) warnings.push('graph.layers is not an array'); graph.layers = []; }
|
||||
if (!Array.isArray(graph.tour)) { if (graph.tour) warnings.push('graph.tour is not an array'); graph.tour = []; }
|
||||
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.cjs \
|
||||
"$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:**
|
||||
>
|
||||
@@ -349,14 +444,16 @@ Assemble the full KnowledgeGraph JSON object:
|
||||
|
||||
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`
|
||||
> 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. After the subagent completes, read `$PROJECT_ROOT/.understand-anything/intermediate/review.json`.
|
||||
---
|
||||
|
||||
5. **If `approved: false`:**
|
||||
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
|
||||
@@ -365,7 +462,7 @@ Pass these parameters in the dispatch prompt:
|
||||
- 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 `approved: true`:** Proceed to Phase 7.
|
||||
6. **If `issues` array is empty:** Proceed to Phase 7.
|
||||
|
||||
---
|
||||
|
||||
@@ -417,6 +514,7 @@ Pass these parameters in the dispatch prompt:
|
||||
3. Clean up intermediate files:
|
||||
```bash
|
||||
rm -rf $PROJECT_ROOT/.understand-anything/intermediate
|
||||
rm -rf $PROJECT_ROOT/.understand-anything/tmp
|
||||
```
|
||||
|
||||
4. Report a summary to the user containing:
|
||||
@@ -437,7 +535,7 @@ Pass these parameters in the dispatch prompt:
|
||||
## Error Handling
|
||||
|
||||
- If any subagent dispatch fails, retry **once** with the same prompt plus additional context about the failure.
|
||||
- Track all warnings and errors from each phase in a `$PHASE_WARNINGS` list. Pass this list to the graph-reviewer in Phase 6 for comprehensive validation.
|
||||
- Track all warnings and errors from each phase in a `$PHASE_WARNINGS` list. When using `--review`, pass this list to the graph-reviewer in Phase 6. On the default path, include accumulated warnings in the Phase 7 final report.
|
||||
- If it fails a second time, skip that phase and continue with partial results.
|
||||
- ALWAYS save partial results — a partial graph is better than no graph.
|
||||
- Report any skipped phases or errors in the final summary so the user knows what happened.
|
||||
@@ -451,7 +549,7 @@ Pass these parameters in the dispatch prompt:
|
||||
| Type | Description | ID Convention |
|
||||
|---|---|---|
|
||||
| `file` | Source file | `file:<relative-path>` |
|
||||
| `function` | Function or method | `func:<relative-path>:<name>` |
|
||||
| `function` | Function or method | `function:<relative-path>:<name>` |
|
||||
| `class` | Class, interface, or type | `class:<relative-path>:<name>` |
|
||||
| `module` | Logical module or package | `module:<name>` |
|
||||
| `concept` | Abstract concept or pattern | `concept:<name>` |
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user