From 4da224338c079e2facb5a2c8d0ce97ee97c05619 Mon Sep 17 00:00:00 2001 From: oleksii-tumanov Date: Thu, 17 Sep 2026 18:28:10 -0500 Subject: [PATCH 1/2] fix: reconcile Markdown link suffixes --- graphify/watch.py | 8 +++++-- tests/test_extract.py | 11 +++++++++ tests/test_watch.py | 53 ++++++++++++++++++++++++++++++++++++++----- 3 files changed, 64 insertions(+), 8 deletions(-) diff --git a/graphify/watch.py b/graphify/watch.py index f5fd10c2f1..4d3e8ca40f 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -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")} @@ -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() diff --git a/tests/test_extract.py b/tests/test_extract.py index d7a263b8e2..13a64dc457 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -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. diff --git a/tests/test_watch.py b/tests/test_watch.py index a056538f44..62183dda36 100644 --- a/tests/test_watch.py +++ b/tests/test_watch.py @@ -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")], [], ) @@ -4085,13 +4087,15 @@ 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 @@ -4099,6 +4103,43 @@ def test_markdown_reconcile_prunes_removed_authored_link(tmp_path): 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"} + + def test_markdown_reconcile_keeps_reverse_stored_edge_unchanged(tmp_path): """#1915/#1954: undirected endpoint order must not churn edge data.""" corpus, graph_path = _markdown_reconcile_fixture( From 3a9cd7b8dda1d3f1a41a055d52422776b41d3163 Mon Sep 17 00:00:00 2001 From: oleksii-tumanov Date: Thu, 17 Sep 2026 18:43:52 -0500 Subject: [PATCH 2/2] fix: preserve Markdown links on parse failure --- graphify/watch.py | 4 +++- tests/test_watch.py | 46 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/graphify/watch.py b/graphify/watch.py index 4d3e8ca40f..9cd8a60438 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -603,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 diff --git a/tests/test_watch.py b/tests/test_watch.py index 62183dda36..e2e50180d7 100644 --- a/tests/test_watch.py +++ b/tests/test_watch.py @@ -4140,6 +4140,52 @@ def test_markdown_reconcile_repoints_incremental_link_to_semantic_target( 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) + + def test_markdown_reconcile_keeps_reverse_stored_edge_unchanged(tmp_path): """#1915/#1954: undirected endpoint order must not churn edge data.""" corpus, graph_path = _markdown_reconcile_fixture(