diff --git a/README.md b/README.md index 98ab4cc20..cb9347d83 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,37 @@ An interactive web dashboard opens with your codebase visualized as a graph — /understand src/frontend ``` +### Agent-friendly graph CLI + +After `/understand` creates `.understand-anything/knowledge-graph.json`, agents and scripts can query the graph without loading the full JSON into context. The installer links `ugraph` for macOS/Linux and creates a Windows command shim; npm-style installs also expose it via the package `bin`. + +```bash +# Project metadata, counts, layers, tags, and graph freshness +ugraph overview + +# Search nodes by name, path, tags, and summary +ugraph find auth --limit 10 + +# Show one node and its direct incoming/outgoing relationships +ugraph node src/auth/login.ts + +# Expand a token-efficient subgraph around a node +ugraph neighbors src/auth/login.ts --depth 2 --limit 30 + +# Approximate callers/importers/tests affected by a changed file +ugraph impact src/auth/login.ts --depth 2 + +# Build compact context for an agent question +ugraph context "payment flow" --limit 25 + +# Inspect architecture layers, guided tour, or graph staleness +ugraph layers +ugraph tour --nodes +ugraph stale +``` + +`ugraph` returns compact JSON by default for reliable agent parsing. Use `--pretty`, `--format text`, or `--format md` for human-readable output. + --- ## 🌐 Multi-Platform Installation diff --git a/install.ps1 b/install.ps1 index 5476887b6..3a3109cc0 100644 --- a/install.ps1 +++ b/install.ps1 @@ -25,6 +25,8 @@ $ErrorActionPreference = 'Stop' $RepoUrl = if ($env:UA_REPO_URL) { $env:UA_REPO_URL } else { 'https://github.com/Egonex-AI/Understand-Anything.git' } $RepoDir = if ($env:UA_DIR) { $env:UA_DIR } else { Join-Path $HOME '.understand-anything\repo' } $PluginLink = Join-Path $HOME '.understand-anything-plugin' +$CliBinDir = if ($env:UA_BIN_DIR) { $env:UA_BIN_DIR } else { Join-Path $HOME '.understand-anything\bin' } +$CliCmd = Join-Path $CliBinDir 'ugraph.cmd' # Platform table — Target = skills directory; Style = "per-skill" | "folder" $Platforms = [ordered]@{ @@ -59,6 +61,7 @@ $($Platforms.Keys -join ', ') Environment: UA_REPO_URL Override clone URL UA_DIR Override clone destination (default: %USERPROFILE%\.understand-anything\repo) + UA_BIN_DIR Override CLI shim directory (default: %USERPROFILE%\.understand-anything\bin) "@ } @@ -198,6 +201,33 @@ function ConvertTo-FileUri([string]$Path) { return 'file:///' + ($Path -replace '\\', '/') } +function Link-Cli { + $script = Join-Path $RepoDir 'understand-anything-plugin\bin\ugraph.js' + if (-not (Test-Path $script)) { + Write-Host " • ugraph CLI not found at $script, skipping" + return + } + + if (-not (Test-Path $CliBinDir)) { New-Item -ItemType Directory -Path $CliBinDir | Out-Null } + $content = "@echo off`r`nnode `"$script`" %*`r`n" + Set-Content -LiteralPath $CliCmd -Value $content -NoNewline -Encoding ASCII + Write-Host " ✓ $CliCmd → $script" + + $pathEntries = ($env:PATH -split ';') | Where-Object { $_ } + if ($pathEntries -notcontains $CliBinDir) { + Write-Host " Tip: add $CliBinDir to PATH to run ugraph from any shell." + } +} + +function Unlink-Cli { + if (-not (Test-Path $CliCmd)) { return } + $content = Get-Content -LiteralPath $CliCmd -Raw + if ($content -match 'understand-anything-plugin[\\/]+bin[\\/]+ugraph\.js') { + Remove-Item -LiteralPath $CliCmd -Force + Write-Host " ✓ removed $CliCmd" + } +} + function Cmd-Install([string]$Id) { $cfg = Resolve-Platform $Id Clone-Or-Update @@ -205,6 +235,8 @@ function Cmd-Install([string]$Id) { Link-Skills $cfg.Target $cfg.Style Write-Host '→ Linking universal plugin root' Link-Plugin-Root + Write-Host '→ Linking ugraph CLI' + Link-Cli if ($Id -eq 'kiro') { Write-Host '→ Creating Kiro agent configuration' @@ -257,6 +289,7 @@ function Cmd-Uninstall([string]$Id) { if (Remove-Reparse $PluginLink) { Write-Host " ✓ removed $PluginLink" } + Unlink-Cli if (Test-Path $RepoDir) { Write-Host "`nThe checkout at $RepoDir was kept (other platforms may still use it)." Write-Host "To remove it: Remove-Item -Recurse -Force '$RepoDir'" diff --git a/install.sh b/install.sh index 1c3423119..43c69b688 100755 --- a/install.sh +++ b/install.sh @@ -15,12 +15,15 @@ # Environment: # UA_REPO_URL Override clone URL (default: official GitHub repo) # UA_DIR Override clone destination (default: $HOME/.understand-anything/repo) +# UA_BIN_DIR Override CLI link directory (default: $HOME/.local/bin) set -euo pipefail REPO_URL="${UA_REPO_URL:-https://github.com/Egonex-AI/Understand-Anything.git}" REPO_DIR="${UA_DIR:-$HOME/.understand-anything/repo}" PLUGIN_LINK="$HOME/.understand-anything-plugin" +CLI_BIN_DIR="${UA_BIN_DIR:-$HOME/.local/bin}" +CLI_LINK="$CLI_BIN_DIR/ugraph" # Platform table — id|skills-target-dir|style # style "per-skill": one symlink per skill into the target dir @@ -178,6 +181,38 @@ link_plugin_root() { fi } +link_cli() { + local target="$REPO_DIR/understand-anything-plugin/bin/ugraph.js" + if [[ ! -f "$target" ]]; then + printf ' • ugraph CLI not found at %s, skipping\n' "$target" + return 0 + fi + + mkdir -p "$CLI_BIN_DIR" + ln -sfn "$target" "$CLI_LINK" + printf ' ✓ %s → %s\n' "$CLI_LINK" "$target" + + case ":$PATH:" in + *":$CLI_BIN_DIR:"*) ;; + *) + printf ' Tip: add %s to PATH to run `ugraph` from any shell.\n' "$CLI_BIN_DIR" + ;; + esac +} + +unlink_cli() { + if [[ ! -L "$CLI_LINK" ]]; then + return 0 + fi + + local resolved + resolved="$(readlink "$CLI_LINK" 2>/dev/null || true)" + if [[ "$resolved" == *"/understand-anything-plugin/bin/ugraph.js" ]]; then + rm -f "$CLI_LINK" + printf ' ✓ removed %s\n' "$CLI_LINK" + fi +} + cmd_install() { local id="$1" local row target style @@ -190,6 +225,8 @@ cmd_install() { link_skills "$target" "$style" printf -- '→ Linking universal plugin root\n' link_plugin_root + printf -- '→ Linking ugraph CLI\n' + link_cli if [[ "$id" == "kiro" ]]; then printf -- '→ Creating Kiro agent configuration\n' @@ -248,6 +285,7 @@ cmd_uninstall() { rm -f "$PLUGIN_LINK" printf ' ✓ removed %s\n' "$PLUGIN_LINK" fi + unlink_cli if [[ -d "$REPO_DIR" ]]; then printf '\nThe checkout at %s was kept (other platforms may still use it).\n' "$REPO_DIR" printf 'To remove it: rm -rf "%s"\n' "$REPO_DIR" @@ -279,6 +317,7 @@ $(platform_ids | sed 's/^/ - /') Environment: UA_REPO_URL Override clone URL (default: official repo) UA_DIR Override clone destination (default: \$HOME/.understand-anything/repo) + UA_BIN_DIR Override CLI link directory (default: \$HOME/.local/bin) USAGE } diff --git a/tests/ugraph.test.mjs b/tests/ugraph.test.mjs new file mode 100644 index 000000000..d52ffb24a --- /dev/null +++ b/tests/ugraph.test.mjs @@ -0,0 +1,207 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const repoRoot = path.resolve(import.meta.dirname, ".."); +const cliPath = path.join(repoRoot, "understand-anything-plugin", "bin", "ugraph.js"); + +function withGraph(testFn) { + const dir = mkdtempSync(path.join(tmpdir(), "ugraph-")); + mkdirSync(path.join(dir, ".understand-anything")); + writeFileSync( + path.join(dir, ".understand-anything", "knowledge-graph.json"), + JSON.stringify(sampleGraph), + ); + + try { + return testFn(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +function run(cwd, args) { + const output = execFileSync(process.execPath, [cliPath, ...args], { + cwd, + encoding: "utf8", + }); + return JSON.parse(output); +} + +const sampleGraph = { + version: "1.0.0", + kind: "codebase", + project: { + name: "Shop", + description: "Example commerce service", + languages: ["TypeScript"], + frameworks: ["Express"], + analyzedAt: "2026-06-15T00:00:00Z", + gitCommitHash: "abc123", + }, + nodes: [ + { + id: "file:src/auth/login.ts", + type: "file", + name: "login.ts", + filePath: "src/auth/login.ts", + summary: "Handles auth login and token validation.", + tags: ["auth", "security"], + complexity: "moderate", + }, + { + id: "function:src/auth/login.ts:validateToken", + type: "function", + name: "validateToken", + filePath: "src/auth/login.ts", + lineRange: [10, 25], + summary: "Validates JWT tokens for authenticated requests.", + tags: ["auth", "jwt"], + complexity: "simple", + }, + { + id: "file:src/routes/payment.ts", + type: "file", + name: "payment.ts", + filePath: "src/routes/payment.ts", + summary: "Payment route that requires authentication before checkout.", + tags: ["payment", "route"], + complexity: "moderate", + }, + { + id: "file:src/db/pool.ts", + type: "file", + name: "pool.ts", + filePath: "src/db/pool.ts", + summary: "Database connection pool.", + tags: ["database"], + complexity: "simple", + }, + { + id: "file:tests/auth.test.ts", + type: "file", + name: "auth.test.ts", + filePath: "tests/auth.test.ts", + summary: "Tests token validation and login behavior.", + tags: ["test", "auth"], + complexity: "simple", + }, + ], + edges: [ + { + source: "file:src/auth/login.ts", + target: "function:src/auth/login.ts:validateToken", + type: "contains", + direction: "forward", + weight: 1, + }, + { + source: "file:src/routes/payment.ts", + target: "function:src/auth/login.ts:validateToken", + type: "calls", + direction: "forward", + weight: 0.9, + }, + { + source: "file:src/auth/login.ts", + target: "file:src/db/pool.ts", + type: "depends_on", + direction: "forward", + weight: 0.7, + }, + { + source: "file:tests/auth.test.ts", + target: "file:src/auth/login.ts", + type: "tested_by", + direction: "backward", + weight: 0.8, + }, + ], + layers: [ + { + id: "api", + name: "API", + description: "HTTP entry points", + nodeIds: ["file:src/routes/payment.ts"], + }, + { + id: "auth", + name: "Auth", + description: "Authentication and authorization", + nodeIds: ["file:src/auth/login.ts", "function:src/auth/login.ts:validateToken"], + }, + { + id: "data", + name: "Data", + description: "Persistence", + nodeIds: ["file:src/db/pool.ts"], + }, + ], + tour: [ + { + order: 1, + title: "Start with auth", + description: "Understand login before reading payment routes.", + nodeIds: ["file:src/auth/login.ts", "file:src/routes/payment.ts"], + }, + ], +}; + +describe("ugraph CLI", () => { + it("prints a compact project overview", () => withGraph((cwd) => { + const result = run(cwd, ["overview"]); + + expect(result.command).toBe("overview"); + expect(result.project.name).toBe("Shop"); + expect(result.graph.nodes).toBe(5); + expect(result.nodeTypes.file).toBe(4); + })); + + it("finds relevant graph nodes", () => withGraph((cwd) => { + const result = run(cwd, ["find", "auth", "--limit", "3"]); + + expect(result.command).toBe("find"); + expect(result.results.length).toBeGreaterThan(0); + expect(result.results[0].node.tags).toContain("auth"); + })); + + it("shows a node with direct relationships", () => withGraph((cwd) => { + const result = run(cwd, ["node", "src/auth/login.ts"]); + + expect(result.node.id).toBe("file:src/auth/login.ts"); + expect(result.relationships.outgoing.map((edge) => edge.type)).toContain("contains"); + expect(result.relationships.incoming.map((edge) => edge.source)).toContain("file:tests/auth.test.ts"); + })); + + it("resolves file paths by suffix for repo-relative callers", () => withGraph((cwd) => { + const result = run(cwd, ["node", "app/src/auth/login.ts", "--no-edges"]); + + expect(result.node.id).toBe("file:src/auth/login.ts"); + })); + + it("expands neighborhoods by depth", () => withGraph((cwd) => { + const result = run(cwd, ["neighbors", "src/auth/login.ts", "--depth", "2", "--limit", "10"]); + + const nodeIds = result.nodes.map((node) => node.id); + expect(nodeIds).toContain("file:src/db/pool.ts"); + expect(nodeIds).toContain("file:src/routes/payment.ts"); + })); + + it("reports incoming impact for a changed file", () => withGraph((cwd) => { + const result = run(cwd, ["impact", "src/auth/login.ts", "--limit", "10"]); + + const impactedIds = result.impactedNodes.map((node) => node.id); + expect(impactedIds).toContain("file:src/routes/payment.ts"); + expect(impactedIds).toContain("file:tests/auth.test.ts"); + })); + + it("builds context around a query", () => withGraph((cwd) => { + const result = run(cwd, ["context", "payment checkout", "--limit", "10"]); + + expect(result.command).toBe("context"); + expect(result.matchedNodes.map((node) => node.id)).toContain("file:src/routes/payment.ts"); + expect(result.relationships.length).toBeGreaterThan(0); + })); +}); diff --git a/understand-anything-plugin/bin/ugraph.js b/understand-anything-plugin/bin/ugraph.js new file mode 100755 index 000000000..d78385719 --- /dev/null +++ b/understand-anything-plugin/bin/ugraph.js @@ -0,0 +1,750 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; + +const DEFAULT_GRAPH_PATH = ".understand-anything/knowledge-graph.json"; +const DEFAULT_LIMIT = 20; +const DEFAULT_DEPTH = 1; + +const USAGE = `ugraph - query an Understand Anything knowledge graph + +Usage: + ugraph [arguments] [options] + +Commands: + overview Print project metadata, counts, layers, and freshness + find Search nodes by name, path, tags, and summary + node Show one node with its direct relationships + neighbors Expand a node neighborhood + impact Show reverse dependencies likely affected by a change + context Build a compact agent context for a question + layers [query] List layers, optionally filtered by a node search + tour Print guided tour steps + stale Compare graph commit with current git HEAD + +Options: + --graph Graph path (default: ${DEFAULT_GRAPH_PATH}) + --format Output format (default: json) + --pretty Pretty-print JSON + --limit Max nodes/results (default: ${DEFAULT_LIMIT}) + --depth Traversal depth for neighbors/impact (default: ${DEFAULT_DEPTH}) + --direction Edge direction for neighbors (default: both) + --type Filter find/context by node type + --nodes Include tour step nodes + --no-edges Omit direct node relationships in node command + -h, --help Show this help + +Examples: + ugraph overview --pretty + ugraph find auth --limit 10 + ugraph node file:src/auth/login.ts + ugraph neighbors src/auth/login.ts --depth 2 --format md + ugraph impact src/auth/login.ts --pretty + ugraph context "payment flow" --limit 30 +`; + +function main(argv) { + const { command, positional, options } = parseArgs(argv); + + if (!command || options.help) { + writeOutput(USAGE.trimEnd(), "text"); + return; + } + + const graphPath = path.resolve(process.cwd(), options.graph ?? DEFAULT_GRAPH_PATH); + const graph = loadGraph(graphPath); + const indexes = buildIndexes(graph); + + let result; + switch (command) { + case "overview": + result = commandOverview(graph, indexes, options); + break; + case "find": + result = commandFind(graph, indexes, positional.join(" "), options); + break; + case "node": + result = commandNode(graph, indexes, requireOne(positional, "node"), options); + break; + case "neighbors": + result = commandNeighbors(graph, indexes, requireOne(positional, "neighbors"), options); + break; + case "impact": + result = commandImpact(graph, indexes, requireOne(positional, "impact"), options); + break; + case "context": + result = commandContext(graph, indexes, positional.join(" "), options); + break; + case "layers": + result = commandLayers(graph, indexes, positional.join(" "), options); + break; + case "tour": + result = commandTour(graph, indexes, options); + break; + case "stale": + result = getFreshness(graph, options); + break; + default: + fail(`Unknown command: ${command}\n\n${USAGE.trimEnd()}`, 1); + } + + writeOutput(result, options.format ?? "json", options); +} + +function parseArgs(argv) { + const options = {}; + const positional = []; + let command; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + + if (arg === "-h" || arg === "--help") { + options.help = true; + continue; + } + + if (arg.startsWith("--")) { + const [rawKey, inlineValue] = arg.slice(2).split("=", 2); + const key = camelCase(rawKey); + + if (["pretty", "nodes", "noEdges"].includes(key)) { + options[key] = true; + continue; + } + + const value = inlineValue ?? argv[index + 1]; + if (value === undefined || value.startsWith("--")) { + fail(`Missing value for --${rawKey}`, 1); + } + index += inlineValue === undefined ? 1 : 0; + options[key] = value; + continue; + } + + if (!command) { + command = arg; + } else { + positional.push(arg); + } + } + + if (options.limit !== undefined) options.limit = positiveInt(options.limit, "--limit"); + if (options.depth !== undefined) options.depth = positiveInt(options.depth, "--depth"); + if (options.type !== undefined) options.types = splitList(options.type); + if (options.format && !["json", "text", "md"].includes(options.format)) { + fail("--format must be one of: json, text, md", 1); + } + if (options.direction && !["in", "out", "both"].includes(options.direction)) { + fail("--direction must be one of: in, out, both", 1); + } + + return { command, positional, options }; +} + +function loadGraph(graphPath) { + if (!existsSync(graphPath)) { + fail(`Knowledge graph not found: ${graphPath}\nRun /understand first or pass --graph .`, 2); + } + + try { + return JSON.parse(readFileSync(graphPath, "utf8")); + } catch (error) { + fail(`Could not parse knowledge graph: ${error.message}`, 2); + } +} + +function buildIndexes(graph) { + const nodeById = new Map(); + const nodesByFile = new Map(); + const edgesBySource = new Map(); + const edgesByTarget = new Map(); + const layersByNode = new Map(); + + for (const node of graph.nodes ?? []) { + nodeById.set(node.id, node); + if (node.filePath) { + const normalized = normalizePath(node.filePath); + pushMap(nodesByFile, normalized, node); + } + } + + for (const edge of graph.edges ?? []) { + pushMap(edgesBySource, edge.source, edge); + pushMap(edgesByTarget, edge.target, edge); + } + + for (const layer of graph.layers ?? []) { + for (const nodeId of layer.nodeIds ?? []) { + pushMap(layersByNode, nodeId, layer); + } + } + + return { nodeById, nodesByFile, edgesBySource, edgesByTarget, layersByNode }; +} + +function commandOverview(graph, indexes, options) { + const nodes = graph.nodes ?? []; + const edges = graph.edges ?? []; + const limit = options.limit ?? DEFAULT_LIMIT; + + return { + command: "overview", + project: graph.project, + graph: { + version: graph.version, + kind: graph.kind, + nodes: nodes.length, + edges: edges.length, + layers: (graph.layers ?? []).length, + tourSteps: (graph.tour ?? []).length, + }, + nodeTypes: countBy(nodes, "type"), + edgeTypes: countBy(edges, "type"), + topTags: topTags(nodes, limit), + layers: (graph.layers ?? []).map((layer) => summarizeLayer(layer)), + freshness: getFreshness(graph, options), + }; +} + +function commandFind(graph, indexes, query, options) { + if (!query.trim()) fail("find requires a query", 1); + const results = searchNodes(graph.nodes ?? [], query, options); + + return { + command: "find", + query, + count: results.length, + results: results.map(({ node, score }) => ({ + score, + node: summarizeNode(node, indexes), + })), + }; +} + +function commandNode(graph, indexes, ref, options) { + const node = resolveNode(ref, graph, indexes); + const includeEdges = !options.noEdges; + + return { + command: "node", + ref, + node: summarizeNode(node, indexes, { full: true }), + relationships: includeEdges ? directRelationships(node.id, indexes, options.limit ?? DEFAULT_LIMIT) : undefined, + }; +} + +function commandNeighbors(graph, indexes, ref, options) { + const seed = resolveNode(ref, graph, indexes); + const depth = options.depth ?? DEFAULT_DEPTH; + const limit = options.limit ?? DEFAULT_LIMIT; + const direction = options.direction ?? "both"; + const subgraph = traverse(indexes, [seed.id], { depth, direction, limit }); + + return { + command: "neighbors", + ref, + seed: summarizeNode(seed, indexes), + depth, + direction, + nodes: subgraph.nodeIds.map((id) => summarizeNode(indexes.nodeById.get(id), indexes)).filter(Boolean), + edges: subgraph.edges.map((edge) => summarizeEdge(edge, indexes)), + truncated: subgraph.truncated, + }; +} + +function commandImpact(graph, indexes, ref, options) { + const seeds = resolveSeedSet(ref, graph, indexes); + const depth = options.depth ?? 2; + const limit = options.limit ?? DEFAULT_LIMIT; + const subgraph = traverse(indexes, seeds.map((node) => node.id), { + depth, + direction: "in", + limit, + }); + const seedIds = new Set(seeds.map((node) => node.id)); + const impactedIds = subgraph.nodeIds.filter((id) => !seedIds.has(id)); + + return { + command: "impact", + ref, + depth, + seeds: seeds.map((node) => summarizeNode(node, indexes)), + impactedNodes: impactedIds.map((id) => summarizeNode(indexes.nodeById.get(id), indexes)).filter(Boolean), + impactEdges: subgraph.edges.map((edge) => summarizeEdge(edge, indexes)), + truncated: subgraph.truncated, + note: "Impact follows incoming graph edges, which approximates callers, importers, dependents, and related tests.", + }; +} + +function commandContext(graph, indexes, query, options) { + if (!query.trim()) fail("context requires a query", 1); + const limit = options.limit ?? DEFAULT_LIMIT; + const matches = searchNodes(graph.nodes ?? [], query, options).slice(0, limit); + const matchedIds = matches.map((result) => result.node.id); + const subgraph = traverse(indexes, matchedIds, { + depth: options.depth ?? 1, + direction: "both", + limit: Math.max(limit, matchedIds.length), + }); + const matchedSet = new Set(matchedIds); + const nodeIds = subgraph.nodeIds; + const layerIds = new Set(); + for (const nodeId of nodeIds) { + for (const layer of indexes.layersByNode.get(nodeId) ?? []) { + layerIds.add(layer.id); + } + } + + return { + command: "context", + query, + project: { + name: graph.project?.name, + description: graph.project?.description, + languages: graph.project?.languages ?? [], + frameworks: graph.project?.frameworks ?? [], + }, + matchedNodes: nodeIds + .filter((id) => matchedSet.has(id)) + .map((id) => summarizeNode(indexes.nodeById.get(id), indexes)) + .filter(Boolean), + relatedNodes: nodeIds + .filter((id) => !matchedSet.has(id)) + .map((id) => summarizeNode(indexes.nodeById.get(id), indexes)) + .filter(Boolean), + relationships: subgraph.edges.map((edge) => summarizeEdge(edge, indexes)), + layers: (graph.layers ?? []).filter((layer) => layerIds.has(layer.id)).map((layer) => summarizeLayer(layer)), + freshness: getFreshness(graph, options), + truncated: subgraph.truncated, + }; +} + +function commandLayers(graph, indexes, query, options) { + const layers = graph.layers ?? []; + + if (!query.trim()) { + return { + command: "layers", + count: layers.length, + layers: layers.map((layer) => summarizeLayer(layer)), + }; + } + + const matches = searchNodes(graph.nodes ?? [], query, options); + const matchedIds = new Set(matches.map((result) => result.node.id)); + const filtered = layers.filter((layer) => (layer.nodeIds ?? []).some((id) => matchedIds.has(id))); + + return { + command: "layers", + query, + count: filtered.length, + layers: filtered.map((layer) => summarizeLayer(layer)), + }; +} + +function commandTour(graph, indexes, options) { + const limit = options.limit ?? DEFAULT_LIMIT; + const steps = (graph.tour ?? []).slice(0, limit).map((step) => { + const result = { + order: step.order, + title: step.title, + description: step.description, + nodeIds: step.nodeIds ?? [], + languageLesson: step.languageLesson, + }; + if (options.nodes) { + result.nodes = (step.nodeIds ?? []).map((id) => summarizeNode(indexes.nodeById.get(id), indexes)).filter(Boolean); + } + return result; + }); + + return { + command: "tour", + count: steps.length, + total: (graph.tour ?? []).length, + steps, + }; +} + +function searchNodes(nodes, query, options) { + const terms = query.toLowerCase().split(/\s+/).filter(Boolean); + const allowedTypes = options.types ? new Set(options.types) : null; + const limit = options.limit ?? DEFAULT_LIMIT; + + return nodes + .filter((node) => !allowedTypes || allowedTypes.has(node.type)) + .map((node) => ({ node, score: scoreNode(node, terms) })) + .filter((result) => result.score > 0) + .sort((a, b) => b.score - a.score || a.node.id.localeCompare(b.node.id)) + .slice(0, limit); +} + +function scoreNode(node, terms) { + if (terms.length === 0) return 0; + + const fields = [ + [node.name, 4], + [node.tags?.join(" "), 3], + [node.id, 2.5], + [node.filePath, 2.5], + [node.summary, 1.5], + [node.languageNotes, 1], + ]; + + let score = 0; + for (const term of terms) { + let best = 0; + for (const [raw, weight] of fields) { + const value = String(raw ?? "").toLowerCase(); + if (!value) continue; + if (value === term) best = Math.max(best, weight * 1.5); + else if (value.includes(term)) best = Math.max(best, weight); + else if (looseIncludes(value, term)) best = Math.max(best, weight * 0.55); + } + score += best; + } + + return Number(score.toFixed(3)); +} + +function looseIncludes(value, term) { + if (term.length < 4) return false; + let valueIndex = 0; + for (const char of term) { + valueIndex = value.indexOf(char, valueIndex); + if (valueIndex === -1) return false; + valueIndex += 1; + } + return true; +} + +function resolveNode(ref, graph, indexes) { + const normalizedRef = normalizePath(ref); + const direct = indexes.nodeById.get(ref) + ?? indexes.nodeById.get(`file:${normalizedRef}`) + ?? indexes.nodeById.get(`config:${normalizedRef}`) + ?? indexes.nodeById.get(`document:${normalizedRef}`); + if (direct) return direct; + + const fileMatches = indexes.nodesByFile.get(normalizedRef); + if (fileMatches?.length) { + const fileNode = fileMatches.find((node) => node.type === "file") ?? fileMatches[0]; + return fileNode; + } + + const suffixMatches = [...indexes.nodesByFile.entries()] + .filter(([filePath]) => normalizedRef.endsWith(filePath) || filePath.endsWith(normalizedRef)) + .flatMap(([, nodes]) => nodes); + if (suffixMatches.length) { + const fileNode = suffixMatches.find((node) => node.type === "file") ?? suffixMatches[0]; + return fileNode; + } + + const lower = ref.toLowerCase(); + const nameMatches = (graph.nodes ?? []).filter((node) => node.name?.toLowerCase() === lower); + if (nameMatches.length === 1) return nameMatches[0]; + + const fuzzy = searchNodes(graph.nodes ?? [], ref, { limit: 1 }); + if (fuzzy.length > 0) return fuzzy[0].node; + + fail(`No node found for: ${ref}`, 2); +} + +function resolveSeedSet(ref, graph, indexes) { + const node = resolveNode(ref, graph, indexes); + const seeds = new Map([[node.id, node]]); + + if (node.filePath) { + for (const related of indexes.nodesByFile.get(normalizePath(node.filePath)) ?? []) { + seeds.set(related.id, related); + } + } else if (node.type === "file" && node.id.startsWith("file:")) { + const filePath = node.id.slice("file:".length); + for (const related of indexes.nodesByFile.get(normalizePath(filePath)) ?? []) { + seeds.set(related.id, related); + } + } + + for (const edge of indexes.edgesBySource.get(node.id) ?? []) { + if (edge.type === "contains") { + const contained = indexes.nodeById.get(edge.target); + if (contained) seeds.set(contained.id, contained); + } + } + + return [...seeds.values()]; +} + +function traverse(indexes, seedIds, options) { + const maxDepth = options.depth ?? DEFAULT_DEPTH; + const maxNodes = options.limit ?? DEFAULT_LIMIT; + const direction = options.direction ?? "both"; + const seen = new Set(seedIds); + const edgeMap = new Map(); + const queue = seedIds.map((id) => ({ id, depth: 0 })); + let truncated = false; + + for (let cursor = 0; cursor < queue.length; cursor += 1) { + const item = queue[cursor]; + if (item.depth >= maxDepth) continue; + + const edges = []; + if (direction === "out" || direction === "both") { + edges.push(...(indexes.edgesBySource.get(item.id) ?? [])); + } + if (direction === "in" || direction === "both") { + edges.push(...(indexes.edgesByTarget.get(item.id) ?? [])); + } + + for (const edge of edges) { + edgeMap.set(edgeKey(edge), edge); + const nextId = edge.source === item.id ? edge.target : edge.source; + if (!indexes.nodeById.has(nextId) || seen.has(nextId)) continue; + + if (seen.size >= maxNodes) { + truncated = true; + continue; + } + + seen.add(nextId); + queue.push({ id: nextId, depth: item.depth + 1 }); + } + } + + return { + nodeIds: [...seen], + edges: [...edgeMap.values()].filter((edge) => seen.has(edge.source) && seen.has(edge.target)), + truncated, + }; +} + +function directRelationships(nodeId, indexes, limit) { + const outgoing = (indexes.edgesBySource.get(nodeId) ?? []).slice(0, limit); + const incoming = (indexes.edgesByTarget.get(nodeId) ?? []).slice(0, limit); + + return { + outgoing: outgoing.map((edge) => summarizeEdge(edge, indexes)), + incoming: incoming.map((edge) => summarizeEdge(edge, indexes)), + outgoingTruncated: (indexes.edgesBySource.get(nodeId) ?? []).length > outgoing.length, + incomingTruncated: (indexes.edgesByTarget.get(nodeId) ?? []).length > incoming.length, + }; +} + +function summarizeNode(node, indexes, options = {}) { + if (!node) return undefined; + const summary = { + id: node.id, + type: node.type, + name: node.name, + filePath: node.filePath, + lineRange: node.lineRange, + summary: node.summary, + tags: node.tags ?? [], + complexity: node.complexity, + layers: (indexes.layersByNode.get(node.id) ?? []).map((layer) => layer.name), + }; + + if (options.full) { + summary.languageNotes = node.languageNotes; + summary.domainMeta = node.domainMeta; + summary.knowledgeMeta = node.knowledgeMeta; + } + + return prune(summary); +} + +function summarizeEdge(edge, indexes) { + return prune({ + source: edge.source, + sourceName: indexes.nodeById.get(edge.source)?.name, + target: edge.target, + targetName: indexes.nodeById.get(edge.target)?.name, + type: edge.type, + direction: edge.direction, + weight: edge.weight, + description: edge.description, + }); +} + +function summarizeLayer(layer) { + return { + id: layer.id, + name: layer.name, + description: layer.description, + nodeCount: (layer.nodeIds ?? []).length, + }; +} + +function getFreshness(graph, options) { + const graphCommitHash = graph.project?.gitCommitHash; + const headCommitHash = git(["rev-parse", "HEAD"]); + let changedFiles = []; + + if (graphCommitHash && headCommitHash && graphCommitHash !== headCommitHash) { + const diff = git(["diff", "--name-only", `${graphCommitHash}..HEAD`]); + changedFiles = diff ? diff.split("\n").filter(Boolean) : []; + } + + const limit = options.limit ?? DEFAULT_LIMIT; + return { + graphCommitHash, + headCommitHash, + isStale: Boolean(graphCommitHash && headCommitHash && graphCommitHash !== headCommitHash), + changedFileCount: changedFiles.length, + changedFiles: changedFiles.slice(0, limit), + changedFilesTruncated: changedFiles.length > limit, + }; +} + +function git(args) { + try { + return execFileSync("git", args, { + cwd: process.cwd(), + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + return null; + } +} + +function writeOutput(value, format, options = {}) { + if (typeof value === "string") { + process.stdout.write(`${value}\n`); + return; + } + + if (format === "json") { + process.stdout.write(`${JSON.stringify(prune(value), null, options.pretty ? 2 : 0)}\n`); + return; + } + + if (format === "md") { + process.stdout.write(`${toMarkdown(value)}\n`); + return; + } + + process.stdout.write(`${toText(value)}\n`); +} + +function toMarkdown(value) { + if (value.command === "find") { + return [ + `# ugraph find: ${value.query}`, + "", + ...value.results.map((result) => `- ${result.node.name} (${result.node.type}) - ${result.node.filePath ?? result.node.id}: ${result.node.summary}`), + ].join("\n"); + } + + if (value.nodes && value.edges) { + return [ + `# ugraph ${value.command}`, + "", + "## Nodes", + ...value.nodes.map((node) => `- ${node.name} (${node.type}) - ${node.filePath ?? node.id}: ${node.summary}`), + "", + "## Relationships", + ...value.edges.map((edge) => `- ${edge.sourceName ?? edge.source} --[${edge.type}]--> ${edge.targetName ?? edge.target}`), + ].join("\n"); + } + + return `\`\`\`json\n${JSON.stringify(prune(value), null, 2)}\n\`\`\``; +} + +function toText(value) { + if (value.command === "overview") { + return [ + `${value.project?.name ?? "Project"}: ${value.project?.description ?? ""}`, + `nodes=${value.graph.nodes} edges=${value.graph.edges} layers=${value.graph.layers} stale=${value.freshness.isStale}`, + `languages=${(value.project?.languages ?? []).join(", ")}`, + `frameworks=${(value.project?.frameworks ?? []).join(", ")}`, + ].join("\n"); + } + + if (value.command === "find") { + return value.results.map((result) => `${result.node.id}\t${result.node.type}\t${result.node.filePath ?? ""}\t${result.node.summary ?? ""}`).join("\n"); + } + + return JSON.stringify(prune(value), null, 2); +} + +function countBy(items, key) { + return Object.fromEntries( + [...items.reduce((map, item) => map.set(item[key], (map.get(item[key]) ?? 0) + 1), new Map()).entries()] + .sort((a, b) => b[1] - a[1] || String(a[0]).localeCompare(String(b[0]))), + ); +} + +function topTags(nodes, limit) { + const counts = new Map(); + for (const node of nodes) { + for (const tag of node.tags ?? []) { + counts.set(tag, (counts.get(tag) ?? 0) + 1); + } + } + return [...counts.entries()] + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .slice(0, limit) + .map(([tag, count]) => ({ tag, count })); +} + +function pushMap(map, key, value) { + const values = map.get(key); + if (values) values.push(value); + else map.set(key, [value]); +} + +function edgeKey(edge) { + return `${edge.source}\0${edge.type}\0${edge.target}`; +} + +function normalizePath(value) { + return String(value).replaceAll("\\", "/").replace(/^\.\//, ""); +} + +function splitList(value) { + return String(value).split(",").map((item) => item.trim()).filter(Boolean); +} + +function positiveInt(value, label) { + const parsed = Number.parseInt(String(value), 10); + if (!Number.isInteger(parsed) || parsed < 1) { + fail(`${label} must be a positive integer`, 1); + } + return parsed; +} + +function camelCase(value) { + return value.replace(/-([a-z])/g, (_, char) => char.toUpperCase()); +} + +function requireOne(values, command) { + if (!values[0]) fail(`${command} requires a node id, file path, or name`, 1); + return values.join(" "); +} + +function prune(value) { + if (Array.isArray(value)) { + return value.map((item) => prune(item)).filter((item) => item !== undefined); + } + + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value) + .filter(([, item]) => item !== undefined && item !== null) + .map(([key, item]) => [key, prune(item)]), + ); + } + + return value; +} + +function fail(message, code) { + process.stderr.write(`${message}\n`); + process.exit(code); +} + +main(process.argv.slice(2)); diff --git a/understand-anything-plugin/package.json b/understand-anything-plugin/package.json index 67b963392..5854da8d7 100644 --- a/understand-anything-plugin/package.json +++ b/understand-anything-plugin/package.json @@ -4,9 +4,13 @@ "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", + "bin": { + "ugraph": "./bin/ugraph.js" + }, "scripts": { "build": "tsc", - "test": "node -e \"console.log('skill tests live at /tests/skill — run via root \\`pnpm test\\`')\"" + "test": "node -e \"console.log('skill tests live at /tests/skill — run via root \\`pnpm test\\`')\"", + "ugraph": "node bin/ugraph.js" }, "dependencies": { "@understand-anything/core": "workspace:*",