Skip to content

importMap edge recovery only considers file: nodes, so imports of config:/schema:/service: files are never recovered #622

Description

@zorgy28

Summary

recover_import_edges_from_import_map() in skills/understand/merge-batch-graphs.py builds its candidate node set from type == "file" only, then hardcodes the file: prefix for both edge endpoints. Whole-file nodes created under any other prefix — config:, schema:, service:, document: — are invisible to the recovery pass, so real importMap edges get reported as missing instead of recovered.

Version: 2.9.4

The code

# 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", ""))
...
src_id = f"file:{src_path}"
if src_id not in file_node_ids: ...
    tgt_id = f"file:{tgt_path}"
    if tgt_id not in file_node_ids:
        skipped_no_tgt_node += 1

The file-analyzer agents legitimately model a whole file under a non-file: prefix — that is what the node-type table asks for. A JSON locale catalogue becomes config:messages/de.json, a Prisma schema becomes schema:prisma/schema.prisma, a compose file becomes service:docker-compose.prod.yml.

Symptom

Imports edge recovery:
  Recovered 0 `imports` edges from importMap (384 entries scanned)
  Skipped 3 importMap target paths with no `file:` node in graph

The message blames the graph for a missing node that exists — under a different prefix. On the project above, the three "missing" targets were all present as config: nodes:

  • config:messages/de.json
  • config:messages/en.json
  • config:src/lib/validation/outcome-config.json

Reproducer

Any project where a code file imports a JSON/YAML/schema file that an analyzer typed as config: or schema:. Concretely: a Next.js app with next-intl where src/app/types/dictionary.ts imports messages/de.json and messages/en.json.

Suggested fix

Build a path -> node_id map across whole-file node types, preferring file: when a path carries more than one, and resolve through it:

WHOLE_FILE_NODE_TYPES: frozenset[str] = frozenset({
    "file", "config", "document", "service", "pipeline", "schema", "resource",
})

node_id_by_path: dict[str, str] = {}
for node in assembled["nodes"]:
    ntype = node.get("type")
    nid = node.get("id", "")
    fpath = node.get("filePath")
    # The id-shape check keeps whole-file nodes and excludes sub-file nodes
    # such as `table:<path>:<name>` or `endpoint:<path>:<method>`.
    if ntype in WHOLE_FILE_NODE_TYPES and fpath and nid == f"{ntype}:{fpath}":
        if fpath not in node_id_by_path or ntype == "file":
            node_id_by_path[fpath] = nid

Then src_id = node_id_by_path.get(src_path) / tgt_id = node_id_by_path.get(tgt_path).

table and endpoint are deliberately excluded — in this schema they are always sub-file constructs and never import endpoints. The two log lines should also change from "with no `file:` node in graph" to "with no whole-file node in graph".

Keep the existing if src_id == tgt_id: continue self-loop guard — it is a useful backstop (see the Python-resolver issue).

Verification

Replaying a real project's 44 batch fragments through the merge:

before after
Recovered … imports edges 0 3
Skipped … target paths 3 0
dangling edges 0 0
self-loops 0 0

Node and edge totals otherwise unchanged; the three recovered edges carry recoveredFromImportMap: true and point at the correct config: nodes.


Found while analysing a production TypeScript + Python repo with /understand. Happy to open a PR.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions