Merge branch 'main' into feat/add-exclude-flag
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "understand-anything",
|
||||
"description": "AI-powered codebase understanding — analyze, visualize, and explain any project",
|
||||
"version": "2.8.2",
|
||||
"version": "2.9.3",
|
||||
"author": {
|
||||
"name": "Egonex"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "understand-anything",
|
||||
"description": "AI-powered codebase understanding — analyze, visualize, and explain any project",
|
||||
"version": "2.8.2",
|
||||
"version": "2.9.3",
|
||||
"author": {
|
||||
"name": "Egonex"
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "understand-anything",
|
||||
"displayName": "Understand Anything",
|
||||
"description": "AI-powered codebase understanding — analyze, visualize, and explain any project",
|
||||
"version": "2.8.2",
|
||||
"version": "2.9.3",
|
||||
"author": {
|
||||
"name": "Egonex"
|
||||
},
|
||||
|
||||
@@ -25,15 +25,15 @@ jobs:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: pnpm/action-setup@v6
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
@@ -51,6 +51,9 @@ jobs:
|
||||
- name: Build skill
|
||||
run: pnpm --filter @understand-anything/skill build
|
||||
|
||||
- name: Build viewer
|
||||
run: pnpm --filter understand-anything-viewer build
|
||||
|
||||
- name: Test core
|
||||
run: pnpm --filter @understand-anything/core test
|
||||
|
||||
@@ -58,4 +61,4 @@ jobs:
|
||||
run: pnpm test
|
||||
|
||||
- name: Test Python skill helpers
|
||||
run: python -m unittest tests.skill.understand.test_merge_batch_graphs -v
|
||||
run: python -m unittest tests.skill.understand.test_merge_batch_graphs tests.skill.understand.test_merge_subdomain_graphs tests.skill.knowledge.test_parse_knowledge_base -v
|
||||
|
||||
@@ -23,11 +23,11 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: pnpm/action-setup@v6
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
- name: Merge demo into homepage output
|
||||
run: cp -r understand-anything-plugin/packages/dashboard/dist homepage/dist/demo
|
||||
|
||||
- uses: actions/upload-pages-artifact@v3
|
||||
- uses: actions/upload-pages-artifact@v5
|
||||
with:
|
||||
path: homepage/dist
|
||||
|
||||
@@ -65,4 +65,4 @@ jobs:
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
uses: actions/deploy-pages@v5
|
||||
|
||||
@@ -17,3 +17,4 @@ venv/
|
||||
*.pyc
|
||||
*.pyo
|
||||
Thumbs.db
|
||||
*.tgz
|
||||
|
||||
@@ -25,7 +25,7 @@ An open-source tool combining LLM intelligence + static analysis to produce inte
|
||||
- Schema validation on graph load with error banner
|
||||
|
||||
## Agent Pipeline
|
||||
- Agents write intermediate results to `.understand-anything/intermediate/` on disk (not returned to context)
|
||||
- Agents write intermediate results to the data directory's `intermediate/` subdirectory on disk (not returned to context) — `.ua/intermediate/`, or `.understand-anything/intermediate/` when that legacy directory is present
|
||||
- Agent model field is omitted from frontmatter so each platform falls back to its configured default — `inherit` was a Claude Code-only keyword that opencode (and similar tools) treated as a literal model id and rejected with `ProviderModelNotFoundError` (see #167)
|
||||
- `/understand` auto-triggers `/understand-dashboard` after completion
|
||||
- Intermediate files cleaned up after graph assembly
|
||||
@@ -44,7 +44,7 @@ An open-source tool combining LLM intelligence + static analysis to produce inte
|
||||
- TypeScript strict mode everywhere
|
||||
- Vitest for testing
|
||||
- ESM modules (`"type": "module"`)
|
||||
- Knowledge graph JSON lives in `.understand-anything/` directory of analyzed projects
|
||||
- Knowledge graph JSON lives in the analyzed project's data directory: `.ua/` for new projects, or the legacy `.understand-anything/` directory when it already exists (if `.understand-anything/` is present it is used for both reads and writes; otherwise `.ua/`). All bundled scripts and core code self-resolve this rule.
|
||||
- Core uses subpath exports (`./search`, `./types`, `./schema`) to avoid pulling Node.js modules into browser
|
||||
|
||||
## Gotchas
|
||||
@@ -52,12 +52,16 @@ An open-source tool combining LLM intelligence + static analysis to produce inte
|
||||
- **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.
|
||||
- `scripts/generate-large-graph.mjs` — Generates a fake knowledge graph for performance testing (e.g. large-graph layout). Writes to the project data directory's `knowledge-graph.json` (`.ua/knowledge-graph.json`, or `.understand-anything/` when that legacy directory is present). Usage: `node scripts/generate-large-graph.mjs [nodeCount]` (default: 3000 nodes). Not part of the production pipeline.
|
||||
|
||||
## Viewer Package
|
||||
`packages/viewer` serves a committed graph without Claude Code, via `npx <release-asset-url>`. Update it when (a) the dashboard UI changes — the tarball embeds the built `dist/` — or (b) the `vite.config.ts` dev-server middleware changes, which `bin/viewer.mjs` deliberately mirrors. On every release, repack (`pack:release` script) and re-upload the tarball to the GitHub release as `understand-anything-viewer.tgz` — exactly that name, the READMEs' `releases/latest/download/` URL depends on it.
|
||||
|
||||
## Versioning
|
||||
When pushing to remote, bump the version in **all five** of these files (keep them in sync):
|
||||
When pushing to remote, bump the version in **all six** of these files (keep them in sync):
|
||||
- `understand-anything-plugin/package.json` → `"version"` field
|
||||
- `understand-anything-plugin/.claude-plugin/plugin.json` → `"version"` field
|
||||
- `understand-anything-plugin/packages/viewer/package.json` → `"version"` field
|
||||
- `.claude-plugin/plugin.json` → `"version"` field
|
||||
- `.cursor-plugin/plugin.json` → `"version"` field
|
||||
- `.copilot-plugin/plugin.json` → `"version"` field
|
||||
|
||||
@@ -125,7 +125,7 @@ Point `/understand-knowledge` at a [Karpathy-pattern LLM wiki](https://gist.gith
|
||||
/understand
|
||||
```
|
||||
|
||||
A multi-agent pipeline scans your project, extracts every file, function, class, and dependency, then builds a knowledge graph saved to `.understand-anything/knowledge-graph.json`.
|
||||
A multi-agent pipeline scans your project, extracts every file, function, class, and dependency, then builds a knowledge graph saved to `.ua/knowledge-graph.json`. (Projects that already have a `.understand-anything/` directory keep using it — it stays the data directory when present, so nothing needs migrating.)
|
||||
|
||||
> **Heads up on token usage:** The initial `/understand` analyzes your whole codebase and can consume a significant number of tokens on large projects. We recommend running it on a token plan / subscription, or using a local model (see above) for initialization. Subsequent runs are incremental by default — only changed files are re-analyzed — so they use far fewer tokens.
|
||||
|
||||
@@ -138,7 +138,7 @@ A multi-agent pipeline scans your project, extracts every file, function, class,
|
||||
# Supported languages: en (default), zh, zh-TW, ja, ko, ru
|
||||
```
|
||||
|
||||
On the **first run** in a project — when you don't pass `--language` and no language is stored yet — `/understand` detects the language you're conversing in. If it isn't English, it asks you to confirm (or override) before generating; English conversations are unaffected. Your choice is saved to `.understand-anything/config.json` and reused on every later run.
|
||||
On the **first run** in a project — when you don't pass `--language` and no language is stored yet — `/understand` detects the language you're conversing in. If it isn't English, it asks you to confirm (or override) before generating; English conversations are unaffected. Your choice is saved to `.ua/config.json` and reused on every later run.
|
||||
|
||||
The `--language` parameter affects:
|
||||
- Node summaries and descriptions in the knowledge graph
|
||||
@@ -215,6 +215,8 @@ iwr -useb https://raw.githubusercontent.com/Egonex-AI/Understand-Anything/main/i
|
||||
|
||||
The installer clones the repo to `~/.understand-anything/repo` and creates the right symlinks for the chosen platform. Restart your CLI/IDE afterwards.
|
||||
|
||||
> **Note on invoking skills:** the invocation prefix differs per platform. Most platforms use slash commands (`/understand`), but **Codex uses `$` instead** — type `$understand`, not `/understand`. If neither prefix is recognized on your platform, just ask in plain language: *"Use the understand skill to analyze this project."*
|
||||
|
||||
- Supported `<platform>` values: `gemini`, `codex`, `opencode`, `pi`, `openclaw`, `antigravity`, `vibe`, `vscode`, `hermes`, `cline`, `kimi`, `trae`, `nanobot`, `kiro`
|
||||
- Update later: `./install.sh --update`
|
||||
- Uninstall: `./install.sh --uninstall <platform>`
|
||||
@@ -280,11 +282,11 @@ The graph is just JSON — **commit it once, and teammates skip the pipeline**.
|
||||
|
||||
> **Example:** [GoogleCloudPlatform/microservices-demo](https://github.com/GoogleCloudPlatform/microservices-demo) — Go / Java / Python / Node reference with a committed graph.
|
||||
|
||||
**What to commit:** everything in `.understand-anything/` *except* `intermediate/` and `diff-overlay.json` (those are local scratch).
|
||||
**What to commit:** everything in `.ua/` *except* `intermediate/` and `diff-overlay.json` (those are local scratch). (Legacy projects use `.understand-anything/` — substitute that directory name below if it's the one present.)
|
||||
|
||||
```gitignore
|
||||
.understand-anything/intermediate/
|
||||
.understand-anything/diff-overlay.json
|
||||
.ua/intermediate/
|
||||
.ua/diff-overlay.json
|
||||
```
|
||||
|
||||
**Keep it fresh:** enable `/understand --auto-update` — a post-commit hook incrementally patches the graph so each commit lands with a matching graph. Or re-run `/understand` manually before releases.
|
||||
@@ -293,10 +295,22 @@ The graph is just JSON — **commit it once, and teammates skip the pipeline**.
|
||||
|
||||
```bash
|
||||
git lfs install
|
||||
git lfs track ".understand-anything/*.json"
|
||||
git add .gitattributes .understand-anything/
|
||||
git lfs track ".ua/*.json"
|
||||
git add .gitattributes .ua/
|
||||
```
|
||||
|
||||
### View the dashboard without Claude Code
|
||||
|
||||
Once a graph has been generated and committed, anyone on the team can open it with one command — no Claude Code, no LLM, no API key. Only Node.js (>= 18) is required:
|
||||
|
||||
```bash
|
||||
npx https://github.com/Egonex-AI/Understand-Anything/releases/latest/download/understand-anything-viewer.tgz /path/to/analyzed/project
|
||||
```
|
||||
|
||||
The terminal prints a tokenized URL (`http://127.0.0.1:5173/?token=…`) and opens the full interactive dashboard in your browser. The project directory (default: current directory) must contain the committed data directory (`.ua/`, or legacy `.understand-anything/`). Everything is served read-only from local disk — no LLM calls, no data leaves your machine.
|
||||
|
||||
Working from a clone instead? `pnpm install && pnpm --filter @understand-anything/core build`, then `GRAPH_DIR=/path/to/analyzed/project pnpm dev:dashboard` does the same via the Vite dev server.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Under the Hood
|
||||
|
||||
+20
-6
@@ -115,7 +115,7 @@ Apunta `/understand-knowledge` a un [wiki LLM con patrón Karpathy](https://gist
|
||||
/understand
|
||||
```
|
||||
|
||||
Un pipeline multi-agente escanea tu proyecto, extrae cada archivo, función, clase y dependencia, y construye un grafo de conocimiento guardado en `.understand-anything/knowledge-graph.json`.
|
||||
Un pipeline multi-agente escanea tu proyecto, extrae cada archivo, función, clase y dependencia, y construye un grafo de conocimiento guardado en `.ua/knowledge-graph.json`. (Los proyectos que ya tienen un directorio `.understand-anything/` lo siguen usando: sigue siendo el directorio de datos cuando está presente, así que no hay que migrar nada.)
|
||||
|
||||
> **Aviso sobre el consumo de tokens:** El primer `/understand` analiza todo tu código y puede consumir una cantidad significativa de tokens en proyectos grandes. Recomendamos ejecutarlo con un plan / suscripción de tokens, o usar un modelo local (ver arriba) para la inicialización. Las ejecuciones posteriores son incrementales por defecto — solo se reanalizan los archivos modificados — por lo que usan muchos menos tokens.
|
||||
|
||||
@@ -201,6 +201,8 @@ iwr -useb https://raw.githubusercontent.com/Egonex-AI/Understand-Anything/main/i
|
||||
|
||||
El instalador clona el repositorio en `~/.understand-anything/repo` y crea los enlaces simbólicos correspondientes para la plataforma elegida. Reinicia tu CLI/IDE al terminar.
|
||||
|
||||
> **Nota sobre cómo invocar las skills:** el prefijo de invocación varía según la plataforma. La mayoría usa comandos con barra (`/understand`), pero **Codex usa `$`** — escribe `$understand`, no `/understand`. Si ningún prefijo funciona, pídelo en lenguaje natural: *«Usa la skill understand para analizar este proyecto»*.
|
||||
|
||||
- Valores soportados de `<platform>`: `gemini`, `codex`, `opencode`, `pi`, `openclaw`, `antigravity`, `vibe`, `vscode`, `hermes`, `cline`, `kimi`, `nanobot`, `kiro`
|
||||
- Actualizar más adelante: `./install.sh --update`
|
||||
- Desinstalar: `./install.sh --uninstall <platform>`
|
||||
@@ -264,11 +266,11 @@ El grafo es solo JSON — **confírmalo una vez y tus compañeros se saltan el p
|
||||
|
||||
> **Ejemplo:** [GoogleCloudPlatform/microservices-demo](https://github.com/GoogleCloudPlatform/microservices-demo) — referencia políglota (Go / Java / Python / Node) con el grafo ya confirmado.
|
||||
|
||||
**Qué confirmar:** todo lo que hay en `.understand-anything/` *excepto* `intermediate/` y `diff-overlay.json` (archivos temporales locales).
|
||||
**Qué confirmar:** todo lo que hay en `.ua/` *excepto* `intermediate/` y `diff-overlay.json` (archivos temporales locales). (Los proyectos heredados usan `.understand-anything/`: sustituye ese nombre de directorio abajo si es el que está presente.)
|
||||
|
||||
```gitignore
|
||||
.understand-anything/intermediate/
|
||||
.understand-anything/diff-overlay.json
|
||||
.ua/intermediate/
|
||||
.ua/diff-overlay.json
|
||||
```
|
||||
|
||||
**Mantenlo al día:** activa `/understand --auto-update` — un hook post-commit parchea el grafo de forma incremental, así cada commit llega con su grafo correspondiente. O vuelve a ejecutar `/understand` manualmente antes de cada release.
|
||||
@@ -277,10 +279,22 @@ El grafo es solo JSON — **confírmalo una vez y tus compañeros se saltan el p
|
||||
|
||||
```bash
|
||||
git lfs install
|
||||
git lfs track ".understand-anything/*.json"
|
||||
git add .gitattributes .understand-anything/
|
||||
git lfs track ".ua/*.json"
|
||||
git add .gitattributes .ua/
|
||||
```
|
||||
|
||||
### Ver el dashboard sin Claude Code
|
||||
|
||||
Una vez que el grafo se ha generado y subido al repositorio, cualquier persona del equipo puede abrirlo con un solo comando: sin Claude Code, sin LLM, sin clave de API. Solo hace falta Node.js (>= 18):
|
||||
|
||||
```bash
|
||||
npx https://github.com/Egonex-AI/Understand-Anything/releases/latest/download/understand-anything-viewer.tgz /path/to/analyzed/project
|
||||
```
|
||||
|
||||
La terminal imprime una URL con token (`http://127.0.0.1:5173/?token=…`) y abre el dashboard interactivo completo en tu navegador. El directorio del proyecto (por defecto: el directorio actual) debe contener el directorio de datos versionado (`.ua/`, o el heredado `.understand-anything/`). Todo se sirve en modo solo lectura desde el disco local: sin llamadas al LLM, sin que ningún dato salga de tu máquina.
|
||||
|
||||
¿Trabajas desde un clon del repositorio? `pnpm install && pnpm --filter @understand-anything/core build`, y luego `GRAPH_DIR=/path/to/analyzed/project pnpm dev:dashboard` hace lo mismo a través del servidor de desarrollo de Vite.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Bajo el Capó
|
||||
|
||||
+38
-12
@@ -6,6 +6,12 @@
|
||||
<em>Claude Code、Codex、Cursor、Copilot、Gemini CLI など、マルチプラットフォーム対応。</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>Understand Anything. <a href="https://egonex.ai">Understand Anyone.</a></strong>
|
||||
<br />
|
||||
<em>AI は人を置き換えるのではなく、人を支えるためにあるべきです。</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/23482" target="_blank"><img src="https://trendshift.io/api/badge/repositories/23482" alt="Understand Anything | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
</p>
|
||||
@@ -23,8 +29,11 @@
|
||||
<a href="#copilot-cli"><img src="https://img.shields.io/badge/Copilot_CLI-24292e" alt="Copilot CLI" /></a>
|
||||
<a href="#gemini-cli"><img src="https://img.shields.io/badge/Gemini_CLI-4285F4" alt="Gemini CLI" /></a>
|
||||
<a href="#opencode"><img src="https://img.shields.io/badge/OpenCode-38bdf8" alt="OpenCode" /></a>
|
||||
<a href="#mistral-vibe-cli"><img src="https://img.shields.io/badge/Vibe_CLI-7c3aed" alt="Vibe CLI" /></a>
|
||||
<a href="#trae"><img src="https://img.shields.io/badge/Trae-7e22ce" alt="Trae" /></a>
|
||||
<a href="https://understand-anything.com"><img src="https://img.shields.io/badge/Homepage-d4a574" alt="ホームページ" /></a>
|
||||
<a href="https://understand-anything.com/demo/"><img src="https://img.shields.io/badge/Live_Demo-00c853" alt="ライブデモ" /></a>
|
||||
<a href="https://egonex.ai"><img src="https://img.shields.io/badge/Understand_Anyone-egonex.ai-d4a574" alt="Understand Anyone" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -32,9 +41,9 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>An open-source project from <a href="https://github.com/Egonex-AI">Egonex</a></strong>
|
||||
<strong><a href="https://github.com/Egonex-AI">Egonex</a> によるオープンソースプロジェクト</strong>
|
||||
<br />
|
||||
<em>Originally created by <a href="https://github.com/Lum1104">Lum1104</a>.</em>
|
||||
<em>原作者: <a href="https://github.com/Lum1104">Lum1104</a></em>
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -116,7 +125,7 @@ Understand Anything は [Claude Code Plugin](https://code.claude.com/docs/en/plu
|
||||
/understand
|
||||
```
|
||||
|
||||
マルチエージェントパイプラインがプロジェクトをスキャンし、すべてのファイル・関数・クラス・依存関係を抽出して、`.understand-anything/knowledge-graph.json` にナレッジグラフを保存します。
|
||||
マルチエージェントパイプラインがプロジェクトをスキャンし、すべてのファイル・関数・クラス・依存関係を抽出して、`.ua/knowledge-graph.json` にナレッジグラフを保存します。(すでに `.understand-anything/` ディレクトリがあるプロジェクトはそれを引き続き使用します。存在する場合はそれがデータディレクトリのままなので、移行は不要です。)
|
||||
|
||||
> **トークン使用量にご注意:** 初回の `/understand` はコードベース全体を分析するため、大規模プロジェクトではかなりのトークンを消費することがあります。トークンプラン / サブスクリプションでの実行、または初期化にはローカルモデル(上記参照)の使用をおすすめします。以降の実行はデフォルトで増分処理され、変更されたファイルのみ再分析するため、消費トークンは大幅に少なくなります。
|
||||
|
||||
@@ -129,6 +138,8 @@ Understand Anything は [Claude Code Plugin](https://code.claude.com/docs/en/plu
|
||||
# サポート言語:en(デフォルト)、zh、zh-TW、ja、ko、ru
|
||||
```
|
||||
|
||||
プロジェクトでの**初回実行時**に `--language` を指定せず、保存済みの言語設定もない場合、`/understand` は会話で使われている言語を検出します。英語以外が検出された場合は、生成前にその言語を使用するか、別の言語へ変更するかを確認します。英語での会話には影響しません。選択結果は `.ua/config.json` に保存され、以降の実行でも再利用されます。
|
||||
|
||||
`--language` パラメータは以下に影響します:
|
||||
- ナレッジグラフのノードサマリーと説明
|
||||
- ダッシュボードUIのラベル、ボタン、ツールチップ
|
||||
@@ -186,7 +197,7 @@ Understand-Anythingは複数のAIコーディングプラットフォームで
|
||||
/plugin install understand-anything
|
||||
```
|
||||
|
||||
### ワンラインインストール(Codex / OpenCode / OpenClaw / Antigravity / Gemini CLI / Pi Agent / Vibe CLI / VS Code Copilot / Hermes / Cline / KIMI CLI / Nanobot / Kiro)
|
||||
### ワンラインインストール(Codex / OpenCode / OpenClaw / Antigravity / Gemini CLI / Pi Agent / Vibe CLI / VS Code Copilot / Hermes / Cline / KIMI CLI / Trae / Nanobot / Kiro)
|
||||
|
||||
**macOS / Linux:**
|
||||
```bash
|
||||
@@ -202,7 +213,9 @@ iwr -useb https://raw.githubusercontent.com/Egonex-AI/Understand-Anything/main/i
|
||||
|
||||
インストーラーはリポジトリを `~/.understand-anything/repo` にクローンし、選択したプラットフォーム用のシンボリックリンクを作成します。完了後はCLI/IDEを再起動してください。
|
||||
|
||||
- サポートされる `<platform>` 値:`gemini`、`codex`、`opencode`、`pi`、`openclaw`、`antigravity`、`vibe`、`vscode`、`hermes`、`cline`、`kimi`、`nanobot`、`kiro`
|
||||
> **スキルの呼び出し方について:** 呼び出しのプレフィックスはプラットフォームごとに異なります。多くのプラットフォームはスラッシュコマンド(`/understand`)を使いますが、**Codexは`$`を使います** — `/understand`ではなく`$understand`と入力してください。どちらのプレフィックスも認識されない場合は、*「understandスキルを使ってこのプロジェクトを分析して」*のように自然言語で依頼できます。
|
||||
|
||||
- サポートされる `<platform>` 値:`gemini`、`codex`、`opencode`、`pi`、`openclaw`、`antigravity`、`vibe`、`vscode`、`hermes`、`cline`、`kimi`、`trae`、`nanobot`、`kiro`
|
||||
- 後で更新:`./install.sh --update`
|
||||
- アンインストール:`./install.sh --uninstall <platform>`
|
||||
|
||||
@@ -254,6 +267,7 @@ curl -fsSL https://raw.githubusercontent.com/Egonex-AI/Understand-Anything/main/
|
||||
| Hermes | ✅ サポート | `install.sh hermes` |
|
||||
| Cline | ✅ サポート | `install.sh cline` |
|
||||
| KIMI CLI | ✅ サポート | `install.sh kimi` |
|
||||
| Trae | ✅ サポート | `install.sh trae` |
|
||||
| Nanobot | ✅ サポート | `install.sh nanobot` |
|
||||
| Kiro CLI / IDE | ✅ サポート | `install.sh kiro` |
|
||||
|
||||
@@ -265,11 +279,11 @@ curl -fsSL https://raw.githubusercontent.com/Egonex-AI/Understand-Anything/main/
|
||||
|
||||
> **例:** [GoogleCloudPlatform/microservices-demo](https://github.com/GoogleCloudPlatform/microservices-demo) —— コミット済みのグラフを含む Go / Java / Python / Node のリファレンスプロジェクト。
|
||||
|
||||
**コミット対象:** `.understand-anything/` 内のすべてのファイル。ただし `intermediate/` と `diff-overlay.json` は除きます(これらはローカルの一時ファイルです)。
|
||||
**コミット対象:** `.ua/` 内のすべてのファイル。ただし `intermediate/` と `diff-overlay.json` は除きます(これらはローカルの一時ファイルです)。(レガシープロジェクトは `.understand-anything/` を使用します。そのディレクトリが存在する場合は、以下のディレクトリ名をそれに置き換えてください。)
|
||||
|
||||
```gitignore
|
||||
.understand-anything/intermediate/
|
||||
.understand-anything/diff-overlay.json
|
||||
.ua/intermediate/
|
||||
.ua/diff-overlay.json
|
||||
```
|
||||
|
||||
**最新状態を保つ:** `/understand --auto-update` を有効にすると、post-commit フックがグラフを増分的に更新し、各コミットに対応するグラフが揃います。またはリリース前に `/understand` を手動で再実行します。
|
||||
@@ -278,10 +292,22 @@ curl -fsSL https://raw.githubusercontent.com/Egonex-AI/Understand-Anything/main/
|
||||
|
||||
```bash
|
||||
git lfs install
|
||||
git lfs track ".understand-anything/*.json"
|
||||
git add .gitattributes .understand-anything/
|
||||
git lfs track ".ua/*.json"
|
||||
git add .gitattributes .ua/
|
||||
```
|
||||
|
||||
### Claude Code なしでダッシュボードを表示する
|
||||
|
||||
グラフを生成してコミットしておけば、チームの誰でもコマンド一つで開けます。Claude Code も LLM も API キーも不要で、必要なのは Node.js(>= 18)だけです:
|
||||
|
||||
```bash
|
||||
npx https://github.com/Egonex-AI/Understand-Anything/releases/latest/download/understand-anything-viewer.tgz /path/to/analyzed/project
|
||||
```
|
||||
|
||||
ターミナルにトークン付き URL(`http://127.0.0.1:5173/?token=…`)が表示され、完全にインタラクティブなダッシュボードがブラウザで開きます。プロジェクトディレクトリ(デフォルト:カレントディレクトリ)には、コミットされたデータディレクトリ(`.ua/`、または旧来の `.understand-anything/`)が含まれている必要があります。すべてローカルディスクから読み取り専用で配信され、LLM 呼び出しは行われず、データがマシンの外に出ることはありません。
|
||||
|
||||
リポジトリのクローンから作業する場合は、`pnpm install && pnpm --filter @understand-anything/core build` の後に `GRAPH_DIR=/path/to/analyzed/project pnpm dev:dashboard` を実行すれば、Vite 開発サーバー経由で同じことができます。
|
||||
|
||||
---
|
||||
|
||||
## 🔧 内部の仕組み
|
||||
@@ -305,11 +331,11 @@ git add .gitattributes .understand-anything/
|
||||
| `file-analyzer` | 関数・クラス・インポートの抽出、グラフノードとエッジの生成 |
|
||||
| `architecture-analyzer` | アーキテクチャ層の特定 |
|
||||
| `tour-builder` | ガイド学習ツアーの生成 |
|
||||
| `graph-reviewer` | グラフの完全性と参照整合性の検証 |
|
||||
| `graph-reviewer` | グラフの完全性と参照整合性を検証(デフォルトではインライン実行。LLMによる完全レビューは `--review` を使用) |
|
||||
| `domain-analyzer` | ビジネスドメイン、フロー、処理ステップの抽出(`/understand-domain` で使用) |
|
||||
| `article-analyzer` | wiki 記事からエンティティ、主張、暗黙の関係を抽出(`/understand-knowledge` で使用) |
|
||||
|
||||
ファイルアナライザーは並列実行されます(最大3つ同時)。インクリメンタル更新に対応しており、前回の実行から変更されたファイルのみを再分析します。
|
||||
ファイルアナライザーは並列実行されます(最大5つ同時、1バッチあたり20〜30ファイル)。インクリメンタル更新に対応しており、前回の実行から変更されたファイルのみを再分析します。
|
||||
|
||||
---
|
||||
|
||||
|
||||
+20
-6
@@ -115,7 +115,7 @@ Understand Anything은 [Claude Code Plugin](https://code.claude.com/docs/en/plug
|
||||
/understand
|
||||
```
|
||||
|
||||
멀티 에이전트 파이프라인이 프로젝트를 스캔하고, 모든 파일, 함수, 클래스, 의존성을 추출한 뒤, `.understand-anything/knowledge-graph.json`에 지식 그래프를 저장합니다.
|
||||
멀티 에이전트 파이프라인이 프로젝트를 스캔하고, 모든 파일, 함수, 클래스, 의존성을 추출한 뒤, `.ua/knowledge-graph.json`에 지식 그래프를 저장합니다. (이미 `.understand-anything/` 디렉터리가 있는 프로젝트는 계속 그것을 사용합니다. 존재하는 경우 그것이 데이터 디렉터리로 유지되므로 마이그레이션이 필요 없습니다.)
|
||||
|
||||
> **토큰 사용량 안내:** 최초 `/understand`는 전체 코드베이스를 분석하므로 대규모 프로젝트에서는 상당한 토큰을 소비할 수 있습니다. 토큰 요금제 / 구독으로 실행하거나, 초기화에는 로컬 모델(위 참조)을 사용하는 것을 권장합니다. 이후 실행은 기본적으로 증분 방식이라 변경된 파일만 다시 분석하므로 훨씬 적은 토큰을 사용합니다.
|
||||
|
||||
@@ -201,6 +201,8 @@ iwr -useb https://raw.githubusercontent.com/Egonex-AI/Understand-Anything/main/i
|
||||
|
||||
설치 스크립트는 저장소를 `~/.understand-anything/repo`에 클론하고 선택한 플랫폼에 맞는 심볼릭 링크를 생성합니다. 설치 후 CLI 또는 IDE를 재시작하세요.
|
||||
|
||||
> **스킬 호출 방식 안내:** 호출 접두사는 플랫폼마다 다릅니다. 대부분의 플랫폼은 슬래시 명령(`/understand`)을 사용하지만, **Codex는 `$`를 사용합니다** — `/understand`가 아니라 `$understand`를 입력하세요. 두 접두사 모두 인식되지 않으면 *"understand 스킬로 이 프로젝트를 분석해 줘"*처럼 자연어로 요청하면 됩니다.
|
||||
|
||||
- 지원되는 `<platform>` 값: `gemini`, `codex`, `opencode`, `pi`, `openclaw`, `antigravity`, `vibe`, `vscode`, `hermes`, `cline`, `kimi`, `nanobot`, `kiro`
|
||||
- 이후 업데이트: `./install.sh --update`
|
||||
- 제거: `./install.sh --uninstall <platform>`
|
||||
@@ -264,11 +266,11 @@ curl -fsSL https://raw.githubusercontent.com/Egonex-AI/Understand-Anything/main/
|
||||
|
||||
> **예시:** [GoogleCloudPlatform/microservices-demo](https://github.com/GoogleCloudPlatform/microservices-demo) — 커밋된 그래프를 포함한 Go / Java / Python / Node 레퍼런스 프로젝트.
|
||||
|
||||
**커밋할 대상:** `.understand-anything/` 내부의 모든 파일. 단, `intermediate/` 와 `diff-overlay.json` 은 제외합니다 (이들은 로컬 임시 파일입니다).
|
||||
**커밋할 대상:** `.ua/` 내부의 모든 파일. 단, `intermediate/` 와 `diff-overlay.json` 은 제외합니다 (이들은 로컬 임시 파일입니다). (레거시 프로젝트는 `.understand-anything/` 를 사용합니다. 해당 디렉터리가 있는 경우 아래의 디렉터리 이름을 그것으로 바꾸세요.)
|
||||
|
||||
```gitignore
|
||||
.understand-anything/intermediate/
|
||||
.understand-anything/diff-overlay.json
|
||||
.ua/intermediate/
|
||||
.ua/diff-overlay.json
|
||||
```
|
||||
|
||||
**최신 상태 유지:** `/understand --auto-update` 를 활성화하면 post-commit 훅이 그래프를 증분 업데이트하여 각 커밋마다 일치하는 그래프가 유지됩니다. 또는 릴리스 전에 `/understand` 를 수동으로 다시 실행하세요.
|
||||
@@ -277,10 +279,22 @@ curl -fsSL https://raw.githubusercontent.com/Egonex-AI/Understand-Anything/main/
|
||||
|
||||
```bash
|
||||
git lfs install
|
||||
git lfs track ".understand-anything/*.json"
|
||||
git add .gitattributes .understand-anything/
|
||||
git lfs track ".ua/*.json"
|
||||
git add .gitattributes .ua/
|
||||
```
|
||||
|
||||
### Claude Code 없이 대시보드 보기
|
||||
|
||||
그래프를 한 번 생성해 커밋해두면, 팀의 누구나 명령어 하나로 열 수 있습니다. Claude Code 도, LLM 도, API 키도 필요 없으며, Node.js(>= 18)만 있으면 됩니다:
|
||||
|
||||
```bash
|
||||
npx https://github.com/Egonex-AI/Understand-Anything/releases/latest/download/understand-anything-viewer.tgz /path/to/analyzed/project
|
||||
```
|
||||
|
||||
터미널에 토큰이 포함된 URL(`http://127.0.0.1:5173/?token=…`)이 출력되고, 완전한 인터랙티브 대시보드가 브라우저에서 열립니다. 프로젝트 디렉터리(기본값: 현재 디렉터리)에는 커밋된 데이터 디렉터리(`.ua/`, 또는 레거시 `.understand-anything/`)가 있어야 합니다. 모든 것은 로컬 디스크에서 읽기 전용으로 제공되며, LLM 호출도 없고 데이터가 사용자의 컴퓨터를 벗어나지 않습니다.
|
||||
|
||||
저장소를 클론해서 작업 중이라면 `pnpm install && pnpm --filter @understand-anything/core build` 후 `GRAPH_DIR=/path/to/analyzed/project pnpm dev:dashboard` 를 실행하면 Vite 개발 서버를 통해 같은 결과를 얻을 수 있습니다.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 작동 원리
|
||||
|
||||
+20
-6
@@ -116,7 +116,7 @@ Understand Anything — это [плагин для Claude Code](https://code.cl
|
||||
/understand
|
||||
```
|
||||
|
||||
Мультиагентный пайплайн сканирует ваш проект, извлекает каждый файл, функцию, класс и зависимость, а затем строит граф знаний и сохраняет его в `.understand-anything/knowledge-graph.json`.
|
||||
Мультиагентный пайплайн сканирует ваш проект, извлекает каждый файл, функцию, класс и зависимость, а затем строит граф знаний и сохраняет его в `.ua/knowledge-graph.json`. (Проекты, в которых уже есть каталог `.understand-anything/`, продолжают использовать его — при наличии он остаётся каталогом данных, поэтому ничего мигрировать не нужно.)
|
||||
|
||||
> **Обратите внимание на расход токенов:** Первый запуск `/understand` анализирует всю кодовую базу и может потреблять значительное количество токенов на больших проектах. Рекомендуем запускать его с тарифным планом / подпиской на токены или использовать локальную модель (см. выше) для инициализации. Последующие запуски по умолчанию инкрементальны — повторно анализируются только изменённые файлы — поэтому расходуют гораздо меньше токенов.
|
||||
|
||||
@@ -202,6 +202,8 @@ iwr -useb https://raw.githubusercontent.com/Egonex-AI/Understand-Anything/main/i
|
||||
|
||||
Установщик клонирует репозиторий в `~/.understand-anything/repo` и создаёт нужные симлинки для выбранной платформы. После установки перезапустите свой CLI/IDE.
|
||||
|
||||
> **Как вызывать skills:** префикс вызова зависит от платформы. Большинство платформ используют слэш-команды (`/understand`), но **Codex использует `$`** — вводите `$understand`, а не `/understand`. Если ни один префикс не распознаётся, просто попросите обычным языком: *«Используй skill understand, чтобы проанализировать этот проект»*.
|
||||
|
||||
- Поддерживаемые значения `<platform>`: `gemini`, `codex`, `opencode`, `pi`, `openclaw`, `antigravity`, `vibe`, `vscode`, `hermes`, `cline`, `kimi`, `nanobot`, `kiro`
|
||||
- Обновление: `./install.sh --update`
|
||||
- Удаление: `./install.sh --uninstall <platform>`
|
||||
@@ -265,11 +267,11 @@ curl -fsSL https://raw.githubusercontent.com/Egonex-AI/Understand-Anything/main/
|
||||
|
||||
> **Пример:** [GoogleCloudPlatform/microservices-demo (форк)](https://github.com/GoogleCloudPlatform/microservices-demo) — мультиязыковой проект (Go / Java / Python / Node) с уже зафиксированным графом.
|
||||
|
||||
**Что коммитить:** всё содержимое `.understand-anything/`, *кроме* `intermediate/` и `diff-overlay.json` (это локальные временные файлы).
|
||||
**Что коммитить:** всё содержимое `.ua/`, *кроме* `intermediate/` и `diff-overlay.json` (это локальные временные файлы). (Устаревшие проекты используют `.understand-anything/` — подставьте это имя каталога ниже, если присутствует именно он.)
|
||||
|
||||
```gitignore
|
||||
.understand-anything/intermediate/
|
||||
.understand-anything/diff-overlay.json
|
||||
.ua/intermediate/
|
||||
.ua/diff-overlay.json
|
||||
```
|
||||
|
||||
**Держите граф в актуальном состоянии:** включите `/understand --auto-update` — post-commit хук будет инкрементально обновлять граф, так что каждый коммит сопровождается соответствующим графом. Либо запускайте `/understand` вручную перед релизами.
|
||||
@@ -278,10 +280,22 @@ curl -fsSL https://raw.githubusercontent.com/Egonex-AI/Understand-Anything/main/
|
||||
|
||||
```bash
|
||||
git lfs install
|
||||
git lfs track ".understand-anything/*.json"
|
||||
git add .gitattributes .understand-anything/
|
||||
git lfs track ".ua/*.json"
|
||||
git add .gitattributes .ua/
|
||||
```
|
||||
|
||||
### Просмотр панели без Claude Code
|
||||
|
||||
Как только граф сгенерирован и закоммичен, любой участник команды может открыть его одной командой — без Claude Code, без LLM, без API-ключа. Нужен только Node.js (>= 18):
|
||||
|
||||
```bash
|
||||
npx https://github.com/Egonex-AI/Understand-Anything/releases/latest/download/understand-anything-viewer.tgz /path/to/analyzed/project
|
||||
```
|
||||
|
||||
В терминале выводится URL с токеном (`http://127.0.0.1:5173/?token=…`), и полностью интерактивная панель открывается в браузере. Каталог проекта (по умолчанию — текущий каталог) должен содержать закоммиченный каталог с данными (`.ua/` или устаревший `.understand-anything/`). Всё раздаётся только для чтения с локального диска — никаких обращений к LLM, никакие данные не покидают вашу машину.
|
||||
|
||||
Работаете из клона репозитория? `pnpm install && pnpm --filter @understand-anything/core build`, затем `GRAPH_DIR=/path/to/analyzed/project pnpm dev:dashboard` — то же самое через dev-сервер Vite.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Под капотом
|
||||
|
||||
+20
-6
@@ -116,7 +116,7 @@ Alan görünümüne geçin ve kodunuzun gerçek iş süreçleriyle nasıl eşle
|
||||
/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.
|
||||
Çok-ajan hattı projenizi tarar, her dosya, fonksiyon, sınıf ve bağımlılığı çıkarır, ardından `.ua/knowledge-graph.json` dosyasına kaydedilen bir bilgi grafiği oluşturur. (Zaten bir `.understand-anything/` dizini olan projeler onu kullanmaya devam eder — mevcut olduğunda veri dizini olarak kalır, bu yüzden taşımaya gerek yoktur.)
|
||||
|
||||
> **Token kullanımı hakkında uyarı:** İlk `/understand` çalıştırması tüm kod tabanınızı analiz eder ve büyük projelerde önemli miktarda token tüketebilir. Bunu bir token planı / aboneliği ile çalıştırmanızı veya başlatma için yerel bir model (yukarıya bakın) kullanmanızı öneririz. Sonraki çalıştırmalar varsayılan olarak artımlıdır — yalnızca değişen dosyalar yeniden analiz edilir — bu yüzden çok daha az token kullanır.
|
||||
|
||||
@@ -202,6 +202,8 @@ iwr -useb https://raw.githubusercontent.com/Egonex-AI/Understand-Anything/main/i
|
||||
|
||||
Kurulum betiği depoyu `~/.understand-anything/repo` dizinine klonlar ve seçilen platform için uygun sembolik bağlantıları oluşturur. Sonrasında CLI/IDE'ni yeniden başlat.
|
||||
|
||||
> **Skill çağırma hakkında not:** Çağırma öneki platforma göre değişir. Çoğu platform eğik çizgi komutları (`/understand`) kullanır, ancak **Codex `$` kullanır** — `/understand` değil, `$understand` yaz. İki önek de tanınmıyorsa doğal dille iste: *"understand skill'ini kullanarak bu projeyi analiz et."*
|
||||
|
||||
- Desteklenen `<platform>` değerleri: `gemini`, `codex`, `opencode`, `pi`, `openclaw`, `antigravity`, `vibe`, `vscode`, `hermes`, `cline`, `kimi`, `nanobot`, `kiro`
|
||||
- Daha sonra güncelle: `./install.sh --update`
|
||||
- Kaldır: `./install.sh --uninstall <platform>`
|
||||
@@ -265,11 +267,11 @@ Graf yalnızca bir JSON dosyasıdır — **bir kez commit'leyin, ekip arkadaşla
|
||||
|
||||
> **Örnek:** [GoogleCloudPlatform/microservices-demo](https://github.com/GoogleCloudPlatform/microservices-demo) — commit'lenmiş grafı içeren Go / Java / Python / Node çok dilli referans projesi.
|
||||
|
||||
**Neyi commit'leyin:** `.understand-anything/` içindeki her şey, *ancak* `intermediate/` ve `diff-overlay.json` hariç (bunlar yerel geçici dosyalardır).
|
||||
**Neyi commit'leyin:** `.ua/` içindeki her şey, *ancak* `intermediate/` ve `diff-overlay.json` hariç (bunlar yerel geçici dosyalardır). (Eski projeler `.understand-anything/` kullanır — mevcut olan buysa aşağıdaki dizin adını onunla değiştirin.)
|
||||
|
||||
```gitignore
|
||||
.understand-anything/intermediate/
|
||||
.understand-anything/diff-overlay.json
|
||||
.ua/intermediate/
|
||||
.ua/diff-overlay.json
|
||||
```
|
||||
|
||||
**Güncel tutun:** `/understand --auto-update` etkinleştirin — bir post-commit kancası grafı artımlı olarak yamalar, böylece her commit eşleşen bir grafla birlikte gelir. Veya sürümden önce `/understand` komutunu elle yeniden çalıştırın.
|
||||
@@ -278,10 +280,22 @@ Graf yalnızca bir JSON dosyasıdır — **bir kez commit'leyin, ekip arkadaşla
|
||||
|
||||
```bash
|
||||
git lfs install
|
||||
git lfs track ".understand-anything/*.json"
|
||||
git add .gitattributes .understand-anything/
|
||||
git lfs track ".ua/*.json"
|
||||
git add .gitattributes .ua/
|
||||
```
|
||||
|
||||
### Dashboard'u Claude Code olmadan görüntüleyin
|
||||
|
||||
Graf bir kez üretilip commit'lendikten sonra, ekipteki herkes onu tek bir komutla açabilir — Claude Code yok, LLM yok, API anahtarı yok. Yalnızca Node.js (>= 18) gerekir:
|
||||
|
||||
```bash
|
||||
npx https://github.com/Egonex-AI/Understand-Anything/releases/latest/download/understand-anything-viewer.tgz /path/to/analyzed/project
|
||||
```
|
||||
|
||||
Terminal, tokenlı bir URL (`http://127.0.0.1:5173/?token=…`) yazdırır ve tam etkileşimli dashboard'u tarayıcınızda açar. Proje dizini (varsayılan: geçerli dizin), commit'lenmiş veri dizinini (`.ua/` veya eski `.understand-anything/`) içermelidir. Her şey yerel diskten salt okunur olarak sunulur — LLM çağrısı yapılmaz, hiçbir veri makinenizden çıkmaz.
|
||||
|
||||
Depoyu klonlayarak mı çalışıyorsunuz? `pnpm install && pnpm --filter @understand-anything/core build`, ardından `GRAPH_DIR=/path/to/analyzed/project pnpm dev:dashboard` aynı işi Vite geliştirme sunucusu üzerinden yapar.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Kaputun Altında
|
||||
|
||||
+20
-6
@@ -115,7 +115,7 @@ Understand Anything 是一个 [Claude Code Plugin](https://code.claude.com/docs/
|
||||
/understand
|
||||
```
|
||||
|
||||
多智能体(multi-agent)架构会:扫描你的项目,提取函数 / 类 / 依赖,构建知识图谱保存至`.understand-anything/knowledge-graph.json`.
|
||||
多智能体(multi-agent)架构会:扫描你的项目,提取函数 / 类 / 依赖,构建知识图谱保存至`.ua/knowledge-graph.json`。(已经有 `.understand-anything/` 目录的项目会继续使用它——存在时它仍是数据目录,因此无需迁移。)
|
||||
|
||||
> **关于 Token 消耗的提醒:** 首次运行 `/understand` 会分析整个代码库,在大型项目上可能消耗大量 token。建议在有 token 套餐 / 订阅的情况下运行,或在初始化时使用本地模型(见上文)。后续运行默认是增量式的——只重新分析变更过的文件——因此消耗的 token 大幅减少。
|
||||
|
||||
@@ -201,6 +201,8 @@ iwr -useb https://raw.githubusercontent.com/Egonex-AI/Understand-Anything/main/i
|
||||
|
||||
安装脚本会将仓库克隆到 `~/.understand-anything/repo`,并为所选平台创建相应的符号链接。安装完成后请重启 CLI 或 IDE。
|
||||
|
||||
> **关于技能调用方式:** 不同平台的调用前缀不同。大多数平台使用斜杠命令(`/understand`),但 **Codex 使用 `$`** —— 请输入 `$understand`,而不是 `/understand`。如果两种前缀都不被识别,直接用自然语言请求即可:*“使用 understand 技能分析这个项目”*。
|
||||
|
||||
- 支持的 `<platform>` 取值:`gemini`、`codex`、`opencode`、`pi`、`openclaw`、`antigravity`、`vibe`、`vscode`、`hermes`、`cline`、`kimi`、`nanobot`、`kiro`
|
||||
- 后续更新:`./install.sh --update`
|
||||
- 卸载:`./install.sh --uninstall <platform>`
|
||||
@@ -264,11 +266,11 @@ curl -fsSL https://raw.githubusercontent.com/Egonex-AI/Understand-Anything/main/
|
||||
|
||||
> **示例:** [GoogleCloudPlatform/microservices-demo](https://github.com/GoogleCloudPlatform/microservices-demo) —— 包含已提交图谱的 Go / Java / Python / Node 多语言参考项目。
|
||||
|
||||
**需要提交的内容:** `.understand-anything/` 下的全部文件,*除了* `intermediate/` 和 `diff-overlay.json`(这些是本地临时文件)。
|
||||
**需要提交的内容:** `.ua/` 下的全部文件,*除了* `intermediate/` 和 `diff-overlay.json`(这些是本地临时文件)。(旧项目使用 `.understand-anything/`——如果存在的是该目录,请将下方的目录名替换为它。)
|
||||
|
||||
```gitignore
|
||||
.understand-anything/intermediate/
|
||||
.understand-anything/diff-overlay.json
|
||||
.ua/intermediate/
|
||||
.ua/diff-overlay.json
|
||||
```
|
||||
|
||||
**保持最新:** 启用 `/understand --auto-update` —— 一个 post-commit 钩子会增量更新图谱,每次提交都能得到匹配的图谱版本。也可以在发布前手动重跑 `/understand`。
|
||||
@@ -277,10 +279,22 @@ curl -fsSL https://raw.githubusercontent.com/Egonex-AI/Understand-Anything/main/
|
||||
|
||||
```bash
|
||||
git lfs install
|
||||
git lfs track ".understand-anything/*.json"
|
||||
git add .gitattributes .understand-anything/
|
||||
git lfs track ".ua/*.json"
|
||||
git add .gitattributes .ua/
|
||||
```
|
||||
|
||||
### 无需 Claude Code 也能查看仪表盘
|
||||
|
||||
图谱生成并提交后,团队中的任何人只需一条命令即可打开它 —— 无需 Claude Code,无需 LLM,无需 API 密钥,只需要 Node.js(>= 18):
|
||||
|
||||
```bash
|
||||
npx https://github.com/Egonex-AI/Understand-Anything/releases/latest/download/understand-anything-viewer.tgz /path/to/analyzed/project
|
||||
```
|
||||
|
||||
终端会打印一个带令牌的 URL(`http://127.0.0.1:5173/?token=…`),并在浏览器中打开完整的交互式仪表盘。项目目录(默认:当前目录)必须包含已提交的数据目录(`.ua/`,或旧版 `.understand-anything/`)。所有内容都从本地磁盘以只读方式提供 —— 没有 LLM 调用,也不会有任何数据离开你的机器。
|
||||
|
||||
如果你是从克隆的仓库工作:先执行 `pnpm install && pnpm --filter @understand-anything/core build`,再运行 `GRAPH_DIR=/path/to/analyzed/project pnpm dev:dashboard`,即可通过 Vite 开发服务器实现同样的效果。
|
||||
|
||||
---
|
||||
|
||||
## 🔧 技术原理
|
||||
|
||||
+20
-6
@@ -115,7 +115,7 @@ Understand Anything 是一個 [Claude Code Plugin](https://code.claude.com/docs/
|
||||
/understand
|
||||
```
|
||||
|
||||
多智能體(multi-agent)架構會:掃描你的專案,提取函式 / 類別 / 相依關係,建構知識圖譜並儲存至 `.understand-anything/knowledge-graph.json`。
|
||||
多智能體(multi-agent)架構會:掃描你的專案,提取函式 / 類別 / 相依關係,建構知識圖譜並儲存至 `.ua/knowledge-graph.json`。(已經有 `.understand-anything/` 目錄的專案會繼續使用它——存在時它仍是資料目錄,因此無需遷移。)
|
||||
|
||||
> **關於 Token 消耗的提醒:** 首次執行 `/understand` 會分析整個程式碼庫,在大型專案上可能消耗大量 token。建議在有 token 方案 / 訂閱的情況下執行,或在初始化時使用本地模型(見上文)。後續執行預設為增量式——只重新分析變更過的檔案——因此消耗的 token 大幅減少。
|
||||
|
||||
@@ -201,6 +201,8 @@ iwr -useb https://raw.githubusercontent.com/Egonex-AI/Understand-Anything/main/i
|
||||
|
||||
安裝指令稿會將儲存庫複製到 `~/.understand-anything/repo`,並為所選平台建立相應的符號連結。安裝完成後請重新啟動 CLI 或 IDE。
|
||||
|
||||
> **關於技能呼叫方式:** 不同平台的呼叫前綴不同。大多數平台使用斜線指令(`/understand`),但 **Codex 使用 `$`** —— 請輸入 `$understand`,而不是 `/understand`。如果兩種前綴都無法辨識,直接用自然語言請求即可:*「使用 understand 技能分析這個專案」*。
|
||||
|
||||
- 支援的 `<platform>` 取值:`gemini`、`codex`、`opencode`、`pi`、`openclaw`、`antigravity`、`vibe`、`vscode`、`hermes`、`cline`、`kimi`、`nanobot`、`kiro`
|
||||
- 後續更新:`./install.sh --update`
|
||||
- 解除安裝:`./install.sh --uninstall <platform>`
|
||||
@@ -264,11 +266,11 @@ curl -fsSL https://raw.githubusercontent.com/Egonex-AI/Understand-Anything/main/
|
||||
|
||||
> **範例:** [GoogleCloudPlatform/microservices-demo](https://github.com/GoogleCloudPlatform/microservices-demo) —— 包含已提交圖譜的 Go / Java / Python / Node 多語言參考專案。
|
||||
|
||||
**需要提交的內容:** `.understand-anything/` 底下的全部檔案,*除了* `intermediate/` 與 `diff-overlay.json`(這些是本機暫存檔)。
|
||||
**需要提交的內容:** `.ua/` 底下的全部檔案,*除了* `intermediate/` 與 `diff-overlay.json`(這些是本機暫存檔)。(舊專案使用 `.understand-anything/`——如果存在的是該目錄,請將下方的目錄名稱替換為它。)
|
||||
|
||||
```gitignore
|
||||
.understand-anything/intermediate/
|
||||
.understand-anything/diff-overlay.json
|
||||
.ua/intermediate/
|
||||
.ua/diff-overlay.json
|
||||
```
|
||||
|
||||
**保持最新:** 啟用 `/understand --auto-update` —— 一個 post-commit 掛鉤會增量更新圖譜,讓每次提交都有對應的圖譜版本。也可以在發布前手動重跑 `/understand`。
|
||||
@@ -277,10 +279,22 @@ curl -fsSL https://raw.githubusercontent.com/Egonex-AI/Understand-Anything/main/
|
||||
|
||||
```bash
|
||||
git lfs install
|
||||
git lfs track ".understand-anything/*.json"
|
||||
git add .gitattributes .understand-anything/
|
||||
git lfs track ".ua/*.json"
|
||||
git add .gitattributes .ua/
|
||||
```
|
||||
|
||||
### 無需 Claude Code 也能檢視儀表盤
|
||||
|
||||
圖譜產生並提交後,團隊中的任何人只需一條命令即可開啟它 —— 無需 Claude Code,無需 LLM,無需 API 金鑰,只需要 Node.js(>= 18):
|
||||
|
||||
```bash
|
||||
npx https://github.com/Egonex-AI/Understand-Anything/releases/latest/download/understand-anything-viewer.tgz /path/to/analyzed/project
|
||||
```
|
||||
|
||||
終端會印出一個帶權杖的 URL(`http://127.0.0.1:5173/?token=…`),並在瀏覽器中開啟完整的互動式儀表盤。專案目錄(預設:目前目錄)必須包含已提交的資料目錄(`.ua/`,或舊版 `.understand-anything/`)。所有內容都從本機磁碟以唯讀方式提供 —— 沒有 LLM 呼叫,也不會有任何資料離開你的機器。
|
||||
|
||||
如果你是從克隆的儲存庫工作:先執行 `pnpm install && pnpm --filter @understand-anything/core build`,再執行 `GRAPH_DIR=/path/to/analyzed/project pnpm dev:dashboard`,即可透過 Vite 開發伺服器達到同樣的效果。
|
||||
|
||||
---
|
||||
|
||||
## 🔧 技術原理
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ the maintainer will reply with a private channel.
|
||||
|
||||
This project is a **local-only** static-analysis tool. It runs on a
|
||||
developer's machine, reads the analyzed project, and writes the resulting
|
||||
graph to `.understand-anything/`. It does not phone home and the dashboard's
|
||||
graph to the project's data directory (`.ua/`, or the legacy `.understand-anything/` when it already exists). It does not phone home and the dashboard's
|
||||
file-content endpoint is gated behind an access token and a graph-derived
|
||||
path allowlist.
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,317 @@
|
||||
# /understand-figma — Figma 수집 & 구조 분석 (기반) 설계
|
||||
|
||||
**날짜**: 2026-06-24
|
||||
**상태**: 승인됨
|
||||
**접근**: 기반 우선 — 본 문서는 **5개 하위 프로젝트 중 1번**입니다. Figma 수집 + 구조 분석 + 가벼운 디자인 시스템 모델을 제공합니다. 사용자 플로우(B), 디자인↔코드 매핑(C), 디자인 시스템 감사(D), 기획문서 분석(E)은 여기서 **로드맵**으로만 정의하며, 각각 별도의 스펙 → 플랜 → 구현 사이클을 가집니다.
|
||||
|
||||
> 이 문서는 영문 원본 `2026-06-24-understand-figma-foundation-design.md`의 한국어 번역본입니다. 내용이 충돌할 경우 영문 원본이 기준입니다.
|
||||
|
||||
## 개요
|
||||
|
||||
기존 Understand Anything 플러그인 안에 추가되는 새 `/understand-figma` 스킬로, Figma 파일을 받아 인터랙티브 지식 그래프 — 페이지, 화면, 컴포넌트, 컴포넌트셋, 인스턴스, 디자인 토큰 — 를 만들어 `kind: "design"` 레이아웃으로 기존 대시보드에 시각화합니다.
|
||||
|
||||
이는 `/understand-knowledge`(위키)와 `/understand-domain`(비즈니스 도메인)이 비(非)코드 입력으로 도구를 확장한 방식 그대로입니다: 결정적 파싱이 구조 골격을 만들고, LLM 에이전트가 의미를 더하고, 머지 단계가 동일한 `knowledge-graph.json`으로 조립하며, 동일한 대시보드가 렌더링합니다.
|
||||
|
||||
### 목표 (Goals)
|
||||
|
||||
- **Figma REST API**(`GET /v1/files/:key`)를 통해 Figma 파일을 수집하되, 교체 가능한 소스 어댑터 경계 뒤에 두어 추후 오프라인 로컬-JSON 소스를 재작업 없이 추가할 수 있게 한다.
|
||||
- **얕은(shallow)** 구조 그래프를 생성한다: `page → screen → component / componentSet / instance`, 그리고 가벼운 **디자인 시스템 모델**(색/타이포/간격/이펙트 스타일을 위한 `token` 노드와 `uses_token` 관계).
|
||||
- 새 `design-analyzer` LLM 에이전트로 의미 보강(요약, 태그, 레이어 힌트, 화면 목적)을 더한다.
|
||||
- 기존 스키마·영속화·검증·대시보드를 재사용하고, `kind: "design"` 뷰와 사이드바 썸네일만 추가한다.
|
||||
- **하이브리드** 전략으로 렌더링한다: 그래프는 가벼운 텍스트 노드, 선택된 노드의 썸네일은 사이드바에 (온디맨드로) 표시.
|
||||
- v1 파싱 중 미래 지향 메타데이터(`prototypeTargets`, `componentKey`)를 기록해, 로드맵 B·C를 재파싱 없이 켤 수 있게 한다.
|
||||
|
||||
### 비목표 (Non-Goals)
|
||||
|
||||
- 디자인 시스템 산출물(코드 컴포넌트 라이브러리, 토큰 파일, Storybook)의 **생성**. 본 작업은 분석/모델링 전용 — 사용자와 확정(해석 "i", "ii" 아님).
|
||||
- 그래프 캔버스 내 인노드(in-node) 썸네일 렌더링(성능/저장 비용) — v1은 사이드바 미리보기만.
|
||||
- 모든 Figma 레이어를 노드로 만들기(한 화면에 수백 개 레이어 가능). 더 깊은 레이어는 **읽되**(`instance_of` 링크, 토큰 사용, 추후 기획텍스트 추출용) 노드로 만들지 않는다. "화면 깊이 펼치기"는 향후 개선 항목.
|
||||
- 사용자 플로우(B), 디자인↔코드(C), 디자인 시스템 감사(D), 기획문서 분석(E) — 이들은 로드맵이며 v1 아님.
|
||||
- 오프라인 `.fig` 파싱(독점 바이너리). 오프라인 지원은 추후 로컬-JSON 소스 어댑터로.
|
||||
|
||||
---
|
||||
|
||||
## 범위 분해 (왜 기반 우선인가)
|
||||
|
||||
사용자는 다섯 가지 기능을 모두 원합니다. 이들은 공통 기반 위에 얹히므로, 기반을 먼저 만듭니다:
|
||||
|
||||
```
|
||||
③ C 디자인 ↔ 코드
|
||||
(Figma 그래프 + 코드 그래프 + 매칭 필요)
|
||||
▲
|
||||
② B 플로우 ② D 감사 ② E 기획텍스트 ← 파싱된 구조 위에 구축
|
||||
▲ ▲ ▲
|
||||
└──────────┴────────────┘
|
||||
│
|
||||
① 기반: Figma 수집 + 구조 (+ 가벼운 디자인 시스템 모델) ← 본 스펙
|
||||
```
|
||||
|
||||
- **A(본 스펙)** 는 수집, `kind: "design"` 스키마, 파싱 모듈, 스킬 골격, 대시보드 뷰를 확립 — 나머지 전부의 전제.
|
||||
- **B / D / E** 는 동일한 파싱 데이터 위의 추가 추출.
|
||||
- **C** 는 캡스톤: Figma 그래프(A) + 코드 그래프(`/understand`) + 매칭 전략이 모두 필요.
|
||||
|
||||
각 로드맵 항목은 별도의 스펙 → 플랜 → 구현 사이클을 가집니다.
|
||||
|
||||
---
|
||||
|
||||
## 스키마 확장
|
||||
|
||||
`domain`·`knowledge` 확장과 동일한 메커니즘입니다: `NodeType`/`EdgeType` zod enum은 **닫혀(closed)** 있어(`validateGraph`가 알 수 없는 타입을 드롭) 새 타입은 enum에 **추가**하고, alias 맵 항목이 LLM 어휘를 정규화합니다. `GraphNode`는 `.passthrough()`라 타입드 `figmaMeta` 필드가 `domainMeta`/`knowledgeMeta`와 나란히 실립니다.
|
||||
|
||||
### 그래프 수준 Kind 플래그
|
||||
|
||||
```typescript
|
||||
export interface KnowledgeGraph {
|
||||
version: string;
|
||||
kind?: "codebase" | "knowledge" | "design"; // "design" 추가
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
`kind`가 없는 그래프는 `"codebase"`로 기본 처리(변경 없음). 대시보드는 `kind`에 따라 레이아웃/스타일을 전환합니다.
|
||||
|
||||
### 신규 노드 타입 (6) — 21 → 27
|
||||
|
||||
| 타입 | 의미 | 예 | ID 규칙 |
|
||||
|------|------|----|---------|
|
||||
| `page` | Figma 페이지(캔버스) | "Onboarding" | `page:<figmaNodeId>` |
|
||||
| `screen` | 최상위 프레임/아트보드(UI 화면) | "Login" | `screen:<figmaNodeId>` |
|
||||
| `component` | 메인 컴포넌트 | "Button/Primary" | `component:<figmaNodeId>` |
|
||||
| `componentSet` | 변형 묶음 | "Button" | `componentSet:<figmaNodeId>` |
|
||||
| `instance` | 컴포넌트 사용처 | "Login › SignInBtn" | `instance:<figmaNodeId>` |
|
||||
| `token` | 디자인 토큰/퍼블리시된 스타일(색·타이포·간격·이펙트·그리드) | "color/brand-500" | `token:<tokenKind>:<name>` |
|
||||
|
||||
Figma "styles"는 `token`으로 통합하며 `figmaMeta.tokenKind`로 구분 — 타입 수를 줄입니다.
|
||||
|
||||
### 신규 엣지 타입 (3) — 35 → 38 (+ `contains` 재사용)
|
||||
|
||||
| 타입 | 방향 | 의미 |
|
||||
|------|------|------|
|
||||
| `contains` *(재사용)* | page → screen, screen → instance, componentSet → component | 구조적 포함 |
|
||||
| `instance_of` *(신규)* | instance → component | 컴포넌트의 인스턴스 |
|
||||
| `variant_of` *(신규)* | component → componentSet | 세트 내 변형 |
|
||||
| `uses_token` *(신규)* | component / screen / instance → token | 토큰/퍼블리시된 스타일 적용 |
|
||||
|
||||
**⚠️ `instance_of` alias 충돌.** `instance_of`는 현재 `EDGE_TYPE_ALIASES`에서 `exemplifies`로 매핑돼 있습니다(knowledge 모드용). design에서는 1급 엣지여야 합니다. 해결: **`instance_of`를 정식 `EdgeType`으로 승격하고 alias 항목을 제거**합니다. knowledge 모드 에이전트는 `exemplifies`를 직접 내보내므로(alias는 안전망일 뿐) 영향은 미미합니다. 본 변경은 여기 명시하며 스키마 테스트로 커버해야 합니다.
|
||||
|
||||
`navigates_to`(프로토타입 링크, screen → screen)는 v1에 **추가하지 않습니다** — 로드맵 B 소관. 프로토타입 링크 데이터는 `figmaMeta.prototypeTargets`에 보존돼, B가 재파싱 없이 해당 엣지를 나중에 만들 수 있습니다.
|
||||
|
||||
### 신규 메타데이터 인터페이스
|
||||
|
||||
```typescript
|
||||
export interface FigmaMeta {
|
||||
fileKey?: string;
|
||||
nodeId?: string; // Figma 노드 id, 예: "1:23"
|
||||
figmaType?: string; // 원본 Figma 타입: FRAME | COMPONENT | COMPONENT_SET | INSTANCE | TEXT ...
|
||||
thumbnailUrl?: string; // GET /v1/images로 지연 채움
|
||||
dimensions?: { width: number; height: number };
|
||||
tokenKind?: "color" | "type" | "spacing" | "effect" | "grid";
|
||||
tokenValue?: string; // 예: "#0A84FF", "16px"
|
||||
prototypeTargets?: string[]; // 로드맵 B(플로우)용 — v1에 기록, 엣지는 나중
|
||||
componentKey?: string; // 로드맵 C(디자인↔코드)용 — v1에 기록
|
||||
}
|
||||
```
|
||||
|
||||
`GraphNode`에 옵션 필드로 추가:
|
||||
|
||||
```typescript
|
||||
export interface GraphNode {
|
||||
// ...기존 필드
|
||||
figmaMeta?: FigmaMeta;
|
||||
}
|
||||
```
|
||||
|
||||
### Alias 맵 추가
|
||||
|
||||
머지 단계에서 LLM/어휘 견고성을 위해:
|
||||
|
||||
- `NODE_TYPE_ALIASES`: `frame → screen`, `artboard → screen`, `canvas → page`, `main_component → component`, `variant_set → componentSet`, `component_set → componentSet`, `design_token → token`, `style → token`.
|
||||
- `EDGE_TYPE_ALIASES`: `instantiates → instance_of`, `variant → variant_of`, `styled_by → uses_token`, `applies_token → uses_token`. (위 노트대로 기존 `instance_of → exemplifies` 항목은 제거.)
|
||||
|
||||
---
|
||||
|
||||
## 수집: 소스 어댑터
|
||||
|
||||
"Figma 문서가 어디서 오는가"를 "어떻게 파싱하는가"로부터 분리하는 교체 가능한 경계입니다.
|
||||
|
||||
```typescript
|
||||
// packages/core/src/figma/source/types.ts
|
||||
export interface FigmaSource {
|
||||
/** Figma 문서 트리 반환 (GET /v1/files/:key 형태). */
|
||||
fetchDocument(): Promise<FigmaDocument>;
|
||||
/** 퍼블리시된 스타일 메타 반환 (GET /v1/files/:key/styles 형태). */
|
||||
fetchStyles(): Promise<FigmaStyles>;
|
||||
/** 주어진 노드 id들의 썸네일 렌더 (GET /v1/images). */
|
||||
renderImages(nodeIds: string[]): Promise<Record<string, string>>;
|
||||
}
|
||||
```
|
||||
|
||||
**v1 구현 — `FigmaApiSource`** (`source/api-source.ts`, Node 전용):
|
||||
- 토큰을 `process.env.FIGMA_TOKEN`에서 읽음. 없으면 스킬은 친절한 메시지와 함께 중단("figma.com/settings에서 토큰 발급 후 `export FIGMA_TOKEN=…`").
|
||||
- 문서 트리는 `GET https://api.figma.com/v1/files/:key`, 스타일은 `GET /v1/files/:key/styles`, 썸네일은 `GET /v1/images/:key?ids=…`(온디맨드).
|
||||
- Figma URL 또는 순수 파일 키를 모두 수용(URL에서 키 파싱).
|
||||
|
||||
**향후 — `LocalJsonSource`**: 미리 내보낸 JSON 문서를 읽음. 동일한 `FigmaSource` 인터페이스, 토큰·네트워크 불필요. 기반이 API 전용(A)에서 "둘 다"(앞선 입력 소스 논의의 C)로 진화하는 방식입니다.
|
||||
|
||||
API 클라이언트와 모든 `fetch` 사용은 `core`의 Node 전용 영역에 있으며, 브라우저-세이프 서브패스(`./search`, `./types`, `./schema`)에서 **절대 export하지 않습니다**. 대시보드는 스키마 타입만 공유합니다.
|
||||
|
||||
---
|
||||
|
||||
## 파싱 & 깊이(Granularity)
|
||||
|
||||
결정적 파서(`packages/core/src/figma/parse/`)가 문서 트리를 순회해 구조 골격을 생성합니다. 깊이는 **얕음**:
|
||||
|
||||
- **노드:** `page`, `screen`(최상위 프레임), `component`, `componentSet`, `instance`, `token`.
|
||||
- **노드 아님:** Figma "섹션"은 v1에서 평탄화(자식 프레임이 상위 `page`에 붙음); 중첩 그룹과 텍스트/벡터/도형 리프 레이어도 노드 아님.
|
||||
- **읽되 노드 아님:** 더 깊은 레이어는 `instance_of` 대상 해석, `uses_token` 사용 수집, `prototypeTargets`/`componentKey`의 `figmaMeta` 기록, (추후 E용) 기획 텍스트 읽기를 위해 순회.
|
||||
|
||||
노드 깊이 ≠ 파싱 깊이: 파서는 전체 트리를 읽되 얕은 집합만 노드로 승격합니다.
|
||||
|
||||
**토큰(의도적으로 제한):** v1은 **퍼블리시된 스타일과 변수**(색/텍스트/이펙트/그리드 스타일, 디자인 변수)만 `token` 노드로 승격 — 토큰 집합을 의미 있고 폭발하지 않게 유지. 원시 인라인 값(예: 일회성 hex)은 소비 노드의 `figmaMeta`에 기록하되, 퍼블리시된 스타일/변수로 해석되지 않는 한 `token` 노드로 **승격하지 않음**. 각 `token` 노드는 `figmaMeta.tokenKind` + `tokenValue`를 가지며, `uses_token` 엣지가 소비자를 연결.
|
||||
|
||||
출력: `scan-manifest.json`(결정적, LLM 없음) — 구조 베이스 그래프.
|
||||
|
||||
---
|
||||
|
||||
## 에이전트 파이프라인
|
||||
|
||||
`/understand-knowledge`(결정적 파싱 → LLM 보강 → 머지 → 저장)를 본뜬 4단계입니다.
|
||||
|
||||
| Phase | 단계 | 위치 | 출력 |
|
||||
|-------|------|------|------|
|
||||
| 1 | FETCH & PARSE | `core/figma` (결정적) | `scan-manifest.json` |
|
||||
| 2 | ANALYZE | `design-analyzer` LLM 서브에이전트(배치) | `analysis-batch-*.json` |
|
||||
| 3 | MERGE | `core/figma/merge` + `validateGraph` 재사용 | `assembled-graph.json` |
|
||||
| 4 | SAVE & LAUNCH | 스킬 + `/understand-dashboard` | `knowledge-graph.json` |
|
||||
|
||||
### 신규 에이전트
|
||||
|
||||
| 에이전트 | 입력 | 출력 |
|
||||
|----------|------|------|
|
||||
| `design-analyzer` *(신규, `article-analyzer` 본뜸)* | 매니페스트 노드 배치(id·이름·타입·`figmaMeta`·자식 요약·토큰 사용) + 기존 노드 ID | 노드별 보강(요약·태그·레이어 힌트·화면 목적) + 보수적 `related` 엣지. **구조 노드/엣지는 재생성하지 않음**. |
|
||||
|
||||
스캐너 에이전트는 불필요 — 스캔은 Phase 1 결정적 파서가 담당(위키 파스 스크립트와 동일).
|
||||
|
||||
### 중간 파일
|
||||
|
||||
`.understand-anything/intermediate/`(조립 후 정리): `figma-doc.json`(원본 트리 캐시), `scan-manifest.json`, `analysis-batch-*.json`, `assembled-graph.json`.
|
||||
|
||||
### 레이어 & 투어
|
||||
|
||||
- **레이어:** Figma 페이지마다 하나 + 별도 "Design System" 레이어(컴포넌트·컴포넌트셋·토큰).
|
||||
- **투어:** "디자인 시스템 먼저 → 핵심 화면", 기존 투어 구조 재사용.
|
||||
|
||||
### 증분 모드
|
||||
|
||||
재실행 시 `meta.json`에 저장된 Figma 파일 `version`/`lastModified`(API 제공)를 비교. 변화 없음 → skip. 변경됨 → v1은 전체 재분석(Figma `nodeId` 단위 증분은 향후 최적화). `/understand`의 커밋 해시 증분의 Figma 버전 버전입니다.
|
||||
|
||||
---
|
||||
|
||||
## 대시보드 변경
|
||||
|
||||
모든 변경은 `kind: "design"`에 한정됩니다. 순수 신규 작업은 네 군데이고, 나머지는 재사용입니다.
|
||||
|
||||
1. **`kind: "design"` 분기** (`App.tsx`) — design 뷰 추가(`KnowledgeGraphView`가 추가됐던 것처럼). 구조가 계층적이므로 기존 dagre/ELK 계층 레이아웃 재사용(`DomainGraphView`의 LR과 유사).
|
||||
2. **타입별 노드 스타일** — `CustomNode`에 타입→색 매핑 추가:
|
||||
|
||||
| 노드 | 강조색 | 비고 |
|
||||
|------|--------|------|
|
||||
| `page` | 컨테이너/중립 | 화면들을 묶음(레이어도 형성) |
|
||||
| `screen` | 블루(accent) | |
|
||||
| `instance` | 그린 | |
|
||||
| `component` | 바이올렛 | |
|
||||
| `componentSet` | 앰버 | |
|
||||
| `token` | 중립 + **색상 스와치** | 색 토큰은 실제 색 표시 |
|
||||
|
||||
3. **사이드바(`NodeInfo`) 썸네일 — 유일한 순수 신규 UI.** figma 노드 선택 시 썸네일 블록(이름·타입·치수·태그·관계)을 표시, 기존 슬라이드업/NodeInfo 패턴 재사용.
|
||||
4. **썸네일 공급** — 코드뷰어의 `/file-content.json`이 쓰는 기존 토큰 게이트 + 경로 allowlist 개발 서버 엔드포인트 패턴을 본떠 `/figma-image` 엔드포인트로 온디맨드 제공; 또는 그래프에 썸네일 URL 저장.
|
||||
|
||||
범례·필터에 신규 노드 타입 항목 추가. 레이아웃·검색·필터·테마·내보내기는 변경 없이 재사용.
|
||||
|
||||
---
|
||||
|
||||
## 스킬 인터페이스
|
||||
|
||||
### 사용법
|
||||
|
||||
```bash
|
||||
/understand-figma https://www.figma.com/file/<KEY>/<name> # URL
|
||||
/understand-figma <FILE_KEY> # 순수 키
|
||||
/understand-figma <KEY> --page "Onboarding" # 특정 페이지만(선택)
|
||||
/understand-figma <KEY> --language ko # 기존 --language 재사용
|
||||
```
|
||||
|
||||
### 동작
|
||||
|
||||
1. URL/키 파싱; `FIGMA_TOKEN` 확인(없으면 친절한 에러).
|
||||
2. Phase 1 fetch & parse → 발견 요약 announce("N pages, N screens, N components, N tokens 발견").
|
||||
3. Phase 2 `design-analyzer` 배치(최대 5개 동시, `/understand`와 동일; 배치 실패 허용 — 매니페스트가 견고한 베이스).
|
||||
4. Phase 3 머지 → 정규화 → `validateGraph` → `kind: "design"`.
|
||||
5. Phase 4 `knowledge-graph.json` + `meta.json`(Figma 파일 버전 포함) 저장 → `/understand-dashboard` 자동 실행.
|
||||
|
||||
### 파일 구조
|
||||
|
||||
```
|
||||
understand-anything-plugin/
|
||||
skills/understand-figma/
|
||||
SKILL.md — 얇은 오케스트레이션
|
||||
agents/
|
||||
design-analyzer.md — 신규 LLM 에이전트
|
||||
packages/core/src/figma/
|
||||
source/
|
||||
types.ts — FigmaSource 인터페이스(어댑터 경계)
|
||||
api-source.ts — FigmaApiSource (REST, Node 전용)
|
||||
parse/
|
||||
parse-document.ts — 트리 → 노드/엣지 (결정적, 테스트)
|
||||
tokens.ts — 토큰/스타일 추출
|
||||
merge.ts — 매니페스트 + 분석 조립
|
||||
index.ts — Node 전용 엔트리(대시보드 서브패스에 미노출)
|
||||
__tests__/ — vitest 단위 테스트
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 로드맵 (B · C · D · E)
|
||||
|
||||
각각 본 기반 위에 구축되는 별도 스펙 → 플랜 → 구현 사이클입니다.
|
||||
|
||||
| 항목 | 기능 | v1 위에 더하는 것 | 주된 신규 작업 |
|
||||
|------|------|-------------------|----------------|
|
||||
| **B** | 사용자 플로우 | `figmaMeta.prototypeTargets` → `navigates_to` 엣지 + 플로우 뷰 | `navigates_to` 엣지 타입; 플로우 레이아웃(flow/step + `DomainGraphView` 재사용) |
|
||||
| **C** | 디자인 ↔ 코드 | `figmaMeta.componentKey` ↔ 코드 그래프 컴포넌트 | 두 그래프 결합; 매칭 전략(이름/구조/LLM); cross-graph 엣지 |
|
||||
| **D** | 디자인 시스템 감사 | 인스턴스/토큰 사용 분석 → 재사용률, detached 인스턴스, 불일치 | 결정적 감사 규칙; 대시보드 배지 |
|
||||
| **E** | 기획문서 분석 | LLM이 Figma 기획 텍스트를 읽어 `claim`/`entity` 노드로(knowledge 모드 재사용) | 깊은 텍스트 레이어 읽기; 분석기 확장 또는 신규 |
|
||||
|
||||
v1이 `prototypeTargets`, `componentKey`를 기록하고 깊은 레이어를 읽어두므로, B/C/E는 재파싱 없이 붙습니다.
|
||||
|
||||
---
|
||||
|
||||
## 하위호환 · 공존 · 보안
|
||||
|
||||
### 하위호환
|
||||
|
||||
- 모든 신규 노드/엣지 타입은 추가형(enum 추가). 기존 codebase/knowledge/domain 그래프는 그대로 유효.
|
||||
- `kind`가 없는 그래프는 `"codebase"`로 기본 처리.
|
||||
- `figmaMeta`는 옵션 passthrough 필드 — 기존 노드 불영향.
|
||||
- `instance_of → exemplifies` alias 제거는 영향 미미(knowledge 에이전트는 `exemplifies` 직접 사용); 스키마 테스트로 커버.
|
||||
|
||||
### 공존
|
||||
|
||||
- 다른 모드와 동일하게 `/understand-figma`는 공유 `.understand-anything/knowledge-graph.json`에 기록. 한 모드 실행 시 이전 그래프를 대체(기존 정책).
|
||||
- 혼합 레포에서는 `figma-knowledge-graph.json` 서브도메인 그래프를 만들어 기존 `merge-subdomain-graphs.py` 패턴으로 병합 가능.
|
||||
|
||||
### 보안
|
||||
|
||||
- **`FIGMA_TOKEN`은 환경변수에서만 읽음.** 그래프·config·`meta.json`·로그·중간파일에 절대 기록하지 않음. 토큰을 담은 요청 헤더는 에러/로그에 출력하지 않음.
|
||||
- 파이프라인은 `api.figma.com`로 **외부 네트워크 호출**을 수행 — `/understand`의 완전 오프라인 성격에서 벗어남. 이는 스킬 출력에서 사용자에게 고지하고 문서화함.
|
||||
- `figma-doc.json`(원본 트리 캐시)와 썸네일은 디자인 데이터(시크릿 아님)지만, `.understand-anything/`는 기본적으로 git-ignore 유지 권장.
|
||||
- 썸네일 엔드포인트는 코드뷰어가 쓰는 기존 토큰 게이트 + 경로 allowlist 패턴을 따름.
|
||||
|
||||
---
|
||||
|
||||
## 향후 과제 / 개선 항목
|
||||
|
||||
- **화면 깊이 펼치기:** 특정 화면의 더 깊은 레이어를 온디맨드로 노드 승격.
|
||||
- **인노드 썸네일:** 사이드바 썸네일 파이프라인이 검증된 후 더 풍부한 렌더링(옵트인).
|
||||
- **로컬-JSON 소스:** `FigmaSource` 경계의 오프라인 구현(A → "둘 다" 진화).
|
||||
- **노드 단위 증분:** 파일 변경 시 전체 재분석 대신 Figma `nodeId` 단위 diff.
|
||||
@@ -0,0 +1,315 @@
|
||||
# /understand-figma — Figma Ingestion & Structure (Foundation) Design
|
||||
|
||||
**Date**: 2026-06-24
|
||||
**Status**: Approved
|
||||
**Approach**: Foundation-first — this is **Sub-project 1 of 5**. It delivers Figma ingestion + structural analysis + a light design-system model. User flows (B), design↔code mapping (C), design-system audit (D), and planning-document analysis (E) are scoped here as a **roadmap**, each to get its own spec → plan → implementation cycle.
|
||||
|
||||
## Overview
|
||||
|
||||
A new `/understand-figma` skill within the existing Understand Anything plugin that takes a Figma file and produces an interactive knowledge graph — pages, screens, components, component sets, instances, and design tokens — visualized in the existing dashboard with a `kind: "design"` layout.
|
||||
|
||||
This mirrors how `/understand-knowledge` (wikis) and `/understand-domain` (business domains) extended the tool to non-code inputs: deterministic parsing builds a structural skeleton, an LLM agent adds semantics, a merge step assembles the same `knowledge-graph.json`, and the same dashboard renders it.
|
||||
|
||||
### Goals
|
||||
|
||||
- Ingest a Figma file via the **Figma REST API** (`GET /v1/files/:key`) behind a pluggable source-adapter seam, so an offline local-JSON source can be added later without rework.
|
||||
- Produce a **shallow** structural graph: `page → screen → component / componentSet / instance`, plus a light **design-system model** (`token` nodes for color/type/spacing/effect styles, with `uses_token` relationships).
|
||||
- Add semantic enrichment (summaries, tags, layer hints, screen purpose) via a new `design-analyzer` LLM agent.
|
||||
- Reuse the existing schema, persistence, validation, and dashboard; add only a `kind: "design"` view and a sidebar thumbnail.
|
||||
- Render with a **hybrid** strategy: lightweight text nodes in the graph; the selected node's thumbnail in the sidebar (on demand).
|
||||
- Record forward-looking metadata (`prototypeTargets`, `componentKey`) during v1 parsing so roadmap items B and C can be enabled later without re-parsing.
|
||||
|
||||
### Non-Goals
|
||||
|
||||
- **Generating** design-system artifacts (code component libraries, token files, Storybook). This is analysis/modeling only — confirmed with the user (interpretation "i", not "ii").
|
||||
- In-node thumbnail rendering in the graph canvas (perf/storage cost) — sidebar preview only in v1.
|
||||
- Turning every Figma layer into a node (a screen can have hundreds of layers). Deeper layers are **read** (for `instance_of` links, token usage, future planning-text extraction) but not made nodes. "Deep-expand a screen" is a future enhancement.
|
||||
- User flows (B), design↔code mapping (C), design-system audit (D), planning-document analysis (E) — these are the roadmap, not v1.
|
||||
- Offline `.fig` parsing (proprietary binary). Offline support arrives later via a local-JSON source adapter.
|
||||
|
||||
---
|
||||
|
||||
## Scope Decomposition (why foundation-first)
|
||||
|
||||
The user wants all five capabilities. They layer on a shared foundation, so we build the foundation first:
|
||||
|
||||
```
|
||||
③ C Design ↔ Code
|
||||
(needs Figma graph + code graph + matching)
|
||||
▲
|
||||
② B Flows ② D Audit ② E Planning-text ← built on the parsed structure
|
||||
▲ ▲ ▲
|
||||
└──────────┴────────────┘
|
||||
│
|
||||
① Foundation: Figma ingestion + structure (+ light design-system model) ← THIS SPEC
|
||||
```
|
||||
|
||||
- **A (this spec)** establishes ingestion, the `kind: "design"` schema, the parsing module, the skill skeleton, and the dashboard view — the prerequisite for everything else.
|
||||
- **B / D / E** are additional extractions over the same parsed data.
|
||||
- **C** is the capstone: it needs both a Figma graph (A) and a code graph (`/understand`) plus a matching strategy.
|
||||
|
||||
Each roadmap item gets its own spec → plan → implementation cycle.
|
||||
|
||||
---
|
||||
|
||||
## Schema Extensions
|
||||
|
||||
Same mechanism as the `domain` and `knowledge` extensions: the `NodeType`/`EdgeType` zod enums are **closed** (`validateGraph` drops unknown types), so new types are **added** to the enums, and alias-map entries normalize LLM vocabulary. `GraphNode` uses `.passthrough()`, so a typed `figmaMeta` field rides alongside `domainMeta`/`knowledgeMeta`.
|
||||
|
||||
### Graph-Level Kind Flag
|
||||
|
||||
```typescript
|
||||
export interface KnowledgeGraph {
|
||||
version: string;
|
||||
kind?: "codebase" | "knowledge" | "design"; // add "design"
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Graphs without a `kind` default to `"codebase"` (unchanged). The dashboard switches layout/styling on `kind`.
|
||||
|
||||
### New Node Types (6) — 21 → 27
|
||||
|
||||
| Type | What it represents | Example | ID convention |
|
||||
|------|-------------------|---------|---------------|
|
||||
| `page` | A Figma page (canvas) | "Onboarding" | `page:<figmaNodeId>` |
|
||||
| `screen` | A top-level frame / artboard (a UI screen) | "Login" | `screen:<figmaNodeId>` |
|
||||
| `component` | A main component | "Button/Primary" | `component:<figmaNodeId>` |
|
||||
| `componentSet` | A set of variants | "Button" | `componentSet:<figmaNodeId>` |
|
||||
| `instance` | A use of a component | "Login › SignInBtn" | `instance:<figmaNodeId>` |
|
||||
| `token` | A design token / published style (color, type, spacing, effect, grid) | "color/brand-500" | `token:<tokenKind>:<name>` |
|
||||
|
||||
Figma "styles" are folded into `token` (distinguished by `figmaMeta.tokenKind`) to keep the type count down.
|
||||
|
||||
### New Edge Types (3) — 35 → 38 (+ reuse `contains`)
|
||||
|
||||
| Type | Direction | Meaning |
|
||||
|------|-----------|---------|
|
||||
| `contains` *(reuse)* | page → screen, screen → instance, componentSet → component | Structural containment |
|
||||
| `instance_of` *(new)* | instance → component | An instance of a component |
|
||||
| `variant_of` *(new)* | component → componentSet | A variant within a set |
|
||||
| `uses_token` *(new)* | component / screen / instance → token | Applies a token / published style |
|
||||
|
||||
**⚠️ `instance_of` alias conflict.** `instance_of` is currently an entry in `EDGE_TYPE_ALIASES` mapping to `exemplifies` (added for knowledge mode). For design it must be a first-class edge. Resolution: **promote `instance_of` to a canonical `EdgeType` and remove its alias entry.** Knowledge-mode agents emit `exemplifies` directly (the alias was only a safety net), so the impact is negligible. This change is called out explicitly here and must be covered by a schema test.
|
||||
|
||||
`navigates_to` (prototype links, screen → screen) is **not** added in v1 — it belongs to roadmap item B. Prototype link data is preserved in `figmaMeta.prototypeTargets` so B can emit those edges later without re-parsing.
|
||||
|
||||
### New Metadata Interface
|
||||
|
||||
```typescript
|
||||
export interface FigmaMeta {
|
||||
fileKey?: string;
|
||||
nodeId?: string; // Figma node id, e.g. "1:23"
|
||||
figmaType?: string; // raw Figma type: FRAME | COMPONENT | COMPONENT_SET | INSTANCE | TEXT ...
|
||||
thumbnailUrl?: string; // lazily filled from GET /v1/images
|
||||
dimensions?: { width: number; height: number };
|
||||
tokenKind?: "color" | "type" | "spacing" | "effect" | "grid";
|
||||
tokenValue?: string; // e.g. "#0A84FF", "16px"
|
||||
prototypeTargets?: string[]; // for roadmap B (flows) — recorded in v1, edges later
|
||||
componentKey?: string; // for roadmap C (design↔code) — recorded in v1
|
||||
}
|
||||
```
|
||||
|
||||
Added as an optional field on `GraphNode`:
|
||||
|
||||
```typescript
|
||||
export interface GraphNode {
|
||||
// ...existing fields
|
||||
figmaMeta?: FigmaMeta;
|
||||
}
|
||||
```
|
||||
|
||||
### Alias-Map Additions
|
||||
|
||||
For LLM/vocabulary robustness in the merge step:
|
||||
|
||||
- `NODE_TYPE_ALIASES`: `frame → screen`, `artboard → screen`, `canvas → page`, `main_component → component`, `variant_set → componentSet`, `component_set → componentSet`, `design_token → token`, `style → token`.
|
||||
- `EDGE_TYPE_ALIASES`: `instantiates → instance_of`, `variant → variant_of`, `styled_by → uses_token`, `applies_token → uses_token`. (Remove the existing `instance_of → exemplifies` entry per the note above.)
|
||||
|
||||
---
|
||||
|
||||
## Ingestion: Source Adapter
|
||||
|
||||
A pluggable seam isolates "where the Figma document comes from" from "how it is parsed."
|
||||
|
||||
```typescript
|
||||
// packages/core/src/figma/source/types.ts
|
||||
export interface FigmaSource {
|
||||
/** Returns the raw Figma document tree (shape of GET /v1/files/:key). */
|
||||
fetchDocument(): Promise<FigmaDocument>;
|
||||
/** Returns published styles metadata (shape of GET /v1/files/:key/styles). */
|
||||
fetchStyles(): Promise<FigmaStyles>;
|
||||
/** Renders thumbnails for the given node ids (GET /v1/images). */
|
||||
renderImages(nodeIds: string[]): Promise<Record<string, string>>;
|
||||
}
|
||||
```
|
||||
|
||||
**v1 implementation — `FigmaApiSource`** (`source/api-source.ts`, Node-only):
|
||||
- Reads the token from `process.env.FIGMA_TOKEN`. If absent, the skill stops with a friendly message ("create a token at figma.com/settings, then `export FIGMA_TOKEN=…`").
|
||||
- `GET https://api.figma.com/v1/files/:key` for the document tree; `GET /v1/files/:key/styles` for styles; `GET /v1/images/:key?ids=…` for thumbnails (on demand).
|
||||
- Accepts a Figma URL or a bare file key (URL parsed for the key).
|
||||
|
||||
**Future — `LocalJsonSource`**: reads a pre-exported JSON document. Same `FigmaSource` interface, no token, no network. This is how the foundation evolves from API-only (A) toward "both" (C in the earlier input-source discussion).
|
||||
|
||||
The API client and any `fetch` usage live in the Node-only part of `core` and are **never** exported from the browser-safe subpaths (`./search`, `./types`, `./schema`). The dashboard shares only schema types.
|
||||
|
||||
---
|
||||
|
||||
## Parsing & Granularity
|
||||
|
||||
The deterministic parser (`packages/core/src/figma/parse/`) walks the document tree and emits the structural skeleton. Granularity is **shallow**:
|
||||
|
||||
- **Nodes:** `page`, `screen` (top-level frames), `component`, `componentSet`, `instance`, `token`.
|
||||
- **Not nodes:** Figma "sections" are flattened in v1 (their child frames attach to the parent `page`); nested groups and text/vector/shape leaf layers are not nodes either.
|
||||
- **Still read (not nodes):** deeper layers are traversed to resolve `instance_of` targets, collect `uses_token` usage, capture `prototypeTargets`/`componentKey` into `figmaMeta`, and (later, for E) read planning text.
|
||||
|
||||
Node granularity ≠ parse granularity: the parser reads the full tree but only promotes the shallow set to nodes.
|
||||
|
||||
**Tokens (bounded on purpose):** v1 promotes **published styles and variables** (color/text/effect/grid styles, design variables) to `token` nodes — this keeps the token set meaningful and prevents node explosion. Raw inline values (e.g. a one-off hex) are recorded on the consuming node's `figmaMeta` but are **not** promoted to `token` nodes unless they resolve to a published style/variable. Each `token` node carries `figmaMeta.tokenKind` + `tokenValue`; `uses_token` edges connect consumers.
|
||||
|
||||
Output: `scan-manifest.json` (deterministic, no LLM) — the structural base graph.
|
||||
|
||||
---
|
||||
|
||||
## Agent Pipeline
|
||||
|
||||
Four phases, mirroring `/understand-knowledge` (deterministic parse → LLM enrich → merge → save).
|
||||
|
||||
| Phase | Step | Where | Output |
|
||||
|-------|------|-------|--------|
|
||||
| 1 | FETCH & PARSE | `core/figma` (deterministic) | `scan-manifest.json` |
|
||||
| 2 | ANALYZE | `design-analyzer` LLM subagents (batched) | `analysis-batch-*.json` |
|
||||
| 3 | MERGE | `core/figma/merge` + reuse `validateGraph` | `assembled-graph.json` |
|
||||
| 4 | SAVE & LAUNCH | skill + `/understand-dashboard` | `knowledge-graph.json` |
|
||||
|
||||
### New Agent
|
||||
|
||||
| Agent | Input | Output |
|
||||
|-------|-------|--------|
|
||||
| `design-analyzer` *(new, modeled on `article-analyzer`)* | Batch of manifest nodes (id, name, type, `figmaMeta`, child summary, token usage) + existing node IDs | Per-node enrichment (summary, tags, layer hint, screen purpose) + conservative `related` edges. **Does not** re-emit structural nodes/edges. |
|
||||
|
||||
No scanner agent is needed — scanning is the Phase-1 deterministic parser (same as the wiki parse script).
|
||||
|
||||
### Intermediate Files
|
||||
|
||||
`.understand-anything/intermediate/` (cleaned up after assembly): `figma-doc.json` (raw tree cache), `scan-manifest.json`, `analysis-batch-*.json`, `assembled-graph.json`.
|
||||
|
||||
### Layers & Tour
|
||||
|
||||
- **Layers:** one per Figma page, plus a dedicated "Design System" layer (components, component sets, tokens).
|
||||
- **Tour:** "Design System first → key screens", reusing the existing tour structure.
|
||||
|
||||
### Incremental Mode
|
||||
|
||||
On re-run, compare the Figma file `version`/`lastModified` (from the API) stored in `meta.json`. Unchanged → skip. Changed → full re-analyze in v1 (node-level incremental by Figma `nodeId` is a future optimization). This is the Figma analog of `/understand`'s commit-hash incremental.
|
||||
|
||||
---
|
||||
|
||||
## Dashboard Changes
|
||||
|
||||
All changes are scoped to `kind: "design"`. Net-new work is four spots; everything else is reused.
|
||||
|
||||
1. **`kind: "design"` branch** in `App.tsx` — adds a design view (like `KnowledgeGraphView` was added). The structure is hierarchical, so reuse the existing dagre/ELK hierarchical layout (similar to `DomainGraphView`'s LR).
|
||||
2. **Node styling by type** — extend `CustomNode` with a type→color map:
|
||||
|
||||
| Node | Accent | Note |
|
||||
|------|--------|------|
|
||||
| `page` | Container / neutral | groups screens (also forms a layer) |
|
||||
| `screen` | Blue (accent) | |
|
||||
| `instance` | Green | |
|
||||
| `component` | Violet | |
|
||||
| `componentSet` | Amber | |
|
||||
| `token` | Neutral + **color swatch** | color tokens show their actual color |
|
||||
|
||||
3. **Sidebar (`NodeInfo`) thumbnail — the only net-new UI.** On selecting a figma node, show a thumbnail block (name, type, dimensions, tags, relationships), reusing the existing slide-up/NodeInfo panel pattern.
|
||||
4. **Thumbnail supply** — reuse the existing token-gated + path-allowlist dev-server endpoint pattern (as used by the code viewer's `/file-content.json`) as a `/figma-image` endpoint serving thumbnails on demand; or store thumbnail URLs in the graph.
|
||||
|
||||
Legend and filter gain the new node-type entries. Layout, search, filter, theme, and export are reused unchanged.
|
||||
|
||||
---
|
||||
|
||||
## Skill Interface
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
/understand-figma https://www.figma.com/file/<KEY>/<name> # URL
|
||||
/understand-figma <FILE_KEY> # bare key
|
||||
/understand-figma <KEY> --page "Onboarding" # scope to one page (optional)
|
||||
/understand-figma <KEY> --language ko # reuse existing --language
|
||||
```
|
||||
|
||||
### Behavior
|
||||
|
||||
1. Parse URL/key; verify `FIGMA_TOKEN` (friendly error if missing).
|
||||
2. Phase 1 fetch & parse → announce ("found N pages, N screens, N components, N tokens").
|
||||
3. Phase 2 `design-analyzer` batches (up to 5 concurrent, as in `/understand`; tolerate batch failure — the manifest is a solid base).
|
||||
4. Phase 3 merge → normalize → `validateGraph` → `kind: "design"`.
|
||||
5. Phase 4 write `knowledge-graph.json` + `meta.json` (with Figma file version) → auto-launch `/understand-dashboard`.
|
||||
|
||||
### File Structure
|
||||
|
||||
```
|
||||
understand-anything-plugin/
|
||||
skills/understand-figma/
|
||||
SKILL.md — thin orchestration
|
||||
agents/
|
||||
design-analyzer.md — new LLM agent
|
||||
packages/core/src/figma/
|
||||
source/
|
||||
types.ts — FigmaSource interface (adapter seam)
|
||||
api-source.ts — FigmaApiSource (REST, Node-only)
|
||||
parse/
|
||||
parse-document.ts — tree → nodes/edges (deterministic, tested)
|
||||
tokens.ts — token/style extraction
|
||||
merge.ts — manifest + analysis assembly
|
||||
index.ts — Node-only entry (not exposed to dashboard subpaths)
|
||||
__tests__/ — vitest unit tests
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Roadmap (B · C · D · E)
|
||||
|
||||
Each is a later spec → plan → implementation cycle, built on this foundation.
|
||||
|
||||
| Item | Capability | Adds on top of v1 | Main new work |
|
||||
|------|-----------|-------------------|---------------|
|
||||
| **B** | User flows | `figmaMeta.prototypeTargets` → `navigates_to` edges + flow view | `navigates_to` edge type; flow layout (reuse flow/step + `DomainGraphView`) |
|
||||
| **C** | Design ↔ code | `figmaMeta.componentKey` ↔ code-graph components | Combine two graphs; matching strategy (name/structure/LLM); cross-graph edges |
|
||||
| **D** | Design-system audit | Analyze instance/token usage → reuse rate, detached instances, inconsistencies | Deterministic audit rules; dashboard badges |
|
||||
| **E** | Planning-document analysis | LLM reads Figma planning text → `claim`/`entity` nodes (reuse knowledge mode) | Deep text-layer reading; extend or add an analyzer |
|
||||
|
||||
v1 records `prototypeTargets`, `componentKey`, and reads deep layers, so B/C/E attach without re-parsing.
|
||||
|
||||
---
|
||||
|
||||
## Backward Compatibility, Coexistence & Security
|
||||
|
||||
### Backward Compatibility
|
||||
|
||||
- All new node/edge types are additive (enum additions). Existing codebase/knowledge/domain graphs remain valid.
|
||||
- Graphs without `kind` default to `"codebase"`.
|
||||
- `figmaMeta` is an optional passthrough field — existing nodes are unaffected.
|
||||
- Removing the `instance_of → exemplifies` alias has negligible impact (knowledge agents emit `exemplifies` directly); covered by a schema test.
|
||||
|
||||
### Coexistence
|
||||
|
||||
- Like the other modes, `/understand-figma` writes the shared `.understand-anything/knowledge-graph.json`. Running one mode replaces the prior graph (existing policy).
|
||||
- For mixed repos, a `figma-knowledge-graph.json` subdomain graph can be produced and merged via the existing `merge-subdomain-graphs.py` pattern.
|
||||
|
||||
### Security
|
||||
|
||||
- **`FIGMA_TOKEN` is read from the environment only.** It is never written to the graph, config, `meta.json`, logs, or intermediate files. Request headers (carrying the token) are never printed in errors or logs.
|
||||
- The pipeline makes an **outbound network call** to `api.figma.com` — a departure from `/understand`'s fully-offline nature. This is surfaced to the user in the skill's output and documented.
|
||||
- `figma-doc.json` (raw tree cache) and thumbnails are design data (not secrets) but `.understand-anything/` should remain git-ignored by default.
|
||||
- The thumbnail endpoint follows the existing token-gate + path-allowlist pattern used by the code viewer.
|
||||
|
||||
---
|
||||
|
||||
## Open Questions / Future Enhancements
|
||||
|
||||
- **Deep-expand a screen:** on-demand promotion of a single screen's deeper layers to nodes.
|
||||
- **In-node thumbnails:** opt-in richer rendering once the sidebar-thumbnail pipeline is proven.
|
||||
- **Local-JSON source:** the `FigmaSource` seam's offline implementation (evolves A → "both").
|
||||
- **Node-level incremental:** diff by Figma `nodeId` instead of full re-analyze on file change.
|
||||
@@ -11,6 +11,7 @@ export default tseslint.config(
|
||||
'**/public/**',
|
||||
'**/coverage/**',
|
||||
'**/.understand-anything/**',
|
||||
'**/.ua/**',
|
||||
'**/.claude-plugin/**',
|
||||
'**/.cursor-plugin/**',
|
||||
'**/.copilot-plugin/**',
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 217 KiB |
Binary file not shown.
Binary file not shown.
@@ -4,7 +4,7 @@ const youTubeUrl = `https://www.youtube.com/watch?v=${youTubeId}`;
|
||||
const embedUrl = `https://www.youtube.com/embed/${youTubeId}?si=IB3cjpjbq9wis5D7`;
|
||||
---
|
||||
|
||||
<section class="community-video">
|
||||
<section class="community-video" id="community">
|
||||
<span class="community-video-label reveal">Community</span>
|
||||
<h2 class="community-video-heading reveal">
|
||||
A walkthrough from the
|
||||
|
||||
@@ -33,7 +33,7 @@ const features = [
|
||||
];
|
||||
---
|
||||
|
||||
<section class="features">
|
||||
<section class="features" id="features">
|
||||
<div class="features-grid">
|
||||
{features.map((f, i) => (
|
||||
<div class={`feature-card reveal reveal-delay-${(i % 2) + 1}`}>
|
||||
|
||||
@@ -1,88 +1,250 @@
|
||||
---
|
||||
const githubUrl = 'https://github.com/Egonex-AI/Understand-Anything';
|
||||
const repositoryUrl = 'https://github.com/Egonex-AI/Understand-Anything';
|
||||
const githubUrl = 'https://github.com/Egonex-AI';
|
||||
const xUrl = 'https://x.com/EgonexAI';
|
||||
const egonexProductUrl = 'https://www.egonex.ai/';
|
||||
const privacyUrl = 'https://legal.egonex.ai/en/privacy';
|
||||
const termsUrl = 'https://legal.egonex.ai/en/terms';
|
||||
const configuredMotherSiteUrl = (import.meta.env.PUBLIC_MOTHER_SITE_URL ?? '')
|
||||
.trim()
|
||||
.replace(/\/+$/, '');
|
||||
const motherHomeUrl = configuredMotherSiteUrl
|
||||
? `${configuredMotherSiteUrl}/#top`
|
||||
: 'https://egonexai.com/#top';
|
||||
const currentYear = new Date().getFullYear();
|
||||
---
|
||||
|
||||
<footer class="footer">
|
||||
<div class="footer-inner">
|
||||
<span class="footer-logo">Understand Anything</span>
|
||||
<div class="footer-links">
|
||||
<a href={githubUrl} target="_blank" rel="noopener noreferrer">GitHub</a>
|
||||
<span class="footer-sep">·</span>
|
||||
<a href="/demo/">Live Demo</a>
|
||||
<span class="footer-sep">·</span>
|
||||
<a href={`${githubUrl}/blob/main/LICENSE`} target="_blank" rel="noopener noreferrer">MIT License</a>
|
||||
<footer class="brand-footer">
|
||||
<div class="brand-footer-inner">
|
||||
<div class="brand-footer-row">
|
||||
<a class="brand-footer-logo" href={motherHomeUrl} data-mother-site-link aria-label="EgonexAI home">
|
||||
<img
|
||||
src="/assets/egonexai/egonex-logo.png"
|
||||
width="28"
|
||||
height="28"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<span>EgonexAI</span>
|
||||
</a>
|
||||
|
||||
<div class="brand-footer-links">
|
||||
<nav class="brand-footer-link-group" aria-label="EgonexAI products">
|
||||
<a href={egonexProductUrl}>Egonex</a>
|
||||
<a href="/" aria-current="page">Understand Anything</a>
|
||||
</nav>
|
||||
|
||||
<nav class="brand-footer-link-group" aria-label="Legal and project links">
|
||||
<a href={motherHomeUrl} data-mother-site-link>About Us</a>
|
||||
<a href="mailto:affiliate@egonex.ai">Contact Us</a>
|
||||
<a href={privacyUrl} target="_blank" rel="noopener noreferrer">Privacy</a>
|
||||
<a href={termsUrl} target="_blank" rel="noopener noreferrer">Terms</a>
|
||||
<a href={`${repositoryUrl}/blob/main/LICENSE`} target="_blank" rel="noopener noreferrer">MIT License</a>
|
||||
<a href={repositoryUrl} target="_blank" rel="noopener noreferrer">Source Code</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="brand-footer-social-links" aria-label="EgonexAI social links">
|
||||
<a href={xUrl} target="_blank" rel="noopener noreferrer" aria-label="EgonexAI on X">
|
||||
<svg viewBox="0 0 24 24" width="17" height="17" fill="currentColor" aria-hidden="true">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24h-6.657l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231 5.45-6.231Zm-1.161 17.52h1.833L7.084 4.126H5.117L17.083 19.77Z"></path>
|
||||
</svg>
|
||||
</a>
|
||||
<a href={githubUrl} target="_blank" rel="noopener noreferrer" aria-label="EgonexAI on GitHub">
|
||||
<svg viewBox="0 0 24 24" width="17" height="17" fill="currentColor" aria-hidden="true">
|
||||
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="brand-footer-bottom">
|
||||
<span>© {currentYear} Infinite Universe, Inc.</span>
|
||||
</div>
|
||||
<p class="footer-note">An open-source project from Egonex. Originally created by Lum1104.</p>
|
||||
<p class="footer-note">© 2026 Infinite Universe, Inc.</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<style>
|
||||
.footer {
|
||||
padding: 4rem 2rem;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: center;
|
||||
.brand-footer {
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
overflow: hidden;
|
||||
padding: 30px 32px 22px;
|
||||
color: #1a0b54;
|
||||
background:
|
||||
radial-gradient(circle at 78% 0%, rgba(88, 43, 232, 0.08), transparent 38%),
|
||||
linear-gradient(180deg, #ffffff 0%, #f7f5ff 100%);
|
||||
font-family: var(--font-brand);
|
||||
}
|
||||
|
||||
.footer-inner {
|
||||
max-width: 1200px;
|
||||
.brand-footer,
|
||||
.brand-footer * {
|
||||
box-sizing: border-box;
|
||||
font-family: var(--font-brand);
|
||||
}
|
||||
|
||||
.brand-footer-inner {
|
||||
width: min(100%, 1152px);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
.brand-footer-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 28px;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.brand-footer-logo {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #1a0b54;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.brand-footer-logo:hover {
|
||||
color: #1a0b54;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.brand-footer-logo img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex: 0 0 auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.brand-footer-logo span {
|
||||
font-size: 22px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.brand-footer-links,
|
||||
.brand-footer-link-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.brand-footer-links {
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
gap: 26px;
|
||||
}
|
||||
|
||||
.brand-footer-link-group {
|
||||
gap: 18px;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 0.25rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.footer-logo {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 1.15rem;
|
||||
color: var(--text);
|
||||
display: block;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.footer-links a {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
.brand-footer-link-group a {
|
||||
color: #746c91;
|
||||
font-size: 13px;
|
||||
line-height: 1.25;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
white-space: nowrap;
|
||||
transition: color 180ms ease;
|
||||
}
|
||||
|
||||
.footer-links a:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.footer-sep {
|
||||
color: var(--border);
|
||||
margin: 0 0.6rem;
|
||||
}
|
||||
|
||||
.footer-note {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.footer-note a {
|
||||
color: var(--accent);
|
||||
.brand-footer-link-group a:hover,
|
||||
.brand-footer-link-group a[aria-current='page'] {
|
||||
color: #582be8;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.footer-note a:hover {
|
||||
text-decoration: underline;
|
||||
.brand-footer-social-links {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.footer { padding: 3rem 1.25rem; }
|
||||
.footer-sep { display: none; }
|
||||
.footer-links { gap: 0.25rem 1rem; }
|
||||
.brand-footer-social-links a {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 11px;
|
||||
color: #582be8;
|
||||
background: #f4f2fb;
|
||||
text-decoration: none;
|
||||
transition: color 180ms ease, background-color 180ms ease, transform 180ms ease;
|
||||
}
|
||||
|
||||
.brand-footer-social-links a:hover {
|
||||
color: #ffffff;
|
||||
background: #582be8;
|
||||
text-decoration: none;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.brand-footer-bottom {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem 1.5rem;
|
||||
margin-top: 18px;
|
||||
color: #817a96;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.brand-footer a:focus-visible {
|
||||
outline: 3px solid rgba(88, 43, 232, 0.48);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.brand-footer-row {
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 18px 24px;
|
||||
}
|
||||
|
||||
.brand-footer-links {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 2;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.brand-footer-social-links {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.brand-footer {
|
||||
padding: 26px 20px 22px;
|
||||
}
|
||||
|
||||
.brand-footer-links {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.brand-footer-link-group {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
gap: 10px 16px;
|
||||
}
|
||||
|
||||
.brand-footer-bottom {
|
||||
justify-content: flex-start;
|
||||
gap: 0.4rem;
|
||||
margin-top: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.brand-footer * {
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,7 +3,7 @@ const githubUrl = 'https://github.com/Egonex-AI/Understand-Anything';
|
||||
const egonexUrl = 'https://egonex.ai';
|
||||
---
|
||||
|
||||
<section class="hero">
|
||||
<section class="hero" id="top">
|
||||
<div class="hero-bg">
|
||||
<img src="/images/hero.jpg" alt="" class="hero-bg-img" loading="eager" />
|
||||
<div class="hero-overlay"></div>
|
||||
@@ -38,8 +38,8 @@ const egonexUrl = 'https://egonex.ai';
|
||||
</div>
|
||||
|
||||
<div class="hero-actions anim anim-6">
|
||||
<a href="#install" class="hero-cta">Get Started</a>
|
||||
<a href="/demo/" class="hero-demo">Live Demo →</a>
|
||||
<a href="#install" class="hero-cta"><span>Get Started</span></a>
|
||||
<a href="#live-demo" class="hero-demo">Live Demo →</a>
|
||||
<a href={githubUrl} target="_blank" rel="noopener noreferrer" class="hero-secondary">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor" style="vertical-align:-2px;margin-right:4px;"><path d="M12 .587l3.668 7.568L24 9.306l-6 5.986 1.416 8.421L12 19.897l-7.416 3.816L6 15.292 0 9.306l8.332-1.151z"/></svg>
|
||||
Star on GitHub
|
||||
@@ -83,12 +83,6 @@ const egonexUrl = 'https://egonex.ai';
|
||||
/>
|
||||
</a>
|
||||
|
||||
<a href={egonexUrl} target="_blank" rel="noopener noreferrer" class="hero-company anim anim-6">
|
||||
<span class="hero-company-label">Company</span>
|
||||
<span class="hero-company-divider">·</span>
|
||||
<span class="hero-company-site">egonex.ai</span>
|
||||
<span class="hero-company-arrow">→</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -289,22 +283,34 @@ const egonexUrl = 'https://egonex.ai';
|
||||
}
|
||||
|
||||
.hero-cta {
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-3));
|
||||
background: var(--egonex-gradient-primary);
|
||||
color: #ffffff;
|
||||
padding: 0.85rem 2.5rem;
|
||||
padding: 1px;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
font-size: 1.05rem;
|
||||
text-decoration: none;
|
||||
box-shadow: 0 16px 34px rgba(88, 43, 232, 0.22);
|
||||
transition: box-shadow 0.3s ease, transform 0.2s ease, filter 0.2s ease;
|
||||
box-shadow: none;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.hero-cta span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.85rem 2.5rem;
|
||||
border-radius: 999px;
|
||||
background: #582be8;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.hero-cta:hover {
|
||||
text-decoration: none;
|
||||
box-shadow: 0 22px 44px rgba(88, 43, 232, 0.28);
|
||||
transform: translateY(-2px);
|
||||
filter: brightness(1.02);
|
||||
}
|
||||
|
||||
.hero-cta:hover span,
|
||||
.hero-cta:focus-visible span {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.hero-demo {
|
||||
@@ -321,11 +327,16 @@ const egonexUrl = 'https://egonex.ai';
|
||||
|
||||
.hero-demo:hover {
|
||||
background: #ffffff;
|
||||
box-shadow: 0 16px 34px rgba(88, 43, 232, 0.12);
|
||||
box-shadow: 0 12px 28px rgba(88, 43, 232, 0.18);
|
||||
transform: translateY(-2px);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.hero-cta:active,
|
||||
.hero-demo:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.hero-secondary {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.95rem;
|
||||
@@ -435,61 +446,6 @@ const egonexUrl = 'https://egonex.ai';
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hero-company {
|
||||
margin-top: 1.25rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
padding: 0.45rem 1.1rem;
|
||||
border: 1px solid rgba(88, 43, 232, 0.18);
|
||||
border-radius: 100px;
|
||||
background: rgba(255, 255, 255, 0.68);
|
||||
box-shadow: var(--shadow-card);
|
||||
backdrop-filter: blur(18px) saturate(1.12);
|
||||
text-decoration: none;
|
||||
transition: border-color 0.25s ease, box-shadow 0.25s ease, transform 0.2s ease,
|
||||
background-color 0.25s ease;
|
||||
}
|
||||
|
||||
.hero-company:hover {
|
||||
border-color: rgba(88, 43, 232, 0.3);
|
||||
background: #ffffff;
|
||||
box-shadow: 0 18px 38px rgba(88, 43, 232, 0.14);
|
||||
transform: translateY(-1px);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.hero-company-label {
|
||||
font-family: var(--font-code);
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.hero-company-divider {
|
||||
color: rgba(88, 43, 232, 0.36);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.hero-company-site {
|
||||
font-family: var(--font-code);
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-soft);
|
||||
}
|
||||
|
||||
.hero-company-arrow {
|
||||
color: var(--accent);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.hero-company:hover .hero-company-arrow {
|
||||
transform: translateX(3px);
|
||||
}
|
||||
|
||||
/* Staggered entrance */
|
||||
@keyframes heroIn {
|
||||
from {
|
||||
@@ -576,16 +532,13 @@ const egonexUrl = 'https://egonex.ai';
|
||||
padding: 0.85rem 1.5rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hero-cta span {
|
||||
width: 100%;
|
||||
padding: 0.85rem 1.5rem;
|
||||
}
|
||||
.hero-pillars { flex-direction: column; gap: 0.4rem; }
|
||||
.pillar-dot { display: none; }
|
||||
.hero-company {
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.6rem 1.25rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
.hero-company-divider { display: none; }
|
||||
.hero-company-site { font-size: 0.78rem; }
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
|
||||
@@ -1,133 +1,714 @@
|
||||
---
|
||||
const githubUrl = 'https://github.com/Egonex-AI/Understand-Anything';
|
||||
const egonexProductUrl = 'https://www.egonex.ai/';
|
||||
const configuredMotherSiteUrl = (import.meta.env.PUBLIC_MOTHER_SITE_URL ?? '')
|
||||
.trim()
|
||||
.replace(/\/+$/, '');
|
||||
|
||||
const motherHomeUrl = configuredMotherSiteUrl
|
||||
? `${configuredMotherSiteUrl}/#top`
|
||||
: 'https://egonexai.com/#top';
|
||||
|
||||
const navigation = {
|
||||
home: '#top',
|
||||
features: '#features',
|
||||
liveDemo: '#live-demo',
|
||||
community: '#community',
|
||||
getStarted: '#install',
|
||||
};
|
||||
---
|
||||
|
||||
<nav class="nav" id="nav">
|
||||
<div class="nav-inner">
|
||||
<a href="/" class="nav-logo">EgonexAI</a>
|
||||
<div class="nav-links">
|
||||
<a href={githubUrl} target="_blank" rel="noopener noreferrer" class="nav-github">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor" aria-hidden="true">
|
||||
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z"/>
|
||||
<span class="brand-scroll-marker" id="brand-scroll-marker" aria-hidden="true"></span>
|
||||
|
||||
<header class="brand-header" id="brand-header">
|
||||
<nav class="brand-nav" aria-label="Primary navigation">
|
||||
<a class="brand-home" href={motherHomeUrl} data-mother-site-link aria-label="EgonexAI home">
|
||||
<img
|
||||
src="/assets/egonexai/egonex-logo.png"
|
||||
width="28"
|
||||
height="28"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
decoding="async"
|
||||
/>
|
||||
<span>EgonexAI</span>
|
||||
</a>
|
||||
|
||||
<div class="brand-desktop-nav" aria-label="EgonexAI navigation">
|
||||
<a href={navigation.home}>Home</a>
|
||||
|
||||
<details class="brand-products" id="brand-products">
|
||||
<summary aria-haspopup="menu">
|
||||
<span>Products</span>
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
|
||||
<path d="m4 6 4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
</summary>
|
||||
<div class="brand-products-menu" role="menu" aria-label="Products">
|
||||
<a href={egonexProductUrl} role="menuitem">
|
||||
<strong>Egonex</strong>
|
||||
<span>Find the right people for real work</span>
|
||||
</a>
|
||||
<a href="/" role="menuitem" aria-current="page">
|
||||
<strong>Understand Anything</strong>
|
||||
<span>Understand codebases through guided context</span>
|
||||
</a>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<a href={navigation.features}>Features</a>
|
||||
<a href={navigation.liveDemo}>Live Demo</a>
|
||||
<a href={navigation.community}>Community</a>
|
||||
</div>
|
||||
|
||||
<div class="brand-desktop-actions">
|
||||
<a
|
||||
class="brand-github"
|
||||
href={githubUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true">
|
||||
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z"></path>
|
||||
</svg>
|
||||
<span>GitHub</span>
|
||||
</a>
|
||||
<a href="#install" class="nav-cta">Get Started</a>
|
||||
<a class="brand-cta" href={navigation.getStarted}><span>Get Started</span></a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<button
|
||||
class="brand-menu-toggle"
|
||||
id="brand-menu-toggle"
|
||||
type="button"
|
||||
aria-expanded="false"
|
||||
aria-controls="brand-mobile-menu"
|
||||
aria-label="Open navigation menu"
|
||||
>
|
||||
<svg class="brand-menu-open-icon" viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
|
||||
<path d="M4 7h16M4 12h16M4 17h16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path>
|
||||
</svg>
|
||||
<svg class="brand-menu-close-icon" viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" hidden>
|
||||
<path d="m6 6 12 12M18 6 6 18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="brand-mobile-panel" id="brand-mobile-menu" hidden>
|
||||
<a href={navigation.home}>Home</a>
|
||||
|
||||
<div class="brand-mobile-products">
|
||||
<span class="brand-mobile-label">Products</span>
|
||||
<a href={egonexProductUrl}>
|
||||
<strong>Egonex</strong>
|
||||
<span>Find the right people for real work</span>
|
||||
</a>
|
||||
<a href="/" aria-current="page">
|
||||
<strong>Understand Anything</strong>
|
||||
<span>Understand codebases through guided context</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<a href={navigation.features}>Features</a>
|
||||
<a href={navigation.liveDemo}>Live Demo</a>
|
||||
<a href={navigation.community}>Community</a>
|
||||
|
||||
<div class="brand-mobile-actions">
|
||||
<a
|
||||
class="brand-github"
|
||||
href={githubUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true">
|
||||
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z"></path>
|
||||
</svg>
|
||||
<span>GitHub</span>
|
||||
</a>
|
||||
<a class="brand-cta" href={navigation.getStarted}><span>Get Started</span></a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<script>
|
||||
const nav = document.getElementById('nav');
|
||||
if (nav) {
|
||||
window.addEventListener('scroll', () => {
|
||||
nav.classList.toggle('scrolled', window.scrollY > 50);
|
||||
const header = document.getElementById('brand-header');
|
||||
const scrollMarker = document.getElementById('brand-scroll-marker');
|
||||
const products = document.getElementById('brand-products');
|
||||
const productsSummary = products?.querySelector('summary');
|
||||
const productItems = products?.querySelectorAll<HTMLElement>('[role="menuitem"]');
|
||||
const menuToggle = document.getElementById('brand-menu-toggle');
|
||||
const mobileMenu = document.getElementById('brand-mobile-menu');
|
||||
const menuOpenIcon = menuToggle?.querySelector<SVGElement>('.brand-menu-open-icon');
|
||||
const menuCloseIcon = menuToggle?.querySelector<SVGElement>('.brand-menu-close-icon');
|
||||
|
||||
if (['127.0.0.1', 'localhost', '::1', '[::1]'].includes(window.location.hostname)) {
|
||||
document.querySelectorAll<HTMLAnchorElement>('[data-mother-site-link]').forEach((link) => {
|
||||
link.href = 'http://127.0.0.1:4174/#top';
|
||||
});
|
||||
}
|
||||
|
||||
if (header && scrollMarker && 'IntersectionObserver' in window) {
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => header.classList.toggle('is-compact', !entry.isIntersecting),
|
||||
{ threshold: 0 }
|
||||
);
|
||||
|
||||
observer.observe(scrollMarker);
|
||||
}
|
||||
|
||||
const closeProducts = (restoreFocus = false) => {
|
||||
if (!(products instanceof HTMLDetailsElement) || !products.open) return;
|
||||
products.open = false;
|
||||
if (restoreFocus && productsSummary instanceof HTMLElement) productsSummary.focus();
|
||||
};
|
||||
|
||||
const setMobileMenu = (open: boolean, restoreFocus = false) => {
|
||||
if (!(menuToggle instanceof HTMLButtonElement) || !(mobileMenu instanceof HTMLElement)) return;
|
||||
|
||||
menuToggle.setAttribute('aria-expanded', String(open));
|
||||
menuToggle.setAttribute('aria-label', open ? 'Close navigation menu' : 'Open navigation menu');
|
||||
mobileMenu.hidden = !open;
|
||||
menuOpenIcon?.toggleAttribute('hidden', open);
|
||||
menuCloseIcon?.toggleAttribute('hidden', !open);
|
||||
document.documentElement.classList.toggle('brand-menu-is-open', open);
|
||||
|
||||
if (restoreFocus) menuToggle.focus();
|
||||
};
|
||||
|
||||
productsSummary?.addEventListener('keydown', (event) => {
|
||||
if (!(products instanceof HTMLDetailsElement) || !productItems?.length) return;
|
||||
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
products.open = true;
|
||||
const item = event.key === 'ArrowDown' ? productItems[0] : productItems[productItems.length - 1];
|
||||
item?.focus();
|
||||
}
|
||||
});
|
||||
|
||||
productItems?.forEach((item, index) => {
|
||||
item.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
|
||||
event.preventDefault();
|
||||
const direction = event.key === 'ArrowDown' ? 1 : -1;
|
||||
const nextIndex = (index + direction + productItems.length) % productItems.length;
|
||||
productItems[nextIndex]?.focus();
|
||||
});
|
||||
});
|
||||
|
||||
menuToggle?.addEventListener('click', () => {
|
||||
const isOpen = menuToggle.getAttribute('aria-expanded') === 'true';
|
||||
setMobileMenu(!isOpen);
|
||||
closeProducts();
|
||||
});
|
||||
|
||||
mobileMenu?.querySelectorAll('a').forEach((link) => {
|
||||
link.addEventListener('click', () => setMobileMenu(false));
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
closeProducts(true);
|
||||
if (menuToggle?.getAttribute('aria-expanded') === 'true') setMobileMenu(false, true);
|
||||
});
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Node)) return;
|
||||
|
||||
if (products && !products.contains(target)) closeProducts();
|
||||
if (header && !header.contains(target)) setMobileMenu(false);
|
||||
});
|
||||
|
||||
const desktopQuery = window.matchMedia('(min-width: 1100px)');
|
||||
|
||||
products?.addEventListener('pointerenter', () => {
|
||||
if (desktopQuery.matches && products instanceof HTMLDetailsElement) products.open = true;
|
||||
});
|
||||
|
||||
products?.addEventListener('pointerleave', () => {
|
||||
if (desktopQuery.matches) closeProducts();
|
||||
});
|
||||
|
||||
products?.addEventListener('focusin', () => {
|
||||
if (desktopQuery.matches && products instanceof HTMLDetailsElement) products.open = true;
|
||||
});
|
||||
|
||||
products?.addEventListener('focusout', (event) => {
|
||||
if (!desktopQuery.matches || !(products instanceof HTMLDetailsElement)) return;
|
||||
const nextTarget = event.relatedTarget;
|
||||
if (!(nextTarget instanceof Node) || !products.contains(nextTarget)) closeProducts();
|
||||
});
|
||||
|
||||
desktopQuery.addEventListener('change', (event) => {
|
||||
if (event.matches) setMobileMenu(false);
|
||||
else closeProducts();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.nav {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
.brand-scroll-marker {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.brand-header {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
left: 50%;
|
||||
z-index: 100;
|
||||
padding: 1rem 2rem;
|
||||
transition: background-color 0.3s ease, backdrop-filter 0.3s ease;
|
||||
width: min(calc(100% - 2rem), 1152px);
|
||||
transform: translateX(-50%);
|
||||
color: #1a0b54;
|
||||
font-family: var(--font-brand);
|
||||
transition: width 500ms ease-in-out;
|
||||
}
|
||||
|
||||
.nav.scrolled {
|
||||
background-color: rgba(255, 255, 255, 0.82);
|
||||
backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
.brand-header.is-compact {
|
||||
width: min(calc(100% - 2rem), 1024px);
|
||||
}
|
||||
|
||||
.nav.scrolled .nav-logo {
|
||||
color: var(--text);
|
||||
.brand-header,
|
||||
.brand-header * {
|
||||
box-sizing: border-box;
|
||||
font-family: var(--font-brand);
|
||||
}
|
||||
|
||||
.nav.scrolled .nav-github {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.nav.scrolled .nav-github:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nav-inner {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
.brand-nav {
|
||||
position: relative;
|
||||
min-height: 54px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 1fr) auto minmax(180px, 1fr);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 6px 8px 6px 20px;
|
||||
border-radius: 16px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
transition: padding 500ms ease-in-out, box-shadow 500ms ease-in-out;
|
||||
}
|
||||
|
||||
.nav-logo {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 1.25rem;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
.brand-header.is-compact .brand-nav {
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.brand-home {
|
||||
grid-column: 1;
|
||||
justify-self: start;
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nav-logo:hover {
|
||||
color: #1a0b54;
|
||||
text-decoration: none;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
.brand-home:hover {
|
||||
color: #1a0b54;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.brand-home img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex: 0 0 auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.brand-home span {
|
||||
font-size: 22px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.brand-desktop-nav {
|
||||
grid-column: 2;
|
||||
justify-self: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
transition: gap 500ms ease-in-out;
|
||||
}
|
||||
|
||||
.nav-github {
|
||||
display: flex;
|
||||
.brand-header.is-compact .brand-desktop-nav {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.brand-desktop-nav > a,
|
||||
.brand-products > summary {
|
||||
min-height: 40px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 0 16px;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
color: #1a0b54;
|
||||
background: transparent;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: background-color 180ms ease, color 180ms ease, padding 500ms ease-in-out, transform 180ms ease;
|
||||
}
|
||||
|
||||
.nav-github:hover {
|
||||
color: var(--text);
|
||||
.brand-header.is-compact .brand-desktop-nav > a,
|
||||
.brand-header.is-compact .brand-products > summary {
|
||||
padding-right: 8px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.brand-desktop-nav > a:hover,
|
||||
.brand-products > summary:hover,
|
||||
.brand-products[open] > summary {
|
||||
color: #1a0b54;
|
||||
background: #f4f2fb;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-cta {
|
||||
background: var(--accent);
|
||||
.brand-desktop-nav > a:active,
|
||||
.brand-products > summary:active,
|
||||
.brand-github:active,
|
||||
.brand-cta:active,
|
||||
.brand-menu-toggle:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.brand-products {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.brand-products::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.brand-products > summary {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.brand-products > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.brand-products > summary svg {
|
||||
transition: transform 180ms ease;
|
||||
}
|
||||
|
||||
.brand-products[open] > summary svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.brand-products-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 12px);
|
||||
left: 50%;
|
||||
width: 330px;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
border: 1px solid rgba(88, 43, 232, 0.12);
|
||||
border-radius: 16px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 20px 48px rgba(26, 11, 84, 0.16);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.brand-products-menu a {
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
color: #1a0b54;
|
||||
text-decoration: none;
|
||||
transition: background-color 180ms ease;
|
||||
}
|
||||
|
||||
.brand-products-menu a:hover,
|
||||
.brand-products-menu a:focus-visible,
|
||||
.brand-products-menu a[aria-current='page'] {
|
||||
background: #f4f1ff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.brand-products-menu a[aria-current='page'] {
|
||||
color: #582be8;
|
||||
}
|
||||
|
||||
.brand-products-menu a[aria-current='page']::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
bottom: 10px;
|
||||
left: 5px;
|
||||
width: 3px;
|
||||
border-radius: 999px;
|
||||
background: #582be8;
|
||||
}
|
||||
|
||||
.brand-products-menu strong {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.brand-products-menu span {
|
||||
color: #746c91;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.brand-desktop-actions {
|
||||
grid-column: 3;
|
||||
justify-self: end;
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.brand-github {
|
||||
min-height: 42px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
padding: 0 12px;
|
||||
border-radius: 12px;
|
||||
color: #1a0b54;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
text-decoration: none;
|
||||
transition: background-color 180ms ease, transform 180ms ease;
|
||||
}
|
||||
|
||||
.brand-github:hover {
|
||||
color: #1a0b54;
|
||||
background: #f4f2fb;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.brand-cta {
|
||||
min-height: 42px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
color: #ffffff;
|
||||
padding: 0.5rem 1.25rem;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
background: var(--egonex-gradient-primary);
|
||||
box-shadow: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s ease;
|
||||
white-space: nowrap;
|
||||
transition: transform 180ms ease;
|
||||
}
|
||||
|
||||
.nav-cta:hover {
|
||||
.brand-cta > span {
|
||||
min-height: 40px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 19px;
|
||||
border-radius: 11px;
|
||||
background: #582be8;
|
||||
transition: background 300ms ease;
|
||||
}
|
||||
|
||||
.brand-cta:hover {
|
||||
color: #ffffff;
|
||||
background: var(--egonex-gradient-primary);
|
||||
text-decoration: none;
|
||||
box-shadow: 0 0 20px var(--accent-glow);
|
||||
}
|
||||
|
||||
.brand-cta:hover > span,
|
||||
.brand-cta:focus-visible > span {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.brand-menu-toggle,
|
||||
.brand-mobile-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.brand-menu-open-icon[hidden],
|
||||
.brand-menu-close-icon[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:global(.brand-menu-is-open) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 1099px) {
|
||||
.brand-nav {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.brand-desktop-nav,
|
||||
.brand-desktop-actions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.brand-menu-toggle {
|
||||
grid-column: 2;
|
||||
justify-self: end;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
color: #1a0b54;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: background-color 180ms ease, transform 180ms ease;
|
||||
}
|
||||
|
||||
.brand-menu-toggle:hover {
|
||||
background: #f4f2fb;
|
||||
}
|
||||
|
||||
.brand-mobile-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
left: 0;
|
||||
width: 100%;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(88, 43, 232, 0.1);
|
||||
border-radius: 16px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 20px 48px rgba(26, 11, 84, 0.16);
|
||||
}
|
||||
|
||||
.brand-mobile-panel[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.brand-mobile-panel > a,
|
||||
.brand-mobile-products > a {
|
||||
min-height: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 14px;
|
||||
border-radius: 12px;
|
||||
color: #1a0b54;
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
transition: background-color 180ms ease;
|
||||
}
|
||||
|
||||
.brand-mobile-panel > a:hover,
|
||||
.brand-mobile-products > a:hover,
|
||||
.brand-mobile-products > a[aria-current='page'] {
|
||||
background: #f4f1ff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.brand-mobile-products {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
padding: 6px;
|
||||
border-radius: 12px;
|
||||
background: #f8f7fc;
|
||||
}
|
||||
|
||||
.brand-mobile-label {
|
||||
padding: 6px 8px 4px;
|
||||
color: #746c91;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.brand-mobile-products > a {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
align-items: start;
|
||||
gap: 3px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.brand-mobile-products > a strong {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.brand-mobile-products > a span {
|
||||
color: #746c91;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.brand-mobile-actions {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr);
|
||||
gap: 8px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid #ece9f5;
|
||||
}
|
||||
|
||||
.brand-mobile-actions .brand-github,
|
||||
.brand-mobile-actions .brand-cta {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.brand-mobile-actions .brand-cta > span {
|
||||
min-height: 42px;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.nav { padding: 0.75rem 1rem; }
|
||||
.nav-github span { display: none; }
|
||||
.brand-header {
|
||||
top: 12px;
|
||||
width: calc(100% - 24px);
|
||||
}
|
||||
|
||||
.brand-header.is-compact {
|
||||
width: calc(100% - 24px);
|
||||
}
|
||||
|
||||
.brand-nav {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.brand-home span {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 540px) {
|
||||
.nav { padding: 0.75rem 1rem; }
|
||||
.nav-logo { font-size: 1.05rem; }
|
||||
.nav-links { gap: 0.85rem; }
|
||||
.nav-github span { display: none; }
|
||||
.nav-cta {
|
||||
padding: 0.48rem 0.85rem;
|
||||
font-size: 0.82rem;
|
||||
white-space: nowrap;
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.brand-header,
|
||||
.brand-header * {
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<section class="problem">
|
||||
<section class="problem" id="what-we-build">
|
||||
<h2 class="problem-heading reveal">
|
||||
Most code graphs show you <span class="grad">structure</span>.<br />
|
||||
Files, functions, edges. A map with no legend.
|
||||
|
||||
@@ -4,15 +4,15 @@ const demoUrl = `${base.endsWith('/') ? base : base + '/'}demo/index.html`;
|
||||
---
|
||||
|
||||
<!-- Live Demo -->
|
||||
<section class="showcase showcase--demo">
|
||||
<section class="showcase showcase--demo" id="live-demo">
|
||||
<span class="showcase-label showcase-label--accent reveal">Live Demo</span>
|
||||
<h2 class="showcase-heading reveal">
|
||||
See what your code is <span class="grad">really</span> doing
|
||||
</h2>
|
||||
<p class="showcase-desc showcase-desc--wide reveal">
|
||||
Explore every file, function, and dependency — or switch to <span class="grad">business
|
||||
knowledge</span> mode and watch your code transform into authentication flows,
|
||||
payment pipelines, and user lifecycles. Not just a map.
|
||||
knowledge</span> mode and watch your code<span class="showcase-desc-break"><br /></span>
|
||||
transform into authentication flows, payment pipelines, and user lifecycles. Not just a map.<span class="showcase-desc-break"><br /></span>
|
||||
<strong>The story behind your codebase, fully interactive.</strong>
|
||||
</p>
|
||||
<div class="showcase-frame showcase-frame--featured reveal reveal-delay-1">
|
||||
@@ -98,7 +98,11 @@ const demoUrl = `${base.endsWith('/') ? base : base + '/'}demo/index.html`;
|
||||
}
|
||||
|
||||
.showcase-desc--wide {
|
||||
max-width: 700px;
|
||||
max-width: 960px;
|
||||
}
|
||||
|
||||
.showcase-desc-break {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.showcase-desc strong {
|
||||
@@ -205,5 +209,6 @@ const demoUrl = `${base.endsWith('/') ? base : base + '/'}demo/index.html`;
|
||||
.showcase-features { gap: 0.5rem; }
|
||||
.showcase-pill { font-size: 0.7rem; padding: 0.25rem 0.65rem; }
|
||||
.showcase-iframe-wrap { aspect-ratio: 4 / 3; }
|
||||
.showcase-desc-break { display: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,6 +12,9 @@ const { title } = Astro.props;
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Turn any codebase into an interactive knowledge graph you can explore, search, and learn from." />
|
||||
<link rel="preload" href="/fonts/MazzardH-Regular.woff2" as="font" type="font/woff2" crossorigin="anonymous" />
|
||||
<link rel="preload" href="/fonts/MazzardH-Medium.woff2" as="font" type="font/woff2" crossorigin="anonymous" />
|
||||
<link rel="preload" href="/assets/egonexai/egonex-logo.png" as="image" type="image/png" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<title>{title}</title>
|
||||
</head>
|
||||
|
||||
@@ -1,4 +1,20 @@
|
||||
/* Font declarations — self-hosted, no external CDN dependency */
|
||||
@font-face {
|
||||
font-family: 'Mazzard H';
|
||||
src: url('/fonts/MazzardH-Regular.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Mazzard H';
|
||||
src: url('/fonts/MazzardH-Medium.woff2') format('woff2');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Geist Variable';
|
||||
src: url('/fonts/GeistVariable.woff2') format('woff2');
|
||||
@@ -55,10 +71,19 @@
|
||||
--surface-dark: #10131d;
|
||||
--border: #e8e1ff;
|
||||
--border-soft: #eceef4;
|
||||
--egonex-violet-deep: #35138f;
|
||||
--egonex-violet: #582be8;
|
||||
--egonex-violet-bright: #744bef;
|
||||
--egonex-violet-light: #a996ff;
|
||||
--egonex-violet-mist: #c2b6ff;
|
||||
--egonex-gradient-primary: linear-gradient(90deg, #35138f 0%, #582be8 52%, #744bef 100%);
|
||||
--egonex-gradient-on-dark: linear-gradient(90deg, #a996ff 0%, #8064f4 45%, #c2b6ff 100%);
|
||||
--egonex-button-glow: 0 12px 28px rgba(88, 43, 232, 0.24);
|
||||
--egonex-button-glow-hover: 0 16px 36px rgba(88, 43, 232, 0.34);
|
||||
--accent: #582be8;
|
||||
--accent-2: #6236ff;
|
||||
--accent-3: #8b5cf6;
|
||||
--accent-deep: #4520c8;
|
||||
--accent-2: #582be8;
|
||||
--accent-3: #744bef;
|
||||
--accent-deep: #35138f;
|
||||
--accent-soft: #f1ecff;
|
||||
--accent-glow: rgba(88, 43, 232, 0.18);
|
||||
--text: #0f1020;
|
||||
@@ -69,15 +94,16 @@
|
||||
--shadow-soft: 0 30px 80px rgba(88, 43, 232, 0.12), 0 10px 30px rgba(15, 16, 32, 0.08);
|
||||
--shadow-card: 0 18px 42px rgba(80, 70, 120, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.86);
|
||||
|
||||
/* Egonex sweep: violet -> purple */
|
||||
--grad-cool: #8b5cf6;
|
||||
--grad-mid: #6236ff;
|
||||
--grad-warm: #582be8;
|
||||
--gradient: linear-gradient(135deg, var(--grad-cool), var(--grad-mid), var(--grad-warm));
|
||||
/* Egonex sweep: deep violet -> brand violet -> bright violet */
|
||||
--grad-cool: #35138f;
|
||||
--grad-mid: #582be8;
|
||||
--grad-warm: #744bef;
|
||||
--gradient: var(--egonex-gradient-primary);
|
||||
|
||||
--font-heading: 'Geist Variable', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
--font-body: 'Geist Variable', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
--font-code: 'Geist Mono Variable', 'JetBrains Mono', 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
|
||||
--font-heading: 'Mazzard H', sans-serif;
|
||||
--font-body: 'Mazzard H', sans-serif;
|
||||
--font-code: 'Mazzard H', sans-serif;
|
||||
--font-brand: 'Mazzard H', sans-serif;
|
||||
}
|
||||
|
||||
/* Reset & base */
|
||||
@@ -91,6 +117,19 @@ html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font-family: 'Mazzard H', sans-serif;
|
||||
}
|
||||
|
||||
section[id] {
|
||||
scroll-margin-top: 6rem;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
background:
|
||||
@@ -157,3 +196,18 @@ a {
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ $Platforms = [ordered]@{
|
||||
pi = @{ Target = (Join-Path $HOME '.agents\skills'); Style = 'per-skill' }
|
||||
openclaw = @{ Target = (Join-Path $HOME '.openclaw\skills'); Style = 'folder' }
|
||||
antigravity = @{ Target = (Join-Path $HOME '.gemini\antigravity\skills'); Style = 'folder' }
|
||||
vibe = @{ Target = (Join-Path $HOME '.vibe\skills'); Style = 'per-skill' }
|
||||
vscode = @{ Target = (Join-Path $HOME '.copilot\skills'); Style = 'per-skill' }
|
||||
hermes = @{ Target = (Join-Path $HOME '.hermes\skills'); Style = 'folder' }
|
||||
cline = @{ Target = (Join-Path $HOME '.cline\skills'); Style = 'folder' }
|
||||
@@ -234,6 +235,9 @@ function Cmd-Install([string]$Id) {
|
||||
|
||||
Write-Host "`n✓ Installed Understand-Anything for $Id"
|
||||
Write-Host ' Restart your CLI or IDE to pick up the skills.'
|
||||
if ($Id -eq 'codex') {
|
||||
Write-Host "`n Tip: Codex invokes skills with `$ instead of / — type `$understand, not /understand."
|
||||
}
|
||||
if ($Id -eq 'vscode') {
|
||||
Write-Host "`n Tip: VS Code can also auto-discover the plugin by opening this repo"
|
||||
Write-Host ' directly (it reads .copilot-plugin/plugin.json), no symlinks needed.'
|
||||
|
||||
@@ -222,6 +222,9 @@ KIROEOF
|
||||
|
||||
printf '\n✓ Installed Understand-Anything for %s\n' "$id"
|
||||
printf ' Restart your CLI or IDE to pick up the skills.\n'
|
||||
if [[ "$id" == "codex" ]]; then
|
||||
printf '\n Tip: Codex invokes skills with $ instead of / — type $understand, not /understand.\n'
|
||||
fi
|
||||
if [[ "$id" == "vscode" ]]; then
|
||||
printf '\n Tip: VS Code can also auto-discover the plugin by opening this repo\n'
|
||||
printf ' directly (it reads .copilot-plugin/plugin.json), no symlinks needed.\n'
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"tree-sitter-python",
|
||||
"tree-sitter-ruby",
|
||||
"tree-sitter-rust",
|
||||
"tree-sitter-scala",
|
||||
"tree-sitter-typescript"
|
||||
]
|
||||
}
|
||||
|
||||
Generated
+21
@@ -99,6 +99,9 @@ importers:
|
||||
tree-sitter-rust:
|
||||
specifier: ^0.24.0
|
||||
version: 0.24.0
|
||||
tree-sitter-scala:
|
||||
specifier: ^0.24.0
|
||||
version: 0.24.0
|
||||
tree-sitter-typescript:
|
||||
specifier: ^0.23.2
|
||||
version: 0.23.2
|
||||
@@ -169,6 +172,9 @@ importers:
|
||||
react-markdown:
|
||||
specifier: ^10.1.0
|
||||
version: 10.1.0(@types/react@19.2.14)(react@19.2.4)
|
||||
remark-gfm:
|
||||
specifier: ^4.0.1
|
||||
version: 4.0.1
|
||||
zustand:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.11(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
|
||||
@@ -208,6 +214,8 @@ importers:
|
||||
|
||||
understand-anything-plugin/packages/tree-sitter-swift-wasm: {}
|
||||
|
||||
understand-anything-plugin/packages/viewer: {}
|
||||
|
||||
packages:
|
||||
|
||||
'@ampproject/remapping@2.3.0':
|
||||
@@ -2915,6 +2923,14 @@ packages:
|
||||
tree-sitter:
|
||||
optional: true
|
||||
|
||||
tree-sitter-scala@0.24.0:
|
||||
resolution: {integrity: sha512-vkMuAUrBZ1zZz2XcGDQk18Kz73JkpgaeXzbNVobPke0G35sd9jH32aUxG6OLRKM7et0TbsfqkWf4DeJoGk4K1g==}
|
||||
peerDependencies:
|
||||
tree-sitter: ^0.21.1
|
||||
peerDependenciesMeta:
|
||||
tree-sitter:
|
||||
optional: true
|
||||
|
||||
tree-sitter-typescript@0.23.2:
|
||||
resolution: {integrity: sha512-e04JUUKxTT53/x3Uq1zIL45DoYKVfHH4CZqwgZhPg5qYROl5nQjV+85ruFzFGZxu+QeFVbRTPDRnqL9UbU4VeA==}
|
||||
peerDependencies:
|
||||
@@ -6304,6 +6320,11 @@ snapshots:
|
||||
node-addon-api: 8.6.0
|
||||
node-gyp-build: 4.8.4
|
||||
|
||||
tree-sitter-scala@0.24.0:
|
||||
dependencies:
|
||||
node-addon-api: 8.6.0
|
||||
node-gyp-build: 4.8.4
|
||||
|
||||
tree-sitter-typescript@0.23.2:
|
||||
dependencies:
|
||||
node-addon-api: 8.6.0
|
||||
|
||||
@@ -16,4 +16,5 @@ allowBuilds:
|
||||
tree-sitter-python: true
|
||||
tree-sitter-ruby: true
|
||||
tree-sitter-rust: true
|
||||
tree-sitter-scala: true
|
||||
tree-sitter-typescript: true
|
||||
|
||||
@@ -11,11 +11,17 @@
|
||||
* 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
|
||||
* Default: 3000 nodes. Writes to the project's data dir —
|
||||
* .ua/knowledge-graph.json (or legacy .understand-anything/knowledge-graph.json
|
||||
* when that directory already exists).
|
||||
*/
|
||||
|
||||
import { writeFileSync, mkdirSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { writeFileSync, mkdirSync, existsSync } from "node:fs";
|
||||
import { resolve, join } from "node:path";
|
||||
|
||||
// Mirror core's resolveUaDir: the legacy `.understand-anything/` dir wins for
|
||||
// both reads and writes when it already exists; otherwise use `.ua/`.
|
||||
const uaDir = (root) => { const legacy = join(root, ".understand-anything"); return existsSync(legacy) ? legacy : join(root, ".ua"); };
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const MESSY = args.includes("--messy");
|
||||
@@ -278,7 +284,7 @@ const graph = {
|
||||
tour: MESSY && Math.random() < 0.5 ? null : tour,
|
||||
};
|
||||
|
||||
const outDir = resolve(process.cwd(), ".understand-anything");
|
||||
const outDir = resolve(uaDir(process.cwd()));
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
const outPath = resolve(outDir, "knowledge-graph.json");
|
||||
writeFileSync(outPath, JSON.stringify(graph, null, 2));
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(__dirname, '../..');
|
||||
|
||||
function readRepoText(path) {
|
||||
return readFileSync(resolve(repoRoot, path), 'utf-8').replace(/\r\n?/g, '\n');
|
||||
}
|
||||
|
||||
const installSh = readRepoText('install.sh');
|
||||
const installPs1 = readRepoText('install.ps1');
|
||||
const readme = readRepoText('README.md');
|
||||
|
||||
/**
|
||||
* Parse the platforms_table() heredoc in install.sh:
|
||||
* id|$HOME/target/dir|style
|
||||
*/
|
||||
function parseShPlatforms(source) {
|
||||
const heredoc = source.match(/platforms_table\(\)\s*\{\s*\n\s*cat <<EOF\n([\s\S]*?)\nEOF/);
|
||||
if (!heredoc) return [];
|
||||
const rows = [];
|
||||
for (const line of heredoc[1].split('\n')) {
|
||||
const m = line.match(/^([a-z0-9][a-z0-9-]*)\|([^|]+)\|(per-skill|folder)$/);
|
||||
if (m) rows.push({ id: m[1], target: m[2], style: m[3] });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the $Platforms ordered hashtable in install.ps1:
|
||||
* id = @{ Target = (Join-Path $HOME 'target\dir'); Style = 'style' }
|
||||
*/
|
||||
function parsePs1Platforms(source) {
|
||||
const block = source.match(/\$Platforms\s*=\s*\[ordered\]@\{\r?\n([\s\S]*?)\r?\n\}/);
|
||||
if (!block) return [];
|
||||
const rows = [];
|
||||
for (const line of block[1].split('\n')) {
|
||||
const m = line.match(
|
||||
/^\s*([a-z0-9][a-z0-9-]*)\s*=\s*@\{\s*Target\s*=\s*\(Join-Path \$HOME '([^']+)'\);\s*Style\s*=\s*'(per-skill|folder)'\s*\}/,
|
||||
);
|
||||
if (m) rows.push({ id: m[1], target: m[2], style: m[3] });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a skills target dir for cross-script comparison: drop the
|
||||
* home-dir prefix (`$HOME/` in bash; PowerShell targets are already relative
|
||||
* to $HOME via Join-Path) and unify path separators.
|
||||
*/
|
||||
function normalizeTarget(target) {
|
||||
return target.replace(/^\$HOME\//, '').replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
/** Backtick-quoted ids on the "Supported `<platform>` values:" README line. */
|
||||
function parseReadmeSupportedValues(source) {
|
||||
const line = source.match(/^- Supported `<platform>` values: (.+)$/m);
|
||||
if (!line) return [];
|
||||
return [...line[1].matchAll(/`([a-z0-9][a-z0-9-]*)`/g)].map((m) => m[1]);
|
||||
}
|
||||
|
||||
/** Ids referenced as `install.sh <id>` in the Platform Compatibility table. */
|
||||
function parseReadmeCompatTableIds(source) {
|
||||
const section = source.match(/### Platform Compatibility\n([\s\S]*?)\n#{2,3} /);
|
||||
if (!section) return [];
|
||||
return [...section[1].matchAll(/`install\.sh ([a-z0-9][a-z0-9-]*)`/g)].map((m) => m[1]);
|
||||
}
|
||||
|
||||
const shRows = parseShPlatforms(installSh);
|
||||
const ps1Rows = parsePs1Platforms(installPs1);
|
||||
|
||||
describe('installer platform table consistency', () => {
|
||||
// Guard against the parsers silently matching nothing (e.g. after a
|
||||
// formatting change in either script): a regex mismatch must fail loudly
|
||||
// here, not let the comparison tests pass vacuously on two empty lists.
|
||||
it('parses a plausible number of platforms from both scripts', () => {
|
||||
expect(shRows.length).toBeGreaterThanOrEqual(10);
|
||||
expect(ps1Rows.length).toBeGreaterThanOrEqual(10);
|
||||
});
|
||||
|
||||
it('install.sh and install.ps1 define the same platform ids in the same order', () => {
|
||||
// Same order matters, not just the same set: both scripts number their
|
||||
// interactive platform menus from the table order, so "3) opencode" must
|
||||
// mean the same thing on macOS/Linux and on Windows.
|
||||
expect(ps1Rows.map((r) => r.id)).toEqual(shRows.map((r) => r.id));
|
||||
});
|
||||
|
||||
it('each platform has the same link style in both scripts', () => {
|
||||
const ps1ById = new Map(ps1Rows.map((r) => [r.id, r]));
|
||||
for (const row of shRows) {
|
||||
expect(ps1ById.get(row.id)?.style, `style for "${row.id}"`).toBe(row.style);
|
||||
}
|
||||
});
|
||||
|
||||
it('each platform has the same skills target dir in both scripts', () => {
|
||||
const ps1ById = new Map(ps1Rows.map((r) => [r.id, r]));
|
||||
for (const row of shRows) {
|
||||
const ps1Row = ps1ById.get(row.id);
|
||||
if (!ps1Row) continue; // id-set mismatch is reported by the test above
|
||||
expect(normalizeTarget(ps1Row.target), `target for "${row.id}"`).toBe(
|
||||
normalizeTarget(row.target),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('README "Supported <platform> values" line matches the installer table', () => {
|
||||
const readmeIds = parseReadmeSupportedValues(readme);
|
||||
expect(readmeIds.length).toBeGreaterThanOrEqual(10);
|
||||
expect([...readmeIds].sort()).toEqual(shRows.map((r) => r.id).sort());
|
||||
});
|
||||
|
||||
it('README Platform Compatibility table only references real installer platforms', () => {
|
||||
// The table may legitimately document a platform via another install
|
||||
// method (e.g. vscode → auto-discovery), so this is a subset check; full
|
||||
// coverage of the id list is enforced by the supported-values test above.
|
||||
const tableIds = parseReadmeCompatTableIds(readme);
|
||||
expect(tableIds.length).toBeGreaterThanOrEqual(10);
|
||||
const shIds = new Set(shRows.map((r) => r.id));
|
||||
for (const id of tableIds) {
|
||||
expect(shIds.has(id), `"install.sh ${id}" in README compatibility table`).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
test_parse_knowledge_base.py — Tests for title-case infra file detection and
|
||||
article-root-prefixed wiki links in the Karpathy wiki parser (issue #342).
|
||||
|
||||
Run from the repo root:
|
||||
python -m unittest tests.skill.knowledge.test_parse_knowledge_base -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
# ── Module loaders ────────────────────────────────────────────────────────
|
||||
# The scripts have hyphens in their names, so we cannot `import` them
|
||||
# directly. Load them via importlib so we can call module-level helpers.
|
||||
|
||||
_HERE = Path(__file__).resolve().parent
|
||||
_REPO_ROOT = _HERE.parent.parent.parent
|
||||
_SKILL_DIR = _REPO_ROOT / "understand-anything-plugin" / "skills" / "understand-knowledge"
|
||||
|
||||
|
||||
def _load(script: str, alias: str) -> Any:
|
||||
spec = importlib.util.spec_from_file_location(alias, _SKILL_DIR / script)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError(f"Could not load {script}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[alias] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
pkb = _load("parse-knowledge-base.py", "parse_knowledge_base")
|
||||
mkg = _load("merge-knowledge-graph.py", "merge_knowledge_graph")
|
||||
|
||||
|
||||
def _fs_is_case_sensitive(tmp: Path) -> bool:
|
||||
probe = tmp / "CaseProbe.md"
|
||||
probe.write_text("x", encoding="utf-8")
|
||||
try:
|
||||
return not (tmp / "caseprobe.md").is_file()
|
||||
finally:
|
||||
probe.unlink()
|
||||
|
||||
|
||||
class TestFindMarkdownCaseInsensitive(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.tmp = Path(tempfile.mkdtemp(prefix="ua-pkb-"))
|
||||
|
||||
def tearDown(self) -> None:
|
||||
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||
|
||||
def test_title_case_file_is_found(self) -> None:
|
||||
(self.tmp / "Index.md").write_text("# Wiki", encoding="utf-8")
|
||||
found = pkb.find_markdown_case_insensitive(self.tmp, "index.md")
|
||||
self.assertTrue(found.is_file())
|
||||
self.assertEqual(found.name.lower(), "index.md")
|
||||
|
||||
def test_exact_lowercase_wins_when_both_exist(self) -> None:
|
||||
if not _fs_is_case_sensitive(self.tmp):
|
||||
self.skipTest("filesystem is case-insensitive; both casings collide")
|
||||
(self.tmp / "Index.md").write_text("# Title", encoding="utf-8")
|
||||
(self.tmp / "index.md").write_text("# lower", encoding="utf-8")
|
||||
found = pkb.find_markdown_case_insensitive(self.tmp, "index.md")
|
||||
self.assertEqual(found.name, "index.md")
|
||||
|
||||
def test_missing_parent_returns_candidate(self) -> None:
|
||||
found = pkb.find_markdown_case_insensitive(self.tmp / "nope", "index.md")
|
||||
self.assertFalse(found.is_file())
|
||||
|
||||
def test_merge_script_helper_matches(self) -> None:
|
||||
(self.tmp / "Index.md").write_text("# Wiki", encoding="utf-8")
|
||||
found = mkg._find_markdown_case_insensitive(self.tmp, "index.md")
|
||||
self.assertTrue(found.is_file())
|
||||
|
||||
|
||||
class TestTitleCaseWiki(unittest.TestCase):
|
||||
"""A wiki using Index.md / Log.md must parse the same as index.md / log.md."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.tmp = Path(tempfile.mkdtemp(prefix="ua-pkb-wiki-"))
|
||||
wiki = self.tmp / "wiki"
|
||||
(wiki / "concepts").mkdir(parents=True)
|
||||
(wiki / "projects").mkdir(parents=True)
|
||||
(wiki / "Index.md").write_text(
|
||||
"# Hermes\n\n## Concepts\n\n- [[wiki/concepts/Index]]\n\n"
|
||||
"## Projects\n\n- [[wiki/projects/Personal Wiki Second Brain]]\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(wiki / "Log.md").write_text(
|
||||
"# Log\n\n## [2026-05-01] CREATE | Seeded wiki\n", encoding="utf-8"
|
||||
)
|
||||
(wiki / "concepts" / "Index.md").write_text(
|
||||
"# Concepts Index\n\nOverview of concepts.\n", encoding="utf-8"
|
||||
)
|
||||
(wiki / "projects" / "Personal Wiki Second Brain.md").write_text(
|
||||
"# Personal Wiki Second Brain\n\nPilot project.\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||
|
||||
def test_detect_format_sees_title_case_infra(self) -> None:
|
||||
signals = pkb.detect_format(self.tmp)
|
||||
self.assertTrue(signals["has_index"])
|
||||
self.assertTrue(signals["has_log"])
|
||||
self.assertTrue(signals["detected"])
|
||||
|
||||
def test_title_case_root_infra_is_not_an_article(self) -> None:
|
||||
manifest = pkb.parse_wiki(self.tmp)
|
||||
article_ids = {n["id"] for n in manifest["nodes"] if n["type"] == "article"}
|
||||
self.assertNotIn("article:Index", article_ids)
|
||||
self.assertNotIn("article:Log", article_ids)
|
||||
# Nested Index.md IS content
|
||||
self.assertIn("article:concepts/Index", article_ids)
|
||||
|
||||
def test_root_prefixed_category_links_resolve(self) -> None:
|
||||
"""[[wiki/concepts/Index]] must map to article:concepts/Index when
|
||||
wiki/ is the detected article root (the #342 pilot saw every node
|
||||
land in "Other" because this lookup missed)."""
|
||||
manifest = pkb.parse_wiki(self.tmp)
|
||||
cat_edges = {
|
||||
(e["source"], e["target"])
|
||||
for e in manifest["edges"]
|
||||
if e["type"] == "categorized_under"
|
||||
}
|
||||
self.assertIn(("article:concepts/Index", "topic:concepts"), cat_edges)
|
||||
self.assertIn(
|
||||
("article:projects/Personal Wiki Second Brain", "topic:projects"),
|
||||
cat_edges,
|
||||
)
|
||||
by_id = {n["id"]: n for n in manifest["nodes"]}
|
||||
self.assertEqual(
|
||||
by_id["article:concepts/Index"]["knowledgeMeta"].get("category"),
|
||||
"Concepts",
|
||||
)
|
||||
|
||||
def test_links_outside_article_root_stay_unresolved(self) -> None:
|
||||
"""maps/*, SCHEMA etc. must not be forced into article layers."""
|
||||
wiki = self.tmp / "wiki"
|
||||
(wiki / "concepts" / "Index.md").write_text(
|
||||
"# Concepts Index\n\nSee [[maps/overview]] and [[SCHEMA]].\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
manifest = pkb.parse_wiki(self.tmp)
|
||||
related = {
|
||||
e["target"] for e in manifest["edges"] if e["source"] == "article:concepts/Index"
|
||||
}
|
||||
self.assertNotIn("article:maps/overview", related)
|
||||
self.assertTrue(any("maps/overview" in w for w in manifest["warnings"]))
|
||||
|
||||
|
||||
class TestLowercaseWikiStillWorks(unittest.TestCase):
|
||||
"""Regression guard: the original all-lowercase layout keeps parsing."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.tmp = Path(tempfile.mkdtemp(prefix="ua-pkb-lower-"))
|
||||
(self.tmp / "concepts").mkdir(parents=True)
|
||||
(self.tmp / "index.md").write_text(
|
||||
"# Wiki\n\n## Concepts\n\n- [[concepts/attention]]\n", encoding="utf-8"
|
||||
)
|
||||
(self.tmp / "log.md").write_text("# Log\n", encoding="utf-8")
|
||||
(self.tmp / "concepts" / "attention.md").write_text(
|
||||
"# Attention\n\nAll you need.\n", encoding="utf-8"
|
||||
)
|
||||
(self.tmp / "concepts" / "transformer.md").write_text(
|
||||
"# Transformer\n\nSee [[attention]].\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||
|
||||
def test_lowercase_layout_parses_with_categories_and_edges(self) -> None:
|
||||
signals = pkb.detect_format(self.tmp)
|
||||
self.assertTrue(signals["detected"])
|
||||
manifest = pkb.parse_wiki(self.tmp)
|
||||
by_id = {n["id"]: n for n in manifest["nodes"]}
|
||||
self.assertIn("article:concepts/attention", by_id)
|
||||
self.assertEqual(
|
||||
by_id["article:concepts/attention"]["knowledgeMeta"].get("category"),
|
||||
"Concepts",
|
||||
)
|
||||
edge_keys = {(e["source"], e["target"], e["type"]) for e in manifest["edges"]}
|
||||
self.assertIn(
|
||||
("article:concepts/transformer", "article:concepts/attention", "related"),
|
||||
edge_keys,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
@@ -25,6 +25,19 @@ function setupProject(fixtureName) {
|
||||
return root;
|
||||
}
|
||||
|
||||
// Variant of setupProject that seeds the fixture into an arbitrary data
|
||||
// directory name (`.ua` for fresh projects, `.understand-anything` for legacy).
|
||||
function setupProjectInDir(fixtureName, dirName) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'ua-cb-dir-test-'));
|
||||
mkdirSync(join(root, dirName, 'intermediate'), { recursive: true });
|
||||
const fixturePath = join(FIXTURES, fixtureName);
|
||||
writeFileSync(
|
||||
join(root, dirName, 'intermediate', 'scan-result.json'),
|
||||
readFileSync(fixturePath, 'utf-8'),
|
||||
);
|
||||
return root;
|
||||
}
|
||||
|
||||
function readBatches(projectRoot) {
|
||||
const p = join(projectRoot, '.understand-anything', 'intermediate', 'batches.json');
|
||||
return JSON.parse(readFileSync(p, 'utf-8'));
|
||||
@@ -703,3 +716,50 @@ describe('compute-batches.mjs — --changed-files', () => {
|
||||
expect(neighbors.find(n => n.path === 'src/b/middle.ts').batchIndex).toBe(batch.batchIndex);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compute-batches.mjs — data-dir resolution (.ua vs legacy)', () => {
|
||||
let root;
|
||||
|
||||
afterEach(() => {
|
||||
if (root) rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('fresh project reads scan-result from .ua/ and writes batches.json there', () => {
|
||||
root = setupProjectInDir('scan-result-3-cliques.json', '.ua');
|
||||
const result = runScript(root);
|
||||
expect(result.status).toBe(0);
|
||||
|
||||
// Output landed in .ua/, and the legacy dir was never created.
|
||||
expect(existsSync(join(root, '.ua', 'intermediate', 'batches.json'))).toBe(true);
|
||||
expect(existsSync(join(root, '.understand-anything'))).toBe(false);
|
||||
|
||||
const batches = JSON.parse(
|
||||
readFileSync(join(root, '.ua', 'intermediate', 'batches.json'), 'utf-8'),
|
||||
);
|
||||
expect(batches.totalFiles).toBe(9);
|
||||
expect(batches.batches.length).toBe(3);
|
||||
});
|
||||
|
||||
it('legacy project keeps using .understand-anything/ (no migration)', () => {
|
||||
// Legacy-compat regression: an existing .understand-anything/ dir wins for
|
||||
// both read and write even though .ua/ is the new default.
|
||||
root = setupProjectInDir('scan-result-3-cliques.json', '.understand-anything');
|
||||
const result = runScript(root);
|
||||
expect(result.status).toBe(0);
|
||||
|
||||
expect(existsSync(join(root, '.understand-anything', 'intermediate', 'batches.json'))).toBe(true);
|
||||
expect(existsSync(join(root, '.ua'))).toBe(false);
|
||||
});
|
||||
|
||||
it('legacy dir wins when both .understand-anything/ and .ua/ exist', () => {
|
||||
root = setupProjectInDir('scan-result-3-cliques.json', '.understand-anything');
|
||||
// A stray empty .ua/ must not divert reads/writes away from the legacy dir.
|
||||
mkdirSync(join(root, '.ua', 'intermediate'), { recursive: true });
|
||||
|
||||
const result = runScript(root);
|
||||
expect(result.status).toBe(0);
|
||||
|
||||
expect(existsSync(join(root, '.understand-anything', 'intermediate', 'batches.json'))).toBe(true);
|
||||
expect(existsSync(join(root, '.ua', 'intermediate', 'batches.json'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -866,6 +866,153 @@ describe('extract-import-map.mjs — Kotlin resolver', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('extract-import-map.mjs — Scala resolver', () => {
|
||||
let projectRoot;
|
||||
|
||||
afterEach(() => {
|
||||
if (projectRoot) {
|
||||
rmSync(projectRoot, { recursive: true, force: true });
|
||||
projectRoot = null;
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves plain, selector-list, and package-object imports', () => {
|
||||
projectRoot = setupTree({
|
||||
'src/main/scala/com/example/Main.scala':
|
||||
`package com.example\n\nimport com.example.foo.Bar\nimport com.example.util.{Helper, Other}\nimport com.example.model._\n\nobject Main\n`,
|
||||
'src/main/scala/com/example/foo/Bar.scala':
|
||||
`package com.example.foo\n\nclass Bar\n`,
|
||||
'src/main/scala/com/example/util/Helper.scala':
|
||||
`package com.example.util\n\nobject Helper\n`,
|
||||
'src/main/scala/com/example/util/Other.scala':
|
||||
`package com.example.util\n\nobject Other\n`,
|
||||
'src/main/scala/com/example/model/package.scala':
|
||||
`package com.example\n\npackage object model\n`,
|
||||
'src/main/scala/com/example/model/User.scala':
|
||||
`package com.example.model\n\ncase class User(id: Long)\n`,
|
||||
'src/main/scala/com/example/model/Order.scala':
|
||||
`package com.example.model\n\ncase class Order(id: Long)\n`,
|
||||
});
|
||||
|
||||
const files = [
|
||||
{ path: 'src/main/scala/com/example/Main.scala', language: 'scala', fileCategory: 'code' },
|
||||
{ path: 'src/main/scala/com/example/foo/Bar.scala', language: 'scala', fileCategory: 'code' },
|
||||
{ path: 'src/main/scala/com/example/util/Helper.scala', language: 'scala', fileCategory: 'code' },
|
||||
{ path: 'src/main/scala/com/example/util/Other.scala', language: 'scala', fileCategory: 'code' },
|
||||
{ path: 'src/main/scala/com/example/model/package.scala', language: 'scala', fileCategory: 'code' },
|
||||
{ path: 'src/main/scala/com/example/model/User.scala', language: 'scala', fileCategory: 'code' },
|
||||
{ path: 'src/main/scala/com/example/model/Order.scala', language: 'scala', fileCategory: 'code' },
|
||||
];
|
||||
|
||||
const result = runScript(projectRoot, { projectRoot, files });
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.output.importMap['src/main/scala/com/example/Main.scala']).toEqual([
|
||||
'src/main/scala/com/example/foo/Bar.scala',
|
||||
'src/main/scala/com/example/model/Order.scala',
|
||||
'src/main/scala/com/example/model/User.scala',
|
||||
'src/main/scala/com/example/model/package.scala',
|
||||
'src/main/scala/com/example/util/Helper.scala',
|
||||
'src/main/scala/com/example/util/Other.scala',
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves renamed selector imports by original source names', () => {
|
||||
projectRoot = setupTree({
|
||||
'src/main/scala/com/example/Main.scala':
|
||||
`package com.example\n\nimport com.example.util.{Helper => H, Other as O}\n\nobject Main\n`,
|
||||
'src/main/scala/com/example/util/Helper.scala':
|
||||
`package com.example.util\n\nobject Helper\n`,
|
||||
'src/main/scala/com/example/util/Other.scala':
|
||||
`package com.example.util\n\nobject Other\n`,
|
||||
});
|
||||
|
||||
const result = runScript(projectRoot, {
|
||||
projectRoot,
|
||||
files: [
|
||||
{ path: 'src/main/scala/com/example/Main.scala', language: 'scala', fileCategory: 'code' },
|
||||
{ path: 'src/main/scala/com/example/util/Helper.scala', language: 'scala', fileCategory: 'code' },
|
||||
{ path: 'src/main/scala/com/example/util/Other.scala', language: 'scala', fileCategory: 'code' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.output.importMap['src/main/scala/com/example/Main.scala']).toEqual([
|
||||
'src/main/scala/com/example/util/Helper.scala',
|
||||
'src/main/scala/com/example/util/Other.scala',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not add package.scala when a plain import resolves directly', () => {
|
||||
projectRoot = setupTree({
|
||||
'src/main/scala/com/example/Main.scala':
|
||||
`package com.example\n\nimport com.example.pkg.Bar\n\nobject Main\n`,
|
||||
'src/main/scala/com/example/pkg/Bar.scala':
|
||||
`package com.example.pkg\n\nclass Bar\n`,
|
||||
'src/main/scala/com/example/pkg/package.scala':
|
||||
`package com.example\n\npackage object pkg { val defaultTimeout = 30 }\n`,
|
||||
});
|
||||
|
||||
const result = runScript(projectRoot, {
|
||||
projectRoot,
|
||||
files: [
|
||||
{ path: 'src/main/scala/com/example/Main.scala', language: 'scala', fileCategory: 'code' },
|
||||
{ path: 'src/main/scala/com/example/pkg/Bar.scala', language: 'scala', fileCategory: 'code' },
|
||||
{ path: 'src/main/scala/com/example/pkg/package.scala', language: 'scala', fileCategory: 'code' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.output.importMap['src/main/scala/com/example/Main.scala']).toEqual([
|
||||
'src/main/scala/com/example/pkg/Bar.scala',
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves imports to .sc Scala script targets', () => {
|
||||
projectRoot = setupTree({
|
||||
'src/main/scala/com/example/Main.scala':
|
||||
`package com.example\n\nimport com.example.scripts.Task\n\nobject Main\n`,
|
||||
'src/main/scala/com/example/scripts/Task.sc':
|
||||
`package com.example.scripts\n\nobject Task\n`,
|
||||
});
|
||||
|
||||
const result = runScript(projectRoot, {
|
||||
projectRoot,
|
||||
files: [
|
||||
{ path: 'src/main/scala/com/example/Main.scala', language: 'scala', fileCategory: 'code' },
|
||||
{ path: 'src/main/scala/com/example/scripts/Task.sc', language: 'scala', fileCategory: 'code' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.output.importMap['src/main/scala/com/example/Main.scala']).toEqual([
|
||||
'src/main/scala/com/example/scripts/Task.sc',
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops scala external imports (cats.effect, scala.concurrent, etc.)', () => {
|
||||
projectRoot = setupTree({
|
||||
'src/app/App.scala':
|
||||
`package app\n\nimport cats.effect.IO\nimport scala.concurrent.Future\nimport app.Local\n\nobject App\n`,
|
||||
'src/app/Local.scala':
|
||||
`package app\n\nclass Local\n`,
|
||||
});
|
||||
|
||||
const result = runScript(projectRoot, {
|
||||
projectRoot,
|
||||
files: [
|
||||
{ path: 'src/app/App.scala', language: 'scala', fileCategory: 'code' },
|
||||
{ path: 'src/app/Local.scala', language: 'scala', fileCategory: 'code' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
// cats.effect/scala.concurrent are external (no project file matches);
|
||||
// app.Local maps via suffix to src/app/Local.scala.
|
||||
expect(result.output.importMap['src/app/App.scala']).toEqual(['src/app/Local.scala']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extract-import-map.mjs — C# resolver', () => {
|
||||
let projectRoot;
|
||||
|
||||
|
||||
@@ -109,6 +109,12 @@ class IsTestPathTests(unittest.TestCase):
|
||||
self.assertTrue(mbg.is_test_path("src/test/kotlin/com/foo/BarTest.kt"))
|
||||
self.assertTrue(mbg.is_test_path("src/test/kotlin/com/foo/BarTests.kt"))
|
||||
|
||||
def test_scala_test_files(self) -> None:
|
||||
self.assertTrue(mbg.is_test_path("src/test/scala/com/foo/BarSpec.scala"))
|
||||
self.assertTrue(mbg.is_test_path("src/test/scala/com/foo/BarSuite.scala"))
|
||||
self.assertTrue(mbg.is_test_path("src/test/scala/com/foo/BarTest.scala"))
|
||||
self.assertTrue(mbg.is_test_path("src/test/scala/com/foo/BarTests.scala"))
|
||||
|
||||
def test_csharp_test_files(self) -> None:
|
||||
self.assertTrue(mbg.is_test_path("Foo.Tests/BarTests.cs"))
|
||||
self.assertTrue(mbg.is_test_path("Foo.Tests/BarTest.cs"))
|
||||
@@ -206,6 +212,14 @@ class ProductionCandidatesTests(unittest.TestCase):
|
||||
cands = mbg.production_candidates("src/test/kotlin/com/foo/BarTest.kt")
|
||||
self.assertIn("src/main/kotlin/com/foo/Bar.kt", cands)
|
||||
|
||||
def test_scala_sbt_layout(self) -> None:
|
||||
cands = mbg.production_candidates("src/test/scala/com/foo/BarSpec.scala")
|
||||
self.assertIn("src/main/scala/com/foo/Bar.scala", cands)
|
||||
|
||||
def test_scala_multimodule_sbt_layout(self) -> None:
|
||||
cands = mbg.production_candidates("modules/core/src/test/scala/com/foo/BarSpec.scala")
|
||||
self.assertIn("modules/core/src/main/scala/com/foo/Bar.scala", cands)
|
||||
|
||||
def test_js_ts_test_subdir_walkout(self) -> None:
|
||||
# Some JS/TS projects use `<dir>/test/` or `<dir>/spec/` instead of
|
||||
# the more idiomatic `__tests__/`. Walk out of either.
|
||||
@@ -299,6 +313,54 @@ class LinkTestsTests(unittest.TestCase):
|
||||
# Test node is not tagged with "tested"
|
||||
self.assertNotIn("tested", nodes_by_id["file:src/foo.test.ts"]["tags"])
|
||||
|
||||
def test_scala_sbt_pairing_emits_forward_edge(self) -> None:
|
||||
nodes_by_id = {
|
||||
"file:src/main/scala/com/foo/Bar.scala": _file_node(
|
||||
"src/main/scala/com/foo/Bar.scala",
|
||||
),
|
||||
"file:src/test/scala/com/foo/BarSpec.scala": _file_node(
|
||||
"src/test/scala/com/foo/BarSpec.scala",
|
||||
),
|
||||
}
|
||||
edges: list[dict[str, Any]] = []
|
||||
|
||||
added, dropped, tagged, swapped = mbg.link_tests(nodes_by_id, edges)
|
||||
|
||||
self.assertEqual(added, 1)
|
||||
self.assertEqual(dropped, 0)
|
||||
self.assertEqual(tagged, 1)
|
||||
self.assertEqual(swapped, 0)
|
||||
self.assertEqual(len(edges), 1)
|
||||
self.assertEqual(edges[0]["source"], "file:src/main/scala/com/foo/Bar.scala")
|
||||
self.assertEqual(edges[0]["target"], "file:src/test/scala/com/foo/BarSpec.scala")
|
||||
|
||||
def test_scala_multimodule_sbt_pairing_emits_forward_edge(self) -> None:
|
||||
nodes_by_id = {
|
||||
"file:modules/core/src/main/scala/com/foo/Bar.scala": _file_node(
|
||||
"modules/core/src/main/scala/com/foo/Bar.scala",
|
||||
),
|
||||
"file:modules/core/src/test/scala/com/foo/BarSpec.scala": _file_node(
|
||||
"modules/core/src/test/scala/com/foo/BarSpec.scala",
|
||||
),
|
||||
}
|
||||
edges: list[dict[str, Any]] = []
|
||||
|
||||
added, dropped, tagged, swapped = mbg.link_tests(nodes_by_id, edges)
|
||||
|
||||
self.assertEqual(added, 1)
|
||||
self.assertEqual(dropped, 0)
|
||||
self.assertEqual(tagged, 1)
|
||||
self.assertEqual(swapped, 0)
|
||||
self.assertEqual(len(edges), 1)
|
||||
self.assertEqual(
|
||||
edges[0]["source"],
|
||||
"file:modules/core/src/main/scala/com/foo/Bar.scala",
|
||||
)
|
||||
self.assertEqual(
|
||||
edges[0]["target"],
|
||||
"file:modules/core/src/test/scala/com/foo/BarSpec.scala",
|
||||
)
|
||||
|
||||
def test_no_production_counterpart_no_edge(self) -> None:
|
||||
nodes_by_id = {
|
||||
"file:src/foo.test.ts": _file_node("src/foo.test.ts"),
|
||||
@@ -1069,6 +1131,70 @@ class TestMultiPart(unittest.TestCase):
|
||||
# ── Unrecognized batch filename handling ───────────────────────────────────
|
||||
|
||||
|
||||
class TestIncrementalBatchExisting(unittest.TestCase):
|
||||
"""The documented incremental baseline file must be merged, not dropped."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
import tempfile
|
||||
self.tmp = Path(tempfile.mkdtemp(prefix="ua-mbg-existing-"))
|
||||
self.intermediate = self.tmp / ".understand-anything" / "intermediate"
|
||||
self.intermediate.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
import shutil
|
||||
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||
|
||||
def _write_batch(self, name: str, nodes: list, edges: list) -> None:
|
||||
import json as _j
|
||||
(self.intermediate / name).write_text(
|
||||
_j.dumps({"nodes": nodes, "edges": edges}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def _run_merge(self) -> tuple[int, str, dict]:
|
||||
import subprocess
|
||||
import json as _j
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(_MODULE_PATH), str(self.tmp)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
out_path = self.intermediate / "assembled-graph.json"
|
||||
assembled = _j.loads(out_path.read_text(encoding="utf-8")) if out_path.exists() else {}
|
||||
return result.returncode, result.stderr, assembled
|
||||
|
||||
def test_batch_existing_baseline_is_loaded_before_fresh_batches(self) -> None:
|
||||
self._write_batch("batch-existing.json", [
|
||||
_file_node("src/unchanged.ts"),
|
||||
_file_node("src/shared.ts", summary="old baseline summary"),
|
||||
], [])
|
||||
self._write_batch("batch-1.json", [
|
||||
_file_node("src/new.ts"),
|
||||
_file_node("src/shared.ts", summary="fresh summary"),
|
||||
], [
|
||||
{
|
||||
"source": "file:src/new.ts",
|
||||
"target": "file:src/shared.ts",
|
||||
"type": "imports",
|
||||
"direction": "forward",
|
||||
"weight": 0.7,
|
||||
}
|
||||
])
|
||||
|
||||
rc, stderr, assembled = self._run_merge()
|
||||
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertNotIn("unrecognized filenames", stderr)
|
||||
self.assertIn("batch-existing.json: 2 nodes, 0 edges", stderr)
|
||||
node_by_id = {n["id"]: n for n in assembled["nodes"]}
|
||||
self.assertEqual(
|
||||
set(node_by_id),
|
||||
{"file:src/unchanged.ts", "file:src/shared.ts", "file:src/new.ts"},
|
||||
)
|
||||
self.assertEqual(node_by_id["file:src/shared.ts"]["summary"], "fresh summary")
|
||||
edge_keys = {(e["source"], e["target"], e["type"]) for e in assembled["edges"]}
|
||||
self.assertIn(("file:src/new.ts", "file:src/shared.ts", "imports"), edge_keys)
|
||||
|
||||
|
||||
class TestUnrecognizedBatchFilename(unittest.TestCase):
|
||||
"""File-analyzer fuses multiple batches into one output (e.g.,
|
||||
`batch-fused-8-13.json`, `batch-8-13.json`) — the merge script's regex
|
||||
@@ -1183,5 +1309,109 @@ class TestUnrecognizedBatchFilename(unittest.TestCase):
|
||||
self.assertNotIn("file:src/y.ts", node_ids)
|
||||
|
||||
|
||||
class TestEmptyBatchGuard(unittest.TestCase):
|
||||
"""A batch file that parses but contributes 0 nodes + 0 edges is how a
|
||||
silent partial merge looks from the outside (#484) — it must be flagged
|
||||
loudly on stderr AND in the phase report, without failing the merge.
|
||||
"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
import tempfile
|
||||
self.tmp = Path(tempfile.mkdtemp(prefix="ua-mbg-empty-"))
|
||||
self.intermediate = self.tmp / ".understand-anything" / "intermediate"
|
||||
self.intermediate.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
import shutil
|
||||
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||
|
||||
def _write_batch(self, name: str, nodes: list, edges: list) -> None:
|
||||
import json as _j
|
||||
(self.intermediate / name).write_text(
|
||||
_j.dumps({"nodes": nodes, "edges": edges}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def _run_merge(self) -> tuple[int, str]:
|
||||
import subprocess
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(_MODULE_PATH), str(self.tmp)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
return result.returncode, result.stderr
|
||||
|
||||
def test_empty_batch_warns_but_does_not_fail(self) -> None:
|
||||
self._write_batch("batch-1.json", [_file_node("src/a.ts")], [])
|
||||
self._write_batch("batch-2.json", [], [])
|
||||
rc, stderr = self._run_merge()
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertIn("batch-2.json loaded but contributed 0 nodes and 0 edges", stderr)
|
||||
# Re-emitted in the phase report section, not just the load log
|
||||
self.assertIn("loaded but contributed no nodes or edges", stderr)
|
||||
|
||||
def test_no_warning_when_all_batches_contribute(self) -> None:
|
||||
self._write_batch("batch-1.json", [_file_node("src/a.ts")], [])
|
||||
rc, stderr = self._run_merge()
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertNotIn("contributed 0 nodes and 0 edges", stderr)
|
||||
|
||||
|
||||
class TestUaDirResolution(unittest.TestCase):
|
||||
"""The merge script reads/writes under the resolved data dir: `.ua/` for
|
||||
fresh projects, legacy `.understand-anything/` when that dir already exists
|
||||
(no migration). Exercised end-to-end via subprocess.
|
||||
"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
import tempfile
|
||||
self.tmp = Path(tempfile.mkdtemp(prefix="ua-mbg-uadir-"))
|
||||
|
||||
def tearDown(self) -> None:
|
||||
import shutil
|
||||
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||
|
||||
def _write_batch(self, dir_name: str, name: str, nodes: list) -> Path:
|
||||
import json as _j
|
||||
inter = self.tmp / dir_name / "intermediate"
|
||||
inter.mkdir(parents=True, exist_ok=True)
|
||||
(inter / name).write_text(_j.dumps({"nodes": nodes, "edges": []}), encoding="utf-8")
|
||||
return inter
|
||||
|
||||
def _run(self) -> int:
|
||||
import subprocess
|
||||
return subprocess.run(
|
||||
[sys.executable, str(_MODULE_PATH), str(self.tmp)],
|
||||
capture_output=True, text=True,
|
||||
).returncode
|
||||
|
||||
def test_fresh_project_uses_dot_ua(self) -> None:
|
||||
self._write_batch(".ua", "batch-1.json", [_file_node("src/a.ts")])
|
||||
rc = self._run()
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertTrue((self.tmp / ".ua" / "intermediate" / "assembled-graph.json").is_file())
|
||||
# Legacy dir must not be created for a fresh project.
|
||||
self.assertFalse((self.tmp / ".understand-anything").exists())
|
||||
|
||||
def test_legacy_project_keeps_understand_anything(self) -> None:
|
||||
self._write_batch(".understand-anything", "batch-1.json", [_file_node("src/a.ts")])
|
||||
rc = self._run()
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertTrue(
|
||||
(self.tmp / ".understand-anything" / "intermediate" / "assembled-graph.json").is_file()
|
||||
)
|
||||
self.assertFalse((self.tmp / ".ua").exists())
|
||||
|
||||
def test_legacy_dir_wins_when_both_present(self) -> None:
|
||||
self._write_batch(".understand-anything", "batch-1.json", [_file_node("src/a.ts")])
|
||||
# A stray empty .ua/ must not divert the merge away from the legacy dir.
|
||||
(self.tmp / ".ua" / "intermediate").mkdir(parents=True, exist_ok=True)
|
||||
rc = self._run()
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertTrue(
|
||||
(self.tmp / ".understand-anything" / "intermediate" / "assembled-graph.json").is_file()
|
||||
)
|
||||
self.assertFalse((self.tmp / ".ua" / "intermediate" / "assembled-graph.json").exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
test_merge_subdomain_graphs.py — Tests for structural-edge drop reporting
|
||||
and cross-run recovery in merge-subdomain-graphs.py (issue #529).
|
||||
|
||||
Run from the repo root:
|
||||
python -m unittest tests.skill.understand.test_merge_subdomain_graphs -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
# ── Module loader ─────────────────────────────────────────────────────────
|
||||
# `merge-subdomain-graphs.py` has hyphens in its name, so we cannot `import`
|
||||
# it directly. Load it via importlib so we can call its module-level helpers.
|
||||
|
||||
_HERE = Path(__file__).resolve().parent
|
||||
_REPO_ROOT = _HERE.parent.parent.parent
|
||||
_MODULE_PATH = (
|
||||
_REPO_ROOT
|
||||
/ "understand-anything-plugin"
|
||||
/ "skills"
|
||||
/ "understand"
|
||||
/ "merge-subdomain-graphs.py"
|
||||
)
|
||||
|
||||
|
||||
def _load_module() -> Any:
|
||||
spec = importlib.util.spec_from_file_location("merge_subdomain_graphs", _MODULE_PATH)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError(f"Could not load module from {_MODULE_PATH}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules["merge_subdomain_graphs"] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
msg = _load_module()
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _node(nid: str) -> dict[str, Any]:
|
||||
return {
|
||||
"id": nid,
|
||||
"type": "domain",
|
||||
"name": nid,
|
||||
"summary": "",
|
||||
"tags": [],
|
||||
"complexity": "simple",
|
||||
}
|
||||
|
||||
|
||||
def _edge(src: str, tgt: str, etype: str) -> dict[str, Any]:
|
||||
return {"source": src, "target": tgt, "type": etype, "direction": "forward", "weight": 0.8}
|
||||
|
||||
|
||||
def _graph(nodes: list[dict], edges: list[dict]) -> dict[str, Any]:
|
||||
return {"nodes": nodes, "edges": edges, "layers": [], "tour": [], "project": {}}
|
||||
|
||||
|
||||
# ── Tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestStructuralEdgeDrops(unittest.TestCase):
|
||||
def test_structural_drop_emits_warning_and_is_recorded(self) -> None:
|
||||
g = _graph([_node("domain:auth")], [_edge("domain:auth", "flow:login", "contains_flow")])
|
||||
merged, report, dropped = msg.merge_graphs([g])
|
||||
|
||||
self.assertEqual(merged["edges"], [])
|
||||
self.assertEqual(len(dropped), 1)
|
||||
self.assertEqual(dropped[0]["type"], "contains_flow")
|
||||
self.assertIn("target 'flow:login'", dropped[0]["missing"][0])
|
||||
warnings = [line for line in report if line.startswith("Warning: dropped structural edge")]
|
||||
self.assertEqual(len(warnings), 1)
|
||||
self.assertIn("contains_flow", warnings[0])
|
||||
|
||||
def test_non_structural_drop_stays_in_could_not_fix(self) -> None:
|
||||
g = _graph([_node("domain:auth")], [_edge("domain:auth", "file:gone.py", "related")])
|
||||
merged, report, dropped = msg.merge_graphs([g])
|
||||
|
||||
self.assertEqual(len(dropped), 1)
|
||||
self.assertFalse(any(line.startswith("Warning: dropped structural edge") for line in report))
|
||||
self.assertTrue(any("Could not fix" in line for line in report))
|
||||
|
||||
def test_valid_edges_are_untouched(self) -> None:
|
||||
g = _graph(
|
||||
[_node("domain:auth"), _node("flow:login")],
|
||||
[_edge("domain:auth", "flow:login", "contains_flow")],
|
||||
)
|
||||
merged, _report, dropped = msg.merge_graphs([g])
|
||||
self.assertEqual(len(merged["edges"]), 1)
|
||||
self.assertEqual(dropped, [])
|
||||
|
||||
|
||||
class TestCrossRunRecovery(unittest.TestCase):
|
||||
def test_dropped_structural_edge_recovers_when_endpoint_arrives(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
report_path = Path(tmp) / "merge-report.json"
|
||||
|
||||
# Run 1: cross_domain edge whose target subdomain doesn't exist yet.
|
||||
g1 = _graph([_node("domain:auth")], [_edge("domain:auth", "domain:billing", "cross_domain")])
|
||||
merged1, _r1, dropped1 = msg.merge_graphs([g1])
|
||||
msg.write_merge_report(report_path, merged1, dropped1, 0)
|
||||
|
||||
# Run 2: the billing subdomain graph has arrived; the pending edge
|
||||
# is re-injected exactly the way main() does it.
|
||||
pending = msg.load_pending_structural_edges(report_path)
|
||||
self.assertEqual(len(pending), 1)
|
||||
self.assertNotIn("missing", pending[0])
|
||||
|
||||
g2 = _graph([_node("domain:auth"), _node("domain:billing")], [])
|
||||
merged2, _r2, dropped2 = msg.merge_graphs([g2, {"nodes": [], "edges": pending}])
|
||||
|
||||
self.assertEqual(dropped2, [])
|
||||
self.assertEqual(len(merged2["edges"]), 1)
|
||||
self.assertEqual(merged2["edges"][0]["type"], "cross_domain")
|
||||
|
||||
def test_non_structural_dropped_edges_are_not_retried(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
report_path = Path(tmp) / "merge-report.json"
|
||||
g = _graph([_node("domain:auth")], [_edge("domain:auth", "file:gone.py", "related")])
|
||||
merged, _report, dropped = msg.merge_graphs([g])
|
||||
msg.write_merge_report(report_path, merged, dropped, 0)
|
||||
|
||||
# The related edge is persisted for investigation but not re-injected.
|
||||
data = json.loads(report_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(len(data["droppedEdges"]), 1)
|
||||
self.assertEqual(msg.load_pending_structural_edges(report_path), [])
|
||||
|
||||
def test_missing_or_corrupt_report_yields_no_pending(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
missing = Path(tmp) / "merge-report.json"
|
||||
self.assertEqual(msg.load_pending_structural_edges(missing), [])
|
||||
missing.write_text("{not json", encoding="utf-8")
|
||||
self.assertEqual(msg.load_pending_structural_edges(missing), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -144,6 +144,21 @@ describe('scan-project.mjs — language detection', () => {
|
||||
expect(byPath(r.output, 'g.swift').language).toBe('swift');
|
||||
});
|
||||
|
||||
it('maps Scala extensions to scala (with .sbt categorized as config)', () => {
|
||||
projectRoot = setupTree({
|
||||
'src/main/scala/App.scala': 'object App\n',
|
||||
'scripts/task.sc': 'println(1)\n',
|
||||
'build.sbt': 'name := "demo"\n',
|
||||
});
|
||||
const r = runScript(projectRoot);
|
||||
expect(r.status).toBe(0);
|
||||
expect(byPath(r.output, 'src/main/scala/App.scala').language).toBe('scala');
|
||||
expect(byPath(r.output, 'src/main/scala/App.scala').fileCategory).toBe('code');
|
||||
expect(byPath(r.output, 'scripts/task.sc').language).toBe('scala');
|
||||
expect(byPath(r.output, 'build.sbt').language).toBe('scala');
|
||||
expect(byPath(r.output, 'build.sbt').fileCategory).toBe('config');
|
||||
});
|
||||
|
||||
it('maps Ruby, PHP, C, C++ to their language ids', () => {
|
||||
projectRoot = setupTree({
|
||||
'a.rb': 'puts 1\n',
|
||||
@@ -460,6 +475,50 @@ describe('scan-project.mjs — .understandignore handling', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('scan-project.mjs — data-dir resolution (.ua vs legacy)', () => {
|
||||
let projectRoot;
|
||||
|
||||
afterEach(() => {
|
||||
if (projectRoot) {
|
||||
rmSync(projectRoot, { recursive: true, force: true });
|
||||
projectRoot = null;
|
||||
}
|
||||
});
|
||||
|
||||
it('honors .ua/.understandignore in a fresh project (no legacy dir)', () => {
|
||||
// scan-project delegates ignore handling to core's createIgnoreFilter,
|
||||
// which reads <resolveUaDir>/.understandignore — .ua/ for fresh projects.
|
||||
projectRoot = setupTree({
|
||||
'.ua/.understandignore': 'fixtures/\n',
|
||||
'src/index.ts': 'export const x = 1;\n',
|
||||
'fixtures/snap1.json': '{ "a": 1 }\n',
|
||||
'fixtures/snap2.json': '{ "b": 2 }\n',
|
||||
});
|
||||
const r = runScript(projectRoot);
|
||||
expect(r.status).toBe(0);
|
||||
expect(byPath(r.output, 'fixtures/snap1.json')).toBeUndefined();
|
||||
expect(byPath(r.output, 'fixtures/snap2.json')).toBeUndefined();
|
||||
// Counted as user-driven drops (dual-filter accounting saw the ua ignore).
|
||||
expect(r.output.filteredByIgnore).toBe(2);
|
||||
});
|
||||
|
||||
it('honors legacy .understand-anything/.understandignore (legacy-compat)', () => {
|
||||
// Legacy-compat regression: projects with an existing
|
||||
// .understand-anything/ keep using it for the .understandignore lookup.
|
||||
projectRoot = setupTree({
|
||||
'.understand-anything/.understandignore': 'fixtures/\n',
|
||||
'src/index.ts': 'export const x = 1;\n',
|
||||
'fixtures/snap1.json': '{ "a": 1 }\n',
|
||||
'fixtures/snap2.json': '{ "b": 2 }\n',
|
||||
});
|
||||
const r = runScript(projectRoot);
|
||||
expect(r.status).toBe(0);
|
||||
expect(byPath(r.output, 'fixtures/snap1.json')).toBeUndefined();
|
||||
expect(byPath(r.output, 'fixtures/snap2.json')).toBeUndefined();
|
||||
expect(r.output.filteredByIgnore).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scan-project.mjs — special-file recognition', () => {
|
||||
let projectRoot;
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(__dirname, '../../..');
|
||||
|
||||
function readRepoFile(relPath) {
|
||||
return readFileSync(resolve(repoRoot, relPath), 'utf-8');
|
||||
}
|
||||
|
||||
describe('skill command hardening', () => {
|
||||
it('quotes PROJECT_ROOT in shell command snippets', () => {
|
||||
const files = [
|
||||
'understand-anything-plugin/skills/understand/SKILL.md',
|
||||
'understand-anything-plugin/hooks/auto-update-prompt.md',
|
||||
];
|
||||
|
||||
const unsafePatterns = [
|
||||
/\b(?:node|python|python3|mkdir|find|rm|cat)\s+(?:-[^\n]*\s+)*\$PROJECT_ROOT\b/,
|
||||
/>\s*\$PROJECT_ROOT\b/,
|
||||
/--changed-files=\$PROJECT_ROOT\b/,
|
||||
/rm\s+-rf\s+\$PROJECT_ROOT\b/,
|
||||
];
|
||||
|
||||
for (const relPath of files) {
|
||||
const content = readRepoFile(relPath);
|
||||
for (const pattern of unsafePatterns) {
|
||||
expect(content, `${relPath} should not contain ${pattern}`).not.toMatch(pattern);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('quotes skill and target directory placeholders in knowledge commands', () => {
|
||||
const content = readRepoFile('understand-anything-plugin/skills/understand-knowledge/SKILL.md');
|
||||
|
||||
expect(content).not.toMatch(/python3\s+<SKILL_DIR>\/[^\n]+ <TARGET_DIR>/);
|
||||
expect(content).not.toMatch(/rm\s+-rf\s+<TARGET_DIR>/);
|
||||
});
|
||||
|
||||
it('quotes dashboard cd targets and GRAPH_DIR assignment', () => {
|
||||
const content = readRepoFile('understand-anything-plugin/skills/understand-dashboard/SKILL.md');
|
||||
|
||||
expect(content).not.toMatch(/<(?:dashboard-dir|plugin-root|project-dir)>/);
|
||||
expect(content).not.toMatch(/\bcd <(?:dashboard-dir|plugin-root)>/);
|
||||
expect(content).not.toMatch(/GRAPH_DIR=<project-dir>/);
|
||||
expect(content).toMatch(/PROJECT_DIR=\$\(pwd -P\)/);
|
||||
expect(content).toMatch(/UA_DIR="\$PROJECT_DIR\/\.understand-anything"/);
|
||||
expect(content).toMatch(/\[ ! -f "\$UA_DIR\/knowledge-graph\.json" \]/);
|
||||
expect(content).toMatch(/DASHBOARD_DIR="\$PLUGIN_ROOT\/packages\/dashboard"/);
|
||||
expect(content).toMatch(/: "\$\{PLUGIN_ROOT:\?Run step 3 first so PLUGIN_ROOT is set\}"/);
|
||||
expect(content).toMatch(/: "\$\{PROJECT_DIR:\?Run step 1 first so PROJECT_DIR is set\}"/);
|
||||
expect(content).toMatch(/: "\$\{DASHBOARD_DIR:\?Run step 5 first so DASHBOARD_DIR is set\}"/);
|
||||
expect(content).toMatch(/cd "\$PLUGIN_ROOT" && pnpm --filter @understand-anything\/core build/);
|
||||
expect(content).toMatch(/cd "\$DASHBOARD_DIR" && GRAPH_DIR="\$PROJECT_DIR" npx vite/);
|
||||
// Fast path: the viewer URL is version-pinned and both npx arguments are quoted.
|
||||
expect(content).toMatch(/VIEWER_URL="https:\/\/github\.com\/Egonex-AI\/Understand-Anything\/releases\/download\/v\$\{PLUGIN_VERSION\}\/understand-anything-viewer\.tgz"/);
|
||||
expect(content).toMatch(/npx --yes "\$VIEWER_URL" "\$PROJECT_DIR"/);
|
||||
});
|
||||
|
||||
it('marks project-controlled context as untrusted data', () => {
|
||||
const understand = readRepoFile('understand-anything-plugin/skills/understand/SKILL.md');
|
||||
const knowledge = readRepoFile('understand-anything-plugin/skills/understand-knowledge/SKILL.md');
|
||||
|
||||
expect(understand).not.toMatch(/README and manifest are authoritative/i);
|
||||
expect(understand).toMatch(/untrusted project data/i);
|
||||
expect(knowledge).toMatch(/untrusted article data/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
// End-to-end tests for the standalone viewer (packages/viewer/bin/viewer.mjs).
|
||||
// Spawns the real server against a fixture project and exercises the token
|
||||
// gate, graph sanitisation, file-content allowlist, and .ua/legacy resolution.
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
||||
const VIEWER_BIN = join(
|
||||
REPO_ROOT,
|
||||
"understand-anything-plugin",
|
||||
"packages",
|
||||
"viewer",
|
||||
"bin",
|
||||
"viewer.mjs",
|
||||
);
|
||||
const VIEWER_DIST = join(REPO_ROOT, "understand-anything-plugin", "packages", "viewer", "dist");
|
||||
|
||||
function fixtureGraph() {
|
||||
return {
|
||||
version: "1.0.0",
|
||||
project: {
|
||||
name: "fixture", languages: ["ts"], frameworks: [], description: "d",
|
||||
analyzedAt: "t", gitCommitHash: "",
|
||||
},
|
||||
nodes: [
|
||||
{
|
||||
id: "file:src/a.ts", type: "file", name: "a.ts", filePath: "src/a.ts",
|
||||
summary: "s", tags: [], complexity: "simple",
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
layers: [],
|
||||
tour: [],
|
||||
};
|
||||
}
|
||||
|
||||
function setupProject(dataDirName) {
|
||||
const root = mkdtempSync(join(tmpdir(), "ua-viewer-"));
|
||||
const dataDir = join(root, dataDirName);
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
writeFileSync(join(dataDir, "knowledge-graph.json"), JSON.stringify(fixtureGraph()));
|
||||
mkdirSync(join(root, "src"), { recursive: true });
|
||||
writeFileSync(join(root, "src", "a.ts"), "export const a = 1;\n");
|
||||
writeFileSync(join(root, "secret.txt"), "not in graph\n");
|
||||
return root;
|
||||
}
|
||||
|
||||
/** Start the viewer and wait for the printed URL. Returns { proc, url, token, port }. */
|
||||
function startViewer(projectRoot) {
|
||||
return new Promise((resolvePromise, rejectPromise) => {
|
||||
const proc = spawn(
|
||||
process.execPath,
|
||||
[VIEWER_BIN, projectRoot, "--no-open", "--port", "0"],
|
||||
{ env: { ...process.env }, stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
let out = "";
|
||||
const timer = setTimeout(() => {
|
||||
proc.kill();
|
||||
rejectPromise(new Error(`viewer did not start.\n${out}`));
|
||||
}, 10_000);
|
||||
const onData = (chunk) => {
|
||||
out += String(chunk);
|
||||
const m = out.match(/http:\/\/127\.0\.0\.1:(\d+)\/\?token=([a-f0-9]+)/);
|
||||
if (m) {
|
||||
clearTimeout(timer);
|
||||
resolvePromise({ proc, url: m[0], port: Number(m[1]), token: m[2] });
|
||||
}
|
||||
};
|
||||
proc.stdout.on("data", onData);
|
||||
proc.stderr.on("data", onData);
|
||||
proc.on("exit", (code) => {
|
||||
clearTimeout(timer);
|
||||
rejectPromise(new Error(`viewer exited with ${code}.\n${out}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe.skipIf(!existsSync(VIEWER_DIST))("understand-anything-viewer", () => {
|
||||
let root;
|
||||
let viewer;
|
||||
|
||||
beforeAll(async () => {
|
||||
root = setupProject(".ua");
|
||||
viewer = await startViewer(root);
|
||||
}, 15_000);
|
||||
|
||||
afterAll(() => {
|
||||
viewer?.proc.kill();
|
||||
if (root) rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const base = () => `http://127.0.0.1:${viewer.port}`;
|
||||
|
||||
it("serves the embedded dashboard index", async () => {
|
||||
const res = await fetch(`${base()}/`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.text()).toContain("<!doctype html>");
|
||||
});
|
||||
|
||||
it("rejects data requests without the token", async () => {
|
||||
const res = await fetch(`${base()}/knowledge-graph.json`);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("serves the graph from .ua/ with a valid token", async () => {
|
||||
const res = await fetch(`${base()}/knowledge-graph.json?token=${viewer.token}`);
|
||||
expect(res.status).toBe(200);
|
||||
const graph = await res.json();
|
||||
expect(graph.nodes).toHaveLength(1);
|
||||
expect(graph.nodes[0].filePath).toBe("src/a.ts");
|
||||
});
|
||||
|
||||
it("serves file content only for files listed in the graph", async () => {
|
||||
const ok = await fetch(
|
||||
`${base()}/file-content.json?token=${viewer.token}&path=${encodeURIComponent("src/a.ts")}`,
|
||||
);
|
||||
expect(ok.status).toBe(200);
|
||||
const body = await ok.json();
|
||||
expect(body.content).toContain("export const a");
|
||||
expect(body.language).toBe("typescript");
|
||||
|
||||
const denied = await fetch(
|
||||
`${base()}/file-content.json?token=${viewer.token}&path=secret.txt`,
|
||||
);
|
||||
expect(denied.status).toBe(404);
|
||||
});
|
||||
|
||||
it("rejects path traversal in file-content", async () => {
|
||||
const res = await fetch(
|
||||
`${base()}/file-content.json?token=${viewer.token}&path=${encodeURIComponent("../outside.txt")}`,
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("blocks static requests escaping dist/", async () => {
|
||||
const res = await fetch(`${base()}/%2e%2e/package.json`);
|
||||
expect([403, 404]).toContain(res.status);
|
||||
});
|
||||
|
||||
it("falls back to legacy .understand-anything/ projects", async () => {
|
||||
const legacyRoot = setupProject(".understand-anything");
|
||||
const legacyViewer = await startViewer(legacyRoot);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`http://127.0.0.1:${legacyViewer.port}/knowledge-graph.json?token=${legacyViewer.token}`,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json()).nodes).toHaveLength(1);
|
||||
} finally {
|
||||
legacyViewer.proc.kill();
|
||||
rmSync(legacyRoot, { recursive: true, force: true });
|
||||
}
|
||||
}, 15_000);
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "understand-anything",
|
||||
"description": "AI-powered codebase understanding — analyze, visualize, and explain any project",
|
||||
"version": "2.8.2",
|
||||
"version": "2.9.3",
|
||||
"author": {
|
||||
"name": "Egonex"
|
||||
},
|
||||
|
||||
@@ -298,10 +298,11 @@ For each pair of groups with imports between them, determine the dominant direct
|
||||
|
||||
### Preparing the Script Input
|
||||
|
||||
Before writing the script, create its input JSON file:
|
||||
Before writing the script, create its input JSON file. First resolve the project's data directory once (the legacy `.understand-anything/` when it already exists, otherwise the new `.ua/`) and reuse `$UA_DIR` for every path below:
|
||||
|
||||
```bash
|
||||
cat > $PROJECT_ROOT/.understand-anything/tmp/ua-arch-input.json << 'ENDJSON'
|
||||
UA_DIR="$PROJECT_ROOT/$([ -d "$PROJECT_ROOT/.understand-anything" ] && echo .understand-anything || echo .ua)"
|
||||
cat > $UA_DIR/tmp/ua-arch-input.json << 'ENDJSON'
|
||||
{
|
||||
"fileNodes": [<file nodes from prompt — all node types>],
|
||||
"importEdges": [<import edges from prompt>],
|
||||
@@ -315,7 +316,7 @@ ENDJSON
|
||||
After writing the script, execute it:
|
||||
|
||||
```bash
|
||||
node $PROJECT_ROOT/.understand-anything/tmp/ua-arch-analyze.js $PROJECT_ROOT/.understand-anything/tmp/ua-arch-input.json $PROJECT_ROOT/.understand-anything/tmp/ua-arch-results.json
|
||||
node $UA_DIR/tmp/ua-arch-analyze.js $UA_DIR/tmp/ua-arch-input.json $UA_DIR/tmp/ua-arch-results.json
|
||||
```
|
||||
|
||||
If the script exits with a non-zero code, read stderr, diagnose the issue, fix the script, and re-run. You have up to 2 retry attempts.
|
||||
@@ -324,7 +325,7 @@ If the script exits with a non-zero code, read stderr, diagnose the issue, fix t
|
||||
|
||||
## Phase 2 -- Semantic Layer Assignment
|
||||
|
||||
After the script completes, read `$PROJECT_ROOT/.understand-anything/tmp/ua-arch-results.json`. Use the structural analysis as the primary input for your layer decisions. Do NOT re-read source files or re-analyze imports -- trust the script's results entirely.
|
||||
After the script completes, read `$UA_DIR/tmp/ua-arch-results.json`. Use the structural analysis as the primary input for your layer decisions. Do NOT re-read source files or re-analyze imports -- trust the script's results entirely.
|
||||
|
||||
### Step 1 -- Evaluate Directory Groups as Layer Candidates
|
||||
|
||||
@@ -473,7 +474,7 @@ Produce a single, valid JSON array. Every field shown is **required**.
|
||||
|
||||
After producing the JSON:
|
||||
|
||||
1. Write the JSON array to: `<project-root>/.understand-anything/intermediate/layers.json`
|
||||
1. Write the JSON array to the `intermediate/layers.json` file inside the project's data directory — `$UA_DIR/intermediate/layers.json` (`.ua/`, or the legacy `.understand-anything/` when that directory is present). Use the exact output path given in your dispatch prompt if one was provided.
|
||||
2. The project root will be provided in your prompt.
|
||||
3. Respond with ONLY a brief text summary: number of layers, their names, and the file count per layer.
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: design-analyzer
|
||||
description: |
|
||||
Analyzes Figma structural nodes (pages, screens, components, instances, tokens) from a deterministic manifest and adds semantic enrichment — concise summaries, tags, and a screen's purpose — plus conservative `related` edges. Does NOT invent structural nodes or edges.
|
||||
---
|
||||
|
||||
# Design Analyzer Agent
|
||||
|
||||
You enrich a Figma design graph. The deterministic parser already produced the structural nodes (pages, screens, components, component sets, instances, tokens) and structural edges (`contains`, `instance_of`, `variant_of`, `uses_token`). Your job is the semantic layer only.
|
||||
|
||||
## Input
|
||||
|
||||
A JSON batch of manifest nodes. Each has:
|
||||
- `id`, `type` (page | screen | component | componentSet | instance | token), `name`
|
||||
- `figmaMeta` (dimensions, tokenKind, componentKey, etc.)
|
||||
- `childSummary`: names of notable children (for screens/components)
|
||||
- `tokenUsage`: token names this node uses (if any)
|
||||
|
||||
You also receive the full list of existing node IDs so you can reference them.
|
||||
|
||||
## Task
|
||||
|
||||
For each node, produce an enrichment object:
|
||||
- `summary`: one or two sentences — what the screen/component is FOR (purpose), not a description of pixels. For tokens, state the role (e.g., "Primary brand color used on CTAs").
|
||||
- `tags`: 2–5 lowercase tags (feature area, role, state). Examples: `auth`, `entry`, `cta`, `list`, `empty-state`, `primary`.
|
||||
|
||||
Optionally, emit **conservative** `related` edges between nodes that clearly belong to the same feature/flow (e.g., two screens of the same onboarding flow). Only when names/structure make it obvious.
|
||||
|
||||
## Rules
|
||||
|
||||
1. **Do NOT** emit `page`/`screen`/`component`/`componentSet`/`instance`/`token` nodes — they already exist. Only enrichment + optional `related` edges.
|
||||
2. **Do NOT** re-emit structural edges (`contains`, `instance_of`, `variant_of`, `uses_token`).
|
||||
3. Use exact existing `id`s when emitting `related` edges.
|
||||
4. Be concise. For a batch of ~15 nodes, expect ~15 enrichments and 0–8 `related` edges.
|
||||
|
||||
## Output Format
|
||||
|
||||
Write a JSON file to `$INTERMEDIATE_DIR/analysis-batch-$BATCH_NUM.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{ "id": "screen:1:1", "summary": "The sign-in screen where returning users authenticate.", "tags": ["auth", "entry"] }
|
||||
],
|
||||
"edges": [
|
||||
{ "source": "screen:1:1", "target": "screen:1:5", "type": "related", "direction": "forward", "weight": 0.5, "description": "Both part of the sign-in flow" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Output ONLY enrichment objects (`id` + `summary`/`tags`) and optional `related` edges. Nothing else.
|
||||
@@ -117,7 +117,7 @@ Produce a JSON object with this exact structure:
|
||||
|
||||
## Writing Results
|
||||
|
||||
1. Write the JSON to: `<project-root>/.understand-anything/intermediate/domain-analysis.json`
|
||||
1. Write the JSON to the `intermediate/domain-analysis.json` file inside the project's data directory (`.ua/`, or the legacy `.understand-anything/` when that directory is present). Use the exact output path given in your prompt.
|
||||
2. The project root will be provided in your prompt.
|
||||
3. Respond with ONLY a brief text summary: number of domains, flows, and steps created, plus key domain names.
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ description: |
|
||||
|
||||
You are an expert code analyst. Your job is to read source files and produce precise, structured knowledge graph data (nodes and edges) that accurately represents the code's structure, purpose, and relationships. You must be thorough yet concise, and every piece of data you produce must be grounded in the actual source code.
|
||||
|
||||
**Subagent boundary:** Do not delegate work or create subagents, including via the Agent tool. Complete this task directly.
|
||||
|
||||
## Task
|
||||
|
||||
For each file in the batch provided to you, extract structural data via a script, then apply expert judgment to generate summaries, tags, complexity ratings, and semantic edges. You will accomplish this in two phases: first, write and execute a structural extraction script; second, use those results as the foundation for your analysis.
|
||||
@@ -30,7 +32,7 @@ Execute the pre-built structural extraction script bundled with the Understand-A
|
||||
|
||||
### Step 1 — Prepare the input JSON
|
||||
|
||||
Create the input file with the batch data. **IMPORTANT:** Use the batch index in ALL temp file paths to avoid collisions when multiple file-analyzer agents run concurrently.
|
||||
Create the input file with the batch data. **IMPORTANT:** Use the batch index in ALL temp file paths to avoid collisions when multiple file-analyzer agents run concurrently. First resolve the project's data directory once (the legacy `.understand-anything/` when it already exists, otherwise the new `.ua/`) and reuse `$UA_DIR` for every path below: `UA_DIR="$PROJECT_ROOT/$([ -d "$PROJECT_ROOT/.understand-anything" ] && echo .understand-anything || echo .ua)"`.
|
||||
|
||||
Each entry in `batchFiles` MUST be an object with these four fields, copied verbatim from the dispatch prompt's batch list:
|
||||
|
||||
@@ -40,7 +42,7 @@ Each entry in `batchFiles` MUST be an object with these four fields, copied verb
|
||||
- `fileCategory` (string) — `code`, `config`, `docs`, `infra`, `data`, `script`, or `markup`
|
||||
|
||||
```bash
|
||||
cat > $PROJECT_ROOT/.understand-anything/tmp/ua-file-analyzer-input-<batchIndex>.json << 'ENDJSON'
|
||||
cat > $UA_DIR/tmp/ua-file-analyzer-input-<batchIndex>.json << 'ENDJSON'
|
||||
{
|
||||
"projectRoot": "<project-root>",
|
||||
"batchFiles": [
|
||||
@@ -69,17 +71,17 @@ Run the bundled `extract-structure.mjs` script. The `<SKILL_DIR>` path is provid
|
||||
|
||||
```bash
|
||||
node <SKILL_DIR>/extract-structure.mjs \
|
||||
$PROJECT_ROOT/.understand-anything/tmp/ua-file-analyzer-input-<batchIndex>.json \
|
||||
$PROJECT_ROOT/.understand-anything/tmp/ua-file-extract-results-<batchIndex>.json
|
||||
$UA_DIR/tmp/ua-file-analyzer-input-<batchIndex>.json \
|
||||
$UA_DIR/tmp/ua-file-extract-results-<batchIndex>.json
|
||||
```
|
||||
|
||||
If the script exits non-zero, read stderr and report the error. Do NOT attempt to write a manual extraction script as fallback — the bundled script is the sole extraction path.
|
||||
|
||||
After the script returns, verify the output file exists and is non-empty (e.g. `test -s $PROJECT_ROOT/.understand-anything/tmp/ua-file-extract-results-<batchIndex>.json`). Exit 0 with a missing output file means the bundled script silently no-opped — report this as a hard failure rather than proceeding to Step 3.
|
||||
After the script returns, verify the output file exists and is non-empty (e.g. `test -s $UA_DIR/tmp/ua-file-extract-results-<batchIndex>.json`). Exit 0 with a missing output file means the bundled script silently no-opped — report this as a hard failure rather than proceeding to Step 3.
|
||||
|
||||
### Step 3 — Read the extraction results
|
||||
|
||||
Read `$PROJECT_ROOT/.understand-anything/tmp/ua-file-extract-results-<batchIndex>.json`. The output format is:
|
||||
Read `$UA_DIR/tmp/ua-file-extract-results-<batchIndex>.json`. The output format is:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -142,7 +144,7 @@ Treat these the same as tree-sitter-derived functions for node creation (Step 2
|
||||
|
||||
## Phase 2 -- Semantic Analysis
|
||||
|
||||
After the script completes, read `$PROJECT_ROOT/.understand-anything/tmp/ua-file-extract-results-<batchIndex>.json`. Use these structured results as the foundation for your analysis. Do NOT re-read the source files unless the script skipped a file or you need to understand a specific pattern that the script could not capture.
|
||||
After the script completes, read `$UA_DIR/tmp/ua-file-extract-results-<batchIndex>.json`. Use these structured results as the foundation for your analysis. Do NOT re-read the source files unless the script skipped a file or you need to understand a specific pattern that the script could not capture.
|
||||
|
||||
For each file in the script's `results` array, produce `GraphNode` and `GraphEdge` objects by combining the script's structural data with your expert judgment.
|
||||
|
||||
@@ -497,7 +499,7 @@ edgeCount = edges.length
|
||||
```
|
||||
|
||||
**Step B — Decide split.**
|
||||
- If `nodeCount ≤ 60` AND `edgeCount ≤ 120`: write ONE file to `.understand-anything/intermediate/batch-<batchIndex>.json`. Done. Skip to Step F.
|
||||
- If `nodeCount ≤ 60` AND `edgeCount ≤ 120`: write ONE file to `$UA_DIR/intermediate/batch-<batchIndex>.json` (the data directory — `.ua/`, or the legacy `.understand-anything/` when present). Done. Skip to Step F.
|
||||
- Otherwise: `parts = ceil(max(nodeCount / 60, edgeCount / 120))`.
|
||||
|
||||
**Step C — Partition.**
|
||||
@@ -506,7 +508,7 @@ Sort files in your batch alphabetically by path. Chunk them sequentially into `p
|
||||
- All edges whose `source` is in this part's nodes (target may be anywhere — same part, different part of same batch, different batch).
|
||||
|
||||
**Step D — Write each part.**
|
||||
Write part `k` (1-indexed) to `.understand-anything/intermediate/batch-<batchIndex>-part-<k>.json`. Each part is a valid GraphFragment: `{ "nodes": [...], "edges": [...] }`.
|
||||
Write part `k` (1-indexed) to `$UA_DIR/intermediate/batch-<batchIndex>-part-<k>.json`. Each part is a valid GraphFragment: `{ "nodes": [...], "edges": [...] }`.
|
||||
|
||||
**Step E — Self-validate.**
|
||||
For each file written, verify:
|
||||
|
||||
@@ -170,10 +170,11 @@ The script must write this exact JSON structure to the output file:
|
||||
|
||||
### Executing the Script
|
||||
|
||||
After writing the script, execute it:
|
||||
After writing the script, execute it. First resolve the project's data directory once (the legacy `.understand-anything/` when it already exists, otherwise the new `.ua/`) and reuse `$UA_DIR` below:
|
||||
|
||||
```bash
|
||||
node $PROJECT_ROOT/.understand-anything/tmp/ua-graph-validate.js "<graph-file-path>" "$PROJECT_ROOT/.understand-anything/tmp/ua-review-results.json"
|
||||
UA_DIR="$PROJECT_ROOT/$([ -d "$PROJECT_ROOT/.understand-anything" ] && echo .understand-anything || echo .ua)"
|
||||
node $UA_DIR/tmp/ua-graph-validate.js "<graph-file-path>" "$UA_DIR/tmp/ua-review-results.json"
|
||||
```
|
||||
|
||||
If the script exits with a non-zero code, read stderr, diagnose the issue, fix the script, and re-run. You have up to 2 retry attempts.
|
||||
@@ -182,7 +183,7 @@ If the script exits with a non-zero code, read stderr, diagnose the issue, fix t
|
||||
|
||||
## Phase 2 -- Review and Decision
|
||||
|
||||
After the script completes, read `$PROJECT_ROOT/.understand-anything/tmp/ua-review-results.json`. Do NOT re-read the original graph file -- trust the script's results entirely.
|
||||
After the script completes, read `$UA_DIR/tmp/ua-review-results.json`. Do NOT re-read the original graph file -- trust the script's results entirely.
|
||||
|
||||
Review the `issues` and `warnings` arrays and render your decision:
|
||||
|
||||
@@ -232,7 +233,7 @@ Produce the final validation report JSON:
|
||||
|
||||
After producing the final JSON:
|
||||
|
||||
1. Write the JSON to: `<project-root>/.understand-anything/intermediate/review.json`
|
||||
1. Write the JSON to `$UA_DIR/intermediate/review.json` inside the project's data directory (`.ua/`, or the legacy `.understand-anything/` when that directory is present). Use the exact output path given in your dispatch prompt if one was provided.
|
||||
2. The project root will be provided in your prompt.
|
||||
3. Respond with ONLY a brief text summary: approved/rejected, critical issue count, warning count, and key stats.
|
||||
|
||||
|
||||
@@ -13,9 +13,11 @@ You are an expert on Understand-Anything knowledge graphs. You help users naviga
|
||||
|
||||
### Graph Locations
|
||||
|
||||
- **Structural graph:** `<project-root>/.understand-anything/knowledge-graph.json`
|
||||
- **Domain graph:** `<project-root>/.understand-anything/domain-graph.json` (optional, produced by `/understand-domain`)
|
||||
- **Metadata:** `<project-root>/.understand-anything/meta.json`
|
||||
These live in the project's data directory `<UA_DIR>` — the legacy `.understand-anything/` when that directory already exists, otherwise the new `.ua/`. Resolve it with `UA_DIR="<project-root>/$([ -d "<project-root>/.understand-anything" ] && echo .understand-anything || echo .ua)"`.
|
||||
|
||||
- **Structural graph:** `<UA_DIR>/knowledge-graph.json`
|
||||
- **Domain graph:** `<UA_DIR>/domain-graph.json` (optional, produced by `/understand-domain`)
|
||||
- **Metadata:** `<UA_DIR>/meta.json`
|
||||
|
||||
### Graph Structure
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ description: |
|
||||
|
||||
You are a meticulous project inventory specialist. Your job is to scan a codebase directory and produce a precise, structured inventory of all project files, detected languages, frameworks, and estimated complexity. Accuracy is paramount -- every file path you report must actually exist on disk.
|
||||
|
||||
**Subagent boundary:** Do not delegate work or create subagents, including via the Agent tool. Complete this task directly.
|
||||
|
||||
## Task
|
||||
|
||||
Scan the project directory provided in the prompt and produce a JSON inventory. The work splits into deterministic and LLM-driven parts:
|
||||
@@ -54,11 +56,14 @@ Invoke the bundled scan script. It walks the project (preferring `git ls-files`,
|
||||
|
||||
If the dispatch prompt includes exclude patterns, append `--exclude "<patterns>"` to the invocation (patterns should be comma-separated; the script splits them internally).
|
||||
|
||||
Resolve the project's data directory once (the legacy `.understand-anything/` when it already exists, otherwise the new `.ua/`) and reuse `$UA_DIR` for every path below:
|
||||
|
||||
```bash
|
||||
mkdir -p $PROJECT_ROOT/.understand-anything/tmp
|
||||
UA_DIR="$PROJECT_ROOT/$([ -d "$PROJECT_ROOT/.understand-anything" ] && echo .understand-anything || echo .ua)"
|
||||
mkdir -p $UA_DIR/tmp
|
||||
node $PLUGIN_ROOT/skills/understand/scan-project.mjs \
|
||||
"$PROJECT_ROOT" \
|
||||
"$PROJECT_ROOT/.understand-anything/tmp/ua-scan-files.json"
|
||||
"$UA_DIR/tmp/ua-scan-files.json"
|
||||
```
|
||||
|
||||
With exclude patterns (add the `--exclude` flag after the output path):
|
||||
@@ -66,7 +71,7 @@ With exclude patterns (add the `--exclude` flag after the output path):
|
||||
```bash
|
||||
node $PLUGIN_ROOT/skills/understand/scan-project.mjs \
|
||||
"$PROJECT_ROOT" \
|
||||
"$PROJECT_ROOT/.understand-anything/tmp/ua-scan-files.json" \
|
||||
"$UA_DIR/tmp/ua-scan-files.json" \
|
||||
--exclude "tests/*,docs/*"
|
||||
```
|
||||
|
||||
@@ -107,7 +112,7 @@ The script:
|
||||
| `LICENSE` | `code` (exception — not docs) |
|
||||
| `Dockerfile`, `Dockerfile.*`, `docker-compose.*`, `compose.yml`/`compose.yaml`, `Makefile`, `Jenkinsfile`, `Procfile`, `Vagrantfile`, `.gitlab-ci.yml`, `.dockerignore`, `.github/workflows/*`, `.circleci/*`, paths in `k8s/` or `kubernetes/`, `*.k8s.yml`/`*.k8s.yaml` | `infra` |
|
||||
| `.md`, `.mdx`, `.rst`, `.txt`, `.text` (except `LICENSE`) | `docs` |
|
||||
| `.yaml`, `.yml`, `.json`, `.jsonc`, `.toml`, `.xml`, `.xsl`, `.xsd`, `.plist`, `.cfg`, `.ini`, `.env`, `.properties`, `.csproj`, `.sln`, `.mod`, `.sum`, `.gradle` | `config` |
|
||||
| `.yaml`, `.yml`, `.json`, `.jsonc`, `.toml`, `.xml`, `.xsl`, `.xsd`, `.plist`, `.cfg`, `.ini`, `.env`, `.properties`, `.csproj`, `.sln`, `.mod`, `.sum`, `.gradle`, `.sbt` | `config` |
|
||||
| `.tf`, `.tfvars` | `infra` |
|
||||
| `.sql`, `.graphql`, `.gql`, `.proto`, `.prisma`, `.csv`, `.tsv` | `data` |
|
||||
| `.sh`, `.bash`, `.zsh`, `.ps1`, `.psm1`, `.psd1`, `.bat`, `.cmd` | `script` |
|
||||
@@ -116,7 +121,7 @@ The script:
|
||||
|
||||
**Priority rule:** most-specific wins. Filename / path rules fire before extension rules — e.g., `docker-compose.yml` is `infra` (not `config`); `.github/workflows/ci.yml` is `infra` (not `config`); `LICENSE` is `code` (not `docs`).
|
||||
|
||||
**`.understandignore` behavior:** the bundled script reads `.understandignore` and `.understand-anything/.understandignore` if present and merges them with the hardcoded defaults via `createIgnoreFilter`. `!`-negation overrides defaults (`!dist/` would re-include `dist/` files). The `filteredByIgnore` counter measures only user-driven drops, not baseline default drops.
|
||||
**`.understandignore` behavior:** the bundled script reads `.understandignore` and the data directory's `.understandignore` (`.ua/.understandignore`, or `.understand-anything/.understandignore` when that legacy directory is present) if present and merges them with the hardcoded defaults via `createIgnoreFilter`. `!`-negation overrides defaults (`!dist/` would re-include `dist/` files). The `filteredByIgnore` counter measures only user-driven drops, not baseline default drops.
|
||||
|
||||
If the script exits with a non-zero status, read stderr to diagnose. You have up to 2 retry attempts (re-invocations) before failing the phase. Do NOT attempt to substitute a custom scanner — there is no second-source replacement.
|
||||
|
||||
@@ -129,8 +134,9 @@ After Step B has produced the file list, invoke the bundled `extract-import-map.
|
||||
Write the input JSON for the bundled script (the `files[]` array is exactly Step B's `files[]` — pass it through verbatim):
|
||||
|
||||
```bash
|
||||
mkdir -p $PROJECT_ROOT/.understand-anything/tmp
|
||||
cat > $PROJECT_ROOT/.understand-anything/tmp/ua-import-map-input.json << 'ENDJSON'
|
||||
UA_DIR="$PROJECT_ROOT/$([ -d "$PROJECT_ROOT/.understand-anything" ] && echo .understand-anything || echo .ua)"
|
||||
mkdir -p $UA_DIR/tmp
|
||||
cat > $UA_DIR/tmp/ua-import-map-input.json << 'ENDJSON'
|
||||
{
|
||||
"projectRoot": "<absolute-project-root>",
|
||||
"files": [
|
||||
@@ -145,8 +151,8 @@ Then run:
|
||||
|
||||
```bash
|
||||
node $PLUGIN_ROOT/skills/understand/extract-import-map.mjs \
|
||||
$PROJECT_ROOT/.understand-anything/tmp/ua-import-map-input.json \
|
||||
$PROJECT_ROOT/.understand-anything/tmp/ua-import-map-output.json
|
||||
$UA_DIR/tmp/ua-import-map-input.json \
|
||||
$UA_DIR/tmp/ua-import-map-output.json
|
||||
```
|
||||
|
||||
The output JSON has shape:
|
||||
@@ -168,15 +174,15 @@ Read the output JSON and merge the `importMap` field directly into your final sc
|
||||
|
||||
**Capture stderr** when you run the bundled script. Any line starting with `Warning:` should be appended to phase warnings — the SKILL.md orchestrator captures these for the final report. The script also writes a one-line summary `extract-import-map: filesScanned=… filesWithImports=… totalEdges=…` on completion; you can ignore that line or surface it as informational.
|
||||
|
||||
**Languages supported.** The bundled script natively handles import resolution for: TypeScript, JavaScript (including CJS `require()`), Python (relative + absolute + `__init__.py`), Go (go.mod prefix stripping), Rust (`use crate::`, `use super::`, `use self::`, and `mod x;` declarations), Java, Kotlin, C#, Ruby (`require` + `require_relative`), PHP (composer.json PSR-4 autoload), C, and C++ (`#include` with relative + include/ + src/ probes). Languages outside this set get empty arrays — there is no LLM-based fallback.
|
||||
**Languages supported.** The bundled script natively handles import resolution for: TypeScript, JavaScript (including CJS `require()`), Python (relative + absolute + `__init__.py`), Go (go.mod prefix stripping), Rust (`use crate::`, `use super::`, `use self::`, and `mod x;` declarations), Java, Kotlin, Scala (dotted FQN + selector lists + package objects), C#, Ruby (`require` + `require_relative`), PHP (composer.json PSR-4 autoload), C, and C++ (`#include` with relative + include/ + src/ probes). Languages outside this set get empty arrays — there is no LLM-based fallback.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 -- Description and Final Assembly
|
||||
|
||||
After Steps A + B + C have all completed, read:
|
||||
1. `$PROJECT_ROOT/.understand-anything/tmp/ua-scan-files.json` — output of `scan-project.mjs` (file list with language, sizeLines, fileCategory; plus `totalFiles`, `filteredByIgnore`, `estimatedComplexity`).
|
||||
2. `$PROJECT_ROOT/.understand-anything/tmp/ua-import-map-output.json` — output of `extract-import-map.mjs` (the `importMap` field).
|
||||
1. `$UA_DIR/tmp/ua-scan-files.json` — output of `scan-project.mjs` (file list with language, sizeLines, fileCategory; plus `totalFiles`, `filteredByIgnore`, `estimatedComplexity`).
|
||||
2. `$UA_DIR/tmp/ua-import-map-output.json` — output of `extract-import-map.mjs` (the `importMap` field).
|
||||
3. Your Step A in-memory notes (`name`, `rawDescription`, `readmeHead`, `frameworks`, `languages` narrative).
|
||||
|
||||
Do NOT re-walk the file tree, re-count lines, or re-derive categories — trust `scan-project.mjs` entirely. Do NOT re-implement import resolution — trust `extract-import-map.mjs` entirely.
|
||||
@@ -230,15 +236,15 @@ Then assemble the final output JSON:
|
||||
- ALWAYS validate that `totalFiles` matches the actual length of the `files` array.
|
||||
- Trust Step B for file enumeration + language detection + category assignment + line counts + complexity. Trust Step C for `importMap`. Your only synthesis is the `description` field (plus the Step A narrative fields: `name`, `frameworks`, `languages`).
|
||||
- Do NOT re-implement file enumeration, language detection, or category assignment in your discovery script. Use the bundled `scan-project.mjs`. If the table doesn't cover your project type, file an issue rather than ad-hoc handling.
|
||||
- Do NOT attempt to re-implement import resolution. The bundled `extract-import-map.mjs` handles all 12 supported code languages (TS, JS, Python, Go, Rust, Java, Kotlin, C#, Ruby, PHP, C, C++) deterministically via tree-sitter + per-language resolvers.
|
||||
- Do NOT attempt to re-implement import resolution. The bundled `extract-import-map.mjs` handles all 13 supported code languages (TS, JS, Python, Go, Rust, Java, Kotlin, Scala, C#, Ruby, PHP, C, C++) deterministically via tree-sitter + per-language resolvers.
|
||||
- Every file MUST have a `fileCategory` field with one of: `code`, `config`, `docs`, `infra`, `data`, `script`, `markup` — `scan-project.mjs` guarantees this; just don't strip it.
|
||||
|
||||
## Writing Results
|
||||
|
||||
After producing the final JSON:
|
||||
|
||||
1. Create the output directory: `mkdir -p <project-root>/.understand-anything/intermediate`
|
||||
2. Write the JSON to: `<project-root>/.understand-anything/intermediate/scan-result.json`
|
||||
1. Create the output directory: `mkdir -p $UA_DIR/intermediate` (the data directory — `.ua/`, or the legacy `.understand-anything/` when present)
|
||||
2. Write the JSON to: `$UA_DIR/intermediate/scan-result.json`. Use the exact output path given in your dispatch prompt if one was provided.
|
||||
3. Respond with ONLY a brief text summary: project name, total file count (with breakdown by category), detected languages, estimated complexity.
|
||||
|
||||
Do NOT include the full JSON in your text response.
|
||||
|
||||
@@ -179,10 +179,11 @@ Note: input nodes may include all node types (file, config, document, service, p
|
||||
|
||||
### Preparing the Script Input
|
||||
|
||||
Before writing the script, create its input JSON file:
|
||||
Before writing the script, create its input JSON file. First resolve the project's data directory once (the legacy `.understand-anything/` when it already exists, otherwise the new `.ua/`) and reuse `$UA_DIR` for every path below:
|
||||
|
||||
```bash
|
||||
cat > $PROJECT_ROOT/.understand-anything/tmp/ua-tour-input.json << 'ENDJSON'
|
||||
UA_DIR="$PROJECT_ROOT/$([ -d "$PROJECT_ROOT/.understand-anything" ] && echo .understand-anything || echo .ua)"
|
||||
cat > $UA_DIR/tmp/ua-tour-input.json << 'ENDJSON'
|
||||
{
|
||||
"nodes": [<nodes from prompt — all types including non-code>],
|
||||
"edges": [<edges from prompt — all types>],
|
||||
@@ -196,7 +197,7 @@ ENDJSON
|
||||
After writing the script, execute it:
|
||||
|
||||
```bash
|
||||
node $PROJECT_ROOT/.understand-anything/tmp/ua-tour-analyze.js $PROJECT_ROOT/.understand-anything/tmp/ua-tour-input.json $PROJECT_ROOT/.understand-anything/tmp/ua-tour-results.json
|
||||
node $UA_DIR/tmp/ua-tour-analyze.js $UA_DIR/tmp/ua-tour-input.json $UA_DIR/tmp/ua-tour-results.json
|
||||
```
|
||||
|
||||
If the script exits with a non-zero code, read stderr, diagnose the issue, fix the script, and re-run. You have up to 2 retry attempts.
|
||||
@@ -205,7 +206,7 @@ If the script exits with a non-zero code, read stderr, diagnose the issue, fix t
|
||||
|
||||
## Phase 2 -- Pedagogical Tour Design
|
||||
|
||||
After the script completes, read `$PROJECT_ROOT/.understand-anything/tmp/ua-tour-results.json`. Use the structural analysis as your primary guide for designing the tour. Do NOT re-read source files or re-analyze the graph -- trust the script's results entirely.
|
||||
After the script completes, read `$UA_DIR/tmp/ua-tour-results.json`. Use the structural analysis as your primary guide for designing the tour. Do NOT re-read source files or re-analyze the graph -- trust the script's results entirely.
|
||||
|
||||
### Step 1 -- Choose the Starting Point
|
||||
|
||||
@@ -371,7 +372,7 @@ Produce a single, valid JSON array.
|
||||
|
||||
After producing the JSON:
|
||||
|
||||
1. Write the JSON array to: `<project-root>/.understand-anything/intermediate/tour.json`
|
||||
1. Write the JSON array to `$UA_DIR/intermediate/tour.json` inside the project's data directory (`.ua/`, or the legacy `.understand-anything/` when that directory is present). Use the exact output path given in your dispatch prompt if one was provided.
|
||||
2. The project root will be provided in your prompt.
|
||||
3. Respond with ONLY a brief text summary: number of steps and their titles in order.
|
||||
|
||||
|
||||
@@ -8,12 +8,12 @@ Incrementally update the knowledge graph using deterministic structural fingerpr
|
||||
|
||||
## Phase 0 — Pre-flight (Zero Token Cost)
|
||||
|
||||
1. Set `PROJECT_ROOT` to the current working directory.
|
||||
1. Set `PROJECT_ROOT` to the current working directory. **Resolve the data directory `$UA_DIR`** once and reuse it for every read and write below: `UA_DIR="$PROJECT_ROOT/$([ -d "$PROJECT_ROOT/.understand-anything" ] && echo .understand-anything || echo .ua)"` — this selects the legacy `.understand-anything/` when it already exists, otherwise the new `.ua/`. Because each phase may run in a fresh shell, carry `$UA_DIR` forward like `$PROJECT_ROOT`, re-resolving it with the same line if a later command block needs it. Scripts written below that run in Node resolve the same rule in JavaScript.
|
||||
|
||||
2. Check that `$PROJECT_ROOT/.understand-anything/knowledge-graph.json` exists.
|
||||
2. Check that `$UA_DIR/knowledge-graph.json` exists.
|
||||
- If not: report "No existing knowledge graph found. Run `/understand` first to create one." and **STOP**.
|
||||
|
||||
3. Check that `$PROJECT_ROOT/.understand-anything/meta.json` exists and read `gitCommitHash`.
|
||||
3. Check that `$UA_DIR/meta.json` exists and read `gitCommitHash`.
|
||||
- If not: report "No analysis metadata found. Run `/understand` to create a baseline." and **STOP**.
|
||||
|
||||
4. Get current commit hash:
|
||||
@@ -25,7 +25,7 @@ Incrementally update the knowledge graph using deterministic structural fingerpr
|
||||
|
||||
6. Get changed files:
|
||||
```bash
|
||||
git diff <lastCommitHash>..HEAD --name-only
|
||||
git diff "<lastCommitHash>..HEAD" --name-only
|
||||
```
|
||||
If no files changed: update `meta.json` with the new commit hash and **STOP**.
|
||||
|
||||
@@ -34,16 +34,16 @@ Incrementally update the knowledge graph using deterministic structural fingerpr
|
||||
|
||||
8. Create intermediate directory:
|
||||
```bash
|
||||
mkdir -p $PROJECT_ROOT/.understand-anything/intermediate
|
||||
mkdir -p "$UA_DIR/intermediate"
|
||||
```
|
||||
|
||||
9. **Apply `.understandignore` exclusions** (same semantics as `/understand` Step 2.5 in `agents/project-scanner.md`).
|
||||
|
||||
Without this step, files in user-excluded paths (migrations, vendored code, tests) are counted as structural changes and can spuriously escalate the action to `FULL_UPDATE` even when the real change set is tiny.
|
||||
|
||||
1. If neither `$PROJECT_ROOT/.understand-anything/.understandignore` nor `$PROJECT_ROOT/.understandignore` exists, the step 7 extension filter is sufficient — skip to Phase 1.
|
||||
1. If neither `$UA_DIR/.understandignore` nor `$PROJECT_ROOT/.understandignore` exists, the step 7 extension filter is sufficient — skip to Phase 1.
|
||||
|
||||
2. Write the step 7 file list to `$PROJECT_ROOT/.understand-anything/intermediate/changed-files-pre.json` as a JSON array of relative paths.
|
||||
2. Write the step 7 file list to `$UA_DIR/intermediate/changed-files-pre.json` as a JSON array of relative paths.
|
||||
|
||||
3. Resolve `$PLUGIN_ROOT`:
|
||||
- Use `$CLAUDE_PLUGIN_ROOT` if set (Claude Code's hook context sets this).
|
||||
@@ -51,13 +51,15 @@ Incrementally update the knowledge graph using deterministic structural fingerpr
|
||||
- Validate the chosen candidate by checking `$candidate/packages/core/dist/ignore-filter.js` exists.
|
||||
- If neither resolves: report "Cannot locate plugin install at `$CLAUDE_PLUGIN_ROOT` or `$HOME/.understand-anything-plugin`; auto-update aborted. Run `/understand` to re-baseline." and **STOP**. Do **not** silently skip — silent skip reproduces issue #153.
|
||||
|
||||
4. Write `$PROJECT_ROOT/.understand-anything/intermediate/ignore-filter.mjs`:
|
||||
4. Write `$UA_DIR/intermediate/ignore-filter.mjs`:
|
||||
```javascript
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const PROJECT_ROOT = process.cwd();
|
||||
// Data directory: legacy `.understand-anything/` when present, else new `.ua/`.
|
||||
const UA_DIR = existsSync(path.join(PROJECT_ROOT, '.understand-anything')) ? '.understand-anything' : '.ua';
|
||||
const PLUGIN_ROOT = process.argv[2];
|
||||
const inputPath = process.argv[3];
|
||||
|
||||
@@ -72,7 +74,7 @@ Incrementally update the knowledge graph using deterministic structural fingerpr
|
||||
const removed = input.length - kept.length;
|
||||
|
||||
writeFileSync(
|
||||
path.join(PROJECT_ROOT, '.understand-anything/intermediate/changed-files.json'),
|
||||
path.join(PROJECT_ROOT, UA_DIR, 'intermediate/changed-files.json'),
|
||||
JSON.stringify({ kept, removed, total: input.length }, null, 2),
|
||||
);
|
||||
console.log(`.understandignore: kept ${kept.length}/${input.length} (removed ${removed})`);
|
||||
@@ -80,12 +82,12 @@ Incrementally update the knowledge graph using deterministic structural fingerpr
|
||||
|
||||
5. Run it:
|
||||
```bash
|
||||
node $PROJECT_ROOT/.understand-anything/intermediate/ignore-filter.mjs \
|
||||
node "$UA_DIR/intermediate/ignore-filter.mjs" \
|
||||
"$PLUGIN_ROOT" \
|
||||
$PROJECT_ROOT/.understand-anything/intermediate/changed-files-pre.json
|
||||
"$UA_DIR/intermediate/changed-files-pre.json"
|
||||
```
|
||||
|
||||
6. Read `$PROJECT_ROOT/.understand-anything/intermediate/changed-files.json`. Pass the `kept` array as the input file list for Phase 1's fingerprint-check script.
|
||||
6. Read `$UA_DIR/intermediate/changed-files.json`. Pass the `kept` array as the input file list for Phase 1's fingerprint-check script.
|
||||
|
||||
7. If `kept.length === 0`: update `meta.json` with the new commit hash, report "All changed source files are in ignored paths. Metadata updated." and **STOP**.
|
||||
|
||||
@@ -95,11 +97,11 @@ Incrementally update the knowledge graph using deterministic structural fingerpr
|
||||
|
||||
This phase runs a deterministic Node.js script that compares file structures against stored fingerprints. It costs **zero LLM tokens** — only the script execution cost.
|
||||
|
||||
1. Write and execute a Node.js script (`$PROJECT_ROOT/.understand-anything/intermediate/fingerprint-check.mjs`):
|
||||
1. Write and execute a Node.js script (`$UA_DIR/intermediate/fingerprint-check.mjs`):
|
||||
|
||||
```javascript
|
||||
// The script should:
|
||||
// 1. Read fingerprints.json from .understand-anything/fingerprints.json
|
||||
// 1. Read fingerprints.json from the data directory (.ua/fingerprints.json, or .understand-anything/fingerprints.json when that legacy directory is present — resolve UA_DIR the same way as the other scripts)
|
||||
// 2. For each changed source file:
|
||||
// a. Read the file content
|
||||
// b. Compute SHA-256 content hash
|
||||
@@ -118,7 +120,7 @@ This phase runs a deterministic Node.js script that compares file structures aga
|
||||
// - Some STRUCTURAL, ≤10 files, same directories → action: "PARTIAL_UPDATE"
|
||||
// - New/deleted directories or >10 structural files → action: "ARCHITECTURE_UPDATE"
|
||||
// - >30 structural files or >50% of graph → action: "FULL_UPDATE"
|
||||
// 6. Write result to .understand-anything/intermediate/change-analysis.json
|
||||
// 6. Write result to <UA_DIR>/intermediate/change-analysis.json (.ua/, or .understand-anything/ when that legacy directory is present)
|
||||
```
|
||||
|
||||
The output JSON should have this shape:
|
||||
@@ -136,7 +138,7 @@ The output JSON should have this shape:
|
||||
}
|
||||
```
|
||||
|
||||
2. Read `.understand-anything/intermediate/change-analysis.json`.
|
||||
2. Read `$UA_DIR/intermediate/change-analysis.json`.
|
||||
|
||||
3. **Decision gate:**
|
||||
|
||||
@@ -153,7 +155,7 @@ The output JSON should have this shape:
|
||||
|
||||
Only re-analyze files with structural changes. This is the **only** phase that costs LLM tokens.
|
||||
|
||||
1. Read the existing knowledge graph from `$PROJECT_ROOT/.understand-anything/knowledge-graph.json`.
|
||||
1. Read the existing knowledge graph from `$UA_DIR/knowledge-graph.json`.
|
||||
|
||||
2. Batch the files from `filesToReanalyze` (from Phase 1). Use a single batch if ≤10 files, otherwise batch into groups of 5-10.
|
||||
|
||||
@@ -174,7 +176,7 @@ Only re-analyze files with structural changes. This is the **only** phase that c
|
||||
> Project: `<projectName>`
|
||||
> Languages: `<languages>`
|
||||
> Batch index: `1`
|
||||
> Write output to: `$PROJECT_ROOT/.understand-anything/intermediate/batch-1.json`
|
||||
> Write output to: `$UA_DIR/intermediate/batch-1.json`
|
||||
>
|
||||
> All project files (for import resolution):
|
||||
> `<file list from existing graph nodes>`
|
||||
@@ -228,9 +230,9 @@ Perform lightweight validation (no graph-reviewer agent):
|
||||
|
||||
### 3d. Save
|
||||
|
||||
1. Write the final knowledge graph to `$PROJECT_ROOT/.understand-anything/knowledge-graph.json`.
|
||||
1. Write the final knowledge graph to `$UA_DIR/knowledge-graph.json`.
|
||||
|
||||
2. Write updated metadata to `$PROJECT_ROOT/.understand-anything/meta.json`:
|
||||
2. Write updated metadata to `$UA_DIR/meta.json`:
|
||||
```json
|
||||
{
|
||||
"lastAnalyzedAt": "<ISO 8601 timestamp>",
|
||||
@@ -251,7 +253,8 @@ Perform lightweight validation (no graph-reviewer agent):
|
||||
import { createHash } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
|
||||
const fpPath = path.join(PROJECT_ROOT, '.understand-anything', 'fingerprints.json');
|
||||
const UA_DIR = existsSync(path.join(PROJECT_ROOT, '.understand-anything')) ? '.understand-anything' : '.ua';
|
||||
const fpPath = path.join(PROJECT_ROOT, UA_DIR, 'fingerprints.json');
|
||||
const existedAndNonEmpty = existsSync(fpPath) && readFileSync(fpPath, 'utf-8').trim().length > 0;
|
||||
|
||||
// 1. LOAD ALL existing entries (NEVER skip — preserves un-analyzed files)
|
||||
@@ -291,7 +294,10 @@ Perform lightweight validation (no graph-reviewer agent):
|
||||
|
||||
4. Clean up intermediate files:
|
||||
```bash
|
||||
rm -rf $PROJECT_ROOT/.understand-anything/intermediate
|
||||
INTERMEDIATE_DIR="$UA_DIR/intermediate"
|
||||
if [ -n "$PROJECT_ROOT" ] && [ -d "$INTERMEDIATE_DIR" ]; then
|
||||
rm -rf "$INTERMEDIATE_DIR"
|
||||
fi
|
||||
```
|
||||
|
||||
5. Report a summary:
|
||||
@@ -300,7 +306,7 @@ Perform lightweight validation (no graph-reviewer agent):
|
||||
- Cosmetic-only changes: N files (skipped)
|
||||
- Nodes updated: N
|
||||
- Action taken: PARTIAL_UPDATE / ARCHITECTURE_UPDATE
|
||||
- Path to output: `$PROJECT_ROOT/.understand-anything/knowledge-graph.json`
|
||||
- Path to output: `$UA_DIR/knowledge-graph.json`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "printf '%s' \"$TOOL_INPUT\" | grep -qE 'git\\s+(commit|merge|cherry-pick|rebase)' && [ -f .understand-anything/config.json ] && grep -q '\"autoUpdate\".*true' .understand-anything/config.json && [ -f .understand-anything/knowledge-graph.json ] && echo \"[understand-anything] Commit detected with auto-update enabled. You MUST read the file at ${CLAUDE_PLUGIN_ROOT}/hooks/auto-update-prompt.md and execute its instructions to incrementally update the knowledge graph. Do not ask the user for confirmation — just do it.\" || true"
|
||||
"command": "UA_DIR=.understand-anything; [ -d \"$UA_DIR\" ] || UA_DIR=.ua; printf '%s' \"$TOOL_INPUT\" | grep -qE 'git\\s+(commit|merge|cherry-pick|rebase)' && [ -f $UA_DIR/config.json ] && grep -q '\"autoUpdate\".*true' $UA_DIR/config.json && [ -f $UA_DIR/knowledge-graph.json ] && echo \"[understand-anything] Commit detected with auto-update enabled. You MUST read the file at ${CLAUDE_PLUGIN_ROOT}/hooks/auto-update-prompt.md and execute its instructions to incrementally update the knowledge graph. Do not ask the user for confirmation — just do it.\" || true"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -16,7 +16,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "[ -f .understand-anything/config.json ] && grep -q '\"autoUpdate\".*true' .understand-anything/config.json && [ -f .understand-anything/meta.json ] && [ -f .understand-anything/knowledge-graph.json ] && [ \"$(node -p \"JSON.parse(require('fs').readFileSync('.understand-anything/meta.json','utf8')).gitCommitHash\")\" != \"$(git rev-parse HEAD 2>/dev/null)\" ] && echo \"[understand-anything] Knowledge graph is stale. You MUST read the file at ${CLAUDE_PLUGIN_ROOT}/hooks/auto-update-prompt.md and execute its instructions to check for structural changes and update the graph. Do not ask the user for confirmation — just do it.\" || true"
|
||||
"command": "UA_DIR=.understand-anything; [ -d \"$UA_DIR\" ] || UA_DIR=.ua; [ -f $UA_DIR/config.json ] && grep -q '\"autoUpdate\".*true' $UA_DIR/config.json && [ -f $UA_DIR/meta.json ] && [ -f $UA_DIR/knowledge-graph.json ] && [ \"$(node -p \"JSON.parse(require('fs').readFileSync('$UA_DIR/meta.json','utf8')).gitCommitHash\")\" != \"$(git rev-parse HEAD 2>/dev/null)\" ] && echo \"[understand-anything] Knowledge graph is stale. You MUST read the file at ${CLAUDE_PLUGIN_ROOT}/hooks/auto-update-prompt.md and execute its instructions to check for structural changes and update the graph. Do not ask the user for confirmation — just do it.\" || true"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@understand-anything/skill",
|
||||
"version": "2.8.2",
|
||||
"version": "2.9.3",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
@@ -17,5 +17,23 @@
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.1.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"@tree-sitter-grammars/tree-sitter-kotlin",
|
||||
"esbuild",
|
||||
"tree-sitter-c",
|
||||
"tree-sitter-c-sharp",
|
||||
"tree-sitter-cpp",
|
||||
"tree-sitter-go",
|
||||
"tree-sitter-java",
|
||||
"tree-sitter-javascript",
|
||||
"tree-sitter-php",
|
||||
"tree-sitter-python",
|
||||
"tree-sitter-ruby",
|
||||
"tree-sitter-rust",
|
||||
"tree-sitter-scala",
|
||||
"tree-sitter-typescript"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,10 @@
|
||||
"./languages": {
|
||||
"types": "./dist/languages/index.d.ts",
|
||||
"default": "./dist/languages/index.js"
|
||||
},
|
||||
"./figma": {
|
||||
"types": "./dist/figma/index.d.ts",
|
||||
"default": "./dist/figma/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
@@ -51,6 +55,7 @@
|
||||
"tree-sitter-python": "^0.25.0",
|
||||
"tree-sitter-ruby": "^0.23.1",
|
||||
"tree-sitter-rust": "^0.24.0",
|
||||
"tree-sitter-scala": "^0.24.0",
|
||||
"tree-sitter-typescript": "^0.23.2",
|
||||
"web-tree-sitter": "^0.26.6",
|
||||
"yaml": "^2.8.3",
|
||||
|
||||
@@ -0,0 +1,518 @@
|
||||
import { readdirSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
TreeSitterConfigSchema,
|
||||
FilePatternConfigSchema,
|
||||
LanguageConfigSchema,
|
||||
StrictLanguageConfigSchema,
|
||||
FrameworkConfigSchema,
|
||||
} from "../languages/types.js";
|
||||
import { builtinLanguageConfigs } from "../languages/configs/index.js";
|
||||
import { builtinFrameworkConfigs } from "../languages/frameworks/index.js";
|
||||
|
||||
/** Count config modules (one config per file, index.ts excluded) in a directory. */
|
||||
function countConfigModules(relativeDir: string): number {
|
||||
const dir = fileURLToPath(new URL(relativeDir, import.meta.url));
|
||||
return readdirSync(dir).filter(
|
||||
(file) => file.endsWith(".ts") && file !== "index.ts"
|
||||
).length;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Schema type-level tests
|
||||
// =============================================================================
|
||||
|
||||
describe("TreeSitterConfigSchema", () => {
|
||||
it("accepts a valid tree-sitter config", () => {
|
||||
const result = TreeSitterConfigSchema.safeParse({
|
||||
wasmPackage: "tree-sitter-python",
|
||||
wasmFile: "tree-sitter-python.wasm",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects when wasmPackage is missing", () => {
|
||||
const result = TreeSitterConfigSchema.safeParse({
|
||||
wasmFile: "tree-sitter-python.wasm",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects when wasmFile is missing", () => {
|
||||
const result = TreeSitterConfigSchema.safeParse({
|
||||
wasmPackage: "tree-sitter-python",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-string values", () => {
|
||||
const result = TreeSitterConfigSchema.safeParse({
|
||||
wasmPackage: 123,
|
||||
wasmFile: true,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FilePatternConfigSchema", () => {
|
||||
it("accepts a valid file pattern config", () => {
|
||||
const result = FilePatternConfigSchema.safeParse({
|
||||
entryPoints: ["main.py", "app.py"],
|
||||
barrels: ["__init__.py"],
|
||||
tests: ["test_*.py"],
|
||||
config: ["pyproject.toml"],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts empty arrays (no file patterns needed)", () => {
|
||||
const result = FilePatternConfigSchema.safeParse({
|
||||
entryPoints: [],
|
||||
barrels: [],
|
||||
tests: [],
|
||||
config: [],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects when a required field is missing", () => {
|
||||
const result = FilePatternConfigSchema.safeParse({
|
||||
entryPoints: [],
|
||||
barrels: [],
|
||||
tests: [],
|
||||
// config is missing
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-array values", () => {
|
||||
const result = FilePatternConfigSchema.safeParse({
|
||||
entryPoints: "main.py",
|
||||
barrels: [],
|
||||
tests: [],
|
||||
config: [],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("LanguageConfigSchema (base, no refinement)", () => {
|
||||
const validConfig = {
|
||||
id: "testlang",
|
||||
displayName: "Test Language",
|
||||
extensions: [".test"],
|
||||
concepts: ["testing", "assertions"],
|
||||
filePatterns: {
|
||||
entryPoints: [],
|
||||
barrels: [],
|
||||
tests: ["*.test.ts"],
|
||||
config: [],
|
||||
},
|
||||
};
|
||||
|
||||
it("accepts a complete valid config", () => {
|
||||
const result = LanguageConfigSchema.safeParse(validConfig);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts config with no extensions and no filenames (content-detected languages)", () => {
|
||||
const result = LanguageConfigSchema.safeParse({
|
||||
...validConfig,
|
||||
extensions: [],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts config with optional treeSitter", () => {
|
||||
const result = LanguageConfigSchema.safeParse({
|
||||
...validConfig,
|
||||
treeSitter: {
|
||||
wasmPackage: "tree-sitter-test",
|
||||
wasmFile: "tree-sitter-test.wasm",
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts config with optional filenames", () => {
|
||||
const result = LanguageConfigSchema.safeParse({
|
||||
...validConfig,
|
||||
filenames: ["SpecialFile"],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects config missing id", () => {
|
||||
const { id: _id, ...withoutId } = validConfig;
|
||||
const result = LanguageConfigSchema.safeParse(withoutId);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects config with empty id", () => {
|
||||
const result = LanguageConfigSchema.safeParse({ ...validConfig, id: "" });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects config missing displayName", () => {
|
||||
const { displayName: _displayName, ...withoutName } = validConfig;
|
||||
const result = LanguageConfigSchema.safeParse(withoutName);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects config missing filePatterns", () => {
|
||||
const { filePatterns: _filePatterns, ...withoutPatterns } = validConfig;
|
||||
const result = LanguageConfigSchema.safeParse(withoutPatterns);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects config with non-array concepts", () => {
|
||||
const result = LanguageConfigSchema.safeParse({
|
||||
...validConfig,
|
||||
concepts: "not-an-array",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("StrictLanguageConfigSchema", () => {
|
||||
const base = {
|
||||
id: "testlang",
|
||||
displayName: "Test",
|
||||
concepts: ["testing"],
|
||||
filePatterns: {
|
||||
entryPoints: [],
|
||||
barrels: [],
|
||||
tests: [],
|
||||
config: [],
|
||||
},
|
||||
};
|
||||
|
||||
it("accepts config with at least one extension", () => {
|
||||
const result = StrictLanguageConfigSchema.safeParse({
|
||||
...base,
|
||||
extensions: [".test"],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts config with at least one filename (no extensions)", () => {
|
||||
const result = StrictLanguageConfigSchema.safeParse({
|
||||
...base,
|
||||
extensions: [],
|
||||
filenames: ["SpecialFile"],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts config with both extensions and filenames", () => {
|
||||
const result = StrictLanguageConfigSchema.safeParse({
|
||||
...base,
|
||||
extensions: [".test"],
|
||||
filenames: ["SpecialFile"],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects config with empty extensions and no filenames field", () => {
|
||||
const result = StrictLanguageConfigSchema.safeParse({
|
||||
...base,
|
||||
extensions: [],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].message).toContain(
|
||||
"at least one extension or filename"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects config with empty extensions and empty filenames", () => {
|
||||
const result = StrictLanguageConfigSchema.safeParse({
|
||||
...base,
|
||||
extensions: [],
|
||||
filenames: [],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FrameworkConfigSchema", () => {
|
||||
const validFramework = {
|
||||
id: "testfw",
|
||||
displayName: "Test Framework",
|
||||
languages: ["typescript"],
|
||||
detectionKeywords: ["test-framework"],
|
||||
manifestFiles: ["package.json"],
|
||||
promptSnippetPath: "./frameworks/test.md",
|
||||
};
|
||||
|
||||
it("accepts a valid framework config with required fields only", () => {
|
||||
const result = FrameworkConfigSchema.safeParse(validFramework);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts config with optional entryPoints", () => {
|
||||
const result = FrameworkConfigSchema.safeParse({
|
||||
...validFramework,
|
||||
entryPoints: ["src/index.ts"],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts config with optional layerHints", () => {
|
||||
const result = FrameworkConfigSchema.safeParse({
|
||||
...validFramework,
|
||||
layerHints: { routes: "api", models: "data" },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects config with empty languages array", () => {
|
||||
const result = FrameworkConfigSchema.safeParse({
|
||||
...validFramework,
|
||||
languages: [],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects config with empty detectionKeywords array", () => {
|
||||
const result = FrameworkConfigSchema.safeParse({
|
||||
...validFramework,
|
||||
detectionKeywords: [],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects config with empty manifestFiles array", () => {
|
||||
const result = FrameworkConfigSchema.safeParse({
|
||||
...validFramework,
|
||||
manifestFiles: [],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects config with empty promptSnippetPath", () => {
|
||||
const result = FrameworkConfigSchema.safeParse({
|
||||
...validFramework,
|
||||
promptSnippetPath: "",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects config with empty language id string in languages array", () => {
|
||||
const result = FrameworkConfigSchema.safeParse({
|
||||
...validFramework,
|
||||
languages: [""],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects config missing required fields", () => {
|
||||
const result = FrameworkConfigSchema.safeParse({
|
||||
id: "incomplete",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Batch validation: all built-in language configs
|
||||
// =============================================================================
|
||||
|
||||
describe("Built-in Language Configs", () => {
|
||||
// These configs intentionally lack both extensions and filenames because they
|
||||
// rely on future content-based detection (e.g. YAML with apiVersion/kind for
|
||||
// Kubernetes, files with $schema key for JSON Schema, .github/workflows/*.yml
|
||||
// for GitHub Actions). They are valid base LanguageConfigs but intentionally
|
||||
// fail StrictLanguageConfigSchema.
|
||||
const CONTENT_DETECTED_IDS = new Set([
|
||||
"kubernetes",
|
||||
"github-actions",
|
||||
"json-schema",
|
||||
]);
|
||||
|
||||
it("registers every config module in the configs directory", () => {
|
||||
expect(builtinLanguageConfigs).toHaveLength(
|
||||
countConfigModules("../languages/configs/")
|
||||
);
|
||||
});
|
||||
|
||||
it("every config passes base LanguageConfigSchema validation", () => {
|
||||
for (const config of builtinLanguageConfigs) {
|
||||
const result = LanguageConfigSchema.safeParse(config);
|
||||
expect(
|
||||
result.success,
|
||||
`"${config.id}" should pass base LanguageConfigSchema: ${result.success ? "" : result.error.issues.map((i) => i.message).join(", ")}`
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("content-detected configs intentionally fail StrictLanguageConfigSchema", () => {
|
||||
for (const config of builtinLanguageConfigs) {
|
||||
if (!CONTENT_DETECTED_IDS.has(config.id)) continue;
|
||||
const result = StrictLanguageConfigSchema.safeParse(config);
|
||||
expect(
|
||||
result.success,
|
||||
`"${config.id}" is content-detected (no extensions/filenames) and should fail strict validation by design`
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("all non-content-detected configs pass StrictLanguageConfigSchema", () => {
|
||||
for (const config of builtinLanguageConfigs) {
|
||||
if (CONTENT_DETECTED_IDS.has(config.id)) continue;
|
||||
const result = StrictLanguageConfigSchema.safeParse(config);
|
||||
expect(
|
||||
result.success,
|
||||
`"${config.id}" should pass StrictLanguageConfigSchema: ${result.success ? "" : JSON.stringify(result.error.issues)}`
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("every config has a non-empty id", () => {
|
||||
for (const config of builtinLanguageConfigs) {
|
||||
expect(config.id.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("every config has a non-empty displayName", () => {
|
||||
for (const config of builtinLanguageConfigs) {
|
||||
expect(config.displayName.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("every config has at least one concept", () => {
|
||||
for (const config of builtinLanguageConfigs) {
|
||||
expect(
|
||||
config.concepts.length,
|
||||
`"${config.id}" should have at least one concept`
|
||||
).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("all config ids are unique", () => {
|
||||
const ids = builtinLanguageConfigs.map((c) => c.id);
|
||||
const unique = new Set(ids);
|
||||
expect(unique.size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it("no extension is mapped by more than one config", () => {
|
||||
const allExtensions: string[] = [];
|
||||
for (const config of builtinLanguageConfigs) {
|
||||
allExtensions.push(...config.extensions);
|
||||
}
|
||||
const unique = new Set(allExtensions);
|
||||
expect(unique.size).toBe(allExtensions.length);
|
||||
});
|
||||
|
||||
it("configs with treeSitter have valid wasmPackage and wasmFile", () => {
|
||||
for (const config of builtinLanguageConfigs) {
|
||||
if (!config.treeSitter) continue;
|
||||
const tsResult = TreeSitterConfigSchema.safeParse(config.treeSitter);
|
||||
expect(
|
||||
tsResult.success,
|
||||
`"${config.id}" treeSitter should be valid: ${tsResult.success ? "" : JSON.stringify(tsResult.error.issues)}`
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("configs with filenames have at least one entry", () => {
|
||||
for (const config of builtinLanguageConfigs) {
|
||||
if (!config.filenames) continue;
|
||||
expect(
|
||||
config.filenames.length,
|
||||
`"${config.id}" has filenames field but it is empty`
|
||||
).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Batch validation: all built-in framework configs
|
||||
// =============================================================================
|
||||
|
||||
describe("Built-in Framework Configs", () => {
|
||||
it("registers every framework module in the frameworks directory", () => {
|
||||
expect(builtinFrameworkConfigs).toHaveLength(
|
||||
countConfigModules("../languages/frameworks/")
|
||||
);
|
||||
});
|
||||
|
||||
it("every framework config passes FrameworkConfigSchema validation", () => {
|
||||
for (const fw of builtinFrameworkConfigs) {
|
||||
const result = FrameworkConfigSchema.safeParse(fw);
|
||||
expect(
|
||||
result.success,
|
||||
`"${fw.id}" should pass FrameworkConfigSchema: ${result.success ? "" : result.error.issues.map((i) => i.message).join(", ")}`
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("every framework has a non-empty id", () => {
|
||||
for (const fw of builtinFrameworkConfigs) {
|
||||
expect(fw.id.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("every framework has a non-empty displayName", () => {
|
||||
for (const fw of builtinFrameworkConfigs) {
|
||||
expect(fw.displayName.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("all framework ids are unique", () => {
|
||||
const ids = builtinFrameworkConfigs.map((fw) => fw.id);
|
||||
const unique = new Set(ids);
|
||||
expect(unique.size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it("every framework's languages array references known language ids", () => {
|
||||
const knownLanguageIds = new Set(builtinLanguageConfigs.map((c) => c.id));
|
||||
for (const fw of builtinFrameworkConfigs) {
|
||||
for (const langId of fw.languages) {
|
||||
expect(
|
||||
knownLanguageIds.has(langId),
|
||||
`"${fw.id}" references unknown language "${langId}"`
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("every framework has at least one detectionKeyword and manifestFile", () => {
|
||||
for (const fw of builtinFrameworkConfigs) {
|
||||
expect(
|
||||
fw.detectionKeywords.length,
|
||||
`"${fw.id}" should have at least one detection keyword`
|
||||
).toBeGreaterThan(0);
|
||||
expect(
|
||||
fw.manifestFiles.length,
|
||||
`"${fw.id}" should have at least one manifest file`
|
||||
).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("every framework has a non-empty promptSnippetPath", () => {
|
||||
for (const fw of builtinFrameworkConfigs) {
|
||||
expect(
|
||||
fw.promptSnippetPath.length,
|
||||
`"${fw.id}" should have a non-empty promptSnippetPath`
|
||||
).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("frameworks with layerHints have valid string key-value pairs", () => {
|
||||
for (const fw of builtinFrameworkConfigs) {
|
||||
if (!fw.layerHints) continue;
|
||||
const entries = Object.entries(fw.layerHints);
|
||||
expect(
|
||||
entries.length,
|
||||
`"${fw.id}" layerHints should have at least one entry`
|
||||
).toBeGreaterThan(0);
|
||||
for (const [dir, layer] of entries) {
|
||||
expect(dir.length).toBeGreaterThan(0);
|
||||
expect(layer.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -56,8 +56,8 @@ describe("domain graph persistence", () => {
|
||||
|
||||
it("saves to domain-graph.json, not knowledge-graph.json", () => {
|
||||
saveDomainGraph(testRoot, domainGraph);
|
||||
const domainPath = join(testRoot, ".understand-anything", "domain-graph.json");
|
||||
const structuralPath = join(testRoot, ".understand-anything", "knowledge-graph.json");
|
||||
const domainPath = join(testRoot, ".ua", "domain-graph.json");
|
||||
const structuralPath = join(testRoot, ".ua", "knowledge-graph.json");
|
||||
expect(existsSync(domainPath)).toBe(true);
|
||||
expect(existsSync(structuralPath)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -208,23 +208,44 @@ describe("generateStarterIgnoreFile", () => {
|
||||
expect(content).toContain("# **/*Benchmark.cpp");
|
||||
});
|
||||
|
||||
it("includes Python pytest / unittest file patterns", () => {
|
||||
const content = generateStarterIgnoreFile(testDir);
|
||||
expect(content).toContain("# Python");
|
||||
// pytest / unittest default discovery — file must start with test_.
|
||||
expect(content).toContain("# **/test_*.py");
|
||||
// Alternate convention used by tensorflow, google-style, etc.
|
||||
expect(content).toContain("# **/*_test.py");
|
||||
});
|
||||
|
||||
it("includes Django's single-file tests.py convention", () => {
|
||||
const content = generateStarterIgnoreFile(testDir);
|
||||
expect(content).toContain("# **/tests.py");
|
||||
});
|
||||
|
||||
it("includes pytest conftest.py convention", () => {
|
||||
const content = generateStarterIgnoreFile(testDir);
|
||||
expect(content).toContain("# **/conftest.py");
|
||||
});
|
||||
|
||||
it("groups patterns under the JS / TS sub-header", () => {
|
||||
const content = generateStarterIgnoreFile(testDir);
|
||||
expect(content).toContain("# JS / TS");
|
||||
});
|
||||
|
||||
it("emits language groups in stable order: JS, C#, Java, Go, C++", () => {
|
||||
it("emits language groups in stable order: JS, C#, Java, Go, C++, Python", () => {
|
||||
const content = generateStarterIgnoreFile(testDir);
|
||||
const jsIdx = content.indexOf("# JS / TS");
|
||||
const csIdx = content.indexOf("# C# / .NET");
|
||||
const javaIdx = content.indexOf("# Java / Kotlin");
|
||||
const goIdx = content.indexOf("# Go");
|
||||
const cppIdx = content.indexOf("# C++");
|
||||
const pyIdx = content.indexOf("# Python");
|
||||
expect(jsIdx).toBeGreaterThan(-1);
|
||||
expect(csIdx).toBeGreaterThan(jsIdx);
|
||||
expect(javaIdx).toBeGreaterThan(csIdx);
|
||||
expect(goIdx).toBeGreaterThan(javaIdx);
|
||||
expect(cppIdx).toBeGreaterThan(goIdx);
|
||||
expect(pyIdx).toBeGreaterThan(cppIdx);
|
||||
});
|
||||
|
||||
it("keeps all suggestions commented even with no detected dirs and no .gitignore", () => {
|
||||
|
||||
@@ -49,10 +49,10 @@ describe("LanguageRegistry", () => {
|
||||
});
|
||||
|
||||
describe("createDefault", () => {
|
||||
it("registers all 41 built-in language configs", () => {
|
||||
it("registers all 42 built-in language configs", () => {
|
||||
const registry = LanguageRegistry.createDefault();
|
||||
const all = registry.getAllLanguages();
|
||||
expect(all.length).toBe(41);
|
||||
expect(all.length).toBe(42);
|
||||
});
|
||||
|
||||
it("maps all expected extensions", () => {
|
||||
@@ -66,6 +66,7 @@ describe("LanguageRegistry", () => {
|
||||
expect(registry.getByExtension(".php")?.id).toBe("php");
|
||||
expect(registry.getByExtension(".swift")?.id).toBe("swift");
|
||||
expect(registry.getByExtension(".kt")?.id).toBe("kotlin");
|
||||
expect(registry.getByExtension(".scala")?.id).toBe("scala");
|
||||
expect(registry.getByExtension(".cs")?.id).toBe("csharp");
|
||||
expect(registry.getByExtension(".cpp")?.id).toBe("cpp");
|
||||
expect(registry.getByExtension(".c")?.id).toBe("c");
|
||||
|
||||
@@ -112,6 +112,38 @@ describe("PluginRegistry", () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("analyzeFileFull delegates when the plugin implements it", () => {
|
||||
const registry = new PluginRegistry();
|
||||
const plugin = createMockPlugin("ts-plugin", ["typescript"]);
|
||||
plugin.analyzeFileFull = () => ({
|
||||
structure: {
|
||||
...emptyAnalysis,
|
||||
functions: [{ name: "hello", lineRange: [1, 5], params: [] }],
|
||||
},
|
||||
callGraph: [{ caller: "hello", callee: "world", lineNumber: 2 }],
|
||||
});
|
||||
registry.register(plugin);
|
||||
|
||||
const result = registry.analyzeFileFull("src/test.ts", "const x = 1;");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.structure.functions).toHaveLength(1);
|
||||
expect(result!.callGraph).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("analyzeFileFull returns null when the plugin lacks the method (caller falls back)", () => {
|
||||
const registry = new PluginRegistry();
|
||||
registry.register(createMockPlugin("ts-plugin", ["typescript"]));
|
||||
const result = registry.analyzeFileFull("src/test.ts", "const x = 1;");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("analyzeFileFull returns null for unsupported files", () => {
|
||||
const registry = new PluginRegistry();
|
||||
registry.register(createMockPlugin("ts-plugin", ["typescript"]));
|
||||
const result = registry.analyzeFileFull("main.py", "print('hello')");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("unregister rebuilds language map correctly", () => {
|
||||
const registry = new PluginRegistry();
|
||||
const plugin1 = createMockPlugin("plugin1", ["typescript", "javascript"]);
|
||||
|
||||
@@ -727,3 +727,110 @@ describe("Extended node/edge types", () => {
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
function designGraph() {
|
||||
return {
|
||||
version: "1.0.0",
|
||||
kind: "design",
|
||||
project: { name: "F", languages: ["figma"], frameworks: [], description: "d", analyzedAt: "t", gitCommitHash: "" },
|
||||
nodes: [
|
||||
{ id: "screen:1:2", type: "screen", name: "Login", summary: "s", tags: ["auth"], complexity: "simple" },
|
||||
{ id: "component:3:4", type: "component", name: "Button/Primary", summary: "s", tags: ["ds"], complexity: "simple" },
|
||||
{ id: "instance:5:6", type: "instance", name: "SignInBtn", summary: "s", tags: ["use"], complexity: "simple" },
|
||||
{ id: "token:color:brand", type: "token", name: "color/brand", summary: "s", tags: ["token"], complexity: "simple" },
|
||||
],
|
||||
edges: [
|
||||
{ source: "instance:5:6", target: "component:3:4", type: "instance_of", direction: "forward", weight: 0.8 },
|
||||
{ source: "component:3:4", target: "token:color:brand", type: "uses_token", direction: "forward", weight: 0.5 },
|
||||
],
|
||||
layers: [],
|
||||
tour: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("design graph schema", () => {
|
||||
it("accepts design node and edge types", () => {
|
||||
const res = validateGraph(designGraph());
|
||||
expect(res.success).toBe(true);
|
||||
expect(res.data!.nodes).toHaveLength(4);
|
||||
expect(res.data!.edges).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps instance_of as a first-class edge (NOT rewritten to exemplifies)", () => {
|
||||
const res = validateGraph(designGraph());
|
||||
const e = res.data!.edges.find((x) => x.source === "instance:5:6");
|
||||
expect(e!.type).toBe("instance_of");
|
||||
});
|
||||
|
||||
it("normalizes figma node-type aliases (frame → screen)", () => {
|
||||
const g = designGraph();
|
||||
g.nodes[0].type = "frame";
|
||||
const res = validateGraph(g);
|
||||
expect(res.data!.nodes.find((n) => n.id === "screen:1:2")!.type).toBe("screen");
|
||||
});
|
||||
|
||||
it("keeps componentSet (the only camelCase node type) through sanitize lowercasing", () => {
|
||||
const g = designGraph();
|
||||
g.nodes.push({ id: "componentSet:7:8", type: "componentSet", name: "Button", summary: "s", tags: ["ds"], complexity: "simple" });
|
||||
g.edges.push({ source: "component:3:4", target: "componentSet:7:8", type: "variant_of", direction: "forward", weight: 0.9 });
|
||||
const res = validateGraph(g);
|
||||
const set = res.data!.nodes.find((n) => n.id === "componentSet:7:8");
|
||||
expect(set).toBeTruthy();
|
||||
expect(set!.type).toBe("componentSet");
|
||||
// the variant_of edge must survive (its target was not dropped)
|
||||
expect(res.data!.edges.some((e) => e.target === "componentSet:7:8" && e.type === "variant_of")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// `page` and `instance_of` are canonical design types, but every other kind
|
||||
// relied on them normalizing to knowledge types (page → article, instance_of
|
||||
// → exemplifies) before the design kind existed. The alias tables are
|
||||
// kind-scoped so promoting them for design graphs doesn't regress the rest.
|
||||
describe("kind-aware alias normalization", () => {
|
||||
function knowledgeGraph(kind?: string) {
|
||||
return {
|
||||
version: "1.0.0",
|
||||
...(kind ? { kind } : {}),
|
||||
project: { name: "K", languages: ["markdown"], frameworks: [], description: "d", analyzedAt: "t", gitCommitHash: "" },
|
||||
nodes: [
|
||||
{ id: "article:home", type: "page", name: "Home", summary: "s", tags: ["wiki"], complexity: "simple" },
|
||||
{ id: "entity:gpt", type: "entity", name: "GPT", summary: "s", tags: ["model"], complexity: "simple" },
|
||||
{ id: "topic:llm", type: "topic", name: "LLMs", summary: "s", tags: ["topic"], complexity: "simple" },
|
||||
],
|
||||
edges: [
|
||||
{ source: "entity:gpt", target: "topic:llm", type: "instance_of", direction: "forward", weight: 0.7 },
|
||||
],
|
||||
layers: [],
|
||||
tour: [],
|
||||
};
|
||||
}
|
||||
|
||||
it('rewrites page → article and instance_of → exemplifies for kind:"knowledge"', () => {
|
||||
const res = validateGraph(knowledgeGraph("knowledge"));
|
||||
expect(res.success).toBe(true);
|
||||
expect(res.data!.nodes.find((n) => n.id === "article:home")!.type).toBe("article");
|
||||
expect(res.data!.edges[0].type).toBe("exemplifies");
|
||||
});
|
||||
|
||||
it("rewrites page → article for graphs without a kind (pre-design behavior)", () => {
|
||||
const res = validateGraph(knowledgeGraph());
|
||||
expect(res.success).toBe(true);
|
||||
expect(res.data!.nodes.find((n) => n.id === "article:home")!.type).toBe("article");
|
||||
expect(res.data!.edges[0].type).toBe("exemplifies");
|
||||
});
|
||||
|
||||
it('keeps page and instance_of first-class for kind:"design"', () => {
|
||||
const g = designGraph();
|
||||
g.nodes.push({ id: "page:1:0", type: "page", name: "Onboarding", summary: "s", tags: ["page"], complexity: "simple" });
|
||||
const res = validateGraph(g);
|
||||
expect(res.data!.nodes.find((n) => n.id === "page:1:0")!.type).toBe("page");
|
||||
expect(res.data!.edges.find((x) => x.source === "instance:5:6")!.type).toBe("instance_of");
|
||||
});
|
||||
|
||||
it('applies design aliases (styled_by → uses_token) only to design graphs', () => {
|
||||
const g = designGraph();
|
||||
g.edges.push({ source: "screen:1:2", target: "token:color:brand", type: "styled_by", direction: "forward", weight: 0.5 });
|
||||
const res = validateGraph(g);
|
||||
expect(res.data!.edges.some((e) => e.source === "screen:1:2" && e.type === "uses_token")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -104,10 +104,18 @@ function matchFileToLayer(filePath: string): string | null {
|
||||
*/
|
||||
export function detectLayers(graph: KnowledgeGraph): Layer[] {
|
||||
const layerMap = new Map<string, string[]>(); // layerName -> nodeIds
|
||||
// file nodes without filePath go to "Core" *after* the main pass, so a
|
||||
// single sweep over graph.nodes replaces the previous two full passes while
|
||||
// preserving the original ordering (all with-path entries first, then
|
||||
// path-less ones) and the Map key-insertion order.
|
||||
const corePathless: string[] = [];
|
||||
|
||||
for (const node of graph.nodes) {
|
||||
if (node.type !== "file") continue;
|
||||
if (!node.filePath) continue;
|
||||
if (!node.filePath) {
|
||||
corePathless.push(node.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
const layerName = matchFileToLayer(node.filePath) ?? "Core";
|
||||
const existing = layerMap.get(layerName) ?? [];
|
||||
@@ -115,13 +123,9 @@ export function detectLayers(graph: KnowledgeGraph): Layer[] {
|
||||
layerMap.set(layerName, existing);
|
||||
}
|
||||
|
||||
// Also catch file nodes without filePath
|
||||
for (const node of graph.nodes) {
|
||||
if (node.type !== "file") continue;
|
||||
if (node.filePath) continue;
|
||||
|
||||
if (corePathless.length > 0) {
|
||||
const existing = layerMap.get("Core") ?? [];
|
||||
existing.push(node.id);
|
||||
for (const id of corePathless) existing.push(id);
|
||||
layerMap.set("Core", existing);
|
||||
}
|
||||
|
||||
|
||||
@@ -165,8 +165,11 @@ export function generateHeuristicTour(graph: KnowledgeGraph): TourStep[] {
|
||||
}
|
||||
|
||||
const topoOrder: string[] = [];
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
// Index cursor instead of queue.shift(): shift() is O(n) (re-indexes the
|
||||
// whole array) → O(n²) over the BFS. A head pointer makes each dequeue O(1).
|
||||
let head = 0;
|
||||
while (head < queue.length) {
|
||||
const current = queue[head++];
|
||||
topoOrder.push(current);
|
||||
|
||||
for (const neighbor of adjacency.get(current) ?? []) {
|
||||
@@ -178,10 +181,15 @@ export function generateHeuristicTour(graph: KnowledgeGraph): TourStep[] {
|
||||
}
|
||||
}
|
||||
|
||||
// Add any nodes not reached by topological sort (isolated nodes or cycles)
|
||||
// Add any nodes not reached by topological sort (isolated nodes or cycles).
|
||||
// `topoOrder.includes()` per node was O(n²) over the full node set; a Set
|
||||
// membership test makes it O(n). Mirror the array-grows semantics by adding
|
||||
// to the set on push so a duplicate node id is still de-duplicated.
|
||||
const inTopo = new Set(topoOrder);
|
||||
for (const node of codeNodes) {
|
||||
if (!topoOrder.includes(node.id)) {
|
||||
if (!inTopo.has(node.id)) {
|
||||
topoOrder.push(node.id);
|
||||
inTopo.add(node.id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,30 @@ export function cosineSimilarity(a: number[], b: number[]): number {
|
||||
return dot / (magA * magB);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cosine similarity when the query vector's magnitude is already known.
|
||||
* The query is constant across an entire search() sweep, so recomputing its
|
||||
* magnitude (and re-squaring every query component) per candidate node is
|
||||
* pure waste. Same arithmetic, same order as cosineSimilarity → bit-identical
|
||||
* results, but it skips the per-node magA loop.
|
||||
*/
|
||||
function cosineSimilarityWithQueryMag(
|
||||
query: number[],
|
||||
queryMag: number,
|
||||
vec: number[],
|
||||
): number {
|
||||
if (queryMag === 0) return 0;
|
||||
let dot = 0;
|
||||
let magB = 0;
|
||||
for (let i = 0; i < query.length; i++) {
|
||||
dot += query[i] * vec[i];
|
||||
magB += vec[i] * vec[i];
|
||||
}
|
||||
magB = Math.sqrt(magB);
|
||||
if (magB === 0) return 0;
|
||||
return dot / (queryMag * magB);
|
||||
}
|
||||
|
||||
/**
|
||||
* Semantic search engine using vector embeddings.
|
||||
* Stores pre-computed embeddings for graph nodes and performs
|
||||
@@ -61,13 +85,24 @@ export class SemanticSearchEngine {
|
||||
|
||||
const scored: Array<{ nodeId: string; score: number }> = [];
|
||||
|
||||
// Hoist the query magnitude out of the per-node loop — it's invariant.
|
||||
let queryMag = 0;
|
||||
for (let i = 0; i < queryEmbedding.length; i++) {
|
||||
queryMag += queryEmbedding[i] * queryEmbedding[i];
|
||||
}
|
||||
queryMag = Math.sqrt(queryMag);
|
||||
|
||||
for (const node of this.nodes) {
|
||||
if (typeFilter && !typeFilter.includes(node.type)) continue;
|
||||
|
||||
const embedding = this.embeddings.get(node.id);
|
||||
if (!embedding) continue;
|
||||
|
||||
const similarity = cosineSimilarity(queryEmbedding, embedding);
|
||||
const similarity = cosineSimilarityWithQueryMag(
|
||||
queryEmbedding,
|
||||
queryMag,
|
||||
embedding,
|
||||
);
|
||||
if (similarity >= threshold) {
|
||||
scored.push({ nodeId: node.id, score: 1 - similarity });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { parseFileKey, FigmaApiSource } from "../source/api-source";
|
||||
|
||||
afterEach(() => { vi.restoreAllMocks(); delete process.env.FIGMA_TOKEN; });
|
||||
|
||||
describe("parseFileKey", () => {
|
||||
it("extracts key from a /file/ URL", () => {
|
||||
expect(parseFileKey("https://www.figma.com/file/ABC123/My-App")).toBe("ABC123");
|
||||
});
|
||||
it("extracts key from a /design/ URL with query", () => {
|
||||
expect(parseFileKey("https://www.figma.com/design/XYZ789/App?node-id=1-2")).toBe("XYZ789");
|
||||
});
|
||||
it("accepts a bare key", () => {
|
||||
expect(parseFileKey("ABC123")).toBe("ABC123");
|
||||
});
|
||||
it("throws on unparseable input", () => {
|
||||
expect(() => parseFileKey("not a key!!")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FigmaApiSource", () => {
|
||||
it("throws a friendly error when FIGMA_TOKEN is missing", () => {
|
||||
delete process.env.FIGMA_TOKEN;
|
||||
expect(() => new FigmaApiSource("ABC123")).toThrow(/FIGMA_TOKEN/);
|
||||
});
|
||||
it("fetches the document and sends the token header", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ name: "Doc", document: { id: "0:0", type: "DOCUMENT", name: "Doc", children: [] } }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const src = new FigmaApiSource("ABC123", "tok_secret");
|
||||
const doc = await src.fetchDocument();
|
||||
expect(doc.name).toBe("Doc");
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(String(url)).toContain("/files/ABC123");
|
||||
expect((init.headers as Record<string, string>)["X-Figma-Token"]).toBe("tok_secret");
|
||||
});
|
||||
it("never leaks the token in error messages", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 403, statusText: "Forbidden" }));
|
||||
const src = new FigmaApiSource("ABC123", "tok_secret");
|
||||
await expect(src.fetchDocument()).rejects.toThrow(/403/);
|
||||
await expect(src.fetchDocument()).rejects.not.toThrow(/tok_secret/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mergeDesignGraph } from "../merge";
|
||||
import type { GraphNode, GraphEdge, ProjectMeta } from "../../types";
|
||||
|
||||
const project: ProjectMeta = { name: "MyApp", languages: ["figma"], frameworks: [], description: "d", analyzedAt: "t", gitCommitHash: "" };
|
||||
const manifest = {
|
||||
nodes: [
|
||||
{ id: "page:1:0", type: "page", name: "Onboarding", summary: "Onboarding", tags: ["page"], complexity: "simple" },
|
||||
{ id: "screen:1:1", type: "screen", name: "Login", summary: "Login", tags: ["screen"], complexity: "simple" },
|
||||
{ id: "component:2:1", type: "component", name: "Primary", summary: "Primary", tags: ["component"], complexity: "simple" },
|
||||
{ id: "token:color:brand", type: "token", name: "brand", summary: "brand", tags: ["token"], complexity: "simple" },
|
||||
] as GraphNode[],
|
||||
edges: [
|
||||
{ source: "page:1:0", target: "screen:1:1", type: "contains", direction: "forward", weight: 1 },
|
||||
{ source: "component:2:1", target: "token:color:brand", type: "uses_token", direction: "forward", weight: 0.5 },
|
||||
] as GraphEdge[],
|
||||
};
|
||||
|
||||
describe("mergeDesignGraph", () => {
|
||||
it("produces a valid kind:design graph", () => {
|
||||
const res = mergeDesignGraph(manifest, [], project);
|
||||
expect(res.success).toBe(true);
|
||||
expect(res.data!.kind).toBe("design");
|
||||
});
|
||||
it("groups screens under their page layer and DS nodes under Design System", () => {
|
||||
const { data } = mergeDesignGraph(manifest, [], project);
|
||||
const ds = data!.layers.find((l) => l.id === "layer:design-system")!;
|
||||
expect(ds.nodeIds).toEqual(expect.arrayContaining(["component:2:1", "token:color:brand"]));
|
||||
const page = data!.layers.find((l) => l.name === "Onboarding")!;
|
||||
expect(page.nodeIds).toEqual(expect.arrayContaining(["page:1:0", "screen:1:1"]));
|
||||
});
|
||||
it("applies design-analyzer enrichment by id", () => {
|
||||
const { data } = mergeDesignGraph(manifest, [{ nodes: [{ id: "screen:1:1", summary: "The sign-in screen", tags: ["auth", "entry"] }] }], project);
|
||||
const screen = data!.nodes.find((n) => n.id === "screen:1:1")!;
|
||||
expect(screen.summary).toBe("The sign-in screen");
|
||||
expect(screen.tags).toEqual(["auth", "entry"]);
|
||||
});
|
||||
it("builds a tour that starts with the Design System", () => {
|
||||
const { data } = mergeDesignGraph(manifest, [], project);
|
||||
expect(data!.tour[0].title).toBe("Design System");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseDocument } from "../parse/parse-document";
|
||||
import type { FigmaDocument } from "../source/types";
|
||||
|
||||
const doc: FigmaDocument = {
|
||||
name: "MyApp",
|
||||
document: {
|
||||
id: "0:0", name: "Document", type: "DOCUMENT", children: [
|
||||
{ id: "1:0", name: "Onboarding", type: "CANVAS", children: [
|
||||
{ id: "1:1", name: "Login", type: "FRAME", absoluteBoundingBox: { width: 375, height: 812 }, children: [
|
||||
{ id: "1:2", name: "SignInBtn", type: "INSTANCE", componentId: "2:1", children: [] },
|
||||
] },
|
||||
] },
|
||||
{ id: "1:9", name: "Components", type: "CANVAS", children: [
|
||||
{ id: "2:0", name: "Button", type: "COMPONENT_SET", children: [
|
||||
{ id: "2:1", name: "Primary", type: "COMPONENT", children: [] },
|
||||
{ id: "2:2", name: "Secondary", type: "COMPONENT", children: [] },
|
||||
] },
|
||||
] },
|
||||
],
|
||||
},
|
||||
components: { "2:1": { key: "COMP_KEY_GUID", name: "Primary", componentSetId: "2:0" } },
|
||||
};
|
||||
|
||||
describe("parseDocument", () => {
|
||||
const { nodes, edges } = parseDocument(doc, "ABC123");
|
||||
const ids = nodes.map((n) => n.id);
|
||||
const has = (s: string, t: string, ty: string) =>
|
||||
edges.some((e) => e.source === s && e.target === t && e.type === ty);
|
||||
|
||||
it("creates page/screen/instance/componentSet/component nodes", () => {
|
||||
expect(ids).toEqual(expect.arrayContaining([
|
||||
"page:1:0", "screen:1:1", "instance:1:2", "page:1:9", "componentSet:2:0", "component:2:1", "component:2:2",
|
||||
]));
|
||||
});
|
||||
it("links containment, instance_of, and variant_of", () => {
|
||||
expect(has("page:1:0", "screen:1:1", "contains")).toBe(true);
|
||||
expect(has("screen:1:1", "instance:1:2", "contains")).toBe(true);
|
||||
expect(has("instance:1:2", "component:2:1", "instance_of")).toBe(true);
|
||||
expect(has("component:2:1", "componentSet:2:0", "variant_of")).toBe(true);
|
||||
expect(has("component:2:2", "componentSet:2:0", "variant_of")).toBe(true);
|
||||
});
|
||||
it("captures screen dimensions and fileKey in figmaMeta", () => {
|
||||
const screen = nodes.find((n) => n.id === "screen:1:1")!;
|
||||
expect(screen.figmaMeta?.dimensions?.width).toBe(375);
|
||||
expect(screen.figmaMeta?.fileKey).toBe("ABC123");
|
||||
});
|
||||
it("records the published component key (not the local node id) on instances", () => {
|
||||
const inst = nodes.find((n) => n.id === "instance:1:2")!;
|
||||
expect(inst.figmaMeta?.componentKey).toBe("COMP_KEY_GUID");
|
||||
});
|
||||
it("omits componentKey when the components map has no entry", () => {
|
||||
const bare: FigmaDocument = { ...doc, components: {} };
|
||||
const inst = parseDocument(bare, "ABC123").nodes.find((n) => n.id === "instance:1:2")!;
|
||||
expect(inst.figmaMeta?.componentKey).toBeUndefined();
|
||||
});
|
||||
it("emits validateGraph-ready nodes (summary/tags/complexity present)", () => {
|
||||
expect(nodes.every((n) => n.summary && n.tags.length > 0 && n.complexity)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { applyScreenThumbnails } from "../thumbnails";
|
||||
import type { GraphNode } from "../../types";
|
||||
|
||||
function node(id: string, type: GraphNode["type"], nodeId: string): GraphNode {
|
||||
return {
|
||||
id, type, name: id, summary: id, tags: [type], complexity: "simple",
|
||||
figmaMeta: { fileKey: "ABC", nodeId },
|
||||
};
|
||||
}
|
||||
|
||||
describe("applyScreenThumbnails", () => {
|
||||
it("sets thumbnailUrl only on screen nodes present in the images map", () => {
|
||||
const nodes: GraphNode[] = [
|
||||
node("screen:10:0", "screen", "10:0"),
|
||||
node("component:2:1", "component", "2:1"), // non-screen → ignored even if in map
|
||||
node("screen:11:0", "screen", "11:0"), // screen but not in map → untouched
|
||||
];
|
||||
const updated = applyScreenThumbnails(nodes, {
|
||||
"10:0": "https://figma/a.png",
|
||||
"2:1": "https://figma/c.png",
|
||||
});
|
||||
expect(updated).toBe(1);
|
||||
expect(nodes[0].figmaMeta?.thumbnailUrl).toBe("https://figma/a.png");
|
||||
expect(nodes[1].figmaMeta?.thumbnailUrl).toBeUndefined();
|
||||
expect(nodes[2].figmaMeta?.thumbnailUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns 0 and mutates nothing when no screens match", () => {
|
||||
const nodes: GraphNode[] = [node("screen:9:9", "screen", "9:9")];
|
||||
const updated = applyScreenThumbnails(nodes, {});
|
||||
expect(updated).toBe(0);
|
||||
expect(nodes[0].figmaMeta?.thumbnailUrl).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { extractTokens } from "../parse/tokens";
|
||||
import type { FigmaDocument, FigmaStyles } from "../source/types";
|
||||
import type { GraphNode } from "../../types";
|
||||
|
||||
// Mirrors the real API shape: node.styles values are file-local style ids
|
||||
// ("100:1") that resolve through the document's top-level styles map to the
|
||||
// published key ("S_KEY") that /files/:key/styles reports.
|
||||
const doc: FigmaDocument = {
|
||||
name: "MyApp",
|
||||
document: {
|
||||
id: "0:0", name: "Document", type: "DOCUMENT", children: [
|
||||
{ id: "1:9", name: "Components", type: "CANVAS", children: [
|
||||
{ id: "2:1", name: "Primary", type: "COMPONENT", styles: { fill: "100:1" }, children: [] },
|
||||
] },
|
||||
],
|
||||
},
|
||||
styles: { "100:1": { key: "S_KEY", name: "color/brand-500", styleType: "FILL" } },
|
||||
};
|
||||
const styles: FigmaStyles = { meta: { styles: [{ key: "S_KEY", name: "color/brand-500", style_type: "FILL" }] } };
|
||||
const structural: GraphNode[] = [
|
||||
{ id: "component:2:1", type: "component", name: "Primary", summary: "Primary", tags: ["component"], complexity: "simple", figmaMeta: { fileKey: "ABC", nodeId: "2:1" } },
|
||||
];
|
||||
|
||||
describe("extractTokens", () => {
|
||||
const { nodes, edges } = extractTokens(doc, styles, structural, "ABC");
|
||||
it("creates a token node per published style with tokenKind", () => {
|
||||
const token = nodes.find((n) => n.type === "token");
|
||||
expect(token).toBeTruthy();
|
||||
expect(token!.figmaMeta?.tokenKind).toBe("color");
|
||||
expect(token!.name).toBe("color/brand-500");
|
||||
});
|
||||
it("links consumers to tokens by bridging local style ids to published keys", () => {
|
||||
const token = nodes.find((n) => n.type === "token")!;
|
||||
expect(edges).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ source: "component:2:1", target: token.id, type: "uses_token" }),
|
||||
]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractTokens — nested styled layers", () => {
|
||||
// The common real-world case: a screen's visible styling lives on a nested
|
||||
// TEXT/RECTANGLE leaf, so the style reference is on 11:0 — NOT on the screen
|
||||
// frame (10:0) that is the structural node.
|
||||
const nestedDoc: FigmaDocument = {
|
||||
name: "MyApp",
|
||||
document: {
|
||||
id: "0:0", name: "Document", type: "DOCUMENT", children: [
|
||||
{ id: "1:0", name: "Home", type: "CANVAS", children: [
|
||||
{ id: "10:0", name: "Home Screen", type: "FRAME", children: [
|
||||
{ id: "11:0", name: "Title", type: "TEXT", styles: { text: "200:1" }, children: [] },
|
||||
] },
|
||||
] },
|
||||
],
|
||||
},
|
||||
styles: { "200:1": { key: "T_KEY", name: "type/heading", styleType: "TEXT" } },
|
||||
};
|
||||
const nestedStyles: FigmaStyles = { meta: { styles: [{ key: "T_KEY", name: "type/heading", style_type: "TEXT" }] } };
|
||||
const nestedStructural: GraphNode[] = [
|
||||
{ id: "screen:10:0", type: "screen", name: "Home Screen", summary: "Home Screen", tags: ["screen"], complexity: "simple", figmaMeta: { fileKey: "ABC", nodeId: "10:0" } },
|
||||
];
|
||||
|
||||
it("attributes nested-layer token usage to the nearest structural ancestor (screen)", () => {
|
||||
const { nodes, edges } = extractTokens(nestedDoc, nestedStyles, nestedStructural, "ABC");
|
||||
const token = nodes.find((n) => n.type === "token");
|
||||
expect(token).toBeTruthy();
|
||||
expect(edges).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ source: "screen:10:0", target: token!.id, type: "uses_token" }),
|
||||
]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractTokens — sources without a top-level styles map", () => {
|
||||
// Offline/local sources may put published keys directly in node.styles;
|
||||
// without doc.styles the value falls back to a direct key match.
|
||||
const bareDoc: FigmaDocument = {
|
||||
name: "MyApp",
|
||||
document: {
|
||||
id: "0:0", name: "Document", type: "DOCUMENT", children: [
|
||||
{ id: "1:9", name: "Components", type: "CANVAS", children: [
|
||||
{ id: "2:1", name: "Primary", type: "COMPONENT", styles: { fill: "S_KEY" }, children: [] },
|
||||
] },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
it("falls back to matching node style values directly against published keys", () => {
|
||||
const { nodes, edges } = extractTokens(bareDoc, styles, structural, "ABC");
|
||||
const token = nodes.find((n) => n.type === "token")!;
|
||||
expect(edges).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ source: "component:2:1", target: token.id, type: "uses_token" }),
|
||||
]));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
export { parseFileKey, FigmaApiSource } from "./source/api-source.js";
|
||||
export type { FigmaSource, FigmaDocument, FigmaStyles, FigmaNode } from "./source/types.js";
|
||||
export { parseDocument } from "./parse/parse-document.js";
|
||||
export { extractTokens } from "./parse/tokens.js";
|
||||
export { applyScreenThumbnails } from "./thumbnails.js";
|
||||
export { mergeDesignGraph, type DesignAnalysis } from "./merge.js";
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { KnowledgeGraph, GraphNode, GraphEdge, Layer, TourStep, ProjectMeta } from "../types.js";
|
||||
import { validateGraph, type ValidationResult } from "../schema.js";
|
||||
|
||||
export interface DesignAnalysis {
|
||||
nodes?: Array<Pick<GraphNode, "id"> & Partial<Pick<GraphNode, "summary" | "tags">>>;
|
||||
edges?: GraphEdge[];
|
||||
}
|
||||
|
||||
const DS_TYPES = new Set<GraphNode["type"]>(["component", "componentSet", "token"]);
|
||||
|
||||
export function mergeDesignGraph(
|
||||
manifest: { nodes: GraphNode[]; edges: GraphEdge[] },
|
||||
analyses: DesignAnalysis[],
|
||||
project: ProjectMeta,
|
||||
): ValidationResult {
|
||||
// 1. index manifest nodes (clone so we can enrich)
|
||||
const byId = new Map<string, GraphNode>();
|
||||
for (const n of manifest.nodes) byId.set(n.id, { ...n });
|
||||
const edges: GraphEdge[] = [...manifest.edges];
|
||||
|
||||
// 2. apply LLM enrichment; design-analyzer must not invent structural nodes
|
||||
for (const a of analyses) {
|
||||
for (const patch of a.nodes ?? []) {
|
||||
const base = byId.get(patch.id);
|
||||
if (!base) continue;
|
||||
if (patch.summary) base.summary = patch.summary;
|
||||
if (patch.tags && patch.tags.length) base.tags = patch.tags;
|
||||
}
|
||||
for (const e of a.edges ?? []) edges.push(e);
|
||||
}
|
||||
const nodes = [...byId.values()];
|
||||
|
||||
// 3. layers: one per page (+ descendants), plus a Design System layer
|
||||
const parent = new Map<string, string>();
|
||||
for (const e of manifest.edges) if (e.type === "contains") parent.set(e.target, e.source);
|
||||
const pageOf = (id: string): string | undefined => {
|
||||
let cur: string | undefined = id;
|
||||
const guard = new Set<string>();
|
||||
while (cur && !guard.has(cur)) {
|
||||
guard.add(cur);
|
||||
if (byId.get(cur)?.type === "page") return cur;
|
||||
cur = parent.get(cur);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const layerMap = new Map<string, string[]>();
|
||||
const ds: string[] = [];
|
||||
for (const n of nodes) {
|
||||
if (DS_TYPES.has(n.type)) { ds.push(n.id); continue; }
|
||||
const key = n.type === "page" ? n.id : (pageOf(n.id) ?? "layer:unscoped");
|
||||
if (!layerMap.has(key)) layerMap.set(key, []);
|
||||
layerMap.get(key)!.push(n.id);
|
||||
}
|
||||
const layers: Layer[] = [];
|
||||
for (const [pageId, ids] of layerMap) {
|
||||
const pageNode = byId.get(pageId);
|
||||
layers.push({
|
||||
id: `layer:${pageId}`,
|
||||
name: pageNode?.name ?? "Unscoped",
|
||||
description: pageNode ? `Figma page: ${pageNode.name}` : "Nodes not under a page",
|
||||
nodeIds: ids,
|
||||
});
|
||||
}
|
||||
if (ds.length) {
|
||||
layers.push({ id: "layer:design-system", name: "Design System", description: "Components, variants, and design tokens", nodeIds: ds });
|
||||
}
|
||||
|
||||
// 4. tour: Design System first, then each page
|
||||
const tour: TourStep[] = [];
|
||||
let order = 1;
|
||||
if (ds.length) tour.push({ order: order++, title: "Design System", description: "Shared components, variants, and tokens the screens are built from.", nodeIds: ds.slice(0, 8) });
|
||||
for (const l of layers) {
|
||||
if (l.id === "layer:design-system") continue;
|
||||
tour.push({ order: order++, title: l.name, description: `Screens on the "${l.name}" page.`, nodeIds: l.nodeIds.slice(0, 8) });
|
||||
}
|
||||
|
||||
// 5. assemble + validate, then re-attach kind (validateGraph drops it)
|
||||
const graph: KnowledgeGraph = { version: "1.0.0", kind: "design", project, nodes, edges, layers, tour };
|
||||
const result = validateGraph(graph);
|
||||
if (result.success && result.data) {
|
||||
(result.data as KnowledgeGraph).kind = "design";
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { GraphNode, GraphEdge } from "../../types.js";
|
||||
import type { FigmaDocument, FigmaNode } from "../source/types.js";
|
||||
|
||||
function mkNode(
|
||||
type: GraphNode["type"],
|
||||
figmaId: string,
|
||||
name: string,
|
||||
figmaMeta: GraphNode["figmaMeta"],
|
||||
): GraphNode {
|
||||
return {
|
||||
id: `${type}:${figmaId}`,
|
||||
type,
|
||||
name,
|
||||
summary: name, // placeholder; design-analyzer enriches in Phase 2
|
||||
tags: [type],
|
||||
complexity: "simple",
|
||||
figmaMeta,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseDocument(doc: FigmaDocument, fileKey: string): { nodes: GraphNode[]; edges: GraphEdge[] } {
|
||||
const nodes: GraphNode[] = [];
|
||||
const edges: GraphEdge[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const add = (n: GraphNode) => { if (!seen.has(n.id)) { seen.add(n.id); nodes.push(n); } };
|
||||
const link = (source: string, target: string, type: GraphEdge["type"], weight: number) =>
|
||||
edges.push({ source, target, type, direction: "forward", weight });
|
||||
|
||||
// Deep-read a screen subtree to find instances (shallow node set, but deep read).
|
||||
function collectInstances(n: FigmaNode, screenId: string) {
|
||||
for (const child of n.children ?? []) {
|
||||
if (child.type === "INSTANCE") {
|
||||
const inst = mkNode("instance", child.id, child.name, {
|
||||
fileKey, nodeId: child.id, figmaType: "INSTANCE",
|
||||
// The global published key (GUID) from the document's components
|
||||
// map — child.componentId is only a file-local node id, already
|
||||
// captured by the instance_of edge below.
|
||||
componentKey: child.componentId ? doc.components?.[child.componentId]?.key : undefined,
|
||||
prototypeTargets: child.transitionNodeID ? [child.transitionNodeID] : undefined,
|
||||
});
|
||||
add(inst);
|
||||
link(screenId, inst.id, "contains", 1.0);
|
||||
if (child.componentId) link(inst.id, `component:${child.componentId}`, "instance_of", 0.8);
|
||||
}
|
||||
if (child.children) collectInstances(child, screenId);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageChild(child: FigmaNode, pageId: string) {
|
||||
switch (child.type) {
|
||||
case "FRAME": {
|
||||
const screen = mkNode("screen", child.id, child.name, {
|
||||
fileKey, nodeId: child.id, figmaType: "FRAME",
|
||||
dimensions: child.absoluteBoundingBox
|
||||
? { width: child.absoluteBoundingBox.width, height: child.absoluteBoundingBox.height }
|
||||
: undefined,
|
||||
});
|
||||
add(screen);
|
||||
link(pageId, screen.id, "contains", 1.0);
|
||||
collectInstances(child, screen.id);
|
||||
break;
|
||||
}
|
||||
case "COMPONENT": {
|
||||
const comp = mkNode("component", child.id, child.name, { fileKey, nodeId: child.id, figmaType: "COMPONENT" });
|
||||
add(comp);
|
||||
link(pageId, comp.id, "contains", 1.0);
|
||||
break;
|
||||
}
|
||||
case "COMPONENT_SET": {
|
||||
const set = mkNode("componentSet", child.id, child.name, { fileKey, nodeId: child.id, figmaType: "COMPONENT_SET" });
|
||||
add(set);
|
||||
link(pageId, set.id, "contains", 1.0);
|
||||
for (const variant of child.children ?? []) {
|
||||
if (variant.type === "COMPONENT") {
|
||||
const comp = mkNode("component", variant.id, variant.name, { fileKey, nodeId: variant.id, figmaType: "COMPONENT" });
|
||||
add(comp);
|
||||
link(comp.id, set.id, "variant_of", 0.9);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "SECTION": {
|
||||
for (const sub of child.children ?? []) handlePageChild(sub, pageId); // flatten sections in v1
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break; // other top-level types are ignored in v1
|
||||
}
|
||||
}
|
||||
|
||||
for (const canvas of doc.document.children ?? []) {
|
||||
if (canvas.type !== "CANVAS") continue;
|
||||
const page = mkNode("page", canvas.id, canvas.name, { fileKey, nodeId: canvas.id, figmaType: "CANVAS" });
|
||||
add(page);
|
||||
for (const child of canvas.children ?? []) handlePageChild(child, page.id);
|
||||
}
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { GraphNode, GraphEdge, FigmaMeta } from "../../types.js";
|
||||
import type { FigmaDocument, FigmaNode, FigmaStyles } from "../source/types.js";
|
||||
|
||||
const STYLE_KIND: Record<string, NonNullable<FigmaMeta["tokenKind"]>> = {
|
||||
FILL: "color", TEXT: "type", EFFECT: "effect", GRID: "grid",
|
||||
};
|
||||
|
||||
function slug(s: string): string {
|
||||
return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
export function extractTokens(
|
||||
doc: FigmaDocument,
|
||||
styles: FigmaStyles,
|
||||
structuralNodes: GraphNode[],
|
||||
fileKey: string,
|
||||
): { nodes: GraphNode[]; edges: GraphEdge[] } {
|
||||
const nodes: GraphNode[] = [];
|
||||
const edges: GraphEdge[] = [];
|
||||
const tokenByStyleKey = new Map<string, string>();
|
||||
|
||||
// Only published styles/variables become token nodes (bounded set).
|
||||
for (const s of styles.meta?.styles ?? []) {
|
||||
const kind = STYLE_KIND[s.style_type] ?? "color";
|
||||
const id = `token:${kind}:${slug(s.name)}`;
|
||||
if (!tokenByStyleKey.has(s.key)) tokenByStyleKey.set(s.key, id);
|
||||
if (!nodes.some((n) => n.id === id)) {
|
||||
nodes.push({
|
||||
id, type: "token", name: s.name, summary: s.name,
|
||||
tags: ["token", kind], complexity: "simple",
|
||||
figmaMeta: { fileKey, tokenKind: kind },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const graphIdByFigmaId = new Map<string, string>();
|
||||
for (const n of structuralNodes) {
|
||||
if (n.figmaMeta?.nodeId) graphIdByFigmaId.set(n.figmaMeta.nodeId, n.id);
|
||||
}
|
||||
|
||||
const usesSeen = new Set<string>();
|
||||
function walk(n: FigmaNode, nearestConsumerId: string | undefined) {
|
||||
// Styles (fills/text/effect/grid) are usually applied to nested leaf
|
||||
// layers (TEXT/RECTANGLE/…), not to the shallow structural node itself.
|
||||
// Attribute a styled node's token usage to the nearest structural
|
||||
// ancestor (screen/component/componentSet/instance/page) so real consumer
|
||||
// relationships aren't dropped when the styled layer isn't itself a node.
|
||||
const consumerId = graphIdByFigmaId.get(n.id) ?? nearestConsumerId;
|
||||
if (consumerId && n.styles) {
|
||||
for (const localStyleId of Object.values(n.styles)) {
|
||||
// node.styles values are file-local style ids (e.g. "2:10") that
|
||||
// index the document's top-level styles map, while token nodes are
|
||||
// keyed by the global published key from /files/:key/styles. Bridge
|
||||
// local id → published key; fall back to a direct match for sources
|
||||
// that don't provide the top-level map.
|
||||
const styleKey = doc.styles?.[localStyleId]?.key ?? localStyleId;
|
||||
const tokenId = tokenByStyleKey.get(styleKey);
|
||||
if (tokenId) {
|
||||
const dedupe = `${consumerId}|${tokenId}`;
|
||||
if (!usesSeen.has(dedupe)) {
|
||||
usesSeen.add(dedupe);
|
||||
edges.push({ source: consumerId, target: tokenId, type: "uses_token", direction: "forward", weight: 0.5 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const c of n.children ?? []) walk(c, consumerId);
|
||||
}
|
||||
walk(doc.document, undefined);
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { FigmaSource, FigmaDocument, FigmaStyles } from "./types.js";
|
||||
|
||||
const FIGMA_API = "https://api.figma.com/v1";
|
||||
|
||||
export function parseFileKey(urlOrKey: string): string {
|
||||
const m = urlOrKey.match(/figma\.com\/(?:file|design)\/([A-Za-z0-9]+)/);
|
||||
if (m) return m[1];
|
||||
if (/^[A-Za-z0-9]+$/.test(urlOrKey.trim())) return urlOrKey.trim();
|
||||
throw new Error(`Could not parse a Figma file key from: ${urlOrKey}`);
|
||||
}
|
||||
|
||||
export class FigmaApiSource implements FigmaSource {
|
||||
private readonly token: string;
|
||||
|
||||
constructor(private readonly fileKey: string, token: string | undefined = process.env.FIGMA_TOKEN) {
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
"FIGMA_TOKEN is not set. Create a personal access token at " +
|
||||
"https://www.figma.com/settings, then run: export FIGMA_TOKEN=<token>",
|
||||
);
|
||||
}
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
private async get<T>(path: string): Promise<T> {
|
||||
// Token travels only in the header — never in the URL, never logged.
|
||||
const res = await fetch(`${FIGMA_API}${path}`, { headers: { "X-Figma-Token": this.token } });
|
||||
if (!res.ok) {
|
||||
throw new Error(`Figma API ${path} failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
fetchDocument(): Promise<FigmaDocument> {
|
||||
return this.get<FigmaDocument>(`/files/${this.fileKey}`);
|
||||
}
|
||||
|
||||
fetchStyles(): Promise<FigmaStyles> {
|
||||
return this.get<FigmaStyles>(`/files/${this.fileKey}/styles`);
|
||||
}
|
||||
|
||||
async renderImages(nodeIds: string[]): Promise<Record<string, string>> {
|
||||
if (nodeIds.length === 0) return {};
|
||||
const ids = encodeURIComponent(nodeIds.join(","));
|
||||
const data = await this.get<{ images: Record<string, string> }>(
|
||||
`/images/${this.fileKey}?ids=${ids}&format=png&scale=1`,
|
||||
);
|
||||
return data.images ?? {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export interface FigmaNode {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string; // DOCUMENT | CANVAS | FRAME | SECTION | COMPONENT | COMPONENT_SET | INSTANCE | TEXT | ...
|
||||
children?: FigmaNode[];
|
||||
componentId?: string; // on INSTANCE → main component node id
|
||||
absoluteBoundingBox?: { width: number; height: number } | null;
|
||||
styles?: Record<string, string>; // styleType (fill/text/effect/grid) → file-local style id, resolved via FigmaDocument.styles
|
||||
transitionNodeID?: string | null; // prototype target node id
|
||||
}
|
||||
|
||||
export interface FigmaDocument {
|
||||
name: string;
|
||||
document: FigmaNode; // root (DOCUMENT) whose children are CANVAS (pages)
|
||||
components?: Record<string, { key: string; name: string; componentSetId?: string }>;
|
||||
componentSets?: Record<string, { key: string; name: string }>;
|
||||
styles?: Record<string, { key: string; name?: string; styleType?: string }>; // file-local style id → published style; bridges node.styles to /files/:key/styles keys
|
||||
version?: string; // Figma file version (changes on every edit)
|
||||
lastModified?: string; // ISO timestamp
|
||||
}
|
||||
|
||||
export interface FigmaStyles {
|
||||
meta?: { styles?: Array<{ key: string; name: string; style_type: string }> };
|
||||
}
|
||||
|
||||
export interface FigmaSource {
|
||||
fetchDocument(): Promise<FigmaDocument>;
|
||||
fetchStyles(): Promise<FigmaStyles>;
|
||||
renderImages(nodeIds: string[]): Promise<Record<string, string>>;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { GraphNode } from "../types.js";
|
||||
|
||||
/**
|
||||
* Set `figmaMeta.thumbnailUrl` on screen nodes from a Figma image-render map
|
||||
* (Figma nodeId → pre-signed image URL). Mutates matching nodes in place and
|
||||
* returns how many were updated.
|
||||
*
|
||||
* Shared by figma-scan.mjs: the normal scan sets thumbnails on freshly parsed
|
||||
* nodes, and the UP_TO_DATE path re-renders and refreshes the existing graph's
|
||||
* thumbnails — the URLs are pre-signed and expire after a few hours, so a
|
||||
* re-run must refresh them or the dashboard shows broken sidebar previews.
|
||||
*/
|
||||
export function applyScreenThumbnails(
|
||||
nodes: GraphNode[],
|
||||
images: Record<string, string>,
|
||||
): number {
|
||||
let updated = 0;
|
||||
for (const n of nodes) {
|
||||
if (n.type !== "screen") continue;
|
||||
const figmaId = n.figmaMeta?.nodeId;
|
||||
if (!figmaId || !n.figmaMeta) continue;
|
||||
const url = images[figmaId];
|
||||
if (url) {
|
||||
n.figmaMeta.thumbnailUrl = url;
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import ignore, { type Ignore } from "ignore";
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { resolveUaDir } from "./persistence/index.js";
|
||||
|
||||
/**
|
||||
* Hardcoded default ignore patterns matching the project-scanner agent's
|
||||
@@ -80,7 +81,8 @@ export interface IgnoreFilter {
|
||||
*
|
||||
* Pattern load order (later entries can override earlier ones via ! negation):
|
||||
* 1. Hardcoded defaults
|
||||
* 2. .understand-anything/.understandignore (if exists)
|
||||
* 2. <ua-dir>/.understandignore (if exists — `.ua/`, or the legacy
|
||||
* `.understand-anything/` when that directory already exists)
|
||||
* 3. .understandignore at project root (if exists)
|
||||
* 4. CLI --exclude patterns (highest priority)
|
||||
*/
|
||||
@@ -90,8 +92,8 @@ export function createIgnoreFilter(projectRoot: string, extraPatterns: string[]
|
||||
// Layer 1: hardcoded defaults
|
||||
ig.add(DEFAULT_IGNORE_PATTERNS);
|
||||
|
||||
// Layer 2: .understand-anything/.understandignore
|
||||
const projectIgnorePath = join(projectRoot, ".understand-anything", ".understandignore");
|
||||
// Layer 2: <ua-dir>/.understandignore
|
||||
const projectIgnorePath = join(resolveUaDir(projectRoot), ".understandignore");
|
||||
if (existsSync(projectIgnorePath)) {
|
||||
const content = readFileSync(projectIgnorePath, "utf-8");
|
||||
ig.add(content);
|
||||
|
||||
@@ -94,6 +94,23 @@ const TEST_PATTERN_GROUPS: Array<{ label: string; patterns: string[] }> = [
|
||||
"**/*Benchmark.cpp",
|
||||
],
|
||||
},
|
||||
{
|
||||
// Python testing conventions are bimodal. Most projects (django,
|
||||
// flask, pandas, numpy) cluster tests inside a top-level tests/ dir,
|
||||
// where the existing directory rules already catch them. But Google-
|
||||
// style codebases (tensorflow, jax, some Meta libs) interleave
|
||||
// *_test.py directly alongside the module under test — e.g. tensor-
|
||||
// flow/python/ops/array_ops.py + array_ops_test.py — so file-pattern
|
||||
// rules add the majority of the token savings for that half of the
|
||||
// ecosystem.
|
||||
label: "Python",
|
||||
patterns: [
|
||||
"**/test_*.py",
|
||||
"**/*_test.py",
|
||||
"**/tests.py",
|
||||
"**/conftest.py",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,7 @@ import { rubyConfig } from "./ruby.js";
|
||||
import { phpConfig } from "./php.js";
|
||||
import { swiftConfig } from "./swift.js";
|
||||
import { kotlinConfig } from "./kotlin.js";
|
||||
import { scalaConfig } from "./scala.js";
|
||||
import { cConfig } from "./c.js";
|
||||
import { cppConfig } from "./cpp.js";
|
||||
import { dartConfig } from "./dart.js";
|
||||
@@ -54,6 +55,7 @@ export const builtinLanguageConfigs: LanguageConfig[] = [
|
||||
phpConfig,
|
||||
swiftConfig,
|
||||
kotlinConfig,
|
||||
scalaConfig,
|
||||
luaConfig,
|
||||
cConfig,
|
||||
cppConfig,
|
||||
@@ -100,6 +102,7 @@ export {
|
||||
phpConfig,
|
||||
swiftConfig,
|
||||
kotlinConfig,
|
||||
scalaConfig,
|
||||
luaConfig,
|
||||
cConfig,
|
||||
cppConfig,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { LanguageConfig } from "../types.js";
|
||||
|
||||
export const scalaConfig = {
|
||||
id: "scala",
|
||||
displayName: "Scala",
|
||||
extensions: [".scala", ".sc"],
|
||||
treeSitter: {
|
||||
wasmPackage: "tree-sitter-scala",
|
||||
wasmFile: "tree-sitter-scala.wasm",
|
||||
},
|
||||
concepts: [
|
||||
"case classes",
|
||||
"pattern matching",
|
||||
"traits",
|
||||
"implicits / given instances",
|
||||
"type classes",
|
||||
"higher-kinded types",
|
||||
"for-comprehensions",
|
||||
"effect systems (Cats Effect, ZIO)",
|
||||
"companion objects",
|
||||
"sealed hierarchies (ADTs)",
|
||||
],
|
||||
filePatterns: {
|
||||
entryPoints: ["**/Main.scala", "**/App.scala", "**/*Main.scala", "**/*App.scala"],
|
||||
barrels: ["**/package.scala"],
|
||||
tests: ["*Spec.scala", "*Suite.scala", "*Test.scala", "*Tests.scala"],
|
||||
config: ["build.sbt", "build.sc", "build.mill", "project/build.properties"],
|
||||
},
|
||||
} satisfies LanguageConfig;
|
||||
@@ -4,14 +4,29 @@ import type { KnowledgeGraph, AnalysisMeta, ProjectConfig } from "../types.js";
|
||||
import type { FingerprintStore } from "../fingerprint.js";
|
||||
import { validateGraph } from "../schema.js";
|
||||
|
||||
const UA_DIR = ".understand-anything";
|
||||
const UA_DIR = ".ua";
|
||||
const LEGACY_UA_DIR = ".understand-anything";
|
||||
const GRAPH_FILE = "knowledge-graph.json";
|
||||
const META_FILE = "meta.json";
|
||||
const FINGERPRINT_FILE = "fingerprints.json";
|
||||
const CONFIG_FILE = "config.json";
|
||||
|
||||
/**
|
||||
* Resolve the data directory NAME for a project. Projects analyzed before
|
||||
* the `.ua` rename keep their existing `.understand-anything/` for both
|
||||
* reads and writes (no migration needed); fresh projects get `.ua/`.
|
||||
*/
|
||||
export function resolveUaDirName(projectRoot: string): string {
|
||||
return existsSync(join(projectRoot, LEGACY_UA_DIR)) ? LEGACY_UA_DIR : UA_DIR;
|
||||
}
|
||||
|
||||
/** Absolute path of the project's data directory (see resolveUaDirName). */
|
||||
export function resolveUaDir(projectRoot: string): string {
|
||||
return join(projectRoot, resolveUaDirName(projectRoot));
|
||||
}
|
||||
|
||||
function ensureDir(projectRoot: string): string {
|
||||
const dir = join(projectRoot, UA_DIR);
|
||||
const dir = resolveUaDir(projectRoot);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
@@ -86,7 +101,7 @@ export function loadGraph(
|
||||
projectRoot: string,
|
||||
options?: { validate?: boolean },
|
||||
): KnowledgeGraph | null {
|
||||
const filePath = join(projectRoot, UA_DIR, GRAPH_FILE);
|
||||
const filePath = join(resolveUaDir(projectRoot), GRAPH_FILE);
|
||||
if (!existsSync(filePath)) return null;
|
||||
|
||||
const data = JSON.parse(readFileSync(filePath, "utf-8"));
|
||||
@@ -110,7 +125,7 @@ export function saveMeta(projectRoot: string, meta: AnalysisMeta): void {
|
||||
}
|
||||
|
||||
export function loadMeta(projectRoot: string): AnalysisMeta | null {
|
||||
const filePath = join(projectRoot, UA_DIR, META_FILE);
|
||||
const filePath = join(resolveUaDir(projectRoot), META_FILE);
|
||||
if (!existsSync(filePath)) return null;
|
||||
return JSON.parse(readFileSync(filePath, "utf-8")) as AnalysisMeta;
|
||||
}
|
||||
@@ -121,7 +136,7 @@ export function saveFingerprints(projectRoot: string, store: FingerprintStore):
|
||||
}
|
||||
|
||||
export function loadFingerprints(projectRoot: string): FingerprintStore | null {
|
||||
const filePath = join(projectRoot, UA_DIR, FINGERPRINT_FILE);
|
||||
const filePath = join(resolveUaDir(projectRoot), FINGERPRINT_FILE);
|
||||
if (!existsSync(filePath)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(filePath, "utf-8")) as FingerprintStore;
|
||||
@@ -138,7 +153,7 @@ export function saveConfig(projectRoot: string, config: ProjectConfig): void {
|
||||
}
|
||||
|
||||
export function loadConfig(projectRoot: string): ProjectConfig {
|
||||
const filePath = join(projectRoot, UA_DIR, CONFIG_FILE);
|
||||
const filePath = join(resolveUaDir(projectRoot), CONFIG_FILE);
|
||||
if (!existsSync(filePath)) return { ...DEFAULT_CONFIG };
|
||||
try {
|
||||
return JSON.parse(readFileSync(filePath, "utf-8")) as ProjectConfig;
|
||||
@@ -163,7 +178,7 @@ export function loadDomainGraph(
|
||||
projectRoot: string,
|
||||
options?: { validate?: boolean },
|
||||
): KnowledgeGraph | null {
|
||||
const filePath = join(projectRoot, UA_DIR, DOMAIN_GRAPH_FILE);
|
||||
const filePath = join(resolveUaDir(projectRoot), DOMAIN_GRAPH_FILE);
|
||||
if (!existsSync(filePath)) return null;
|
||||
|
||||
const data = JSON.parse(readFileSync(filePath, "utf-8"));
|
||||
|
||||
@@ -3,7 +3,8 @@ import { mkdtempSync, rmSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { saveGraph, loadGraph, saveMeta, loadMeta, saveFingerprints, loadFingerprints, saveConfig, loadConfig } from "./index.js";
|
||||
import { saveGraph, loadGraph, saveMeta, loadMeta, saveFingerprints, loadFingerprints, saveConfig, loadConfig, resolveUaDirName } from "./index.js";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import type { KnowledgeGraph, AnalysisMeta } from "../types.js";
|
||||
import type { FingerprintStore } from "../fingerprint.js";
|
||||
|
||||
@@ -75,10 +76,10 @@ describe("persistence", () => {
|
||||
};
|
||||
|
||||
describe("saveGraph / loadGraph", () => {
|
||||
it("should write knowledge-graph.json to .understand-anything/", () => {
|
||||
it("should write knowledge-graph.json to .ua/", () => {
|
||||
saveGraph(tempDir, sampleGraph);
|
||||
|
||||
const filePath = join(tempDir, ".understand-anything", "knowledge-graph.json");
|
||||
const filePath = join(tempDir, ".ua", "knowledge-graph.json");
|
||||
expect(existsSync(filePath)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -115,10 +116,10 @@ describe("persistence", () => {
|
||||
});
|
||||
|
||||
describe("saveMeta / loadMeta", () => {
|
||||
it("should write meta.json to .understand-anything/", () => {
|
||||
it("should write meta.json to .ua/", () => {
|
||||
saveMeta(tempDir, sampleMeta);
|
||||
|
||||
const filePath = join(tempDir, ".understand-anything", "meta.json");
|
||||
const filePath = join(tempDir, ".ua", "meta.json");
|
||||
expect(existsSync(filePath)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -168,7 +169,7 @@ describe("persistence", () => {
|
||||
});
|
||||
|
||||
it("should return null when fingerprints.json is corrupted", () => {
|
||||
const dir = join(tempDir, ".understand-anything");
|
||||
const dir = join(tempDir, ".ua");
|
||||
// Ensure the directory exists by saving first, then overwrite with garbage
|
||||
saveFingerprints(tempDir, sampleFingerprints);
|
||||
writeFileSync(join(dir, "fingerprints.json"), "{{not valid json!!", "utf-8");
|
||||
@@ -194,7 +195,7 @@ describe("persistence", () => {
|
||||
|
||||
it("should return default config when config.json is corrupted", () => {
|
||||
saveConfig(tempDir, { autoUpdate: true });
|
||||
const dir = join(tempDir, ".understand-anything");
|
||||
const dir = join(tempDir, ".ua");
|
||||
writeFileSync(join(dir, "config.json"), "not json!!", "utf-8");
|
||||
|
||||
const loaded = loadConfig(tempDir);
|
||||
@@ -202,3 +203,44 @@ describe("persistence", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy .understand-anything compatibility", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "ua-legacy-test-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("resolves to .ua for fresh projects", () => {
|
||||
expect(resolveUaDirName(tempDir)).toBe(".ua");
|
||||
});
|
||||
|
||||
it("keeps using an existing .understand-anything/ for reads and writes", () => {
|
||||
mkdirSync(join(tempDir, ".understand-anything"));
|
||||
expect(resolveUaDirName(tempDir)).toBe(".understand-anything");
|
||||
|
||||
saveMeta(tempDir, { analyzedAt: "t", gitCommitHash: "abc", fileCount: 1 } as never);
|
||||
expect(existsSync(join(tempDir, ".understand-anything", "meta.json"))).toBe(true);
|
||||
expect(existsSync(join(tempDir, ".ua"))).toBe(false);
|
||||
expect(loadMeta(tempDir)?.gitCommitHash).toBe("abc");
|
||||
});
|
||||
|
||||
it("reads a graph saved under the legacy directory", () => {
|
||||
mkdirSync(join(tempDir, ".understand-anything"));
|
||||
const graph = {
|
||||
version: "1.0.0",
|
||||
project: { name: "p", languages: [], frameworks: [], description: "d", analyzedAt: "t", gitCommitHash: "" },
|
||||
nodes: [{ id: "file:a.ts", type: "file", name: "a.ts", summary: "s", tags: [], complexity: "simple" }],
|
||||
edges: [],
|
||||
layers: [],
|
||||
tour: [],
|
||||
} as never;
|
||||
saveGraph(tempDir, graph);
|
||||
expect(existsSync(join(tempDir, ".understand-anything", "knowledge-graph.json"))).toBe(true);
|
||||
expect(loadGraph(tempDir)?.nodes).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
+537
@@ -0,0 +1,537 @@
|
||||
import { describe, it, expect, beforeAll } from "vitest";
|
||||
import { createRequire } from "node:module";
|
||||
import { ScalaExtractor } from "../scala-extractor.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
let Parser: any;
|
||||
let Language: any;
|
||||
let scalaLang: any;
|
||||
|
||||
beforeAll(async () => {
|
||||
const mod = await import("web-tree-sitter");
|
||||
Parser = mod.Parser;
|
||||
Language = mod.Language;
|
||||
await Parser.init();
|
||||
const wasmPath = require.resolve("tree-sitter-scala/tree-sitter-scala.wasm");
|
||||
scalaLang = await Language.load(wasmPath);
|
||||
});
|
||||
|
||||
function parse(code: string) {
|
||||
const parser = new Parser();
|
||||
parser.setLanguage(scalaLang);
|
||||
const tree = parser.parse(code);
|
||||
const root = tree.rootNode;
|
||||
return { tree, parser, root };
|
||||
}
|
||||
|
||||
describe("ScalaExtractor", () => {
|
||||
const extractor = new ScalaExtractor();
|
||||
|
||||
it("has correct languageIds", () => {
|
||||
expect(extractor.languageIds).toEqual(["scala"]);
|
||||
});
|
||||
|
||||
describe("extractStructure - functions", () => {
|
||||
it("extracts a Scala 3 top-level function with params and return type", () => {
|
||||
const { tree, parser, root } = parse(`def add(a: Int, b: Int): Int = a + b
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
expect(result.functions).toHaveLength(1);
|
||||
expect(result.functions[0].name).toBe("add");
|
||||
expect(result.functions[0].params).toEqual(["a", "b"]);
|
||||
expect(result.functions[0].returnType).toBe("Int");
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
|
||||
it("extracts a function with an inferred return type", () => {
|
||||
const { tree, parser, root } = parse(`def greet(name: String) = s"hello $name"
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
expect(result.functions).toHaveLength(1);
|
||||
expect(result.functions[0].name).toBe("greet");
|
||||
expect(result.functions[0].params).toEqual(["name"]);
|
||||
expect(result.functions[0].returnType).toBeUndefined();
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
|
||||
it("extracts curried and using-clause parameter lists", () => {
|
||||
const { tree, parser, root } = parse(
|
||||
`def run(a: Int)(b: String)(using ec: scala.concurrent.ExecutionContext): Unit = ()
|
||||
`,
|
||||
);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
expect(result.functions).toHaveLength(1);
|
||||
expect(result.functions[0].name).toBe("run");
|
||||
expect(result.functions[0].params).toContain("a");
|
||||
expect(result.functions[0].params).toContain("b");
|
||||
expect(result.functions[0].returnType).toBe("Unit");
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
|
||||
it("extracts an effect-typed function (Cats Effect IO)", () => {
|
||||
const { tree, parser, root } = parse(`import cats.effect.IO
|
||||
|
||||
def fetchUser(id: Long): IO[Option[String]] = IO.pure(None)
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
expect(result.functions).toHaveLength(1);
|
||||
expect(result.functions[0].name).toBe("fetchUser");
|
||||
expect(result.functions[0].returnType).toBe("IO[Option[String]]");
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
|
||||
it("extracts extension methods as top-level functions", () => {
|
||||
const { tree, parser, root } = parse(`extension (s: String)
|
||||
def shout: String = s.toUpperCase
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
expect(result.functions).toHaveLength(1);
|
||||
expect(result.functions[0].name).toBe("shout");
|
||||
expect(result.functions[0].returnType).toBe("String");
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractStructure - classes, traits, objects, enums", () => {
|
||||
it("extracts a case class with parameters as properties", () => {
|
||||
const { tree, parser, root } = parse(`case class User(id: Long, name: String)
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
expect(result.classes).toHaveLength(1);
|
||||
expect(result.classes[0].name).toBe("User");
|
||||
expect(result.classes[0].properties).toEqual(["id", "name"]);
|
||||
expect(result.classes[0].methods).toEqual([]);
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
|
||||
it("treats only val/var constructor params of a regular class as properties", () => {
|
||||
const { tree, parser, root } = parse(
|
||||
`class Service(val name: String, dep: Int, var counter: Long)
|
||||
`,
|
||||
);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
expect(result.classes).toHaveLength(1);
|
||||
expect(result.classes[0].properties).toEqual(["name", "counter"]);
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
|
||||
it("extracts a class with methods and val members", () => {
|
||||
const { tree, parser, root } = parse(`class UserService(repo: AnyRef) {
|
||||
private val cacheSize: Int = 128
|
||||
|
||||
def getUser(id: Long): Option[String] = None
|
||||
|
||||
private def logAccess(id: Long): Unit = ()
|
||||
}
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
expect(result.classes).toHaveLength(1);
|
||||
expect(result.classes[0].name).toBe("UserService");
|
||||
expect(result.classes[0].methods).toEqual(["getUser", "logAccess"]);
|
||||
expect(result.classes[0].properties).toEqual(["cacheSize"]);
|
||||
|
||||
// Methods also land in the top-level functions array
|
||||
const names = result.functions.map((f) => f.name);
|
||||
expect(names).toEqual(["getUser", "logAccess"]);
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
|
||||
it("extracts a trait with abstract method declarations", () => {
|
||||
const { tree, parser, root } = parse(`trait UserRepo[F[_]] {
|
||||
def find(id: Long): F[Option[String]]
|
||||
}
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
expect(result.classes).toHaveLength(1);
|
||||
expect(result.classes[0].name).toBe("UserRepo");
|
||||
expect(result.classes[0].methods).toEqual(["find"]);
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
|
||||
it("extracts an object and recurses into companion-object ADT members", () => {
|
||||
const { tree, parser, root } = parse(`sealed trait Command
|
||||
|
||||
object Command {
|
||||
final case class Create(name: String) extends Command
|
||||
case object Refresh extends Command
|
||||
}
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
const names = result.classes.map((c) => c.name);
|
||||
expect(names).toContain("Command"); // trait + object entries
|
||||
expect(names).toContain("Create");
|
||||
expect(names).toContain("Refresh");
|
||||
const create = result.classes.find((c) => c.name === "Create")!;
|
||||
expect(create.properties).toEqual(["name"]);
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
|
||||
it("extracts extension methods inside objects", () => {
|
||||
const { tree, parser, root } = parse(`object syntax {
|
||||
extension (s: String)
|
||||
def shout: String = s.toUpperCase
|
||||
}
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
const syntax = result.classes.find((c) => c.name === "syntax");
|
||||
expect(syntax?.methods).toContain("shout");
|
||||
expect(result.functions.map((f) => f.name)).toContain("shout");
|
||||
expect(result.exports.map((e) => e.name)).toEqual(
|
||||
expect.arrayContaining(["syntax", "shout"]),
|
||||
);
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
|
||||
it("extracts declarations inside braced package clauses", () => {
|
||||
const { tree, parser, root } = parse(`package com.example {
|
||||
class Foo
|
||||
|
||||
object Bar {
|
||||
def run(): Unit = ()
|
||||
}
|
||||
}
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
expect(result.classes.map((c) => c.name)).toEqual(["Foo", "Bar"]);
|
||||
expect(result.functions.map((f) => f.name)).toEqual(["run"]);
|
||||
expect(result.exports.map((e) => e.name)).toEqual(
|
||||
expect.arrayContaining(["Foo", "run", "Bar"]),
|
||||
);
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
|
||||
it("extracts package objects with their members", () => {
|
||||
const { tree, parser, root } = parse(`package com.example
|
||||
|
||||
package object syntax {
|
||||
val defaultTimeout: Int = 30
|
||||
def helper(x: Int): Int = x
|
||||
}
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
expect(result.classes).toHaveLength(1);
|
||||
expect(result.classes[0].name).toBe("syntax");
|
||||
expect(result.classes[0].properties).toEqual(["defaultTimeout"]);
|
||||
expect(result.classes[0].methods).toEqual(["helper"]);
|
||||
expect(result.functions.map((f) => f.name)).toEqual(["helper"]);
|
||||
expect(result.exports.map((e) => e.name)).toEqual(
|
||||
expect.arrayContaining(["defaultTimeout", "helper", "syntax"]),
|
||||
);
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
|
||||
it("extracts a Scala 3 enum with its cases as properties", () => {
|
||||
const { tree, parser, root } = parse(`enum Color {
|
||||
case Red, Green, Blue
|
||||
}
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
expect(result.classes).toHaveLength(1);
|
||||
expect(result.classes[0].name).toBe("Color");
|
||||
expect(result.classes[0].properties).toEqual(["Red", "Green", "Blue"]);
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe("extractStructure - imports", () => {
|
||||
it("extracts a plain import", () => {
|
||||
const { tree, parser, root } = parse(`import cats.effect.IO
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
expect(result.imports).toHaveLength(1);
|
||||
expect(result.imports[0].source).toBe("cats.effect.IO");
|
||||
expect(result.imports[0].specifiers).toEqual(["IO"]);
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
|
||||
it("extracts multiple importers from one import declaration", () => {
|
||||
const { tree, parser, root } = parse(`import cats.effect.IO, scala.concurrent.Future
|
||||
import cats.effect.{Resource, ExitCode}, scala.concurrent.duration.*
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
expect(result.imports).toHaveLength(4);
|
||||
expect(result.imports.map((i) => i.source)).toEqual([
|
||||
"cats.effect.IO",
|
||||
"scala.concurrent.Future",
|
||||
"cats.effect",
|
||||
"scala.concurrent.duration",
|
||||
]);
|
||||
expect(result.imports.map((i) => i.specifiers)).toEqual([
|
||||
["IO"],
|
||||
["Future"],
|
||||
["Resource", "ExitCode"],
|
||||
["*"],
|
||||
]);
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
|
||||
it("extracts a selector-list import", () => {
|
||||
const { tree, parser, root } = parse(`import cats.effect.{IO, Resource}
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
expect(result.imports).toHaveLength(1);
|
||||
expect(result.imports[0].source).toBe("cats.effect");
|
||||
expect(result.imports[0].specifiers).toEqual(["IO", "Resource"]);
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
|
||||
it("extracts Scala 2 and Scala 3 wildcard imports", () => {
|
||||
const { tree, parser, root } = parse(`import cats.syntax.all._
|
||||
import scala.concurrent.duration.*
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
expect(result.imports).toHaveLength(2);
|
||||
expect(result.imports[0].source).toBe("cats.syntax.all");
|
||||
expect(result.imports[0].specifiers).toEqual(["*"]);
|
||||
expect(result.imports[1].source).toBe("scala.concurrent.duration");
|
||||
expect(result.imports[1].specifiers).toEqual(["*"]);
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
|
||||
it("extracts source names for renamed imports (Scala 2 arrow and Scala 3 as)", () => {
|
||||
const { tree, parser, root } = parse(`import cats.effect.{IO => Effect}
|
||||
import cats.effect.kernel.{Async as AsyncEff}
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
expect(result.imports).toHaveLength(2);
|
||||
expect(result.imports[0].specifiers).toEqual(["IO"]);
|
||||
expect(result.imports[1].specifiers).toEqual(["Async"]);
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
|
||||
it("does not treat excluded renamed imports as imported specifiers", () => {
|
||||
const { tree, parser, root } = parse(`import cats.effect.{IO, Resource => _, Async as AsyncEff}
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
expect(result.imports).toHaveLength(1);
|
||||
expect(result.imports[0].source).toBe("cats.effect");
|
||||
expect(result.imports[0].specifiers).toEqual(["IO", "Async"]);
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractStructure - exports and visibility", () => {
|
||||
it("treats public declarations as exported and private ones as internal", () => {
|
||||
const { tree, parser, root } = parse(`class Api {
|
||||
def visible(): Unit = ()
|
||||
private def hidden(): Unit = ()
|
||||
protected def inherited(): Unit = ()
|
||||
}
|
||||
|
||||
private class Internal
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
const exported = result.exports.map((e) => e.name);
|
||||
expect(exported).toContain("Api");
|
||||
expect(exported).toContain("visible");
|
||||
expect(exported).toContain("inherited");
|
||||
expect(exported).not.toContain("hidden");
|
||||
expect(exported).not.toContain("Internal");
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
|
||||
it("does not export public members inherited from a private outer type", () => {
|
||||
const { tree, parser, root } = parse(`private class Internal {
|
||||
def leak(): Unit = ()
|
||||
}
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
const exported = result.exports.map((e) => e.name);
|
||||
expect(exported).not.toContain("Internal");
|
||||
expect(exported).not.toContain("leak");
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
|
||||
it("treats private[scope] as not exported", () => {
|
||||
const { tree, parser, root } = parse(`private[service] def helper(): Unit = ()
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
expect(result.exports.map((e) => e.name)).not.toContain("helper");
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
|
||||
it("exports Scala 3 top-level vals and given instances", () => {
|
||||
const { tree, parser, root } = parse(`val defaultTimeout: Int = 30
|
||||
|
||||
given intOrd: Ordering[Int] = Ordering.Int
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
const exported = result.exports.map((e) => e.name);
|
||||
expect(exported).toContain("defaultTimeout");
|
||||
expect(exported).toContain("intOrd");
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
|
||||
it("extracts Scala 3 export declarations", () => {
|
||||
const { tree, parser, root } = parse(`export service.{run as start, stop}
|
||||
export config.defaultTimeout
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
expect(result.exports.map((e) => e.name)).toEqual([
|
||||
"start",
|
||||
"stop",
|
||||
"defaultTimeout",
|
||||
]);
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractCallGraph", () => {
|
||||
it("extracts direct and method calls with the enclosing caller", () => {
|
||||
const { tree, parser, root } = parse(`object Main {
|
||||
def run(args: List[String]): Unit = {
|
||||
val svc = helper(args)
|
||||
svc.getUser(1L)
|
||||
}
|
||||
|
||||
def helper(args: List[String]): AnyRef = null
|
||||
}
|
||||
`);
|
||||
const entries = extractor.extractCallGraph(root);
|
||||
|
||||
expect(entries).toContainEqual(
|
||||
expect.objectContaining({ caller: "run", callee: "helper" }),
|
||||
);
|
||||
expect(entries).toContainEqual(
|
||||
expect.objectContaining({ caller: "run", callee: "getUser" }),
|
||||
);
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
|
||||
it("extracts generic calls and ignores calls outside functions", () => {
|
||||
const { tree, parser, root } = parse(`val eager = compute(1)
|
||||
|
||||
def caller(): Unit = {
|
||||
helper[Int](1)
|
||||
IO.pure[String]("x")
|
||||
}
|
||||
`);
|
||||
const entries = extractor.extractCallGraph(root);
|
||||
|
||||
const callees = entries.map((e) => e.callee);
|
||||
expect(callees).toContain("helper");
|
||||
expect(callees).toContain("pure");
|
||||
// `compute(1)` is not inside a function definition
|
||||
expect(callees).not.toContain("compute");
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
|
||||
it("extracts infix and constructor calls", () => {
|
||||
const { tree, parser, root } = parse(`def caller(xs: List[Int]): Unit = {
|
||||
xs map println
|
||||
val x = new Foo()
|
||||
}
|
||||
`);
|
||||
const entries = extractor.extractCallGraph(root);
|
||||
|
||||
expect(entries).toContainEqual(
|
||||
expect.objectContaining({ caller: "caller", callee: "map" }),
|
||||
);
|
||||
expect(entries).toContainEqual(
|
||||
expect.objectContaining({ caller: "caller", callee: "Foo" }),
|
||||
);
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
|
||||
it("tracks nested for-comprehension style calls (Cats Effect)", () => {
|
||||
const { tree, parser, root } = parse(`import cats.effect.IO
|
||||
|
||||
def program(): IO[Unit] = {
|
||||
IO.println("start").flatMap(_ => IO.println("done"))
|
||||
}
|
||||
`);
|
||||
const entries = extractor.extractCallGraph(root);
|
||||
|
||||
const callees = entries.map((e) => e.callee);
|
||||
expect(callees).toContain("println");
|
||||
expect(callees).toContain("flatMap");
|
||||
expect(entries.every((e) => e.caller === "program")).toBe(true);
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ export { CSharpExtractor } from "./csharp-extractor.js";
|
||||
export { DartExtractor } from "./dart-extractor.js";
|
||||
export { KotlinExtractor } from "./kotlin-extractor.js";
|
||||
export { SwiftExtractor } from "./swift-extractor.js";
|
||||
export { ScalaExtractor } from "./scala-extractor.js";
|
||||
|
||||
import type { LanguageExtractor } from "./types.js";
|
||||
import { TypeScriptExtractor } from "./typescript-extractor.js";
|
||||
@@ -26,6 +27,7 @@ import { CSharpExtractor } from "./csharp-extractor.js";
|
||||
import { DartExtractor } from "./dart-extractor.js";
|
||||
import { KotlinExtractor } from "./kotlin-extractor.js";
|
||||
import { SwiftExtractor } from "./swift-extractor.js";
|
||||
import { ScalaExtractor } from "./scala-extractor.js";
|
||||
|
||||
export const builtinExtractors: LanguageExtractor[] = [
|
||||
new TypeScriptExtractor(),
|
||||
@@ -40,4 +42,5 @@ export const builtinExtractors: LanguageExtractor[] = [
|
||||
new DartExtractor(),
|
||||
new KotlinExtractor(),
|
||||
new SwiftExtractor(),
|
||||
new ScalaExtractor(),
|
||||
];
|
||||
|
||||
@@ -0,0 +1,604 @@
|
||||
import type { StructuralAnalysis, CallGraphEntry } from "../../types.js";
|
||||
import type { LanguageExtractor, TreeSitterNode } from "./types.js";
|
||||
import { findChild, findChildren } from "./base-extractor.js";
|
||||
|
||||
/** Node types that declare a Scala type (all map to `classes` in the graph). */
|
||||
const TYPE_DEFINITION_KINDS = new Set([
|
||||
"class_definition",
|
||||
"trait_definition",
|
||||
"object_definition",
|
||||
"package_object",
|
||||
"enum_definition",
|
||||
]);
|
||||
|
||||
/** Node types that declare a function (with or without a body). */
|
||||
const FUNCTION_DEFINITION_KINDS = new Set([
|
||||
"function_definition",
|
||||
"function_declaration",
|
||||
]);
|
||||
|
||||
/** Node types that declare a field/value member. */
|
||||
const FIELD_DEFINITION_KINDS = new Set([
|
||||
"val_definition",
|
||||
"var_definition",
|
||||
"val_declaration",
|
||||
"var_declaration",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Extract the access-modifier text (e.g. "private", "private[pkg]") from a
|
||||
* declaration's `modifiers` child, or null when no access modifier is present.
|
||||
*
|
||||
* Scala's default visibility is public, so `null` means the declaration IS
|
||||
* exported — callers must treat absence as exported.
|
||||
*/
|
||||
function extractAccessModifier(declNode: TreeSitterNode): string | null {
|
||||
const modifiers = findChild(declNode, "modifiers");
|
||||
if (!modifiers) return null;
|
||||
const access = findChild(modifiers, "access_modifier");
|
||||
if (!access) return null;
|
||||
return access.text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a Scala declaration is visible to other files.
|
||||
*
|
||||
* Default visibility is public, so a declaration with no access modifier
|
||||
* counts as exported. Only `private` (including `private[scope]`) opts out;
|
||||
* `protected` remains exported in the project-graph sense because it is
|
||||
* still resolvable from other files via inheritance.
|
||||
*/
|
||||
function isExported(declNode: TreeSitterNode): boolean {
|
||||
const access = extractAccessModifier(declNode);
|
||||
return access === null || !access.startsWith("private");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the name of a Scala declaration: the first direct `identifier` child
|
||||
* (the keyword and optional modifiers precede it, type/value parameters
|
||||
* follow it).
|
||||
*/
|
||||
function extractDeclarationName(declNode: TreeSitterNode): string | null {
|
||||
for (let i = 0; i < declNode.childCount; i++) {
|
||||
const child = declNode.child(i);
|
||||
if (child && child.type === "identifier") return child.text;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract parameter names from a function-like definition. Scala functions
|
||||
* may carry several parameter lists (currying / implicit / using clauses):
|
||||
* every direct `parameters` child contributes its `parameter` names in order.
|
||||
*/
|
||||
function extractParams(declNode: TreeSitterNode): string[] {
|
||||
const params: string[] = [];
|
||||
for (const paramList of findChildren(declNode, "parameters")) {
|
||||
for (const param of findChildren(paramList, "parameter")) {
|
||||
const id = findChild(param, "identifier");
|
||||
if (id) params.push(id.text);
|
||||
}
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the declared return type from a function-like definition. The
|
||||
* grammar puts the return-type annotation as a direct `:` token followed by
|
||||
* a named type node (`def f(x: Int): IO[Unit] = ...`). Returns undefined
|
||||
* when the return type is inferred.
|
||||
*/
|
||||
function extractReturnType(declNode: TreeSitterNode): string | undefined {
|
||||
for (let i = 0; i < declNode.childCount; i++) {
|
||||
const child = declNode.child(i);
|
||||
if (!child || child.type !== ":") continue;
|
||||
for (let j = i + 1; j < declNode.childCount; j++) {
|
||||
const next = declNode.child(j);
|
||||
if (next && next.isNamed) return next.text;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a `class_definition` is a case class (carries a leading `case`
|
||||
* keyword token). Case-class parameters are public vals, so they all count
|
||||
* as properties.
|
||||
*/
|
||||
function isCaseDefinition(declNode: TreeSitterNode): boolean {
|
||||
for (let i = 0; i < declNode.childCount; i++) {
|
||||
const child = declNode.child(i);
|
||||
if (child && child.type === "case") return true;
|
||||
if (child && child.type === "identifier") break;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect constructor parameters that are properties. For case classes every
|
||||
* `class_parameter` is a public val; for regular classes only parameters
|
||||
* with an explicit `val` / `var` keyword become fields.
|
||||
*/
|
||||
function collectClassParameterProperties(
|
||||
declNode: TreeSitterNode,
|
||||
properties: string[],
|
||||
): void {
|
||||
const caseClass = isCaseDefinition(declNode);
|
||||
for (const paramList of findChildren(declNode, "class_parameters")) {
|
||||
for (const param of findChildren(paramList, "class_parameter")) {
|
||||
let isProperty = caseClass;
|
||||
if (!isProperty) {
|
||||
for (let i = 0; i < param.childCount; i++) {
|
||||
const child = param.child(i);
|
||||
if (child && (child.type === "val" || child.type === "var")) {
|
||||
isProperty = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!isProperty) continue;
|
||||
const id = findChild(param, "identifier");
|
||||
if (id) properties.push(id.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the name of a val/var member. The grammar puts the binding name
|
||||
* as a direct `identifier` child (tuple/pattern bindings have no single
|
||||
* identifier and are skipped).
|
||||
*/
|
||||
function extractFieldName(fieldNode: TreeSitterNode): string | null {
|
||||
return extractDeclarationName(fieldNode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scala extractor for tree-sitter structural analysis and call graph
|
||||
* extraction. Covers Scala 2 and Scala 3 syntax: classes, case classes,
|
||||
* traits, objects, enums, top-level and member functions, extension
|
||||
* methods, and the three import shapes (plain, selector list, wildcard).
|
||||
*/
|
||||
export class ScalaExtractor implements LanguageExtractor {
|
||||
readonly languageIds = ["scala"];
|
||||
|
||||
extractStructure(rootNode: TreeSitterNode): StructuralAnalysis {
|
||||
const functions: StructuralAnalysis["functions"] = [];
|
||||
const classes: StructuralAnalysis["classes"] = [];
|
||||
const imports: StructuralAnalysis["imports"] = [];
|
||||
const exports: StructuralAnalysis["exports"] = [];
|
||||
|
||||
this.walkTopLevel(rootNode, functions, classes, imports, exports);
|
||||
|
||||
return { functions, classes, imports, exports };
|
||||
}
|
||||
|
||||
extractCallGraph(rootNode: TreeSitterNode): CallGraphEntry[] {
|
||||
const entries: CallGraphEntry[] = [];
|
||||
const functionStack: string[] = [];
|
||||
|
||||
const walk = (node: TreeSitterNode) => {
|
||||
let pushed = false;
|
||||
|
||||
if (node.type === "function_definition") {
|
||||
const name = extractDeclarationName(node);
|
||||
if (name) {
|
||||
functionStack.push(name);
|
||||
pushed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (functionStack.length > 0) {
|
||||
const callee = this.extractCallLikeName(node);
|
||||
if (callee) {
|
||||
entries.push({
|
||||
caller: functionStack[functionStack.length - 1],
|
||||
callee,
|
||||
lineNumber: node.startPosition.row + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (child) walk(child);
|
||||
}
|
||||
|
||||
if (pushed) functionStack.pop();
|
||||
};
|
||||
|
||||
walk(rootNode);
|
||||
return entries;
|
||||
}
|
||||
|
||||
// ---- Private helpers ----
|
||||
|
||||
/**
|
||||
* Walk the direct children of the compilation unit (or of a braceless
|
||||
* `package foo { ... }` / top-level region) and dispatch declarations.
|
||||
*/
|
||||
private walkTopLevel(
|
||||
node: TreeSitterNode,
|
||||
functions: StructuralAnalysis["functions"],
|
||||
classes: StructuralAnalysis["classes"],
|
||||
imports: StructuralAnalysis["imports"],
|
||||
exports: StructuralAnalysis["exports"],
|
||||
): void {
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (!child) continue;
|
||||
|
||||
if (child.type === "package_clause") {
|
||||
// Package is metadata about this file, not a graph member — but a
|
||||
// `package foo { ... }` block nests real declarations underneath.
|
||||
this.walkTopLevel(child, functions, classes, imports, exports);
|
||||
} else if (child.type === "template_body") {
|
||||
// Braced package clauses wrap top-level declarations in a template body.
|
||||
this.walkTopLevel(child, functions, classes, imports, exports);
|
||||
} else if (child.type === "import_declaration") {
|
||||
this.extractImport(child, imports);
|
||||
} else if (child.type === "export_declaration") {
|
||||
this.extractExportDeclaration(child, exports);
|
||||
} else if (FUNCTION_DEFINITION_KINDS.has(child.type)) {
|
||||
this.extractFunction(child, functions, exports);
|
||||
} else if (TYPE_DEFINITION_KINDS.has(child.type)) {
|
||||
this.extractTypeDefinition(child, classes, functions, exports);
|
||||
} else if (FIELD_DEFINITION_KINDS.has(child.type)) {
|
||||
// Scala 3 top-level val/var
|
||||
const name = extractFieldName(child);
|
||||
if (name && isExported(child)) {
|
||||
exports.push({ name, lineNumber: child.startPosition.row + 1 });
|
||||
}
|
||||
} else if (child.type === "extension_definition") {
|
||||
// Extension methods are surfaced as top-level functions.
|
||||
this.extractExtensionDefinition(child, null, functions, exports);
|
||||
} else if (child.type === "given_definition") {
|
||||
const name = extractDeclarationName(child);
|
||||
if (name && isExported(child)) {
|
||||
exports.push({ name, lineNumber: child.startPosition.row + 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extractFunction(
|
||||
declNode: TreeSitterNode,
|
||||
functions: StructuralAnalysis["functions"],
|
||||
exports: StructuralAnalysis["exports"],
|
||||
exportAllowed = true,
|
||||
): void {
|
||||
const name = extractDeclarationName(declNode);
|
||||
if (!name) return;
|
||||
functions.push({
|
||||
name,
|
||||
lineRange: [declNode.startPosition.row + 1, declNode.endPosition.row + 1],
|
||||
params: extractParams(declNode),
|
||||
returnType: extractReturnType(declNode),
|
||||
});
|
||||
if (exportAllowed && isExported(declNode)) {
|
||||
exports.push({ name, lineNumber: declNode.startPosition.row + 1 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a class / trait / object / enum definition. Nested type
|
||||
* definitions inside the body (the companion-object ADT idiom:
|
||||
* `object Command { case class Create(...) }`) are recursed into and
|
||||
* surfaced as their own class entries.
|
||||
*/
|
||||
private extractTypeDefinition(
|
||||
declNode: TreeSitterNode,
|
||||
classes: StructuralAnalysis["classes"],
|
||||
functions: StructuralAnalysis["functions"],
|
||||
exports: StructuralAnalysis["exports"],
|
||||
exportAllowed = true,
|
||||
): void {
|
||||
const name = extractDeclarationName(declNode);
|
||||
if (!name) return;
|
||||
|
||||
const properties: string[] = [];
|
||||
const methods: string[] = [];
|
||||
const memberExportAllowed = exportAllowed && isExported(declNode);
|
||||
|
||||
// 1. Constructor `val`/`var` (and all case-class) parameters.
|
||||
collectClassParameterProperties(declNode, properties);
|
||||
|
||||
// 2. Body members, if any (`class Empty` / `case class Point(...)`
|
||||
// have no template_body). Enums keep cases in an `enum_body`.
|
||||
const body =
|
||||
findChild(declNode, "template_body") ?? findChild(declNode, "enum_body");
|
||||
if (body) {
|
||||
this.collectTemplateBody(
|
||||
body,
|
||||
methods,
|
||||
properties,
|
||||
classes,
|
||||
functions,
|
||||
exports,
|
||||
memberExportAllowed,
|
||||
);
|
||||
}
|
||||
|
||||
classes.push({
|
||||
name,
|
||||
lineRange: [declNode.startPosition.row + 1, declNode.endPosition.row + 1],
|
||||
methods,
|
||||
properties,
|
||||
});
|
||||
|
||||
if (memberExportAllowed) {
|
||||
exports.push({ name, lineNumber: declNode.startPosition.row + 1 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a `template_body` / `enum_body` and collect member functions and
|
||||
* fields. Function entries are added to both the type's `methods` array
|
||||
* and the top-level `functions` array (matching the Go / Swift / Kotlin
|
||||
* extractor convention).
|
||||
*/
|
||||
private collectTemplateBody(
|
||||
body: TreeSitterNode,
|
||||
methods: string[],
|
||||
properties: string[],
|
||||
classes: StructuralAnalysis["classes"],
|
||||
functions: StructuralAnalysis["functions"],
|
||||
exports: StructuralAnalysis["exports"],
|
||||
exportAllowed = true,
|
||||
): void {
|
||||
for (let i = 0; i < body.childCount; i++) {
|
||||
const member = body.child(i);
|
||||
if (!member) continue;
|
||||
|
||||
if (FUNCTION_DEFINITION_KINDS.has(member.type)) {
|
||||
const name = extractDeclarationName(member);
|
||||
if (!name) continue;
|
||||
methods.push(name);
|
||||
functions.push({
|
||||
name,
|
||||
lineRange: [member.startPosition.row + 1, member.endPosition.row + 1],
|
||||
params: extractParams(member),
|
||||
returnType: extractReturnType(member),
|
||||
});
|
||||
if (exportAllowed && isExported(member)) {
|
||||
exports.push({ name, lineNumber: member.startPosition.row + 1 });
|
||||
}
|
||||
} else if (FIELD_DEFINITION_KINDS.has(member.type)) {
|
||||
const name = extractFieldName(member);
|
||||
if (!name) continue;
|
||||
properties.push(name);
|
||||
if (exportAllowed && isExported(member)) {
|
||||
exports.push({ name, lineNumber: member.startPosition.row + 1 });
|
||||
}
|
||||
} else if (TYPE_DEFINITION_KINDS.has(member.type)) {
|
||||
this.extractTypeDefinition(member, classes, functions, exports, exportAllowed);
|
||||
} else if (member.type === "extension_definition") {
|
||||
this.extractExtensionDefinition(
|
||||
member,
|
||||
methods,
|
||||
functions,
|
||||
exports,
|
||||
exportAllowed,
|
||||
);
|
||||
} else if (member.type === "enum_case_definitions") {
|
||||
// `case Red, Green` inside an enum body — each case is a property.
|
||||
for (let j = 0; j < member.childCount; j++) {
|
||||
const enumCase = member.child(j);
|
||||
if (!enumCase || !enumCase.isNamed) continue;
|
||||
const id = findChild(enumCase, "identifier");
|
||||
if (id) properties.push(id.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a Scala import. The dotted prefix is a run of direct
|
||||
* `identifier` children; the trailing element decides the shape:
|
||||
*
|
||||
* - `import cats.effect.IO` → source="cats.effect.IO", specifiers=["IO"]
|
||||
* - `import cats.effect._` / `.*` → source="cats.effect", specifiers=["*"]
|
||||
* - `import a.{B, C => D, E as F}` → source="a", specifiers=["B", "D", "F"]
|
||||
*/
|
||||
private extractImport(
|
||||
declNode: TreeSitterNode,
|
||||
imports: StructuralAnalysis["imports"],
|
||||
): void {
|
||||
const itemChildren: TreeSitterNode[][] = [];
|
||||
let current: TreeSitterNode[] = [];
|
||||
|
||||
for (let i = 0; i < declNode.childCount; i++) {
|
||||
const child = declNode.child(i);
|
||||
if (!child) continue;
|
||||
if (child.type === ",") {
|
||||
if (current.length > 0) itemChildren.push(current);
|
||||
current = [];
|
||||
} else if (child.isNamed) {
|
||||
current.push(child);
|
||||
}
|
||||
}
|
||||
if (current.length > 0) itemChildren.push(current);
|
||||
|
||||
for (const item of itemChildren) {
|
||||
this.extractImportItem(item, declNode.startPosition.row + 1, imports);
|
||||
}
|
||||
}
|
||||
|
||||
private extractImportItem(
|
||||
itemChildren: TreeSitterNode[],
|
||||
lineNumber: number,
|
||||
imports: StructuralAnalysis["imports"],
|
||||
): void {
|
||||
const parts: string[] = [];
|
||||
for (const child of itemChildren) {
|
||||
if (child.type === "identifier") parts.push(child.text);
|
||||
}
|
||||
|
||||
const selectors = itemChildren.find((child) => child.type === "namespace_selectors");
|
||||
const wildcard = itemChildren.find((child) => child.type === "namespace_wildcard");
|
||||
|
||||
let source: string;
|
||||
let specifiers: string[];
|
||||
|
||||
if (wildcard) {
|
||||
if (parts.length === 0) return;
|
||||
source = parts.join(".");
|
||||
specifiers = ["*"];
|
||||
} else if (selectors) {
|
||||
if (parts.length === 0) return;
|
||||
source = parts.join(".");
|
||||
specifiers = this.extractSelectorSpecifiers(selectors);
|
||||
if (specifiers.length === 0) specifiers = ["*"];
|
||||
} else {
|
||||
if (parts.length === 0) return;
|
||||
source = parts.join(".");
|
||||
specifiers = [parts[parts.length - 1]];
|
||||
}
|
||||
|
||||
imports.push({
|
||||
source,
|
||||
specifiers,
|
||||
lineNumber,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the imported names from a `{ ... }` selector list. Renames
|
||||
* (`A => B` in Scala 2, `A as B` in Scala 3) surface the source name so
|
||||
* file resolution can still probe `A.scala`; excluded `A => _` selectors
|
||||
* are skipped. `given` / `*` selectors surface as "*".
|
||||
*/
|
||||
private extractSelectorSpecifiers(selectors: TreeSitterNode): string[] {
|
||||
const specifiers: string[] = [];
|
||||
for (let i = 0; i < selectors.childCount; i++) {
|
||||
const child = selectors.child(i);
|
||||
if (!child || !child.isNamed) continue;
|
||||
|
||||
if (child.type === "identifier") {
|
||||
specifiers.push(child.text);
|
||||
} else if (child.type === "namespace_wildcard") {
|
||||
specifiers.push("*");
|
||||
} else {
|
||||
// Renamed selector (arrow_renamed_identifier / as_renamed_identifier):
|
||||
// the source name is the FIRST identifier child.
|
||||
if (findChild(child, "wildcard")) continue;
|
||||
const ids = findChildren(child, "identifier");
|
||||
if (ids.length > 0) specifiers.push(ids[0].text);
|
||||
}
|
||||
}
|
||||
return specifiers;
|
||||
}
|
||||
|
||||
private extractExtensionDefinition(
|
||||
declNode: TreeSitterNode,
|
||||
methods: string[] | null,
|
||||
functions: StructuralAnalysis["functions"],
|
||||
exports: StructuralAnalysis["exports"],
|
||||
exportAllowed = true,
|
||||
): void {
|
||||
for (const fn of findChildren(declNode, "function_definition")) {
|
||||
const name = extractDeclarationName(fn);
|
||||
if (name && methods) methods.push(name);
|
||||
this.extractFunction(fn, functions, exports, exportAllowed);
|
||||
}
|
||||
}
|
||||
|
||||
private extractExportDeclaration(
|
||||
declNode: TreeSitterNode,
|
||||
exports: StructuralAnalysis["exports"],
|
||||
): void {
|
||||
const selectors = findChild(declNode, "namespace_selectors");
|
||||
const names = selectors
|
||||
? this.extractExportSelectorNames(selectors)
|
||||
: this.extractExportedPathName(declNode);
|
||||
|
||||
for (const name of names) {
|
||||
if (name !== "*") {
|
||||
exports.push({ name, lineNumber: declNode.startPosition.row + 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extractExportSelectorNames(selectors: TreeSitterNode): string[] {
|
||||
const names: string[] = [];
|
||||
for (let i = 0; i < selectors.childCount; i++) {
|
||||
const child = selectors.child(i);
|
||||
if (!child || !child.isNamed) continue;
|
||||
|
||||
if (child.type === "identifier") {
|
||||
names.push(child.text);
|
||||
} else if (child.type === "namespace_wildcard") {
|
||||
names.push("*");
|
||||
} else if (!findChild(child, "wildcard")) {
|
||||
const ids = findChildren(child, "identifier");
|
||||
if (ids.length > 0) names.push(ids[ids.length - 1].text);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
private extractExportedPathName(declNode: TreeSitterNode): string[] {
|
||||
let name: string | null = null;
|
||||
for (const id of findChildren(declNode, "identifier")) {
|
||||
name = id.text;
|
||||
}
|
||||
return name ? [name] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the callee name from a Scala `call_expression`. Shapes:
|
||||
*
|
||||
* foo(...) → identifier "foo"
|
||||
* target.method(...) → field_expression whose last identifier is
|
||||
* the method name
|
||||
* foo[T](...) / x.f[T](…) → generic_function wrapping either shape
|
||||
*/
|
||||
private extractCallLikeName(node: TreeSitterNode): string | null {
|
||||
if (node.type === "call_expression") return this.extractCalleeName(node);
|
||||
if (node.type === "infix_expression") return this.extractInfixName(node);
|
||||
if (node.type === "instance_expression") return this.extractConstructorName(node);
|
||||
return null;
|
||||
}
|
||||
|
||||
private extractCalleeName(callNode: TreeSitterNode): string | null {
|
||||
let target = callNode.child(0);
|
||||
if (!target) return null;
|
||||
|
||||
if (target.type === "generic_function") {
|
||||
target = target.child(0);
|
||||
if (!target) return null;
|
||||
}
|
||||
|
||||
if (target.type === "identifier") return target.text;
|
||||
|
||||
if (target.type === "field_expression") {
|
||||
let lastIdentifier: string | null = null;
|
||||
for (let i = 0; i < target.childCount; i++) {
|
||||
const child = target.child(i);
|
||||
if (child && child.type === "identifier") {
|
||||
lastIdentifier = child.text;
|
||||
}
|
||||
}
|
||||
return lastIdentifier;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private extractInfixName(infixNode: TreeSitterNode): string | null {
|
||||
const identifiers: string[] = [];
|
||||
for (let i = 0; i < infixNode.childCount; i++) {
|
||||
const child = infixNode.child(i);
|
||||
if (child && child.type === "identifier") identifiers.push(child.text);
|
||||
}
|
||||
return identifiers[1] ?? identifiers[0] ?? null;
|
||||
}
|
||||
|
||||
private extractConstructorName(instanceNode: TreeSitterNode): string | null {
|
||||
for (let i = 0; i < instanceNode.childCount; i++) {
|
||||
const child = instanceNode.child(i);
|
||||
if (child && (child.type === "type_identifier" || child.type === "identifier")) {
|
||||
return child.text;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,20 @@ export class PluginRegistry {
|
||||
return plugin.extractCallGraph(filePath, content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-parse fast path: returns both structure and call graph from one
|
||||
* parse when the resolved plugin supports it, else null so the caller can
|
||||
* fall back to separate analyzeFile + extractCallGraph calls.
|
||||
*/
|
||||
analyzeFileFull(
|
||||
filePath: string,
|
||||
content: string,
|
||||
): { structure: StructuralAnalysis; callGraph: CallGraphEntry[] } | null {
|
||||
const plugin = this.getPluginForFile(filePath);
|
||||
if (!plugin?.analyzeFileFull) return null;
|
||||
return plugin.analyzeFileFull(filePath, content);
|
||||
}
|
||||
|
||||
getPlugins(): AnalyzerPlugin[] {
|
||||
return [...this.plugins];
|
||||
}
|
||||
|
||||
@@ -294,6 +294,72 @@ function main() {
|
||||
});
|
||||
});
|
||||
|
||||
describe("analyzeFileFull", () => {
|
||||
const code = `
|
||||
import { helper } from "./helper";
|
||||
|
||||
export function greet(name: string): string {
|
||||
return formatMessage("Hello " + name);
|
||||
}
|
||||
|
||||
function formatMessage(msg: string): string {
|
||||
return msg.trim();
|
||||
}
|
||||
|
||||
export class Greeter {
|
||||
greet(name: string): string {
|
||||
return greet(name);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
it("produces exactly the same output as analyzeFile + extractCallGraph", () => {
|
||||
const separate = {
|
||||
structure: plugin.analyzeFile("test.ts", code),
|
||||
callGraph: plugin.extractCallGraph!("test.ts", code),
|
||||
};
|
||||
const full = plugin.analyzeFileFull("test.ts", code);
|
||||
|
||||
expect(full).toEqual(separate);
|
||||
// Guard against vacuous equality — both sides must be non-trivial
|
||||
expect(full.structure.functions.length).toBeGreaterThan(0);
|
||||
expect(full.structure.classes.length).toBeGreaterThan(0);
|
||||
expect(full.structure.imports.length).toBeGreaterThan(0);
|
||||
expect(full.callGraph.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("is stable across repeated calls (cached parser reuse)", () => {
|
||||
const first = plugin.analyzeFileFull("test.ts", code);
|
||||
const second = plugin.analyzeFileFull("test.ts", code);
|
||||
expect(second).toEqual(first);
|
||||
});
|
||||
|
||||
it("returns empty results for unsupported extensions", () => {
|
||||
const full = plugin.analyzeFileFull("styles.xyz", "body { color: red; }");
|
||||
expect(full.structure).toEqual({
|
||||
functions: [],
|
||||
classes: [],
|
||||
imports: [],
|
||||
exports: [],
|
||||
});
|
||||
expect(full.callGraph).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns fresh arrays per call — mutating one result cannot leak into the next", () => {
|
||||
const first = plugin.analyzeFileFull("styles.xyz", "whatever");
|
||||
first.structure.functions.push({
|
||||
name: "injected",
|
||||
lineRange: [1, 1],
|
||||
params: [],
|
||||
});
|
||||
first.callGraph.push({ caller: "a", callee: "b", lineNumber: 1 });
|
||||
|
||||
const second = plugin.analyzeFileFull("styles.xyz", "whatever");
|
||||
expect(second.structure.functions).toEqual([]);
|
||||
expect(second.callGraph).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin metadata", () => {
|
||||
it("should have correct name", () => {
|
||||
expect(plugin.name).toBe("tree-sitter");
|
||||
|
||||
@@ -23,7 +23,7 @@ type TreeSitterLanguage = import("web-tree-sitter").Language;
|
||||
* and how to load their WASM grammars. Provides deep structural analysis
|
||||
* (functions, classes, imports, exports, call graphs) for all languages
|
||||
* with registered extractors: TypeScript, JavaScript, Python, Go, Rust,
|
||||
* Java, Ruby, PHP, C/C++, and C#.
|
||||
* Java, Ruby, PHP, C/C++, C#, Dart, Kotlin, Swift, and Scala.
|
||||
*
|
||||
* Languages without tree-sitter configs are gracefully skipped (the LLM
|
||||
* agent handles analysis for those).
|
||||
@@ -40,6 +40,11 @@ export class TreeSitterPlugin implements AnalyzerPlugin {
|
||||
| null = null;
|
||||
private _languages = new Map<string, TreeSitterLanguage>();
|
||||
private _extensionToLang = new Map<string, string>();
|
||||
// One reusable parser per language key. web-tree-sitter parsers are reusable
|
||||
// across parse() calls (only the Tree is per-parse, and it's still deleted);
|
||||
// creating + setLanguage + delete on every call wasted an allocation and a
|
||||
// WASM setLanguage on every file. Cached here, created lazily on first use.
|
||||
private _parsers = new Map<string, TreeSitterParser>();
|
||||
private _initialized = false;
|
||||
|
||||
// Language-specific extractors (keyed by language id)
|
||||
@@ -213,11 +218,22 @@ export class TreeSitterPlugin implements AnalyzerPlugin {
|
||||
// Language grammar not loaded — graceful degradation
|
||||
return null;
|
||||
}
|
||||
const parser = new this._ParserClass();
|
||||
parser.setLanguage(lang);
|
||||
let parser = this._parsers.get(langKey);
|
||||
if (!parser) {
|
||||
parser = new this._ParserClass();
|
||||
parser.setLanguage(lang);
|
||||
this._parsers.set(langKey, parser);
|
||||
}
|
||||
return parser;
|
||||
}
|
||||
|
||||
// Fresh object AND fresh arrays on every call — a shared static would leak
|
||||
// the same array instances to every caller, so one caller mutating its
|
||||
// result would corrupt everyone else's.
|
||||
private static emptyStructure(): StructuralAnalysis {
|
||||
return { functions: [], classes: [], imports: [], exports: [] };
|
||||
}
|
||||
|
||||
analyzeFile(
|
||||
filePath: string,
|
||||
content: string,
|
||||
@@ -229,7 +245,6 @@ export class TreeSitterPlugin implements AnalyzerPlugin {
|
||||
|
||||
const tree = parser.parse(content);
|
||||
if (!tree) {
|
||||
parser.delete();
|
||||
return { functions: [], classes: [], imports: [], exports: [] };
|
||||
}
|
||||
|
||||
@@ -244,11 +259,46 @@ export class TreeSitterPlugin implements AnalyzerPlugin {
|
||||
}
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the file ONCE and return both structural analysis and the call
|
||||
* graph. `extract-structure.mjs` runs `analyzeFile` then `extractCallGraph`
|
||||
* on every code file — two full tree-sitter parses of identical content.
|
||||
* Both extractors are pure functions of the same rootNode, so a single
|
||||
* parse yields byte-identical results (verified) at ~40% less parse work
|
||||
* on the indexing hot path. Callers without this method fall back to the
|
||||
* two separate calls.
|
||||
*/
|
||||
analyzeFileFull(
|
||||
filePath: string,
|
||||
content: string,
|
||||
): { structure: StructuralAnalysis; callGraph: CallGraphEntry[] } {
|
||||
const parser = this.getParser(filePath);
|
||||
if (!parser) {
|
||||
return { structure: TreeSitterPlugin.emptyStructure(), callGraph: [] };
|
||||
}
|
||||
|
||||
const tree = parser.parse(content);
|
||||
if (!tree) {
|
||||
return { structure: TreeSitterPlugin.emptyStructure(), callGraph: [] };
|
||||
}
|
||||
|
||||
const langKey = this.languageKeyFromPath(filePath);
|
||||
const extractor = langKey ? this.getExtractor(langKey) : null;
|
||||
|
||||
const structure = extractor
|
||||
? extractor.extractStructure(tree.rootNode)
|
||||
: TreeSitterPlugin.emptyStructure();
|
||||
const callGraph = extractor ? extractor.extractCallGraph(tree.rootNode) : [];
|
||||
|
||||
tree.delete();
|
||||
|
||||
return { structure, callGraph };
|
||||
}
|
||||
|
||||
resolveImports(
|
||||
filePath: string,
|
||||
content: string,
|
||||
@@ -283,7 +333,6 @@ export class TreeSitterPlugin implements AnalyzerPlugin {
|
||||
|
||||
const tree = parser.parse(content);
|
||||
if (!tree) {
|
||||
parser.delete();
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -292,7 +341,6 @@ export class TreeSitterPlugin implements AnalyzerPlugin {
|
||||
const result = extractor ? extractor.extractCallGraph(tree.rootNode) : [];
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// Edge types (35 values across 8 categories)
|
||||
// Edge types (38 values across 9 categories)
|
||||
export const EdgeTypeSchema = z.enum([
|
||||
"imports", "exports", "contains", "inherits", "implements", // Structural
|
||||
"calls", "subscribes", "publishes", "middleware", // Behavioral
|
||||
@@ -11,6 +11,7 @@ export const EdgeTypeSchema = z.enum([
|
||||
"migrates", "documents", "routes", "defines_schema", // Schema/Data
|
||||
"contains_flow", "flow_step", "cross_domain", // Domain
|
||||
"cites", "contradicts", "builds_on", "exemplifies", "categorized_under", "authored_by", // Knowledge
|
||||
"instance_of", "variant_of", "uses_token", // Design
|
||||
]);
|
||||
|
||||
// Aliases that LLMs commonly generate instead of canonical node types
|
||||
@@ -58,7 +59,6 @@ export const NODE_TYPE_ALIASES: Record<string, string> = {
|
||||
business_step: "step",
|
||||
// Knowledge aliases
|
||||
note: "article",
|
||||
page: "article",
|
||||
wiki_page: "article",
|
||||
person: "entity",
|
||||
actor: "entity",
|
||||
@@ -74,6 +74,32 @@ export const NODE_TYPE_ALIASES: Record<string, string> = {
|
||||
paper: "source",
|
||||
};
|
||||
|
||||
// Design aliases (Figma node types) — applied only when the graph's kind is
|
||||
// "design". Terms like "page" and "style" mean something else in other
|
||||
// kinds, so these must not leak into them (see NON_DESIGN_NODE_TYPE_ALIASES).
|
||||
export const DESIGN_NODE_TYPE_ALIASES: Record<string, string> = {
|
||||
frame: "screen",
|
||||
artboard: "screen",
|
||||
canvas: "page",
|
||||
main_component: "component",
|
||||
component_set: "componentSet",
|
||||
variant_set: "componentSet",
|
||||
// sanitizeGraph lowercases every node type, and "componentSet" is the only
|
||||
// camelCase canonical NodeType — so it arrives here as "componentset" and
|
||||
// must be mapped back, otherwise it fails the enum check and gets dropped.
|
||||
componentset: "componentSet",
|
||||
design_token: "token",
|
||||
style: "token",
|
||||
};
|
||||
|
||||
// Applied to every non-design kind: `page` is a first-class *design* node
|
||||
// type, but knowledge/codebase graphs relied on it normalizing to "article"
|
||||
// (see 2fc85e6) — the sanitizer can't tell a wiki page from a Figma page,
|
||||
// so the graph's `kind` decides which table wins.
|
||||
export const NON_DESIGN_NODE_TYPE_ALIASES: Record<string, string> = {
|
||||
page: "article",
|
||||
};
|
||||
|
||||
// Aliases that LLMs commonly generate instead of canonical edge types
|
||||
export const EDGE_TYPE_ALIASES: Record<string, string> = {
|
||||
extends: "inherits",
|
||||
@@ -113,7 +139,6 @@ export const EDGE_TYPE_ALIASES: Record<string, string> = {
|
||||
refines: "builds_on",
|
||||
elaborates: "builds_on",
|
||||
illustrates: "exemplifies",
|
||||
instance_of: "exemplifies",
|
||||
example_of: "exemplifies",
|
||||
belongs_to: "categorized_under",
|
||||
tagged_with: "categorized_under",
|
||||
@@ -124,6 +149,21 @@ export const EDGE_TYPE_ALIASES: Record<string, string> = {
|
||||
// "implements" with correct source/target instead.
|
||||
};
|
||||
|
||||
// Design edge aliases — applied only when the graph's kind is "design".
|
||||
export const DESIGN_EDGE_TYPE_ALIASES: Record<string, string> = {
|
||||
instantiates: "instance_of",
|
||||
variant: "variant_of",
|
||||
styled_by: "uses_token",
|
||||
applies_token: "uses_token",
|
||||
};
|
||||
|
||||
// Applied to every non-design kind: `instance_of` is a first-class design
|
||||
// edge, but knowledge graphs relied on it normalizing to "exemplifies"
|
||||
// ("X is an instance of Y" — see 2fc85e6).
|
||||
export const NON_DESIGN_EDGE_TYPE_ALIASES: Record<string, string> = {
|
||||
instance_of: "exemplifies",
|
||||
};
|
||||
|
||||
// Aliases for complexity values LLMs commonly generate
|
||||
export const COMPLEXITY_ALIASES: Record<string, string> = {
|
||||
low: "simple",
|
||||
@@ -365,6 +405,18 @@ const KnowledgeMetaSchema = z.object({
|
||||
content: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const FigmaMetaSchema = z.object({
|
||||
fileKey: z.string().optional(),
|
||||
nodeId: z.string().optional(),
|
||||
figmaType: z.string().optional(),
|
||||
thumbnailUrl: z.string().optional(),
|
||||
dimensions: z.object({ width: z.number(), height: z.number() }).optional(),
|
||||
tokenKind: z.enum(["color", "type", "spacing", "effect", "grid"]).optional(),
|
||||
tokenValue: z.string().optional(),
|
||||
prototypeTargets: z.array(z.string()).optional(),
|
||||
componentKey: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
export const GraphNodeSchema = z.object({
|
||||
id: z.string(),
|
||||
type: z.enum([
|
||||
@@ -373,6 +425,7 @@ export const GraphNodeSchema = z.object({
|
||||
"pipeline", "schema", "resource",
|
||||
"domain", "flow", "step",
|
||||
"article", "entity", "topic", "claim", "source",
|
||||
"page", "screen", "component", "componentSet", "instance", "token",
|
||||
]),
|
||||
name: z.string(),
|
||||
filePath: z.string().optional(),
|
||||
@@ -383,6 +436,7 @@ export const GraphNodeSchema = z.object({
|
||||
languageNotes: z.string().optional(),
|
||||
domainMeta: DomainMetaSchema.optional(),
|
||||
knowledgeMeta: KnowledgeMetaSchema.optional(),
|
||||
figmaMeta: FigmaMetaSchema.optional(),
|
||||
}).passthrough();
|
||||
|
||||
export const GraphEdgeSchema = z.object({
|
||||
@@ -420,7 +474,7 @@ export const ProjectMetaSchema = z.object({
|
||||
|
||||
export const KnowledgeGraphSchema = z.object({
|
||||
version: z.string(),
|
||||
kind: z.enum(["codebase", "knowledge"]).optional(),
|
||||
kind: z.enum(["codebase", "knowledge", "design"]).optional(),
|
||||
project: ProjectMetaSchema,
|
||||
nodes: z.array(GraphNodeSchema),
|
||||
edges: z.array(GraphEdgeSchema),
|
||||
@@ -465,15 +519,25 @@ export function normalizeGraph(data: unknown): unknown {
|
||||
const d = data as Record<string, unknown>;
|
||||
const result = { ...d };
|
||||
|
||||
// "page" and "instance_of" are canonical design types but alias *sources*
|
||||
// in every other kind, so the active alias tables depend on the graph's kind.
|
||||
const isDesign = typeof d.kind === "string" && d.kind.toLowerCase() === "design";
|
||||
const nodeAliases: Record<string, string> = isDesign
|
||||
? { ...NODE_TYPE_ALIASES, ...DESIGN_NODE_TYPE_ALIASES }
|
||||
: { ...NODE_TYPE_ALIASES, ...NON_DESIGN_NODE_TYPE_ALIASES };
|
||||
const edgeAliases: Record<string, string> = isDesign
|
||||
? { ...EDGE_TYPE_ALIASES, ...DESIGN_EDGE_TYPE_ALIASES }
|
||||
: { ...EDGE_TYPE_ALIASES, ...NON_DESIGN_EDGE_TYPE_ALIASES };
|
||||
|
||||
if (Array.isArray(d.nodes)) {
|
||||
result.nodes = (d.nodes as Array<Record<string, unknown>>).map((node) => {
|
||||
if (
|
||||
typeof node === "object" &&
|
||||
node !== null &&
|
||||
typeof node.type === "string" &&
|
||||
node.type in NODE_TYPE_ALIASES
|
||||
node.type in nodeAliases
|
||||
) {
|
||||
return { ...node, type: NODE_TYPE_ALIASES[node.type] };
|
||||
return { ...node, type: nodeAliases[node.type] };
|
||||
}
|
||||
return node;
|
||||
});
|
||||
@@ -485,9 +549,9 @@ export function normalizeGraph(data: unknown): unknown {
|
||||
typeof edge === "object" &&
|
||||
edge !== null &&
|
||||
typeof edge.type === "string" &&
|
||||
edge.type in EDGE_TYPE_ALIASES
|
||||
edge.type in edgeAliases
|
||||
) {
|
||||
return { ...edge, type: EDGE_TYPE_ALIASES[edge.type] };
|
||||
return { ...edge, type: edgeAliases[edge.type] };
|
||||
}
|
||||
return edge;
|
||||
});
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
// Node types (21 total: 5 code + 8 non-code + 3 domain + 5 knowledge)
|
||||
// Node types (27 total: 5 code + 8 non-code + 3 domain + 5 knowledge + 6 design)
|
||||
export type NodeType =
|
||||
| "file" | "function" | "class" | "module" | "concept"
|
||||
| "config" | "document" | "service" | "table" | "endpoint"
|
||||
| "pipeline" | "schema" | "resource"
|
||||
| "domain" | "flow" | "step"
|
||||
| "article" | "entity" | "topic" | "claim" | "source";
|
||||
| "article" | "entity" | "topic" | "claim" | "source"
|
||||
| "page" | "screen" | "component" | "componentSet" | "instance" | "token";
|
||||
|
||||
// Edge types (35 total in 8 categories: Structural, Behavioral, Data flow, Dependencies, Semantic, Infrastructure/Schema, Domain, Knowledge)
|
||||
// Edge types (38 total in 9 categories: Structural, Behavioral, Data flow, Dependencies, Semantic, Infrastructure/Schema, Domain, Knowledge, Design)
|
||||
export type EdgeType =
|
||||
| "imports" | "exports" | "contains" | "inherits" | "implements" // Structural
|
||||
| "calls" | "subscribes" | "publishes" | "middleware" // Behavioral
|
||||
@@ -16,7 +17,8 @@ export type EdgeType =
|
||||
| "deploys" | "serves" | "provisions" | "triggers" // Infrastructure
|
||||
| "migrates" | "documents" | "routes" | "defines_schema" // Schema/Data
|
||||
| "contains_flow" | "flow_step" | "cross_domain" // Domain
|
||||
| "cites" | "contradicts" | "builds_on" | "exemplifies" | "categorized_under" | "authored_by"; // Knowledge
|
||||
| "cites" | "contradicts" | "builds_on" | "exemplifies" | "categorized_under" | "authored_by" // Knowledge
|
||||
| "instance_of" | "variant_of" | "uses_token"; // Design
|
||||
|
||||
// Optional knowledge metadata for article/entity/topic/claim/source nodes
|
||||
export interface KnowledgeMeta {
|
||||
@@ -35,7 +37,20 @@ export interface DomainMeta {
|
||||
entryType?: "http" | "cli" | "event" | "cron" | "manual";
|
||||
}
|
||||
|
||||
// GraphNode with 21 types: 5 code + 8 non-code + 3 domain + 5 knowledge
|
||||
// Optional Figma metadata for page/screen/component/componentSet/instance/token nodes
|
||||
export interface FigmaMeta {
|
||||
fileKey?: string;
|
||||
nodeId?: string; // Figma node id, e.g. "1:23"
|
||||
figmaType?: string; // FRAME | COMPONENT | COMPONENT_SET | INSTANCE | TEXT ...
|
||||
thumbnailUrl?: string; // lazily filled from GET /v1/images
|
||||
dimensions?: { width: number; height: number };
|
||||
tokenKind?: "color" | "type" | "spacing" | "effect" | "grid";
|
||||
tokenValue?: string; // e.g. "#0A84FF", "16px"
|
||||
prototypeTargets?: string[]; // roadmap B — recorded now, edges later
|
||||
componentKey?: string; // roadmap C — recorded now
|
||||
}
|
||||
|
||||
// GraphNode with 27 types: 5 code + 8 non-code + 3 domain + 5 knowledge + 6 design
|
||||
export interface GraphNode {
|
||||
id: string;
|
||||
type: NodeType;
|
||||
@@ -48,6 +63,7 @@ export interface GraphNode {
|
||||
languageNotes?: string;
|
||||
domainMeta?: DomainMeta;
|
||||
knowledgeMeta?: KnowledgeMeta;
|
||||
figmaMeta?: FigmaMeta;
|
||||
}
|
||||
|
||||
// GraphEdge with rich relationship modeling
|
||||
@@ -90,7 +106,7 @@ export interface ProjectMeta {
|
||||
// Root KnowledgeGraph
|
||||
export interface KnowledgeGraph {
|
||||
version: string;
|
||||
kind?: "codebase" | "knowledge";
|
||||
kind?: "codebase" | "knowledge" | "design";
|
||||
project: ProjectMeta;
|
||||
nodes: GraphNode[];
|
||||
edges: GraphEdge[];
|
||||
@@ -199,4 +215,14 @@ export interface AnalyzerPlugin {
|
||||
resolveImports?(filePath: string, content: string): ImportResolution[];
|
||||
extractCallGraph?(filePath: string, content: string): CallGraphEntry[];
|
||||
extractReferences?(filePath: string, content: string): ReferenceResolution[];
|
||||
/**
|
||||
* Optional single-parse fast path returning both structure and call graph.
|
||||
* Plugins that parse source (e.g. tree-sitter) can implement this to avoid
|
||||
* parsing the same file twice when a caller needs both. Output must equal
|
||||
* `analyzeFile` + `extractCallGraph` called separately.
|
||||
*/
|
||||
analyzeFileFull?(
|
||||
filePath: string,
|
||||
content: string,
|
||||
): { structure: StructuralAnalysis; callGraph: CallGraphEntry[] };
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -813,7 +813,7 @@
|
||||
18,
|
||||
21
|
||||
],
|
||||
"summary": "Writes a KnowledgeGraph as JSON to .understand-anything/knowledge-graph.json.",
|
||||
"summary": "Writes a KnowledgeGraph as JSON to the data directory's knowledge-graph.json (.ua/, or legacy .understand-anything/ when present).",
|
||||
"tags": [
|
||||
"persistence",
|
||||
"write",
|
||||
@@ -848,7 +848,7 @@
|
||||
45,
|
||||
48
|
||||
],
|
||||
"summary": "Writes AnalysisMeta to .understand-anything/meta.json.",
|
||||
"summary": "Writes AnalysisMeta to the data directory's meta.json (.ua/, or legacy .understand-anything/ when present).",
|
||||
"tags": [
|
||||
"persistence",
|
||||
"write",
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { ALL_NODE_TYPES } from "../store";
|
||||
import type { NodeType as CoreNodeType } from "@understand-anything/core/types";
|
||||
|
||||
/**
|
||||
* Guard for the node-type FILTER / EXPORT path.
|
||||
*
|
||||
* `filters.nodeTypes` is initialised from ALL_NODE_TYPES, and
|
||||
* ExportMenu.exportJSON always runs filterNodes() with that set. Any core
|
||||
* NodeType missing from ALL_NODE_TYPES is therefore silently stripped from a
|
||||
* freshly-loaded graph on export — which is exactly what dropped every
|
||||
* kind:"design" (Figma) node and its edges before this fix (PR #516 review).
|
||||
*
|
||||
* EXPECTED_NODE_TYPES is compile-time exhaustive over the core NodeType union
|
||||
* via `satisfies Record<CoreNodeType, true>`:
|
||||
* • Add a NodeType to core → this object is missing a key → the dashboard
|
||||
* `build` (tsc -b) fails until it is listed here.
|
||||
* • The runtime checks below then fail until it is ALSO added to
|
||||
* ALL_NODE_TYPES.
|
||||
* Together they make it impossible to add a core NodeType that export would
|
||||
* silently drop.
|
||||
*/
|
||||
const EXPECTED_NODE_TYPES = {
|
||||
// code (5)
|
||||
file: true, function: true, class: true, module: true, concept: true,
|
||||
// non-code (8)
|
||||
config: true, document: true, service: true, table: true, endpoint: true,
|
||||
pipeline: true, schema: true, resource: true,
|
||||
// domain (3)
|
||||
domain: true, flow: true, step: true,
|
||||
// knowledge (5)
|
||||
article: true, entity: true, topic: true, claim: true, source: true,
|
||||
// design (6) — Figma graphs must remain exportable by default
|
||||
page: true, screen: true, component: true, componentSet: true, instance: true, token: true,
|
||||
} satisfies Record<CoreNodeType, true>;
|
||||
|
||||
const DESIGN_TYPES = ["page", "screen", "component", "componentSet", "instance", "token"] as const;
|
||||
|
||||
describe("ALL_NODE_TYPES (filter / export default set)", () => {
|
||||
it('includes all 6 design node types so kind:"design" graphs survive JSON export', () => {
|
||||
for (const t of DESIGN_TYPES) {
|
||||
expect(ALL_NODE_TYPES).toContain(t);
|
||||
}
|
||||
});
|
||||
|
||||
it("contains every core NodeType (regression guard)", () => {
|
||||
for (const t of Object.keys(EXPECTED_NODE_TYPES)) {
|
||||
expect(ALL_NODE_TYPES).toContain(t);
|
||||
}
|
||||
});
|
||||
|
||||
it("has no duplicates and no types beyond the core set", () => {
|
||||
expect(new Set(ALL_NODE_TYPES).size).toBe(ALL_NODE_TYPES.length);
|
||||
expect(ALL_NODE_TYPES.length).toBe(Object.keys(EXPECTED_NODE_TYPES).length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { EDGE_CATEGORY_MAP, ALL_EDGE_CATEGORIES } from "../store";
|
||||
import type { EdgeType as CoreEdgeType } from "@understand-anything/core/types";
|
||||
|
||||
/**
|
||||
* Guard for the edge-category FILTER path — the edge analog of
|
||||
* allNodeTypes.test.ts.
|
||||
*
|
||||
* filterEdges() keeps edges whose type maps to no category, so a missing
|
||||
* entry doesn't drop edges — but it does make them invisible to the
|
||||
* FilterPanel edge-category toggles (which is exactly what happened to the
|
||||
* design edges instance_of/variant_of/uses_token before this fix).
|
||||
*
|
||||
* EXPECTED_EDGE_TYPES is compile-time exhaustive over the core EdgeType union
|
||||
* via `satisfies Record<CoreEdgeType, true>`: add an EdgeType to core and the
|
||||
* dashboard build fails until it is listed here, then the runtime checks fail
|
||||
* until it is also placed in EDGE_CATEGORY_MAP.
|
||||
*/
|
||||
const EXPECTED_EDGE_TYPES = {
|
||||
// structural (5)
|
||||
imports: true, exports: true, contains: true, inherits: true, implements: true,
|
||||
// behavioral (4)
|
||||
calls: true, subscribes: true, publishes: true, middleware: true,
|
||||
// data-flow (4)
|
||||
reads_from: true, writes_to: true, transforms: true, validates: true,
|
||||
// dependencies (3)
|
||||
depends_on: true, tested_by: true, configures: true,
|
||||
// semantic (2)
|
||||
related: true, similar_to: true,
|
||||
// infrastructure (8)
|
||||
deploys: true, serves: true, provisions: true, triggers: true,
|
||||
migrates: true, documents: true, routes: true, defines_schema: true,
|
||||
// domain (3)
|
||||
contains_flow: true, flow_step: true, cross_domain: true,
|
||||
// knowledge (6)
|
||||
cites: true, contradicts: true, builds_on: true, exemplifies: true,
|
||||
categorized_under: true, authored_by: true,
|
||||
// design (3) — Figma edges must be reachable from the edge-category filter
|
||||
instance_of: true, variant_of: true, uses_token: true,
|
||||
} satisfies Record<CoreEdgeType, true>;
|
||||
|
||||
describe("EDGE_CATEGORY_MAP (edge-category filter)", () => {
|
||||
const mapped = Object.values(EDGE_CATEGORY_MAP).flat();
|
||||
|
||||
it("covers every core EdgeType (regression guard)", () => {
|
||||
for (const t of Object.keys(EXPECTED_EDGE_TYPES)) {
|
||||
expect(mapped).toContain(t);
|
||||
}
|
||||
});
|
||||
|
||||
it("maps each edge type to exactly one category", () => {
|
||||
expect(new Set(mapped).size).toBe(mapped.length);
|
||||
expect(mapped.length).toBe(Object.keys(EXPECTED_EDGE_TYPES).length);
|
||||
});
|
||||
|
||||
it("lists every map key in ALL_EDGE_CATEGORIES so the FilterPanel renders it", () => {
|
||||
for (const category of Object.keys(EDGE_CATEGORY_MAP)) {
|
||||
expect(ALL_EDGE_CATEGORIES).toContain(category);
|
||||
}
|
||||
expect(ALL_EDGE_CATEGORIES.length).toBe(Object.keys(EDGE_CATEGORY_MAP).length);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Highlight, themes } from "prism-react-renderer";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { useDashboardStore } from "../store";
|
||||
import { useI18n } from "../contexts/I18nContext";
|
||||
|
||||
@@ -56,6 +58,66 @@ function formatBytes(bytes: number): string {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/** Rendered markdown view for .md files, styled for the dark theme. */
|
||||
function MarkdownView({ content }: { content: string }) {
|
||||
return (
|
||||
<div className="px-6 py-5 max-w-3xl text-sm text-text-secondary leading-relaxed">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ children }) => <h1 className="text-xl font-heading text-text-primary mt-6 mb-3 first:mt-0">{children}</h1>,
|
||||
h2: ({ children }) => <h2 className="text-lg font-heading text-text-primary mt-5 mb-2 first:mt-0 border-b border-border-subtle pb-1">{children}</h2>,
|
||||
h3: ({ children }) => <h3 className="text-base font-heading text-text-primary mt-4 mb-2 first:mt-0">{children}</h3>,
|
||||
h4: ({ children }) => <h4 className="text-sm font-heading text-text-primary mt-3 mb-1.5 first:mt-0">{children}</h4>,
|
||||
h5: ({ children }) => <h5 className="text-sm font-heading text-text-primary mt-3 mb-1.5 first:mt-0">{children}</h5>,
|
||||
h6: ({ children }) => <h6 className="text-xs font-heading text-text-primary mt-3 mb-1.5 first:mt-0 uppercase tracking-wider">{children}</h6>,
|
||||
p: ({ children }) => <p className="mb-3 last:mb-0">{children}</p>,
|
||||
a: ({ children, href }) => (
|
||||
<a href={href} target="_blank" rel="noreferrer" className="text-accent hover:underline">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
ul: ({ children }) => <ul className="list-disc pl-5 mb-3 space-y-1">{children}</ul>,
|
||||
ol: ({ children }) => <ol className="list-decimal pl-5 mb-3 space-y-1">{children}</ol>,
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-2 border-accent/40 pl-3 text-text-muted italic mb-3">{children}</blockquote>
|
||||
),
|
||||
hr: () => <hr className="border-border-subtle my-4" />,
|
||||
pre: ({ children }) => (
|
||||
<pre className="bg-elevated border border-border-subtle rounded-lg p-3 mb-3 overflow-x-auto text-xs font-mono">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
code: ({ className, children }) => {
|
||||
const isInline = !className && !String(children).includes("\n");
|
||||
return isInline ? (
|
||||
<code className="bg-elevated px-1.5 py-0.5 rounded text-[0.85em] font-mono text-accent">{children}</code>
|
||||
) : (
|
||||
<code className={className}>{children}</code>
|
||||
);
|
||||
},
|
||||
table: ({ children }) => (
|
||||
<div className="overflow-x-auto mb-3">
|
||||
<table className="text-xs border-collapse">{children}</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th className="border border-border-subtle bg-elevated px-2.5 py-1.5 text-left text-text-primary font-semibold">
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }) => <td className="border border-border-subtle px-2.5 py-1.5">{children}</td>,
|
||||
img: ({ src, alt }) => (
|
||||
<img src={src} alt={alt} className="max-w-full rounded-lg border border-border-subtle my-2" />
|
||||
),
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CodeViewer({
|
||||
accessToken,
|
||||
presentation = "sidebar",
|
||||
@@ -79,6 +141,9 @@ export default function CodeViewer({
|
||||
source: null,
|
||||
error: null,
|
||||
});
|
||||
// Markdown files default to the rendered view (#555); toggle back to
|
||||
// source for line numbers / lineRange highlighting.
|
||||
const [mdView, setMdView] = useState<"rendered" | "source">("rendered");
|
||||
const { t } = useI18n();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -134,6 +199,8 @@ export default function CodeViewer({
|
||||
|
||||
const source = state.source;
|
||||
const language = source?.language ?? fallbackLanguage(node.filePath);
|
||||
const isMarkdown = language === "markdown";
|
||||
const showRendered = isMarkdown && mdView === "rendered";
|
||||
const lineInfo = highlightedRange
|
||||
? `${t.codeViewer.lines} ${highlightedRange.start}-${highlightedRange.end}`
|
||||
: t.codeViewer.fullFile;
|
||||
@@ -212,8 +279,31 @@ export default function CodeViewer({
|
||||
<>
|
||||
<div className="px-4 py-2 border-b border-border-subtle bg-surface text-[11px] text-text-muted flex items-center justify-between">
|
||||
<span>{source.lineCount} {t.codeViewer.linesLabel}</span>
|
||||
<span>{formatBytes(source.sizeBytes)}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
{isMarkdown && (
|
||||
<div className="flex items-center rounded border border-border-subtle overflow-hidden" role="group">
|
||||
{(["rendered", "source"] as const).map((view) => (
|
||||
<button
|
||||
key={view}
|
||||
type="button"
|
||||
onClick={() => setMdView(view)}
|
||||
className={`px-2 py-0.5 text-[10px] uppercase tracking-wider transition-colors ${
|
||||
mdView === view
|
||||
? "bg-accent/15 text-accent"
|
||||
: "text-text-muted hover:text-text-primary"
|
||||
}`}
|
||||
aria-pressed={mdView === view}
|
||||
>
|
||||
{view === "rendered" ? t.codeViewer.rendered : t.codeViewer.source}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<span>{formatBytes(source.sizeBytes)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{showRendered && <MarkdownView content={source.content} />}
|
||||
{!showRendered && (
|
||||
<Highlight code={source.content} language={language} theme={themes.vsDark}>
|
||||
{({ className, style, tokens, getLineProps, getTokenProps }) => (
|
||||
<pre
|
||||
@@ -251,6 +341,7 @@ export default function CodeViewer({
|
||||
</pre>
|
||||
)}
|
||||
</Highlight>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user