diff --git a/graphify/wiki.py b/graphify/wiki.py index 9eef581c52..f660d1dbdf 100644 --- a/graphify/wiki.py +++ b/graphify/wiki.py @@ -56,6 +56,48 @@ def _safe_filename(name: str, limit: int = 200) -> str: return s[:limit] if s else 'unnamed' +def _escape_md_brackets(text: object) -> str: + r"""Escape `[`/`]` so raw text embedded in a generated article can never be + misread as markdown/Obsidian link syntax (#3547). + + A node's own label is source content (an extracted heading, identifier, or + doc excerpt) and occasionally contains a literal ``[[...]]`` substring — + e.g. a doc that itself explains or demonstrates wikilink syntax. Printed + unescaped, that string renders as a real (and always dead — the wiki + export never writes bracket-style links, see ``_md_link``) wikilink + instead of the plain text it actually is. + + Callers fall back to a node's own id when it has no ``label`` attribute, + and a networkx node id is not always a string (an int or a tuple id is + legal). ``str()`` first so that fallback stringifies exactly like the + plain f-interpolation this call replaced, instead of raising. + + A RUN of one or more backslashes immediately before a bracket in the + SOURCE text is doubled first, before the bracket is escaped. Source + content occasionally already contains one or more literal backslashes + right before a bracket (a doc excerpt showing a regex character class, + ``\]+``, or an escaped backslash inside one, ``[\\]``, for example). + Doubling only the LAST backslash of a run, rather than every backslash + in the run, still leaves the earlier ones unpaired: two source + backslashes before a bracket produced four backslashes then a bare + bracket, and CommonMark reads two backslash pairs as two literal + backslashes with nothing left to escape the bracket, un-escaping it + right back into live link syntax. The whole run must double, then get + one more backslash for the bracket's own escape, in a single pass — + two separate ``.replace()`` calls for the brackets can't do this, since + the second call has no way to know which backslashes a previous + replacement result already produced versus which were in the run. + Matching (and touching) only a run that actually precedes a bracket + keeps an unrelated pre-existing escape elsewhere in the source intact + (``\*`` meaning a literal asterisk, doubled unconditionally, would + itself un-escape into a bare, newly-live ``*``). + """ + def _double_and_escape(m: "re.Match[str]") -> str: + return m.group(1) * 2 + "\\" + m.group(2) + + return re.sub(r"(\\*)([\[\]])", _double_and_escape, str(text)) + + def _md_link(label: str, resolver: dict[str, str]) -> str: """Render a link to another wiki article as a portable relative markdown link. @@ -83,7 +125,7 @@ def _md_link(label: str, resolver: dict[str, str]) -> str: god nodes get article files — render as plain text instead of a dead link that points nowhere even inside Obsidian. """ - text = label.replace("[", r"\[").replace("]", r"\]") + text = _escape_md_brackets(label) slug = resolver.get(label) if slug is None: return text @@ -137,7 +179,7 @@ def _community_article( sources = sorted({G.nodes[n].get("source_file") or "" for n in nodes} - {""}) lines: list[str] = [] - lines += [f"# {label}", ""] + lines += [f"# {_escape_md_brackets(label)}", ""] meta_parts = [f"{len(nodes)} nodes"] if cohesion is not None: @@ -147,7 +189,7 @@ def _community_article( lines += ["## Key Concepts", ""] for nid in top_nodes: d = G.nodes[nid] - node_label = d.get("label", nid) + node_label = _escape_md_brackets(d.get("label", nid)) src = d.get("source_file", "") degree = G.degree(nid) src_str = f" — `{src}`" if src else "" @@ -185,7 +227,7 @@ def _community_article( def _god_node_article(G: nx.Graph, nid: str, labels: dict[int, str], node_community: dict[str, int] | None = None, resolver: dict[str, str] | None = None) -> str: resolver = resolver or {} d = G.nodes[nid] - node_label = d.get("label", nid) + node_label = _escape_md_brackets(d.get("label", nid)) src = d.get("source_file", "") cid = (node_community or {}).get(nid) community_name = labels.get(cid, f"Community {cid}") if cid is not None else None diff --git a/tests/test_wiki.py b/tests/test_wiki.py index 130da843dc..05748fd123 100644 --- a/tests/test_wiki.py +++ b/tests/test_wiki.py @@ -3,7 +3,7 @@ import pytest from pathlib import Path import networkx as nx -from graphify.wiki import to_wiki, _index_md, _community_article, _god_node_article +from graphify.wiki import to_wiki, _index_md, _community_article, _god_node_article, _escape_md_brackets _MD_LINK = re.compile(r"\[([^\]]+)\]\(([^)]+)\)") @@ -392,6 +392,55 @@ def test_wiki_link_with_bracketed_label_resolves(tmp_path): assert (tmp_path / "Array[T]_Models.md").exists() +def test_community_article_title_escapes_a_bare_bracket_label(tmp_path): + """A community label that is JUST a bracket character (`_community_article`'s + own `# {label}` title heading, not a link target) must render escaped too, + or the lone `[` opens a markdown link/image syntax the rest of the line + never closes.""" + G = nx.Graph() + G.add_node(1, label="a", file_type="code", source_file="a.py", community=0) + G.add_node(2, label="b", file_type="code", source_file="b.py", community=0) + G.add_node(3, label="c", file_type="code", source_file="c.py", community=0) + G.add_edge(1, 2, relation="references", confidence="INFERRED", weight=1.0) + G.add_edge(1, 3, relation="references", confidence="INFERRED", weight=1.0) + G.add_edge(2, 3, relation="references", confidence="INFERRED", weight=1.0) + article = _community_article(G, 0, [1, 2, 3], "[", {0: "["}, 1.0) + assert article.startswith("# \\[\n") + + +def test_escape_md_brackets_leaves_an_unrelated_escape_alone(): + """A backslash that precedes something other than a bracket (`\\*`, + escaping a literal asterisk so it doesn't open emphasis) must survive + unchanged. Doubling every backslash unconditionally -- rather than only + ones that precede a bracket -- would itself un-escape that unrelated + escape: `\\*` doubled becomes `\\\\*`, and CommonMark reads `\\\\` as one + literal backslash followed by a bare, newly-live `*`.""" + assert _escape_md_brackets(r"\*bold*\ ") == r"\*bold*\ " + + +def test_escape_md_brackets_still_escapes_a_backslash_before_a_bracket(): + """The one case the backslash handling exists for is unaffected: a + backslash already sitting right before a bracket in source (a regex + character class, `\\]+`) still round-trips as a literal backslash + followed by a literal bracket once escaped.""" + assert _escape_md_brackets(r"\]+") == r"\\\]+" + + +def test_escape_md_brackets_handles_a_run_of_backslashes_before_a_bracket(): + """A RUN of two or more backslashes right before a bracket in source (an + escaped backslash inside a regex character class, `[\\]`, for example) + must still leave the bracket escaped. Doubling only the single backslash + immediately adjacent to the bracket (rather than the whole run) left the + earlier backslashes in the run unpaired: two source backslashes produced + four escaped ones with nothing left to escape the bracket itself, so + CommonMark read it as two literal backslashes followed by a BARE, + newly-live bracket -- un-escaping it right back into link syntax.""" + # Two backslashes + a close bracket: CommonMark must read this back as + # two literal backslashes followed by one literal (escaped) bracket, five + # backslashes then the bracket (2 pairs + 1 leftover that pairs with it). + assert _escape_md_brackets("\\" * 2 + "]") == "\\" * 5 + "]" + + def test_wiki_links_to_nodes_without_articles_are_plain_text(tmp_path): """A god node links its neighbours, but only communities and god nodes get article files — neighbours without one must render as plain text, not as a diff --git a/tests/test_wiki_label_bracket_escaping.py b/tests/test_wiki_label_bracket_escaping.py new file mode 100644 index 0000000000..af93861283 --- /dev/null +++ b/tests/test_wiki_label_bracket_escaping.py @@ -0,0 +1,117 @@ +"""Regression tests for issue #3547: a node label that literally contains +`[[...]]` (extracted from source content — a doc discussing or demonstrating +wikilink syntax, for example) must not be printed raw into a generated +article. `_md_link` already escapes `[`/`]` for anything it links, but three +other sites print a label directly into the article body without going +through it: a community's own title, its "Key Concepts" node listing, and a +god node's own title. Printed unescaped, `[[wikilink]]` renders as a real +(and always dead — the wiki export never writes bracket-style links) wikilink +instead of the plain text it actually is. +""" +from __future__ import annotations + +import networkx as nx + +from graphify.wiki import _community_article, _god_node_article, _escape_md_brackets, to_wiki + + +def test_escape_md_brackets_escapes_both_brackets(): + assert _escape_md_brackets("[[wikilink]]") == r"\[\[wikilink\]\]" + assert _escape_md_brackets("plain text") == "plain text" + + +def _bracket_is_live_after_escaping(text: str) -> bool: + """True if unwinding CommonMark backslash-escapes left to right (a `\\X` + pair consumes both characters and yields one INERT literal `X`) leaves any + `[`/`]` reachable as a BARE, unpaired character -- i.e. still able to act + as link syntax rather than literal text.""" + i = 0 + while i < len(text): + if text[i] == "\\" and i + 1 < len(text): + i += 2 # the pair is consumed together; its second char is inert + continue + if text[i] in "[]": + return True + i += 1 + return False + + +def test_escape_md_brackets_escapes_a_pre_existing_backslash_before_a_bracket(): + # A source label can already contain a literal backslash right before a + # bracket (a doc excerpt showing a regex character class, `\]+`, for + # example). Escaping the bracket alone would turn it into `\\]` -- CommonMark + # reads the doubled backslash as one literal backslash, which un-escapes + # the bracket right back into live syntax. The backslash must be escaped + # first so the bracket's own escape survives. + escaped = _escape_md_brackets("regex: \\]+") + assert escaped == "regex: \\\\\\]+" + assert not _bracket_is_live_after_escaping(escaped) + + +def test_escape_md_brackets_stringifies_a_non_string_node_id_fallback(): + # A networkx node id is not always a string (int and tuple ids are + # legal). The label callers fall back to a node's own id when it has no + # `label` attribute, so this must stringify instead of raising. + assert _escape_md_brackets(1) == "1" + assert _escape_md_brackets(("a", "b")) == "('a', 'b')" + + +def test_community_article_handles_a_node_with_no_label_and_a_non_string_id(): + G = nx.Graph() + G.add_nodes_from([(1, {}), (2, {}), (3, {})]) + G.add_edges_from([(1, 2, {}), (1, 3, {}), (2, 3, {})]) + article = _community_article(G, 0, [1, 2, 3], "hello world", {0: "hello world"}, + None, {1: 0, 2: 0, 3: 0}, {}) + assert "**1**" in article + + +def test_community_title_escapes_bracket_label(): + G = nx.Graph() + G.add_node("n1", label="sym", file_type="code", source_file="a.py") + article = _community_article(G, 0, ["n1"], "[[Foo Bar Baz]]", {0: "[[Foo Bar Baz]]"}, + None, {"n1": 0}, {}) + assert "# \\[\\[Foo Bar Baz\\]\\]" in article + assert "# [[Foo Bar Baz]]" not in article + + +def test_community_key_concepts_escapes_node_label(): + G = nx.Graph() + G.add_node("n1", label="[[wikilink]]", file_type="concept", source_file="doc.md") + G.add_node("n2", label="ordinary", file_type="code", source_file="a.py") + G.add_edge("n1", "n2", relation="related") + article = _community_article(G, 0, ["n1", "n2"], "Community 0", {0: "Community 0"}, + None, {"n1": 0, "n2": 0}, {}) + assert r"\[\[wikilink\]\]" in article + assert "[[wikilink]]" not in article + + +def test_god_node_title_escapes_bracket_label(): + G = nx.Graph() + G.add_node("n1", label="[[...]]", file_type="concept", source_file="doc.md") + G.add_node("n2", label="caller", file_type="code", source_file="a.py") + G.add_edge("n1", "n2", relation="calls", confidence="EXTRACTED") + article = _god_node_article(G, "n1", {0: "Community 0"}, {"n1": 0, "n2": 0}, {}) + assert "# \\[\\[...\\]\\]" in article # only [ and ] are escaped, not the dots + assert "# [[...]]" not in article + + +def test_end_to_end_wiki_export_has_no_literal_wikilinks(tmp_path): + """A label with content that resembles wikilink placeholder text ("[[link]]", + "[[new_stem]]", empty "[[]]", per the issue's own examples) must not survive + verbatim into any generated page.""" + G = nx.Graph() + G.add_node("n1", label="[[link]]", file_type="concept", source_file="a.md", community=0) + G.add_node("n2", label="[[new_stem]]", file_type="concept", source_file="a.md", community=0) + G.add_node("n3", label="[[]]", file_type="concept", source_file="b.md", community=0) + G.add_edge("n1", "n2", relation="related") + G.add_edge("n2", "n3", relation="related") + communities = {0: ["n1", "n2", "n3"]} + + out = tmp_path / "wiki" + to_wiki(G, communities, out, community_labels={0: "Community 0"}) + + for md in out.glob("*.md"): + text = md.read_text(encoding="utf-8") + assert "[[link]]" not in text, f"{md.name} still has a literal placeholder wikilink" + assert "[[new_stem]]" not in text, f"{md.name} still has a literal placeholder wikilink" + assert "[[]]" not in text, f"{md.name} still has a literal empty wikilink"