diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 1f9678e..8b824ab 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "understand-anything", "description": "AI-powered codebase understanding — analyze, visualize, and explain any project", - "version": "2.6.2", + "version": "2.6.3", "author": { "name": "Lum1104" }, diff --git a/.copilot-plugin/plugin.json b/.copilot-plugin/plugin.json index db732ce..34ee7e2 100644 --- a/.copilot-plugin/plugin.json +++ b/.copilot-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "understand-anything", "description": "AI-powered codebase understanding — analyze, visualize, and explain any project", - "version": "2.6.2", + "version": "2.6.3", "author": { "name": "Lum1104" }, diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 3e117fb..11662ee 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -2,7 +2,7 @@ "name": "understand-anything", "displayName": "Understand Anything", "description": "AI-powered codebase understanding — analyze, visualize, and explain any project", - "version": "2.6.2", + "version": "2.6.3", "author": { "name": "Lum1104" }, diff --git a/understand-anything-plugin/.claude-plugin/plugin.json b/understand-anything-plugin/.claude-plugin/plugin.json index 1f9678e..8b824ab 100644 --- a/understand-anything-plugin/.claude-plugin/plugin.json +++ b/understand-anything-plugin/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "understand-anything", "description": "AI-powered codebase understanding — analyze, visualize, and explain any project", - "version": "2.6.2", + "version": "2.6.3", "author": { "name": "Lum1104" }, diff --git a/understand-anything-plugin/agents/file-analyzer.md b/understand-anything-plugin/agents/file-analyzer.md index dcea0ad..92e7a12 100644 --- a/understand-anything-plugin/agents/file-analyzer.md +++ b/understand-anything-plugin/agents/file-analyzer.md @@ -260,7 +260,17 @@ Using the script's structural data and file categories, create edges: | `related` | Non-code file is topically related to another file without a specific structural relationship | `0.5` | `forward` | | `depends_on` | Non-code file depends on another file (e.g., docker-compose depends on Dockerfile, CI workflow depends on Makefile targets) | `0.6` | `forward` | -**Import edge creation rule for code files:** For each resolved path in `batchImportData[filePath]` (provided in the input JSON), create an `imports` edge from the current file node to `file:`. The `batchImportData` values contain only resolved project-internal paths — external packages have already been filtered out. Do NOT attempt to re-resolve imports from source. +**Import edge creation rule for code files (1:1 emission, NO aggregation):** + +For every code file in this batch: + +1. Read its `batchImportData[filePath]` array (provided in the input JSON). +2. For EACH path in that array, emit ONE `imports` edge object: `{ "source": "file:", "target": "file:", "type": "imports", "direction": "forward", "weight": 0.7 }`. +3. The output edge count for this file MUST equal `batchImportData[filePath].length`. Not 90% of it. Not "the meaningful ones". All of them. + +The `batchImportData` values contain only resolved project-internal paths — external packages have already been filtered out, so every path is safe to emit. Do NOT attempt to re-resolve imports from source. Do NOT skip imports because the target lives in another batch (cross-batch references are explicitly allowed for `imports` edges, since the project-scanner already verified the path exists). + +**Self-check before writing the batch JSON:** sum `batchImportData[file].length` across every code file in your batch. The number of `imports` edges in your output MUST equal that sum. If it doesn't, you dropped some during enumeration — go back and add them. (A deterministic post-processing pass in `merge-batch-graphs.py` will recover anything you still miss, but it is your job to get this right at emission time so the recovery report stays empty.) **Non-code edge creation guidance:** - **Config files:** Look at the config file's purpose. `tsconfig.json` configures all `.ts` files; `package.json` configures the build. Create `configures` edges to the most relevant entry points or directories. diff --git a/understand-anything-plugin/package.json b/understand-anything-plugin/package.json index bb42482..60c9ca0 100644 --- a/understand-anything-plugin/package.json +++ b/understand-anything-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@understand-anything/skill", - "version": "2.6.2", + "version": "2.6.3", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/understand-anything-plugin/skills/understand/merge-batch-graphs.py b/understand-anything-plugin/skills/understand/merge-batch-graphs.py index 9b6c949..17bd862 100644 --- a/understand-anything-plugin/skills/understand/merge-batch-graphs.py +++ b/understand-anything-plugin/skills/understand/merge-batch-graphs.py @@ -367,6 +367,96 @@ def merge_and_normalize(batches: list[dict[str, Any]]) -> tuple[dict[str, Any], return assembled, report +# ── Imports-edge recovery from importMap ────────────────────────────────── + +def recover_imports_from_scan( + assembled: dict[str, Any], + scan_result_path: Path, +) -> tuple[int, list[str]]: + """Re-emit any `imports` edges that exist in `scan-result.json#importMap` + but never made it into a batch's output. The project-scanner's importMap + is the deterministic source of truth for resolved internal imports; + file-analyzer agents are expected to transcribe those into edges 1:1 + but in practice drop ~25% of them on real projects (orchestrator-side + batch construction loses entries, agent-side enumeration drops more). + + Returns (recovered_count, report_lines). + """ + if not scan_result_path.is_file(): + return 0, [f" importMap recovery skipped — {scan_result_path.name} not found"] + + try: + scan = json.loads(scan_result_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as e: + return 0, [f" importMap recovery skipped — could not parse {scan_result_path.name}: {e}"] + + import_map = scan.get("importMap") + if not isinstance(import_map, dict): + return 0, [f" importMap recovery skipped — no importMap field in {scan_result_path.name}"] + + # Build the set of file: node ids actually present in the assembled graph. + file_node_ids: set[str] = set() + for node in assembled["nodes"]: + if node.get("type") == "file": + file_node_ids.add(node.get("id", "")) + + # Build the set of (source, target) imports edges already present. + existing: set[tuple[str, str]] = set() + for edge in assembled["edges"]: + if edge.get("type") == "imports": + existing.add((edge.get("source", ""), edge.get("target", ""))) + + recovered = 0 + skipped_no_src_node = 0 + skipped_no_tgt_node = 0 + for src_path, targets in import_map.items(): + if not isinstance(targets, list): + continue + src_id = f"file:{src_path}" + if src_id not in file_node_ids: + if targets: + skipped_no_src_node += 1 + continue + for tgt_path in targets: + if not isinstance(tgt_path, str) or not tgt_path: + continue + tgt_id = f"file:{tgt_path}" + if tgt_id not in file_node_ids: + skipped_no_tgt_node += 1 + continue + if src_id == tgt_id: + continue + if (src_id, tgt_id) in existing: + continue + assembled["edges"].append({ + "source": src_id, + "target": tgt_id, + "type": "imports", + "direction": "forward", + "weight": 0.7, + "recoveredFromImportMap": True, + }) + existing.add((src_id, tgt_id)) + recovered += 1 + + lines: list[str] = [] + lines.append( + f" Recovered {recovered} `imports` edges from importMap " + f"({len(import_map)} entries scanned)" + ) + if skipped_no_src_node: + lines.append( + f" Skipped {skipped_no_src_node} importMap source files " + f"with no `file:` node in graph" + ) + if skipped_no_tgt_node: + lines.append( + f" Skipped {skipped_no_tgt_node} importMap target paths " + f"with no `file:` node in graph" + ) + return recovered, lines + + # ── Main ────────────────────────────────────────────────────────────────── def main() -> None: @@ -411,6 +501,16 @@ def main() -> None: # Merge and normalize assembled, report = merge_and_normalize(batches) + # Recover any imports edges file-analyzer batches dropped despite + # `batchImportData` containing them. The project-scanner's importMap + # is the deterministic source of truth. + scan_result_path = intermediate_dir / "scan-result.json" + recovered, recovery_report = recover_imports_from_scan(assembled, scan_result_path) + if recovery_report: + report.append("") + report.append("Imports edge recovery:") + report.extend(recovery_report) + # Print report print("", file=sys.stderr) for line in report: diff --git a/understand-anything-plugin/src/__tests__/merge-recover-imports.test.mjs b/understand-anything-plugin/src/__tests__/merge-recover-imports.test.mjs new file mode 100644 index 0000000..a98f080 --- /dev/null +++ b/understand-anything-plugin/src/__tests__/merge-recover-imports.test.mjs @@ -0,0 +1,187 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const MERGE_SCRIPT = resolve(__dirname, "../../skills/understand/merge-batch-graphs.py"); + +let projectRoot; +let intermediateDir; + +function runMerge() { + const result = spawnSync("python3", [MERGE_SCRIPT, projectRoot], { + encoding: "utf-8", + }); + if (result.status !== 0) { + throw new Error(`merge script failed: status=${result.status}\nstderr:\n${result.stderr}`); + } + const assembled = JSON.parse( + readFileSync(join(intermediateDir, "assembled-graph.json"), "utf-8"), + ); + return { assembled, stderr: result.stderr }; +} + +function fileNode(path) { + return { + id: `file:${path}`, + type: "file", + name: path.split("/").pop(), + filePath: path, + summary: "", + tags: [], + complexity: "simple", + }; +} + +function importsEdge(src, tgt) { + return { + source: `file:${src}`, + target: `file:${tgt}`, + type: "imports", + direction: "forward", + weight: 0.7, + }; +} + +beforeEach(() => { + projectRoot = mkdtempSync(join(tmpdir(), "ua-merge-test-")); + intermediateDir = join(projectRoot, ".understand-anything", "intermediate"); + mkdirSync(intermediateDir, { recursive: true }); +}); + +afterEach(() => { + rmSync(projectRoot, { recursive: true, force: true }); +}); + +describe("merge-batch-graphs.py imports recovery", () => { + it("recovers imports edges that batches dropped despite importMap having them", () => { + // Batch contains all the file nodes but only emits ONE of three imports edges. + writeFileSync( + join(intermediateDir, "batch-0.json"), + JSON.stringify({ + nodes: [fileNode("src/a.py"), fileNode("src/b.py"), fileNode("src/c.py"), fileNode("src/d.py")], + edges: [importsEdge("src/a.py", "src/b.py")], + }), + ); + // scan-result.json has the full importMap — agent dropped 2/3 of these. + writeFileSync( + join(intermediateDir, "scan-result.json"), + JSON.stringify({ + importMap: { + "src/a.py": ["src/b.py", "src/c.py", "src/d.py"], + "src/b.py": [], + }, + }), + ); + + const { assembled, stderr } = runMerge(); + const importsEdges = assembled.edges.filter((e) => e.type === "imports"); + expect(importsEdges).toHaveLength(3); + const targets = new Set(importsEdges.map((e) => e.target)); + expect(targets).toEqual(new Set(["file:src/b.py", "file:src/c.py", "file:src/d.py"])); + // Recovered edges are tagged so downstream consumers can audit. + const recovered = importsEdges.filter((e) => e.recoveredFromImportMap); + expect(recovered).toHaveLength(2); + expect(stderr).toContain("Recovered 2 `imports` edges"); + }); + + it("does not duplicate edges the batch already emitted", () => { + writeFileSync( + join(intermediateDir, "batch-0.json"), + JSON.stringify({ + nodes: [fileNode("src/a.py"), fileNode("src/b.py")], + edges: [importsEdge("src/a.py", "src/b.py")], + }), + ); + writeFileSync( + join(intermediateDir, "scan-result.json"), + JSON.stringify({ + importMap: { "src/a.py": ["src/b.py"], "src/b.py": [] }, + }), + ); + + const { assembled, stderr } = runMerge(); + const importsEdges = assembled.edges.filter((e) => e.type === "imports"); + expect(importsEdges).toHaveLength(1); + expect(stderr).toContain("Recovered 0 `imports` edges"); + }); + + it("skips importMap entries whose source file is missing from the graph", () => { + // src/missing.py is in importMap but has no file: node — must not produce a dangling edge. + writeFileSync( + join(intermediateDir, "batch-0.json"), + JSON.stringify({ + nodes: [fileNode("src/b.py")], + edges: [], + }), + ); + writeFileSync( + join(intermediateDir, "scan-result.json"), + JSON.stringify({ + importMap: { "src/missing.py": ["src/b.py"] }, + }), + ); + + const { assembled, stderr } = runMerge(); + expect(assembled.edges.filter((e) => e.type === "imports")).toHaveLength(0); + expect(stderr).toContain("Skipped 1 importMap source files with no `file:` node"); + }); + + it("skips importMap targets that don't have a file: node", () => { + writeFileSync( + join(intermediateDir, "batch-0.json"), + JSON.stringify({ + nodes: [fileNode("src/a.py")], + edges: [], + }), + ); + writeFileSync( + join(intermediateDir, "scan-result.json"), + JSON.stringify({ + importMap: { "src/a.py": ["src/dropped.py", "src/also-missing.py"] }, + }), + ); + + const { assembled, stderr } = runMerge(); + expect(assembled.edges.filter((e) => e.type === "imports")).toHaveLength(0); + expect(stderr).toContain("Skipped 2 importMap target paths with no `file:` node"); + }); + + it("works when scan-result.json is missing (incremental update path)", () => { + writeFileSync( + join(intermediateDir, "batch-0.json"), + JSON.stringify({ + nodes: [fileNode("src/a.py"), fileNode("src/b.py")], + edges: [importsEdge("src/a.py", "src/b.py")], + }), + ); + // No scan-result.json written. + + const { assembled, stderr } = runMerge(); + expect(assembled.edges.filter((e) => e.type === "imports")).toHaveLength(1); + expect(stderr).toContain("importMap recovery skipped — scan-result.json not found"); + }); + + it("never produces self-import edges", () => { + writeFileSync( + join(intermediateDir, "batch-0.json"), + JSON.stringify({ + nodes: [fileNode("src/a.py")], + edges: [], + }), + ); + writeFileSync( + join(intermediateDir, "scan-result.json"), + JSON.stringify({ + importMap: { "src/a.py": ["src/a.py"] }, // pathological self-reference + }), + ); + + const { assembled } = runMerge(); + expect(assembled.edges.filter((e) => e.type === "imports")).toHaveLength(0); + }); +});