diff --git a/graphify/watch.py b/graphify/watch.py index f5fd10c2f..9cd8a6043 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() @@ -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 diff --git a/tests/test_extract.py b/tests/test_extract.py index d7a263b8e..13a64dc45 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 a056538f4..e2e50180d 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,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)