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
12 changes: 9 additions & 3 deletions graphify/watch.py
Original file line number Diff line number Diff line change
Expand Up @@ -550,7 +550,7 @@ def _reconcile_markdown_links(
from graphify.extract import _file_node_id, _safe_extract_with_xaml_root
from graphify.extractors.base import _make_id
from graphify.extractors.markdown import extract_markdown
from graphify.markdown_resolution import _is_file_node
from graphify.markdown_resolution import MARKDOWN_MENTION_SUFFIXES, _is_file_node

all_nodes = result.get("nodes", []) + preserved_nodes
nodes_by_id = {node["id"]: node for node in all_nodes if node.get("id")}
Expand Down Expand Up @@ -580,7 +580,11 @@ def _reconcile_markdown_links(
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"]
markdown_files = [
path
for path in markdown_files
if path.suffix.lower() in MARKDOWN_MENTION_SUFFIXES
]
parsed_sources: set[str] = set()
authored_links: set[tuple[str, str]] = set()
authored_raw_pairs: set[frozenset[str]] = set()
Expand All @@ -599,12 +603,14 @@ def _reconcile_markdown_links(
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
)
if extraction.get("error"):
continue
parsed_sources.add(source_file)
for edge in extraction.get("edges", []):
if edge.get("relation") != "references":
continue
Expand Down
11 changes: 11 additions & 0 deletions tests/test_extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -4178,6 +4178,17 @@ def test_get_extractor_routes_matlab_m_away_from_objc(tmp_path):
assert _get_extractor(mm) is extract_objc # .mm is unambiguously ObjC++


def test_markdown_dispatch_matches_resolution_suffixes():
from graphify.extract import _DISPATCH, extract_markdown
from graphify.markdown_resolution import MARKDOWN_MENTION_SUFFIXES

dispatched = {
suffix for suffix, extractor in _DISPATCH.items()
if extractor is extract_markdown
}
assert dispatched == MARKDOWN_MENTION_SUFFIXES


def test_matlab_m_not_extracted_as_garbage(tmp_path, capsys):
# End to end: a MATLAB .m produces no (garbage) nodes and is surfaced by the
# no-AST-extractor warning (#1702 + #1689), rather than mis-parsed as ObjC.
Expand Down
99 changes: 93 additions & 6 deletions tests/test_watch.py
Original file line number Diff line number Diff line change
Expand Up @@ -4027,11 +4027,13 @@ def _ast_reference(source, target, source_file, **extra):
}


def test_markdown_reconcile_links_new_source_to_semantic_target(tmp_path):
@pytest.mark.parametrize("suffix", [".md", ".mdx", ".qmd", ".skill"])
def test_markdown_reconcile_links_new_source_to_semantic_target(tmp_path, suffix):
"""#1915/#1954: a fresh source reaches a semantic-only target."""
source_file = f"a{suffix}"
corpus, graph_path = _markdown_reconcile_fixture(
tmp_path,
{"a.md": "[link](b.md)\n", "b.md": "target\n"},
{source_file: "[link](b.md)\n", "b.md": "target\n"},
[_semantic_doc("b_sem", "b.md")],
[],
)
Expand Down Expand Up @@ -4085,15 +4087,100 @@ def test_markdown_reconcile_preserves_ambiguous_source(tmp_path):
assert references[0]["sentinel"] == "keep"


def test_markdown_reconcile_prunes_removed_authored_link(tmp_path):
@pytest.mark.parametrize("suffix", [".md", ".mdx", ".qmd", ".skill"])
def test_markdown_reconcile_prunes_removed_authored_link(tmp_path, suffix):
"""#1915/#1954: removing a Markdown link removes its owned AST edge."""
source_file = f"a{suffix}"
corpus, graph_path = _markdown_reconcile_fixture(
tmp_path,
{"a.md": "no link\n", "b.md": "target\n"},
[_semantic_doc("a_sem", "a.md"), _semantic_doc("b_sem", "b.md")],
[_ast_reference("a_sem", "b_sem", "a.md")],
{source_file: "no link\n", "b.md": "target\n"},
[_semantic_doc("a_sem", source_file), _semantic_doc("b_sem", "b.md")],
[_ast_reference("a_sem", "b_sem", source_file)],
)

assert _rebuild_code(corpus, no_cluster=True, acquire_lock=False) is True
links = json.loads(graph_path.read_text(encoding="utf-8"))["links"]
assert not any(edge.get("relation") == "references" for edge in links)


@pytest.mark.parametrize("suffix", [".md", ".mdx", ".qmd", ".skill"])
def test_markdown_reconcile_repoints_incremental_link_to_semantic_target(
tmp_path, suffix
):
"""A changed Markdown source reuses the target's semantic representative."""
source_file = f"a{suffix}"
corpus, graph_path = _markdown_reconcile_fixture(
tmp_path,
{source_file: "[link](b.md)\n", "b.md": "target\n"},
[
{
"id": "a",
"label": source_file,
"node_kind": "page",
"file_type": "document",
"source_file": source_file,
"source_location": "L1",
"_origin": "ast",
},
_semantic_doc("b_sem", "b.md"),
],
[],
)

for _ in range(2):
assert _rebuild_code(
corpus,
changed_paths=[corpus / source_file],
no_cluster=True,
acquire_lock=False,
) is True
links = json.loads(graph_path.read_text(encoding="utf-8"))["links"]
references = [edge for edge in links if edge.get("relation") == "references"]
assert len(references) == 1
assert {references[0]["source"], references[0]["target"]} == {"a", "b_sem"}


@pytest.mark.parametrize("suffix", [".md", ".mdx", ".qmd", ".skill"])
def test_markdown_reconcile_preserves_links_on_extraction_error(
tmp_path, monkeypatch, suffix
):
"""A failed parse cannot claim ownership of persisted authored links."""
import graphify.extract as extract_module

source_file = f"a{suffix}"
corpus, graph_path = _markdown_reconcile_fixture(
tmp_path,
{source_file: "[link](b.md)\n", "b.md": "target\n"},
[_semantic_doc("a_sem", source_file), _semantic_doc("b_sem", "b.md")],
[_ast_reference("a_sem", "b_sem", source_file, sentinel="keep")],
)
source_path = (corpus / source_file).resolve()
real_extract = extract_module._safe_extract_with_xaml_root
fail_source = True

def controlled_extract(extractor, path, root):
if fail_source and path.resolve() == source_path:
return {"nodes": [], "edges": [], "error": "simulated read failure"}
return real_extract(extractor, path, root)

monkeypatch.setattr(
extract_module, "_safe_extract_with_xaml_root", controlled_extract
)

assert _rebuild_code(corpus, no_cluster=True, acquire_lock=False) is True
links = json.loads(graph_path.read_text(encoding="utf-8"))["links"]
references = [edge for edge in links if edge.get("relation") == "references"]
assert len(references) == 1
assert references[0]["sentinel"] == "keep"

fail_source = False
assert _rebuild_code(corpus, no_cluster=True, acquire_lock=False) is True
links = json.loads(graph_path.read_text(encoding="utf-8"))["links"]
references = [edge for edge in links if edge.get("relation") == "references"]
assert len(references) == 1
assert {references[0]["source"], references[0]["target"]} == {"a_sem", "b_sem"}

(corpus / source_file).write_text("no link\n", encoding="utf-8")
assert _rebuild_code(corpus, no_cluster=True, acquire_lock=False) is True
links = json.loads(graph_path.read_text(encoding="utf-8"))["links"]
assert not any(edge.get("relation") == "references" for edge in links)
Expand Down
Loading