Skip to content
Closed
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
188 changes: 188 additions & 0 deletions graphify/watch.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from __future__ import annotations
import contextlib
import json
import logging
import os
import posixpath
import re
Expand All @@ -12,6 +13,8 @@

# Single source of truth in graphify.paths (#1423); re-exported as _GRAPHIFY_OUT.
from graphify.paths import GRAPHIFY_OUT as _GRAPHIFY_OUT, is_absolute_any_platform

logger = logging.getLogger(__name__)
_PENDING_FILENAME = ".pending_changes"
_PENDING_DRAIN_MAX_PASSES = 20

Expand Down Expand Up @@ -506,6 +509,180 @@ def _is_remote_source(source_file: str) -> bool:
return bool(_REMOTE_SOURCE_RE.match(source_file))


def _reconcile_markdown_links(
result: dict,
preserved_edges: list[dict],
preserved_nodes: list[dict],
code_files: list[Path],
extract_targets: list[Path],
full_rebuild: bool,
project_root: Path,
source_paths,
) -> list[dict]:
"""Reconcile Markdown links without losing semantic-backed targets.

Semantic-backed documents are deliberately skipped by the AST quick scan
(#1915/#1954), so their canonical file node can be absent. Re-point an
authored link only when both files have unique representatives. If either
side is ambiguous, retain the existing AST edge instead of guessing or
deleting it. A link removed from its owning Markdown source is pruned.
"""
from graphify.build import _is_ast_tier
from graphify.extract import _file_node_id, _safe_extract_with_xaml_root
from graphify.extractors.markdown import extract_markdown

all_nodes = result.get("nodes", []) + preserved_nodes
nodes_by_source: dict[str, list[dict]] = {}
for node in all_nodes:
if source_file := node.get("source_file"):
normalized = source_paths.normalize(source_file)
nodes_by_source.setdefault(normalized, []).append(node)

representatives: dict[str, str | None] = {}
for source_file, nodes in nodes_by_source.items():
canonical_id = _file_node_id(Path(source_file))
canonical = [node for node in nodes if node["id"] == canonical_id]
pages = [node for node in nodes if node.get("node_kind") == "page"]
semantic_documents = [
node
for node in nodes
if node.get("file_type") == "document" and node.get("_origin") != "ast"
]
if len(canonical) == 1:
representatives[source_file] = canonical[0]["id"]
elif len(pages) == 1:
representatives[source_file] = pages[0]["id"]
elif len(semantic_documents) == 1:
representatives[source_file] = semantic_documents[0]["id"]
else:
representatives[source_file] = None

markdown_files = code_files if full_rebuild else extract_targets
markdown_files = [path for path in markdown_files if path.suffix.lower() == ".md"]
parsed_sources: set[str] = set()
authored_links: set[tuple[str, str]] = set()
authored_raw_pairs: set[frozenset[str]] = set()
resolved_raw_pairs: dict[frozenset[str], frozenset[str]] = {}
desired_edges: list[tuple[str, str, dict, str, str]] = []
node_sources = {
node["id"]: source_paths.normalize(node["source_file"])
for node in all_nodes
if node.get("source_file")
}

for markdown_file in markdown_files:
try:
relative_source = markdown_file.relative_to(project_root)
except ValueError:
relative_source = markdown_file
source_file = source_paths.normalize(str(relative_source))
parsed_sources.add(source_file)
source_rep = representatives.get(source_file)

extraction = _safe_extract_with_xaml_root(
extract_markdown, markdown_file, project_root
)
for edge in extraction.get("edges", []):
if edge.get("relation") != "references" or not edge.get("target_file"):
continue
target_path = Path(edge["target_file"])
try:
target_path = target_path.relative_to(project_root)
except ValueError:
pass
target_file = source_paths.normalize(str(target_path))
authored_links.add((source_file, target_file))
raw_pair = frozenset(
(_file_node_id(Path(source_file)), _file_node_id(Path(target_file)))
)
authored_raw_pairs.add(raw_pair)

target_rep = representatives.get(target_file)
if not source_rep or not target_rep:
logger.debug(
"Preserving ambiguous Markdown link %s -> %s",
source_file,
target_file,
)
continue
if source_rep != target_rep:
resolved_raw_pairs[raw_pair] = frozenset((source_rep, target_rep))
desired_edges.append(
(source_rep, target_rep, edge, source_file, target_file)
)

desired_file_pairs = {
frozenset((source_file, target_file))
for _, _, _, source_file, target_file in desired_edges
}
desired_reps_by_file_pair = {
frozenset((source_file, target_file)): frozenset((source_rep, target_rep))
for source_rep, target_rep, _, source_file, target_file in desired_edges
}

def _keep_edge(edge: dict) -> bool:
if not (_is_ast_tier(edge) and edge.get("relation") == "references"):
return True
owner = source_paths.normalize(edge.get("source_file"))
if owner not in parsed_sources:
return True

raw_pair = frozenset((edge.get("source"), edge.get("target")))
if raw_pair in authored_raw_pairs:
desired_pair = resolved_raw_pairs.get(raw_pair)
return desired_pair is None or raw_pair == desired_pair

endpoint_files = {
node_sources.get(edge.get("source")),
node_sources.get(edge.get("target")),
}
endpoint_files.discard(None)
targets = endpoint_files - {owner}
if len(targets) == 1:
target_file = targets.pop()
elif edge.get("target_file"):
target_path = Path(edge["target_file"])
try:
target_path = target_path.relative_to(project_root)
except ValueError:
pass
target_file = source_paths.normalize(str(target_path))
else:
return True

file_pair = frozenset((owner, target_file))
if (owner, target_file) not in authored_links:
return False
if file_pair not in desired_file_pairs:
return True
return raw_pair == desired_reps_by_file_pair[file_pair]

result["edges"] = [edge for edge in result.get("edges", []) if _keep_edge(edge)]
preserved_edges = [edge for edge in preserved_edges if _keep_edge(edge)]
existing_pairs = {
frozenset((edge.get("source"), edge.get("target")))
for edge in result["edges"] + preserved_edges
}
for source_rep, target_rep, edge, source_file, _ in desired_edges:
pair = frozenset((source_rep, target_rep))
# graphify uses a simple graph. Never overwrite another relation that
# already occupies the representative pair.
if pair in existing_pairs:
continue
reconciled = edge.copy()
reconciled.pop("target_file", None)
reconciled.update(
source=source_rep,
target=target_rep,
source_file=source_file,
_origin="ast",
)
result["edges"].append(reconciled)
existing_pairs.add(pair)

return preserved_edges


def _reconcile_existing_graph(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression_reconcile_existing_graph()

fans out to 8 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

existing_graph: Path,
result: dict,
Expand Down Expand Up @@ -747,6 +924,17 @@ def _ignored_now(identity: str) -> bool:
)
]

preserved_edges = _reconcile_markdown_links(
result,
preserved_edges,
preserved_nodes,
code_files,
extract_targets,
full_rebuild,
project_root,
source_paths,
)

new_hyperedge_ids = {
edge.get("id") for edge in result.get("hyperedges", []) if edge.get("id")
}
Expand Down
Loading