diff --git a/README.md b/README.md index 04efe8311..74b1caffc 100644 --- a/README.md +++ b/README.md @@ -198,7 +198,7 @@ Understand-Anything works across multiple AI coding platforms. ``` -### One-line install (Codex / OpenCode / OpenClaw / Antigravity / Gemini CLI / Pi Agent / Vibe CLI / VS Code Copilot / Hermes / Cline / KIMI CLI / Trae / Nanobot / Kiro) +### One-line install (Codex / OpenCode / OpenClaw / Antigravity / Gemini CLI / Pi Agent / Vibe CLI / VS Code Copilot / Hermes / Cline / KIMI CLI / Trae / Nanobot / Kiro / AdaL) **macOS / Linux:** @@ -217,7 +217,7 @@ The installer clones the repo to `~/.understand-anything/repo` and creates the r > **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 `` values: `gemini`, `codex`, `opencode`, `pi`, `openclaw`, `antigravity`, `vibe`, `vscode`, `hermes`, `cline`, `kimi`, `trae`, `nanobot`, `kiro` +- Supported `` values: `gemini`, `codex`, `opencode`, `pi`, `openclaw`, `antigravity`, `vibe`, `vscode`, `hermes`, `cline`, `kimi`, `trae`, `nanobot`, `kiro`, `adal` - Update later: `./install.sh --update` - Uninstall: `./install.sh --uninstall ` @@ -239,6 +239,16 @@ For personal skills (available across all projects), run the `install.sh` above copilot plugin install Egonex-AI/Understand-Anything:understand-anything-plugin ``` +### AdaL CLI + +```bash +curl -fsSL https://raw.githubusercontent.com/Egonex-AI/Understand-Anything/main/install.sh | bash -s adal +``` + +After installation, the skills are symlinked into `~/.adal/skills/`. Restart your AdaL CLI session to pick up the skills. + +For personal skills (available across all projects), run the `install.sh` above with the `adal` platform. + ### Kiro CLI / IDE ```bash @@ -272,6 +282,7 @@ For personal skills (available across all projects), run the `install.sh` above | Trae | ✅ Supported | `install.sh trae` | | Nanobot | ✅ Supported | `install.sh nanobot` | | Kiro CLI / IDE | ✅ Supported | `install.sh kiro` | +| AdaL CLI | ✅ Supported | `install.sh adal` | --- diff --git a/READMEs/README.es-ES.md b/READMEs/README.es-ES.md index 69a6e2d65..5abe8c07d 100644 --- a/READMEs/README.es-ES.md +++ b/READMEs/README.es-ES.md @@ -257,6 +257,7 @@ Para habilidades personales (disponibles en todos los proyectos), ejecuta el `in | KIMI CLI | ✅ Soportado | `install.sh kimi` | | Nanobot | ✅ Soportado | `install.sh nanobot` | | Kiro CLI / IDE | ✅ Soportado | `install.sh kiro` | +| AdaL CLI | ✅ Soportado | `install.sh adal` | --- diff --git a/READMEs/README.ja-JP.md b/READMEs/README.ja-JP.md index 0cb341654..b01423479 100644 --- a/READMEs/README.ja-JP.md +++ b/READMEs/README.ja-JP.md @@ -270,6 +270,7 @@ curl -fsSL https://raw.githubusercontent.com/Egonex-AI/Understand-Anything/main/ | Trae | ✅ サポート | `install.sh trae` | | Nanobot | ✅ サポート | `install.sh nanobot` | | Kiro CLI / IDE | ✅ サポート | `install.sh kiro` | +| AdaL CLI | ✅ サポート | `install.sh adal` | --- diff --git a/READMEs/README.ko-KR.md b/READMEs/README.ko-KR.md index 25ad0420f..63079a86a 100644 --- a/READMEs/README.ko-KR.md +++ b/READMEs/README.ko-KR.md @@ -257,6 +257,7 @@ curl -fsSL https://raw.githubusercontent.com/Egonex-AI/Understand-Anything/main/ | KIMI CLI | ✅ 지원 | `install.sh kimi` | | Nanobot | ✅ 지원 | `install.sh nanobot` | | Kiro CLI / IDE | ✅ 지원 | `install.sh kiro` | +| AdaL CLI | ✅ 지원 | `install.sh adal` | --- diff --git a/READMEs/README.ru-RU.md b/READMEs/README.ru-RU.md index a13dfc893..cdaeb28d7 100644 --- a/READMEs/README.ru-RU.md +++ b/READMEs/README.ru-RU.md @@ -258,6 +258,7 @@ curl -fsSL https://raw.githubusercontent.com/Egonex-AI/Understand-Anything/main/ | KIMI CLI | ✅ Поддерживается | `install.sh kimi` | | Nanobot | ✅ Поддерживается | `install.sh nanobot` | | Kiro CLI / IDE | ✅ Поддерживается | `install.sh kiro` | +| AdaL CLI | ✅ Поддерживается | `install.sh adal` | --- diff --git a/READMEs/README.tr-TR.md b/READMEs/README.tr-TR.md index e1fb6c80b..fd111ef85 100644 --- a/READMEs/README.tr-TR.md +++ b/READMEs/README.tr-TR.md @@ -258,6 +258,7 @@ Tüm projelerde kullanmak için kişisel beceri olarak kurmak istersen yukarıda | KIMI CLI | ✅ Destekleniyor | `install.sh kimi` | | Nanobot | ✅ Destekleniyor | `install.sh nanobot` | | Kiro CLI / IDE | ✅ Destekleniyor | `install.sh kiro` | +| AdaL CLI | ✅ Destekleniyor | `install.sh adal` | --- diff --git a/READMEs/README.zh-CN.md b/READMEs/README.zh-CN.md index d3ba928fe..d9ec8c287 100644 --- a/READMEs/README.zh-CN.md +++ b/READMEs/README.zh-CN.md @@ -257,6 +257,7 @@ curl -fsSL https://raw.githubusercontent.com/Egonex-AI/Understand-Anything/main/ | KIMI CLI | ✅ 支持 | `install.sh kimi` | | Nanobot | ✅ 支持 | `install.sh nanobot` | | Kiro CLI / IDE | ✅ 支持 | `install.sh kiro` | +| AdaL CLI | ✅ 支持 | `install.sh adal` | --- diff --git a/READMEs/README.zh-TW.md b/READMEs/README.zh-TW.md index f7b145fc1..35596d497 100644 --- a/READMEs/README.zh-TW.md +++ b/READMEs/README.zh-TW.md @@ -257,6 +257,7 @@ curl -fsSL https://raw.githubusercontent.com/Egonex-AI/Understand-Anything/main/ | KIMI CLI | ✅ 支援 | `install.sh kimi` | | Nanobot | ✅ 支援 | `install.sh nanobot` | | Kiro CLI / IDE | ✅ 支援 | `install.sh kiro` | +| AdaL CLI | ✅ 支援 | `install.sh adal` | --- diff --git a/install.ps1 b/install.ps1 index 77c6595e4..4e1451c2c 100644 --- a/install.ps1 +++ b/install.ps1 @@ -42,6 +42,7 @@ $Platforms = [ordered]@{ trae = @{ Target = (Join-Path $HOME '.trae\skills'); Style = 'per-skill' } nanobot = @{ Target = (Join-Path $HOME '.nanobot\workspace\skills'); Style = 'per-skill' } kiro = @{ Target = (Join-Path $HOME '.kiro\skills'); Style = 'per-skill' } + adal = @{ Target = (Join-Path $HOME '.adal\skills'); Style = 'per-skill' } } function Show-Usage { @@ -245,6 +246,9 @@ function Cmd-Install([string]$Id) { if ($Id -eq 'kiro') { Write-Host "`n Usage: kiro-cli chat --agent understand `"Analyze this project`"" } + if ($Id -eq 'adal') { + Write-Host "`n Tip: AdaL uses /plugin commands — install via the marketplace: /plugin marketplace add Egonex-AI/Understand-Anything then /plugin install understand-anything (or run install.ps1 adal)." + } } function Cmd-Uninstall([string]$Id) { diff --git a/install.sh b/install.sh index eb7336f46..0f9761896 100755 --- a/install.sh +++ b/install.sh @@ -42,6 +42,7 @@ kimi|$HOME/.kimi/skills|folder trae|$HOME/.trae/skills|per-skill nanobot|$HOME/.nanobot/workspace/skills|per-skill kiro|$HOME/.kiro/skills|per-skill +adal|$HOME/.adal/skills|per-skill EOF } @@ -178,6 +179,22 @@ link_plugin_root() { fi } +link_opencode_commands() { + local src="$REPO_DIR/understand-anything-plugin/commands/opencode/understand.md" + [[ -f "$src" ]] || { printf ' • %s not found, skipping command link\n' "$src"; return 0; } + mkdir -p "$HOME/.config/opencode/commands" + ln -sfn "$src" "$HOME/.config/opencode/commands/understand.md" + printf ' ✓ %s → %s\n' "$HOME/.config/opencode/commands/understand.md" "$src" +} + +unlink_opencode_commands() { + local link="$HOME/.config/opencode/commands/understand.md" + if [[ -L "$link" ]]; then + rm -f "$link" + printf ' ✓ removed %s\n' "$link" + fi +} + cmd_install() { local id="$1" local row target style @@ -191,6 +208,11 @@ cmd_install() { printf -- '→ Linking universal plugin root\n' link_plugin_root + if [[ "$id" == "opencode" ]]; then + printf -- '→ Linking OpenCode slash command\n' + link_opencode_commands + fi + if [[ "$id" == "kiro" ]]; then printf -- '→ Creating Kiro agent configuration\n' mkdir -p "$HOME/.kiro/agents" @@ -225,6 +247,11 @@ KIROEOF if [[ "$id" == "codex" ]]; then printf '\n Tip: Codex invokes skills with $ instead of / — type $understand, not /understand.\n' fi + if [[ "$id" == "opencode" ]]; then + printf '\n Tip: OpenCode runs /understand as a structural-only analysis (no subagent\n' + printf ' dispatch — see the upstream OpenCode platform documentation). Run it in\n' + printf ' Claude Code for the full LLM-enriched graph.\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' @@ -232,6 +259,9 @@ KIROEOF if [[ "$id" == "kiro" ]]; then printf '\n Usage: kiro-cli chat --agent understand "Analyze this project"\n' fi + if [[ "$id" == "adal" ]]; then + printf '\n Tip: AdaL uses /plugin commands — install via the marketplace: /plugin marketplace add Egonex-AI/Understand-Anything then /plugin install understand-anything (or run install.sh adal).\n' + fi } cmd_uninstall() { @@ -243,6 +273,9 @@ cmd_uninstall() { printf -- '→ Removing skill links for %s\n' "$id" unlink_skills "$target" "$style" + if [[ "$id" == "opencode" ]]; then + unlink_opencode_commands + fi if [[ "$id" == "kiro" && -f "$HOME/.kiro/agents/understand.json" ]]; then rm -f "$HOME/.kiro/agents/understand.json" printf ' ✓ removed %s\n' "$HOME/.kiro/agents/understand.json" diff --git a/tests/skill/understand/test_enrich_structural_graph.py b/tests/skill/understand/test_enrich_structural_graph.py new file mode 100644 index 000000000..cc535bece --- /dev/null +++ b/tests/skill/understand/test_enrich_structural_graph.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +""" +test_enrich_structural_graph.py — Tests for enrich-structural-graph.py +(schema-valid complexity/tags/summary enrichment for structural-only graphs). + +Run from the repo root: + python -m unittest tests.skill.understand.test_enrich_structural_graph -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 ───────────────────────────────────────────────────────── +# `enrich-structural-graph.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" + / "enrich-structural-graph.py" +) + + +def _load_module() -> Any: + spec = importlib.util.spec_from_file_location("enrich_structural_graph", _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["enrich_structural_graph"] = module + spec.loader.exec_module(module) + return module + + +msg = _load_module() + + +# ── Helpers ─────────────────────────────────────────────────────────────── + +def _file_node(name: str, **overrides: Any) -> dict[str, Any]: + node: dict[str, Any] = { + "id": f"file:src/{name}", + "type": "file", + "name": name, + "filePath": f"src/{name}", + "language": "python", + "fileCategory": "code", + "sizeLines": 120, + } + node.update(overrides) + return node + + +# ── Complexity ──────────────────────────────────────────────────────────── + +class TestComplexity(unittest.TestCase): + def test_none_defaults_to_moderate(self) -> None: + self.assertEqual(msg.complexity_for(None), "moderate") + + def test_simple_threshold(self) -> None: + self.assertEqual(msg.complexity_for(0), "simple") + self.assertEqual(msg.complexity_for(msg.COMPLEXITY_SIMPLE_MAX), "simple") + + def test_moderate_band(self) -> None: + self.assertEqual(msg.complexity_for(msg.COMPLEXITY_SIMPLE_MAX + 1), "moderate") + self.assertEqual(msg.complexity_for(msg.COMPLEXITY_MODERATE_MAX), "moderate") + + def test_complex_band(self) -> None: + self.assertEqual(msg.complexity_for(msg.COMPLEXITY_MODERATE_MAX + 1), "complex") + + +# ── Tags ────────────────────────────────────────────────────────────────── + +class TestTags(unittest.TestCase): + def test_category_language_dir_and_type(self) -> None: + node = _file_node("app.py") + tags = msg.tags_for(node) + self.assertIn("code", tags) + self.assertIn("python", tags) + self.assertIn("dir:src", tags) + + def test_test_marker(self) -> None: + tags = msg.tags_for(_file_node("test_app.py")) + self.assertIn("test", tags) + + def test_documentation_label(self) -> None: + tags = msg.tags_for(_file_node("readme.md", language="markdown", fileCategory="docs")) + self.assertIn("documentation", tags) + + def test_dedupes_tags(self) -> None: + tags = msg.tags_for(_file_node("app.py", fileCategory="code")) + self.assertEqual(len(tags), len(set(tags))) + + def test_non_file_type_tagged(self) -> None: + node = _file_node("mod.py", type="module") + self.assertIn("module", msg.tags_for(node)) + + +# ── Summary ────────────────────────────────────────────────────────────── + +class TestSummary(unittest.TestCase): + def test_with_size(self) -> None: + summary = msg.summary_for(_file_node("app.py", sizeLines=120)) + self.assertIn("Source code", summary) + self.assertIn("app.py", summary) + self.assertIn("120 lines", summary) + self.assertIn("src", summary) + + def test_without_size(self) -> None: + summary = msg.summary_for(_file_node("app.py", sizeLines=None)) + self.assertNotIn("lines", summary) + + +# ── enrich() ────────────────────────────────────────────────────────────── + +class TestEnrich(unittest.TestCase): + def test_fills_missing_fields_and_counts(self) -> None: + graph = {"nodes": [_file_node("app.py", complexity="simple", tags=["x"], summary="s"), + _file_node("bare.py", sizeLines=120)]} + stats = msg.enrich(graph) + node = graph["nodes"][1] + self.assertEqual(stats["complexity"], 1) + self.assertEqual(stats["tags"], 1) + self.assertEqual(stats["summary"], 1) + self.assertEqual(node["complexity"], "moderate") + self.assertTrue(node["tags"]) + self.assertTrue(node["summary"]) + + def test_does_not_overwrite_existing_fields(self) -> None: + node = _file_node("app.py", complexity="complex", tags=["hand"], summary="handwritten") + stats = msg.enrich({"nodes": [node]}) + self.assertEqual(stats, {}) + self.assertEqual(node["complexity"], "complex") + self.assertEqual(node["tags"], ["hand"]) + self.assertEqual(node["summary"], "handwritten") + + def test_skips_non_dict_nodes(self) -> None: + graph = {"nodes": [{"id": "x"}, "not-a-dict", None]} + stats = msg.enrich(graph) + self.assertEqual(stats["complexity"], 1) + + +# ── main() round-trip + UTF-8 ───────────────────────────────────────────── + +class TestMainRoundTrip(unittest.TestCase): + def test_write_read_round_trip_utf8(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + graph_path = Path(tmp) / "knowledge-graph.json" + graph = {"nodes": [_file_node("app.py"), _file_node("übersicht.md", + language="markdown", + fileCategory="docs", + sizeLines=10)]} + for node in graph["nodes"]: + node.pop("complexity", None) + node.pop("tags", None) + node.pop("summary", None) + graph_path.write_text(json.dumps(graph, ensure_ascii=False), encoding="utf-8") + + old_argv = sys.argv + sys.argv = [str(_MODULE_PATH), str(graph_path), "--write"] + try: + exit_code = msg.main() + finally: + sys.argv = old_argv + self.assertEqual(exit_code, 0) + + written = json.loads(graph_path.read_text(encoding="utf-8")) + self.assertEqual(len(written["nodes"]), 2) + for node in written["nodes"]: + self.assertIn("complexity", node) + self.assertIn("tags", node) + self.assertIn("summary", node) + markdown = next(n for n in written["nodes"] if n["name"] == "übersicht.md") + self.assertEqual(markdown["complexity"], "simple") + + def test_usage_when_no_args(self) -> None: + old_argv = sys.argv + sys.argv = [str(_MODULE_PATH)] + try: + self.assertEqual(msg.main(), 2) + finally: + sys.argv = old_argv \ No newline at end of file diff --git a/understand-anything-plugin/commands/opencode/understand.md b/understand-anything-plugin/commands/opencode/understand.md new file mode 100644 index 000000000..09df43b33 --- /dev/null +++ b/understand-anything-plugin/commands/opencode/understand.md @@ -0,0 +1,104 @@ +--- +description: Analyze the codebase into an interactive knowledge graph (structural-only on OpenCode) +--- + +# /understand (OpenCode — structural-only) + +Analyze the current codebase and produce a schema-valid `knowledge-graph.json` +in the project's data directory (`.ua/`, or legacy `.understand-anything/`), +then launch the interactive dashboard. + +**Platform constraint:** OpenCode does not implement Claude Code's `Task` tool, +so the LLM subagent phases of `/understand` (file analysis, architecture, +tour, review) cannot run. This command runs the deterministic pipeline only — +the result is a structural graph (file nodes + import edges) with +schema-valid `complexity`, `tags`, and `summary` fields. No LLM summaries, +layers, or tours. See the upstream OpenCode platform documentation for the +compatibility matrix and future directions. + +## Procedure + +1. **Resolve the skill directory.** The deterministic scripts live next to + this skill. Prefer the symlink-resolved path: + + ```bash + SKILL_ROOT="$(python3 -c "import os;print(os.path.realpath(os.path.expanduser('$HOME/.agents/skills/understand')))" 2>/dev/null)" + [ -d "$SKILL_ROOT" ] || SKILL_ROOT="$HOME/.understand-anything-plugin/skills/understand" + [ -d "$SKILL_ROOT" ] || { echo "[understand] skill dir not found"; exit 1; } + ``` + +2. **Resolve the project root.** Parse `$ARGUMENTS` for a non-flag token; if + found and it is a directory, use it. Otherwise use the current working + directory. + +3. **Phase 1 — SCAN** (deterministic): + + ```bash + UA_DIR="$PROJECT_ROOT/.ua" + mkdir -p "$UA_DIR/intermediate" + node "$SKILL_ROOT/scan-project.mjs" "$PROJECT_ROOT" "$UA_DIR/intermediate/scan-result.json" --exclude-analysis-data + ``` + +4. **Phase 1.5 — IMPORT MAP.** `extract-import-map.mjs` needs an input file + with a `projectRoot` field and the scanned `files` array: + + ```bash + node -e ' + const fs = require("fs"); + const scan = JSON.parse(fs.readFileSync("'"$UA_DIR"'/intermediate/scan-result.json", "utf8")); + fs.writeFileSync("'"$UA_DIR"'/intermediate/scan-for-importmap.json", + JSON.stringify({ projectRoot: scan.projectRoot ?? "'"$PROJECT_ROOT"'", files: scan.files }, null, 2)); + ' + node "$SKILL_ROOT/extract-import-map.mjs" "$UA_DIR/intermediate/scan-for-importmap.json" "$UA_DIR/intermediate/import-map.json" + ``` + +5. **Phase 1.5b — BATCHES** (optional but recommended; needs `@understand-anything/core` — run via the plugin checkout if the symlinked skill dir lacks `node_modules`): + + ```bash + node "$SKILL_ROOT/compute-batches.mjs" "$PROJECT_ROOT" + ``` + +6. **Assemble the structural graph.** Build `knowledge-graph.json` with the + project metadata schema (`version`, `kind`, `project` with `name`, + `languages`, `frameworks`, `description`, `analyzedAt`, `gitCommitHash`; + `nodes` keyed `file:` with `type: "file"`, `filePath`, + `language`, `fileCategory`, `sizeLines`, `name`; `edges` with `source`, + `target`, `type: "imports"`, `direction: "forward"`, `weight: 0.7`; + plus `layers: []` and `tour: []`). For file count per language, use the + scan's stats. Write it to `$UA_DIR/knowledge-graph.json`. + +7. **Enrich (schema validity).** Add `complexity`, `tags`, `summary` to every + node deterministically: + + ```bash + python3 "$SKILL_ROOT/enrich-structural-graph.py" "$UA_DIR/knowledge-graph.json" --write + ``` + + Verify with the plugin's validator: + + ```bash + node -e ' + const { autoFixGraph } = require(process.env.HOME + "/.understand-anything-plugin/packages/core/dist/schema.js"); + const kg = JSON.parse(require("fs").readFileSync("'"$UA_DIR"'/knowledge-graph.json", "utf8")); + const { issues } = autoFixGraph(kg); + console.log("[understand] validation issues:", issues.length); + if (issues.length) process.exit(1); + ' + ``` + +8. **Launch the dashboard** and report the URL: + + ```bash + GRAPH_DIR="$PROJECT_ROOT/.ua" npx --prefix "$HOME/.understand-anything-plugin/packages/dashboard" vite --config "$HOME/.understand-anything-plugin/packages/dashboard/vite.config.ts" --port 5173 & + ``` + + Report `http://localhost:5173/?token=` to the user. + +## Report + +Report to the user: +- Files scanned, languages, edge count (from `scan-result.json` stats) +- The dashboard URL +- A one-line caveat: structural-only graph (no LLM summaries/layers/tour — + OpenCode lacks subagent dispatch; run `/understand` in Claude Code for the + full graph) diff --git a/understand-anything-plugin/skills/understand/enrich-structural-graph.py b/understand-anything-plugin/skills/understand/enrich-structural-graph.py new file mode 100644 index 000000000..b581c3481 --- /dev/null +++ b/understand-anything-plugin/skills/understand/enrich-structural-graph.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""Enrich a structural-only knowledge graph with per-node complexity, tags, +and summary so it passes GraphNodeSchema validation without LLM phases. + +Heuristics (no model calls — deterministic and fast): + - complexity: derived from sizeLines (simple <= 80, moderate <= 300, else complex) + - tags: fileCategory + language + top-level directory + special markers + - summary: templated from name/path/category/language + +Usage: + python3 enrich-structural-graph.py [--write] +""" + +import json +import re +import sys +from collections import Counter +from pathlib import Path + +COMPLEXITY_SIMPLE_MAX = 80 +COMPLEXITY_MODERATE_MAX = 300 + +MARKERS = [ + (r"(^|[/._-])test[s]?([/._-]|$)", "test"), + (r"(^|[/._-])test_|_test\.|\.test\.", "test"), + (r"(^|[/._-])__tests__([/._-]|$)", "test"), + (r"(^|[/._-])specs?([/._-]|$)", "test"), + (r"\.(lock|lockb|sum)$", "lockfile"), + (r"(^|[/._-])node_modules([/._-]|$)", "third-party"), + (r"(^|[/._-])vendor([/._-]|$)", "third-party"), + (r"(^|[/._-])dist([/._-]|$)", "generated"), + (r"(^|[/._-])build([/._-]|$)", "generated"), + (r"(^|[/._-])\.next([/._-]|$)", "generated"), + (r"(^|[/._-])generated([/._-]|$)", "generated"), + (r"(^|[/._-])migrations?([/._-]|$)", "migration"), + (r"(^|[/._-])fixtures?([/._-]|$)", "fixture"), + (r"(^|[/._-])benchmarks?([/._-]|$)", "benchmark"), + (r"(^|[/._-])\.github([/._-]|$)", "ci"), + (r"(^|[/._-])\.gitlab([/._-]|$)", "ci"), + (r"(^|[/._-])\.circleci([/._-]|$)", "ci"), + (r"(^|[/._-])\.husky([/._-]|$)", "tooling"), + (r"(^|[/._-])\.vscode([/._-]|$)", "tooling"), + (r"(^|[/._-])\.idea([/._-]|$)", "tooling"), +] + +MARKDOWN_LANGUAGE_LABELS = { + "markdown": "documentation", + "txt": "text", + "rst": "documentation", + "adoc": "documentation", +} + + +def complexity_for(size_lines: int) -> str: + if size_lines is None: + return "moderate" + if size_lines <= COMPLEXITY_SIMPLE_MAX: + return "simple" + if size_lines <= COMPLEXITY_MODERATE_MAX: + return "moderate" + return "complex" + + +def tags_for(node: dict) -> list[str]: + path = node.get("filePath") or node.get("id") or node.get("name") or "" + tags: list[str] = [] + + category = node.get("fileCategory") + if category: + tags.append(category) + + language = node.get("language") + if language and language != "unknown": + tags.append(language) + if language in MARKDOWN_LANGUAGE_LABELS: + tags.append(MARKDOWN_LANGUAGE_LABELS[language]) + + top_dir = path.split("/", 1)[0] if "/" in path else path + if top_dir and top_dir not in (".", ""): + tags.append("dir:" + top_dir) + + lower = path.lower() + for pattern, marker in MARKERS: + if re.search(pattern, lower): + tags.append(marker) + break + + node_type = node.get("type") + if node_type and node_type != "file": + tags.append(node_type) + + seen: set[str] = set() + deduped: list[str] = [] + for tag in tags: + if tag not in seen: + seen.add(tag) + deduped.append(tag) + return deduped + + +def summary_for(node: dict) -> str: + name = node.get("name") or "" + path = node.get("filePath") or node.get("id") or "" + category = node.get("fileCategory") or "file" + language = node.get("language") or "unknown" + size = node.get("sizeLines") + + prefix = { + "code": "Source code", + "markup": "Markup", + "docs": "Documentation", + "config": "Configuration", + "infra": "Infrastructure definition", + "data": "Data", + "script": "Script", + }.get(category, "File") + + location = path.rsplit("/", 1)[0] if "/" in path else "project root" + if size is not None: + return f"{prefix} `{name}` ({language}, {size} lines) in {location}." + return f"{prefix} `{name}` ({language}) in {location}." + + +def enrich(graph: dict) -> Counter: + stats: Counter = Counter() + for node in graph.get("nodes", []): + if not isinstance(node, dict): + continue + if not node.get("complexity"): + node["complexity"] = complexity_for(node.get("sizeLines")) + stats["complexity"] += 1 + if not isinstance(node.get("tags"), list) or not node["tags"]: + node["tags"] = tags_for(node) + stats["tags"] += 1 + if not node.get("summary"): + node["summary"] = summary_for(node) + stats["summary"] += 1 + return stats + + +def main() -> int: + if len(sys.argv) < 2: + print(__doc__) + return 2 + + path = Path(sys.argv[1]) + graph = json.loads(path.read_text(encoding="utf-8")) + stats = enrich(graph) + + if "--write" in sys.argv: + path.write_text(json.dumps(graph, indent=2, ensure_ascii=False), encoding="utf-8") + print(f"Wrote {path}") + print(f"Enriched nodes: {len(graph.get('nodes', []))}") + print("Fields added:", dict(stats)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())