Files
graphify/graphify/report.py
T
safishamsiandClaude Opus 4.8 54825b6a1c fix: windows skill name, opencode plugin separator, doc-corpus report noise (#1635, #1646, #1657)
#1635: the windows skill variant declared `name: graphify-windows`, but
`graphify install --platform windows` writes it to ~/.claude/skills/graphify/
SKILL.md and Claude Code requires the folder name to equal the frontmatter
`name` — the suffix broke discovery. platforms.toml now sets name = "graphify"
(regenerated + re-blessed).

#1646: the OpenCode (and Kilo) plugin prepended its reminder with `&&`, which
Windows PowerShell 5.1 rejects as a statement separator, breaking the first
bash command of every session. Switched to `;` (valid in PowerShell 5.1, Bash,
POSIX).

#1657: the GRAPH_REPORT.md "Import Cycles" section printed "None detected" on
documents-only corpora where imports don't exist — now gated on code nodes /
import edges being present. The other two items in that issue (mojibake in
manifest/report, stdout encoding) are already handled on current v8: both files
are written UTF-8 and main() reconfigures stdout/stderr to UTF-8.

Full suite: 2909 passed, 3 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 22:43:31 +01:00

293 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# generate GRAPH_REPORT.md - the human-readable audit trail
from __future__ import annotations
import re
from datetime import date
import networkx as nx
def _safe_community_name(label: str) -> str:
"""Mirrors export.safe_name so community hub filenames and report wikilinks always agree."""
cleaned = re.sub(r'[\\/*?:"<>|#^[\]]', "", label.replace("\r\n", " ").replace("\r", " ").replace("\n", " ")).strip()
cleaned = re.sub(r"\.(md|mdx|markdown)$", "", cleaned, flags=re.IGNORECASE)
return cleaned or "unnamed"
def load_learning_for_report(graph_path) -> dict | None:
"""Assemble the report's work-memory inputs from sibling artifacts.
Reads the ``.graphify_learning.json`` overlay (preferred sources) next to
``graph_path`` and re-aggregates the memory docs for the query-scoped
dead-ends. Best-effort: returns None if neither is available, so the report
simply omits the section. Never raises.
"""
from pathlib import Path as _Path
try:
gp = _Path(graph_path)
from graphify.reflect import load_learning_overlay, load_memory_docs, aggregate_lessons
overlay = load_learning_overlay(gp)
dead_ends: list[dict] = []
mem = gp.parent / "memory"
if mem.is_dir():
agg = aggregate_lessons(load_memory_docs(mem))
dead_ends = agg.get("dead_ends", [])
if not overlay and not dead_ends:
return None
return {"overlay": overlay, "dead_ends": dead_ends}
except Exception:
return None
def _learning_section(lines: list, learning: dict | None, top_n: int = 10) -> None:
"""Append the ``## Work-memory lessons`` section, or nothing when empty."""
if not learning:
return
overlay = learning.get("overlay") or {}
dead_ends = learning.get("dead_ends") or []
preferred = [
(nid, e) for nid, e in overlay.items()
if isinstance(e, dict) and e.get("status") == "preferred"
]
# Most-corroborated first (uses desc), then by score, then id for stability.
preferred.sort(key=lambda kv: (-kv[1].get("uses", 0),
-float(kv[1].get("score", 0) or 0), kv[0]))
if not preferred and not dead_ends:
return
lines += ["", "## Work-memory lessons"]
if preferred:
lines += ["", "**Preferred sources** — corroborated by past sessions; start here."]
for nid, e in preferred[:top_n]:
label = e.get("label") or nid
stale = " _(code changed — re-verify)_" if e.get("stale") else ""
lines.append(f"- `{label}` ({e.get('uses', 0)}× useful, "
f"score={e.get('score', 0)}){stale}")
if dead_ends:
lines += ["", "**Known dead ends** — questions that led nowhere; don't re-derive."]
for d in dead_ends:
nodes = ", ".join(f"`{n}`" for n in d.get("nodes", []))
lines.append(f"- \"{d.get('question', '')}\""
+ (f" -> {nodes}" if nodes else ""))
def generate(
G: nx.Graph,
communities: dict[int, list[str]],
cohesion_scores: dict[int, float],
community_labels: dict[int, str],
god_node_list: list[dict],
surprise_list: list[dict],
detection_result: dict,
token_cost: dict,
root: str,
suggested_questions: list[dict] | None = None,
min_community_size: int = 3,
built_at_commit: str | None = None,
learning: dict | None = None,
) -> str:
today = date.today().isoformat()
# JSON deserialization produces string keys; normalize to int so .get(cid) works.
if community_labels:
community_labels = {int(k) if isinstance(k, str) else k: v for k, v in community_labels.items()}
confidences = [d.get("confidence", "EXTRACTED") for _, _, d in G.edges(data=True)]
total = len(confidences) or 1
ext_pct = round(confidences.count("EXTRACTED") / total * 100)
inf_pct = round(confidences.count("INFERRED") / total * 100)
amb_pct = round(confidences.count("AMBIGUOUS") / total * 100)
inf_edges = [(u, v, d) for u, v, d in G.edges(data=True) if d.get("confidence") == "INFERRED"]
inf_scores = [d.get("confidence_score", 0.5) for _, _, d in inf_edges]
inf_avg = round(sum(inf_scores) / len(inf_scores), 2) if inf_scores else None
lines = [
f"# Graph Report - {root} ({today})",
"",
"## Corpus Check",
]
if detection_result.get("warning"):
lines.append(f"- {detection_result['warning']}")
else:
lines += [
f"- {detection_result['total_files']} files · ~{detection_result['total_words']:,} words",
"- Verdict: corpus is large enough that graph structure adds value.",
]
from .analyze import _is_file_node as _ifn
non_empty = {cid: nodes for cid, nodes in communities.items()
if any(not _ifn(G, n) for n in nodes)}
thin_count_summary = sum(
1 for nodes in communities.values()
if 0 < sum(1 for n in nodes if not _ifn(G, n)) < min_community_size
)
shown_count = len(communities) - thin_count_summary
lines += [
"",
"## Summary",
f"- {G.number_of_nodes()} nodes · {G.number_of_edges()} edges · {len(communities)} communities"
+ (f" ({shown_count} shown, {thin_count_summary} thin omitted)" if thin_count_summary else ""),
f"- Extraction: {ext_pct}% EXTRACTED · {inf_pct}% INFERRED · {amb_pct}% AMBIGUOUS"
+ (f" · INFERRED: {len(inf_edges)} edges (avg confidence: {inf_avg})" if inf_avg is not None else ""),
f"- Token cost: {token_cost.get('input', 0):,} input · {token_cost.get('output', 0):,} output",
]
if built_at_commit:
lines += [
"",
"## Graph Freshness",
f"- Built from commit: `{built_at_commit[:8]}`",
"- Run `git rev-parse HEAD` and compare to check if the graph is stale.",
"- Run `graphify update .` after code changes (no API cost).",
]
# Community hub navigation - links to _COMMUNITY_*.md files in the Obsidian vault.
# Without these, GRAPH_REPORT.md is a dead-end and the vault splits into disconnected components.
if non_empty:
lines += ["", "## Community Hubs (Navigation)"]
for cid in non_empty:
label = community_labels.get(cid, f"Community {cid}")
safe = _safe_community_name(label)
lines.append(f"- [[_COMMUNITY_{safe}|{label}]]")
lines += [
"",
"## God Nodes (most connected - your core abstractions)",
]
for i, node in enumerate(god_node_list, 1):
lines.append(f"{i}. `{node['label']}` - {node['degree']} edges")
lines += ["", "## Surprising Connections (you probably didn't know these)"]
if surprise_list:
for s in surprise_list:
relation = s.get("relation", "related_to")
note = s.get("note", "")
files = s.get("source_files", ["", ""])
conf = s.get("confidence", "EXTRACTED")
cscore = s.get("confidence_score")
if conf == "INFERRED" and cscore is not None:
conf_tag = f"INFERRED {cscore:.2f}"
else:
conf_tag = conf
sem_tag = " [semantically similar]" if relation == "semantically_similar_to" else ""
lines += [
f"- `{s['source']}` --{relation}--> `{s['target']}` [{conf_tag}]{sem_tag}",
f" {files[0]} → {files[1]}" + (f" _{note}_" if note else ""),
]
else:
lines.append("- None detected - all connections are within the same source files.")
# Circular imports surfaced from file-level dependency graph. Only meaningful
# for code — a documents-only corpus has no imports, so the section is pure
# noise there ("None detected" on every run). Emit it only when the graph
# actually contains code (#1657).
_has_code = any(
d.get("file_type") == "code" for _, d in G.nodes(data=True)
) or any(
d.get("relation") in ("imports", "imports_from")
for *_e, d in G.edges(data=True)
)
if _has_code:
from .analyze import find_import_cycles
cycles = find_import_cycles(G)
lines += ["", "## Import Cycles"]
if cycles:
for c in cycles:
cycle = c.get("cycle", [])
length = c.get("length", len(cycle))
if not cycle:
continue
cycle_path = " -> ".join(cycle + [cycle[0]])
lines.append(f"- {length}-file cycle: `{cycle_path}`")
else:
lines.append("- None detected.")
hyperedges = G.graph.get("hyperedges", [])
if hyperedges:
lines += ["", "## Hyperedges (group relationships)"]
for h in hyperedges:
node_labels = ", ".join(h.get("nodes", []))
conf = h.get("confidence", "INFERRED")
cscore = h.get("confidence_score")
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}]")
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}")
score = cohesion_scores.get(cid, 0.0)
# Filter method/function stubs from display - they're structural noise
real_nodes = [n for n in nodes if not _ifn(G, n)]
if not real_nodes:
continue
if len(real_nodes) < min_community_size:
continue
display = [G.nodes[n].get("label", n) for n in real_nodes[:8]]
suffix = f" (+{len(real_nodes)-8} more)" if len(real_nodes) > 8 else ""
lines += [
"",
f"### Community {cid} - \"{label}\"",
f"Cohesion: {score:.2f}",
f"Nodes ({len(real_nodes)}): {', '.join(display)}{suffix}",
]
ambiguous = [(u, v, d) for u, v, d in G.edges(data=True) if d.get("confidence") == "AMBIGUOUS"]
if ambiguous:
lines += ["", "## Ambiguous Edges - Review These"]
for u, v, d in ambiguous:
ul = G.nodes[u].get("label", u)
vl = G.nodes[v].get("label", v)
lines += [
f"- `{ul}` → `{vl}` [AMBIGUOUS]",
f" {d.get('source_file', '')} · relation: {d.get('relation', 'unknown')}",
]
# --- Gaps section ---
from .analyze import _is_file_node, _is_concept_node
isolated = [
n for n in G.nodes()
if G.degree(n) <= 1
and not _is_file_node(G, n)
and not _is_concept_node(G, n)
and G.nodes[n].get("file_type") != "rationale"
]
thin_communities = {
cid: nodes for cid, nodes in communities.items()
if 0 < sum(1 for n in nodes if not _is_file_node(G, n)) < 3
}
gap_count = len(isolated) + len(thin_communities)
if gap_count > 0 or amb_pct > 20:
lines += ["", "## Knowledge Gaps"]
if isolated:
isolated_labels = [G.nodes[n].get("label", n) for n in isolated[:5]]
suffix = f" (+{len(isolated)-5} more)" if len(isolated) > 5 else ""
lines.append(f"- **{len(isolated)} isolated node(s):** {', '.join(f'`{l}`' for l in isolated_labels)}{suffix}")
lines.append(" These have ≤1 connection - possible missing edges or undocumented components.")
if thin_communities:
lines.append(f"- **{len(thin_communities)} thin communities (<{min_community_size} nodes) omitted from report** — run `graphify query` to explore isolated nodes.")
if amb_pct > 20:
lines.append(f"- **High ambiguity: {amb_pct}% of edges are AMBIGUOUS.** Review the Ambiguous Edges section above.")
# --- Work-memory lessons (derived overlay) ---
# Preferred sources come from the .graphify_learning.json sidecar; the
# query-scoped dead-ends come from the reflect aggregate. Section omitted
# entirely when neither is present, so a graph with no work-memory is
# byte-identical to the pre-feature report.
_learning_section(lines, learning)
if suggested_questions:
lines += ["", "## Suggested Questions"]
no_signal = len(suggested_questions) == 1 and suggested_questions[0].get("type") == "no_signal"
if no_signal:
lines.append(f"_{suggested_questions[0]['why']}_")
else:
lines.append("_Questions this graph is uniquely positioned to answer:_")
lines.append("")
for q in suggested_questions:
if q.get("question"):
lines.append(f"- **{q['question']}**")
lines.append(f" _{q['why']}_")
return "\n".join(lines)