Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 89 additions & 4 deletions graphify/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -893,6 +893,10 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat
):
extraction = dict(extraction, hyperedges=extraction["graph"]["hyperedges"])

degraded = extraction.get("degraded_passes")
if degraded is None and isinstance(extraction.get("graph"), dict):
degraded = extraction["graph"].get("degraded_passes")

# Numeric ids from a loose backend become str before anything keys on them
# (#2326) — after the links remap so aliased edges are covered too.
_coerce_non_string_ids(extraction)
Expand Down Expand Up @@ -1476,6 +1480,8 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat
f"will be emptied on the next export.",
file=sys.stderr,
)
if degraded:
G.graph["degraded_passes"] = list(degraded)
# Runs LAST, after the alias-competition above (which relies on file-node
# labels still being bare basenames): give colliding-basename file nodes a
# directory-qualified display label so lookup/discovery can disambiguate
Expand Down Expand Up @@ -1512,12 +1518,23 @@ def build(
"""
from graphify.dedup import deduplicate_entities
combined: dict = {"nodes": [], "edges": [], "hyperedges": [], "input_tokens": 0, "output_tokens": 0}
degraded_passes: list[dict] = []
for ext in extractions:
combined["nodes"].extend(ext.get("nodes", []))
combined["edges"].extend(ext.get("edges", []))
combined["hyperedges"].extend(ext.get("hyperedges", []))
combined["input_tokens"] += ext.get("input_tokens", 0)
combined["output_tokens"] += ext.get("output_tokens", 0)
if ext.get("degraded_passes"):
degraded_passes.extend(ext["degraded_passes"])
if degraded_passes:
seen_passes: set[str] = set()
deduped: list[dict] = []
for dp in degraded_passes:
if isinstance(dp, dict) and dp.get("pass") and dp["pass"] not in seen_passes:
seen_passes.add(dp["pass"])
deduped.append(dp)
combined["degraded_passes"] = deduped
_root = str(Path(root).resolve()) if root else None
if dedup and combined["nodes"]:
# Numeric ids must be str before dedup, which keys on them and would
Expand Down Expand Up @@ -1613,8 +1630,8 @@ def deduplicate_by_label(nodes: list[dict], edges: list[dict]) -> tuple[list[dic
return deduped_nodes, deduped_edges


def _load_existing_graph(graph_path: Path) -> "tuple[list, list, list, bool] | None":
"""Load (nodes, edges, hyperedges, directed) from an existing graph.json for
def _load_existing_graph(graph_path: Path) -> "tuple[list, list, list, bool, list] | None":
"""Load (nodes, edges, hyperedges, directed, degraded_passes) from an existing graph.json for
an incremental merge, accepting both the ``links`` and ``edges`` spellings.

Reads the JSON directly instead of going through node_link_graph().
Expand Down Expand Up @@ -1655,11 +1672,16 @@ def _load_existing_graph(graph_path: Path) -> "tuple[list, list, list, bool] | N
for item in edges:
if isinstance(item, dict):
item.setdefault("_origin", "ast" if _is_ast_tier(item) else "semantic")
degraded = data.get("degraded_passes")
if degraded is None and isinstance(data.get("graph"), dict):
degraded = data["graph"].get("degraded_passes")
degraded_passes = list(degraded) if isinstance(degraded, list) else []
return (
nodes,
edges,
list(data.get("hyperedges", [])),
bool(data.get("directed", False)),
degraded_passes,
)


Expand Down Expand Up @@ -1770,7 +1792,7 @@ def merge_raw_extraction(
loaded = _load_existing_graph(graph_path)
if loaded is None:
return new
existing_nodes, existing_edges, existing_hyperedges, _ = loaded
existing_nodes, existing_edges, existing_hyperedges, _, existing_degraded = loaded

_eff_root = (
str(Path(root).resolve()) if root is not None
Expand Down Expand Up @@ -1870,6 +1892,33 @@ def _dropped(item: dict) -> bool:
new["hyperedges"] = carried_hyper + list(new.get("hyperedges", []))
if unverified_semantic_shrink:
new["_unverified_semantic_shrink"] = unverified_semantic_shrink

surviving_ast_suffixes: set[str] = set()
for n in existing_nodes:
if isinstance(n, dict) and _is_ast_tier(n) and not _dropped(n):
sf = n.get("source_file")
if sf:
sfx = Path(sf).suffix.lower()
if sfx:
surviving_ast_suffixes.add(sfx)

fresh_degraded = list(new.get("degraded_passes", []))
fresh_pass_names = {d["pass"] for d in fresh_degraded if isinstance(d, dict) and "pass" in d}
reconciled_degraded = []
for dp in existing_degraded:
if not isinstance(dp, dict) or "pass" not in dp:
continue
if dp["pass"] in fresh_pass_names:
continue
dp_suffixes = {s.lower() for s in dp.get("suffixes", [])}
if dp_suffixes & surviving_ast_suffixes:
reconciled_degraded.append(dp)
reconciled_degraded.extend(fresh_degraded)
reconciled_degraded.sort(key=lambda d: d.get("pass", ""))
if reconciled_degraded:
new["degraded_passes"] = reconciled_degraded
else:
new.pop("degraded_passes", None)
return new


Expand Down Expand Up @@ -1913,13 +1962,14 @@ def build_merge(
graph_path = Path(graph_path if graph_path is not None else _default_graph_json())
_loaded = _load_existing_graph(graph_path)
if _loaded is not None:
existing_nodes, existing_edges, existing_hyperedges, existing_directed = _loaded
existing_nodes, existing_edges, existing_hyperedges, existing_directed, existing_degraded = _loaded
had_graph = True
else:
existing_nodes = []
existing_edges = []
existing_hyperedges = []
existing_directed = False
existing_degraded = []
had_graph = False
if directed is None:
directed = existing_directed if had_graph else False
Expand Down Expand Up @@ -2307,6 +2357,41 @@ def _explained(n: dict) -> bool:
f"Pass prune_sources explicitly if you intend to remove them. (#479)"
)

surviving_ast_suffixes: set[str] = set()
for n in existing_nodes:
if isinstance(n, dict) and _is_ast_tier(n):
sf = n.get("source_file")
if sf and not _prune_match(sf):
sfx = Path(sf).suffix.lower()
if sfx:
surviving_ast_suffixes.add(sfx)

fresh_degraded: list[dict] = []
for ch in new_chunks:
if isinstance(ch, dict) and "degraded_passes" in ch:
fresh_degraded.extend(ch.get("degraded_passes") or [])

if had_graph:
fresh_pass_names = {d["pass"] for d in fresh_degraded if isinstance(d, dict) and "pass" in d}
reconciled_degraded = []
for dp in existing_degraded:
if not isinstance(dp, dict) or "pass" not in dp:
continue
if dp["pass"] in fresh_pass_names:
continue
dp_suffixes = {s.lower() for s in dp.get("suffixes", [])}
if dp_suffixes & surviving_ast_suffixes:
reconciled_degraded.append(dp)
reconciled_degraded.extend(fresh_degraded)
else:
reconciled_degraded = list(fresh_degraded)
reconciled_degraded.sort(key=lambda d: d.get("pass", ""))

if reconciled_degraded:
G.graph["degraded_passes"] = reconciled_degraded
elif hasattr(G, "graph") and "degraded_passes" in G.graph:
del G.graph["degraded_passes"]

if unverified_semantic_shrink:
G.graph["_unverified_semantic_shrink"] = unverified_semantic_shrink

Expand Down
2 changes: 2 additions & 0 deletions graphify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4200,6 +4200,8 @@ def _progress(idx: int, total: int, _result: dict) -> None:
"output_tokens": ast_result.get("output_tokens", 0) + sem_result.get("output_tokens", 0),
"extracted_sources": list(ast_result.get("extracted_sources", [])),
}
if ast_result.get("degraded_passes"):
merged["degraded_passes"] = list(ast_result["degraded_passes"])

graph_json_path = graphify_out / "graph.json"
analysis_path = graphify_out / ".graphify_analysis.json"
Expand Down
16 changes: 16 additions & 0 deletions graphify/diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,12 @@ def diagnose_extraction(
Path(extract_path) if extract_path else Path(__file__).with_name("extract.py")
)

degraded_passes = extraction.get("degraded_passes")
if degraded_passes is None and isinstance(extraction.get("graph"), dict):
degraded_passes = extraction["graph"].get("degraded_passes")
if not isinstance(degraded_passes, list):
degraded_passes = []

return {
"node_count": len(node_ids),
"unverified_node_count": unverified_node_count,
Expand Down Expand Up @@ -289,6 +295,7 @@ def diagnose_extraction(
"post_build_error": build_error,
"producer_suppression": scan_producer_suppression_sites(suppression_path),
"examples": examples,
"degraded_passes": degraded_passes,
}


Expand Down Expand Up @@ -394,7 +401,16 @@ def format_diagnostic_report(summary: dict[str, Any]) -> str:
f"post_build_graph_type: {summary['post_build_graph_type']}",
f"post_build_edges: {summary['post_build_edge_count']}",
f"producer_suppression_sites: {suppression.get('total_sites', 0)}",
f"degraded_resolution_passes: {len(summary.get('degraded_passes', []))}",
]
if summary.get("degraded_passes"):
lines.append("degraded_resolution_pass_details:")
for item in summary["degraded_passes"]:
p = item.get("pass", "unknown")
err_type = item.get("error_type", "Exception")
err = item.get("error", "")
sfx = ", ".join(item.get("suffixes", []))
lines.append(f" - {p} ({err_type}: {err}) suffixes=[{sfx}]")
if summary.get("post_build_error"):
lines.append(f"post_build_error: {summary['post_build_error']}")
if suppression.get("error"):
Expand Down
9 changes: 9 additions & 0 deletions graphify/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,15 @@ def _canonical(item: dict, lead: tuple[str, ...]) -> dict:
if isinstance(data.get("graph"), dict) and "hyperedges" in data["graph"]:
data["graph"]["hyperedges"] = hyperedges
data["hyperedges"] = hyperedges

# Prevent dual persistence: exclude degraded_passes from serialized
# data["graph"] without mutating G.graph, and assign to top-level
# data["degraded_passes"] only when non-empty.
degraded = getattr(G, "graph", {}).get("degraded_passes")
if isinstance(data.get("graph"), dict):
data["graph"] = {k: v for k, v in data["graph"].items() if k != "degraded_passes"}
if degraded:
data["degraded_passes"] = sorted(degraded, key=lambda d: d.get("pass", ""))
# Fallback provenance comes from the repo the graph is being written INTO
# (output_path lives in <target>/graphify-out/), never the shell's cwd —
# the same cwd-anchoring mistake #2316 fixed for `update`.
Expand Down
35 changes: 33 additions & 2 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -5135,6 +5135,20 @@ def _resolve_kotlin_member_calls(
})


def _record_degraded_pass(
degraded_passes: list[dict],
name: str,
suffixes: Any,
exc: Exception,
) -> None:
degraded_passes.append({
"pass": name,
"error": str(exc),
"error_type": type(exc).__name__,
"suffixes": sorted(suffixes),
})


# Kotlin import-target resolution runs EARLY (directly in extract(), before the
# shared call pass builds its import-evidence index) — registering it in the
# tail registry would rewrite the targets after promotion already read them.
Expand Down Expand Up @@ -6892,6 +6906,7 @@ def extract(
# cache directory's location diverges from it.
cache_location = (cache_root if cache_root is not None else Path(".")).resolve()
total = len(paths)
degraded_passes: list[dict] = []

# Phase 1: separate cached hits from uncached work
per_file: list[dict | None] = [None] * total
Expand Down Expand Up @@ -7557,6 +7572,7 @@ def _learn(e: dict) -> None:
except Exception as exc:
import logging
logging.getLogger(__name__).warning("PHP type-reference resolution failed, skipping: %s", exc)
_record_degraded_pass(degraded_passes, "php_type_references", _php_exts, exc)
# Java package/import disambiguation must likewise run BEFORE the rewire
# (#2504): an EXTERNAL import (`org.springframework.stereotype.Component`)
# leaves a bare `Component` stub that the rewire would collapse onto the only
Expand All @@ -7572,6 +7588,7 @@ def _learn(e: dict) -> None:
except Exception as exc:
import logging
logging.getLogger(__name__).warning("Java type-reference resolution failed, skipping: %s", exc)
_record_degraded_pass(degraded_passes, "java_type_references", [".java"], exc)
# Resolve internal Go pkg.Type references exactly and park external ones
# before the generic bare-label stub rewire can manufacture a collision.
_go_sel = [(r, p) for r, p in zip(per_file, paths) if p.suffix == ".go"]
Expand All @@ -7587,6 +7604,7 @@ def _learn(e: dict) -> None:
logging.getLogger(__name__).warning(
"Go type-reference resolution failed, skipping: %s", exc
)
_record_degraded_pass(degraded_passes, "go_type_references", [".go"], exc)
# Cross-file Python import resolution and type-reference repointing (#3252)
py_paths = [p for p in paths if p.suffix == ".py"]
if py_paths:
Expand All @@ -7597,6 +7615,7 @@ def _learn(e: dict) -> None:
except Exception as exc:
import logging
logging.getLogger(__name__).warning("Cross-file import resolution failed, skipping: %s", exc)
_record_degraded_pass(degraded_passes, "python_imports", [".py"], exc)
_rewire_unique_stub_nodes(all_nodes, all_edges)

# Cross-file Java import resolution
Expand All @@ -7608,6 +7627,7 @@ def _learn(e: dict) -> None:
except Exception as exc:
import logging
logging.getLogger(__name__).warning("Java cross-file import resolution failed, skipping: %s", exc)
_record_degraded_pass(degraded_passes, "java_cross_file_imports", [".java"], exc)

# Cross-file C# type-reference resolution: re-point dangling inherits/implements/
# references edges left on shadow stubs, disambiguating same-named types by the
Expand All @@ -7621,11 +7641,13 @@ def _learn(e: dict) -> None:
except Exception as exc:
import logging
logging.getLogger(__name__).warning("C# type-reference resolution failed, skipping: %s", exc)
_record_degraded_pass(degraded_passes, "csharp_type_references", _DOTNET_TYPE_EXTS, exc)
try:
_resolve_cross_file_csharp_imports(cs_results, cs_paths, all_nodes, all_edges)
except Exception as exc:
import logging
logging.getLogger(__name__).warning("C# cross-file import resolution failed, skipping: %s", exc)
_record_degraded_pass(degraded_passes, "csharp_cross_file_imports", _DOTNET_TYPE_EXTS, exc)

# Cross-file Bash source-backed call resolution: a call to a function defined
# in a file this one `source`s is left unresolved by the per-file extractor
Expand Down Expand Up @@ -7670,6 +7692,7 @@ def _looks_like_bash(result: object) -> bool:
except Exception as exc:
import logging
logging.getLogger(__name__).warning("Bash cross-file call resolution failed, skipping: %s", exc)
_record_degraded_pass(degraded_passes, "bash_source_edges", [".bash", ".sh"], exc)

# Cross-file call resolution for all languages
# Each extractor saved unresolved calls in raw_calls. Now that we have all
Expand Down Expand Up @@ -7739,6 +7762,7 @@ def _looks_like_bash(result: object) -> bool:
run_language_resolvers(
paths, per_file, all_nodes, all_edges,
resolvers=[_KOTLIN_IMPORT_TARGET_RESOLVER],
degraded_passes=degraded_passes,
)

# Build evidence index from import edges so cross-file calls backed by an
Expand Down Expand Up @@ -8034,11 +8058,17 @@ def _has_import_evidence(candidate_id: str) -> bool:
_rl_nodes = list(resolution_nodes)
_rl_edges = all_edges + list(resolution_context_edges or [])
_n0, _e0 = len(_rl_nodes), len(_rl_edges)
run_language_resolvers(paths, per_file, _rl_nodes, _rl_edges)
run_language_resolvers(
paths, per_file, _rl_nodes, _rl_edges,
degraded_passes=degraded_passes,
)
all_nodes.extend(_rl_nodes[_n0:])
all_edges.extend(_rl_edges[_e0:])
else:
run_language_resolvers(paths, per_file, all_nodes, all_edges)
run_language_resolvers(
paths, per_file, all_nodes, all_edges,
degraded_passes=degraded_passes,
)

# Relativize source_file fields so paths are portable across machines (#555).
# When the node's id was itself minted from the absolute path, remap it to a
Expand Down Expand Up @@ -8244,6 +8274,7 @@ def _canon(nid: str) -> str:
# merge_raw_extraction know which files were genuinely re-extracted
# rather than guessing ownership from node["source_file"] (#3411).
"extracted_sources": [str(p) for p in paths],
"degraded_passes": degraded_passes,
}


Expand Down
15 changes: 15 additions & 0 deletions graphify/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,21 @@ def _real_count(nodes) -> int:
conf_tag = f"{conf} {cscore:.2f}" if cscore is not None else conf
lines.append(f"- **{h.get('label', h.get('id', ''))}** — {node_labels} [{conf_tag}]")

degraded_passes = G.graph.get("degraded_passes", [])
if degraded_passes:
lines += [
"",
"## Degraded Resolution Passes",
"> [!WARNING]",
"> One or more cross-file resolution passes failed during extraction. Call edges or type references for the affected languages may be incomplete.",
]
for dp in degraded_passes:
pass_name = dp.get("pass", "unknown")
err = dp.get("error", "")
err_type = dp.get("error_type", "Exception")
sfx = ", ".join(dp.get("suffixes", []))
lines.append(f"- **{pass_name}** ({err_type}: {err}): suffixes `{sfx}`")

lines += ["", f"## Communities ({len(communities)} total, {thin_count_summary} thin omitted)"]
for cid, nodes in communities.items():
label = community_labels.get(cid, f"Community {cid}")
Expand Down
Loading
Loading