fix(merge): recover imports edges file-analyzer batches drop

A controlled-experiment audit on a 1240-file Python project (opensre)
showed that 27.2% of resolved-internal imports never made it from
project-scanner's `importMap` into the final knowledge graph. Of the
404 source files with internal imports, 91 ended up with ZERO imports
edges in the graph despite their `file:` node being present (consistent
with main-session orchestrator dropping the entry from `batchImportData`
during batch construction), and 104 had partial coverage (consistent
with file-analyzer agent dropping rows during edge enumeration).
GitHub issue #128 reported the same failure mode at 16-21% on a Go
monorepo.

The fix has two layers:

1. `merge-batch-graphs.py` now runs a deterministic recovery pass
   after merge: for every `(source, target)` in scan-result.json's
   `importMap` whose source `file:` node exists in the assembled graph
   and whose target `file:` node also exists, emit an `imports` edge
   if the batches didn't already. Recovered edges are tagged
   `recoveredFromImportMap: true` so downstream consumers can audit
   which edges came from the deterministic source vs. agent emission.
   The merge report logs the recovered count plus how many importMap
   entries were skipped because their source/target had no graph node.

2. `file-analyzer.md` rewrites the imports edge rule to demand 1:1
   emission with a self-check: "the number of `imports` edges in your
   output MUST equal `sum(batchImportData[file].length)` across the
   batch's code files". This drives the agent to enumerate every row
   instead of summarizing — recovery should report 0 when this works.

Tests: +6 cases covering the recovery path — drops, no-double-emit,
missing source/target nodes, missing scan-result.json (incremental
update), and self-import suppression. 770 passing (was 764).

Bumps version to 2.6.3 across the five tracked manifests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lum1104
2026-05-08 19:35:34 +08:00
Unverified
parent c49c46d974
commit 05fd42343c
8 changed files with 303 additions and 6 deletions
+1 -1
View File
@@ -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"
},
+1 -1
View File
@@ -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"
},
+1 -1
View File
@@ -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"
},
@@ -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"
},
@@ -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:<resolvedPath>`. The `batchImportData` values contain only resolved project-internal paths — external packages have already been filtered out. Do NOT attempt to re-resolve imports from source.
**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:<filePath>", "target": "file:<resolvedPath>", "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.
+1 -1
View File
@@ -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",
@@ -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:
@@ -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);
});
});