Skip to content

Commit 2775a3a

Browse files
Aldominguez12claude
andcommitted
fix(lint): preserve Obsidian heading, block, and embed link syntax
The single-capture wikilink regex treated [[page#Heading]], [[page#^block]], ![[file.png]] and [[report.pdf]] as whole page targets. None of them can ever match a known target, so lint --fix (strip_ghost_wikilinks) demoted valid Obsidian links to plain text — silently destroying hand-written notes in explorations/ on a full sweep — and find_broken_links reported them all as broken. - Parse the embed marker, target, fragment and alias as named groups. - Validate only the page target; fragments survive fuzzy canonical rewrites ([[concepts/Gist_Memory#Notes]] -> [[concepts/gist-memory#Notes]]). - Pass through attachment embeds/links (extension whitelist) and same-page [[#Heading]] links untouched. - Treat note embeds ![[concepts/x]] as regular page links: validated, rewritten keeping the embed marker, counted as incoming links for orphan detection and as graph edges in visualize. Adds regression coverage including a hand-written explorations/ note that must survive a wiki-wide lint --fix byte-for-byte. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent bd9fe39 commit 2775a3a

2 files changed

Lines changed: 263 additions & 20 deletions

File tree

openkb/lint.py

Lines changed: 76 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,42 @@
2020
from openkb.locks import atomic_write_text
2121
from openkb.schema import PAGE_CONTENT_DIRS
2222

23-
# Matches [[wikilink]] or [[subdir/link]]
24-
_WIKILINK_RE = re.compile(r"\[\[([^\]]+)\]\]")
23+
# Matches [[target]], [[target|alias]], [[target#Heading]], [[target#^block]],
24+
# same-page fragment links [[#Heading]], and embeds ![[file]]. Named groups
25+
# keep the pieces separable so Obsidian fragment/embed syntax survives
26+
# validation and rewriting instead of being demoted to plain text.
27+
_WIKILINK_RE = re.compile(
28+
r"(?P<embed>!)?\[\[(?P<target>[^\]|#]*)(?P<frag>#[^\]|]*)?(?:\|(?P<alias>[^\]]+))?\]\]"
29+
)
30+
31+
# Non-page link targets ([[report.pdf]], ![[figure.png]]): Obsidian resolves
32+
# these to vault attachments, so they are never validated against wiki pages.
33+
_ATTACHMENT_SUFFIXES = {
34+
".png",
35+
".jpg",
36+
".jpeg",
37+
".gif",
38+
".webp",
39+
".bmp",
40+
".svg",
41+
".pdf",
42+
".mp3",
43+
".mp4",
44+
".wav",
45+
".webm",
46+
".csv",
47+
".json",
48+
".xlsx",
49+
".docx",
50+
".pptx",
51+
".zip",
52+
}
53+
54+
55+
def _is_attachment(target: str) -> bool:
56+
"""True when a wikilink target points at a file attachment, not a page."""
57+
return Path(target).suffix.lower() in _ATTACHMENT_SUFFIXES
58+
2559

2660
# Files to exclude from lint scanning (schema, logs, etc.)
2761
_EXCLUDED_FILES = {"AGENTS.md", "SCHEMA.md", "log.md"}
@@ -68,15 +102,23 @@ def strip_ghost_wikilinks(
68102
) -> tuple[str, list[str]]:
69103
"""Strip [[wikilinks]] whose targets do not exist in ``known_targets``.
70104
71-
For each ``[[X]]`` or ``[[X|alias]]`` in ``content``:
105+
For each ``[[X]]``, ``[[X|alias]]``, ``[[X#frag]]``, or
106+
``[[X#frag|alias]]`` in ``content``:
72107
73108
- If ``X`` is in ``known_targets`` exactly, the link is kept as-is.
74109
- Otherwise, ``X`` is normalized (see :func:`_normalize_target`) and
75110
matched against the normalized form of each known target. On a hit,
76-
the link is rewritten to the canonical target form.
111+
the link is rewritten to the canonical target form, preserving any
112+
``#Heading`` / ``#^block`` fragment and alias.
77113
- Otherwise, the brackets are removed and the link becomes plain text
78114
(the alias if provided, otherwise the slug rendered as words).
79115
116+
Obsidian syntax that never targets a wiki page is passed through
117+
untouched: attachment embeds and links (``![[file.png]]``,
118+
``[[report.pdf]]``) and same-page fragment links (``[[#Heading]]``).
119+
Note embeds (``![[concepts/attention]]``) target pages like any other
120+
link and go through the same validation, keeping the ``!`` on rewrite.
121+
80122
Args:
81123
content: Markdown text containing zero or more ``[[wikilinks]]``.
82124
known_targets: Valid link targets, e.g.
@@ -96,25 +138,29 @@ def strip_ghost_wikilinks(
96138
ghosts: list[str] = []
97139

98140
def _repl(m: re.Match) -> str:
99-
raw = m.group(1)
100-
if "|" in raw:
101-
target, alias = raw.split("|", 1)
102-
target = target.strip()
103-
alias = alias.strip()
104-
else:
105-
target = raw.strip()
106-
alias = None
141+
embed = m.group("embed") or ""
142+
target = (m.group("target") or "").strip()
143+
frag = m.group("frag") or ""
144+
alias = (m.group("alias") or "").strip() or None
145+
146+
# [[#Heading]] links within the same page carry no target to check;
147+
# attachment embeds/links (![[fig.png]], [[report.pdf]]) resolve to
148+
# vault files, not pages. Note embeds (![[concepts/x]]) fall through
149+
# and are validated like any page link.
150+
if not target or _is_attachment(target):
151+
return m.group(0)
107152

108153
# Direct hit
109154
if target in known_targets:
110155
return m.group(0)
111156

112-
# Fuzzy normalized hit → rewrite to canonical
157+
# Fuzzy normalized hit → rewrite to canonical, keeping the
158+
# fragment and the embed marker
113159
canonical = norm_index.get(_normalize_target(target))
114160
if canonical is not None:
115161
if alias:
116-
return f"[[{canonical}|{alias}]]"
117-
return f"[[{canonical}]]"
162+
return f"{embed}[[{canonical}{frag}|{alias}]]"
163+
return f"{embed}[[{canonical}{frag}]]"
118164

119165
# Ghost — strip brackets, leave readable display
120166
ghosts.append(target)
@@ -152,12 +198,22 @@ def _all_wiki_pages(wiki: Path) -> dict[str, Path]:
152198

153199

154200
def _extract_wikilinks(text: str) -> list[str]:
155-
"""Return all wikilink targets found in *text*.
156-
157-
Handles ``[[target|display text]]`` alias syntax — only the target is returned.
201+
"""Return all page-link targets found in *text*.
202+
203+
Aliases and ``#Heading`` / ``#^block`` fragments are dropped — only the
204+
page part is returned. Note embeds (``![[concepts/x]]``) count as page
205+
links; attachment embeds/links (``![[fig.png]]``, ``[[report.pdf]]``)
206+
and same-page fragment links (``[[#Heading]]``) are skipped — they
207+
never target a wiki page, so reporting them as broken would be a
208+
false positive.
158209
"""
159-
raw = _WIKILINK_RE.findall(text)
160-
return [link.split("|")[0].strip() for link in raw]
210+
targets: list[str] = []
211+
for m in _WIKILINK_RE.finditer(text):
212+
target = (m.group("target") or "").strip()
213+
if not target or _is_attachment(target):
214+
continue
215+
targets.append(target)
216+
return targets
161217

162218

163219
def list_existing_wiki_targets(wiki_dir: Path) -> set[str]:

tests/test_lint.py

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -545,6 +545,193 @@ def test_accepts_prebuilt_norm_index_with_identical_result(self):
545545
assert "[[concepts/missing]]" not in out_b
546546

547547

548+
class TestObsidianSyntax:
549+
"""Obsidian link syntax beyond ``[[page]]`` / ``[[page|alias]]``.
550+
551+
Heading links (``[[page#H]]``), block refs (``[[page#^b]]``), same-page
552+
fragments (``[[#H]]``), embeds (``![[file.png]]``), and attachment
553+
links (``[[file.pdf]]``) are all valid in Obsidian. The linter must
554+
neither demote them to plain text (strip/fix) nor report them as
555+
broken (find_broken_links).
556+
"""
557+
558+
# --- strip_ghost_wikilinks -------------------------------------------
559+
560+
def test_keeps_heading_fragment_on_direct_match(self):
561+
out, ghosts = strip_ghost_wikilinks(
562+
"See [[concepts/attention#Scaled Dot-Product]].",
563+
{"concepts/attention"},
564+
)
565+
assert out == "See [[concepts/attention#Scaled Dot-Product]]."
566+
assert ghosts == []
567+
568+
def test_keeps_block_ref_on_direct_match(self):
569+
out, ghosts = strip_ghost_wikilinks(
570+
"See [[concepts/attention#^abc123]].",
571+
{"concepts/attention"},
572+
)
573+
assert out == "See [[concepts/attention#^abc123]]."
574+
assert ghosts == []
575+
576+
def test_keeps_fragment_with_alias(self):
577+
out, ghosts = strip_ghost_wikilinks(
578+
"See [[concepts/attention#Scores|the scores]].",
579+
{"concepts/attention"},
580+
)
581+
assert out == "See [[concepts/attention#Scores|the scores]]."
582+
assert ghosts == []
583+
584+
def test_preserves_fragment_through_fuzzy_rewrite(self):
585+
out, ghosts = strip_ghost_wikilinks(
586+
"See [[concepts/gist_memory#Notes]].",
587+
{"concepts/gist-memory"},
588+
)
589+
assert out == "See [[concepts/gist-memory#Notes]]."
590+
assert ghosts == []
591+
592+
def test_preserves_fragment_and_alias_through_fuzzy_rewrite(self):
593+
out, ghosts = strip_ghost_wikilinks(
594+
"See [[concepts/gist_memory#Notes|notes]].",
595+
{"concepts/gist-memory"},
596+
)
597+
assert out == "See [[concepts/gist-memory#Notes|notes]]."
598+
assert ghosts == []
599+
600+
def test_ghost_with_fragment_demoted_without_fragment_text(self):
601+
out, ghosts = strip_ghost_wikilinks(
602+
"See [[concepts/missing#Section]].",
603+
{"concepts/attention"},
604+
)
605+
assert out == "See missing."
606+
assert ghosts == ["concepts/missing"]
607+
608+
def test_attachment_embed_left_untouched(self):
609+
text = "Figure: ![[images/doc/p1_img1.png]] here."
610+
out, ghosts = strip_ghost_wikilinks(text, set())
611+
assert out == text
612+
assert ghosts == []
613+
614+
def test_note_embed_kept_on_direct_match(self):
615+
# ![[page]] embeds a note — a page link like any other, keep the "!".
616+
out, ghosts = strip_ghost_wikilinks(
617+
"Embedded: ![[concepts/attention]].",
618+
{"concepts/attention"},
619+
)
620+
assert out == "Embedded: ![[concepts/attention]]."
621+
assert ghosts == []
622+
623+
def test_note_embed_fuzzy_rewrite_preserves_embed_marker(self):
624+
out, ghosts = strip_ghost_wikilinks(
625+
"Embedded: ![[concepts/Gist_Memory#Notes]].",
626+
{"concepts/gist-memory"},
627+
)
628+
assert out == "Embedded: ![[concepts/gist-memory#Notes]]."
629+
assert ghosts == []
630+
631+
def test_ghost_note_embed_demoted_without_bang_residue(self):
632+
out, ghosts = strip_ghost_wikilinks(
633+
"Embedded: ![[concepts/missing]].",
634+
{"concepts/attention"},
635+
)
636+
assert out == "Embedded: missing."
637+
assert ghosts == ["concepts/missing"]
638+
639+
def test_attachment_link_left_untouched(self):
640+
text = "Original: [[report.pdf]]."
641+
out, ghosts = strip_ghost_wikilinks(text, set())
642+
assert out == text
643+
assert ghosts == []
644+
645+
def test_same_page_fragment_left_untouched(self):
646+
text = "Jump to [[#Conclusiones]]."
647+
out, ghosts = strip_ghost_wikilinks(text, set())
648+
assert out == text
649+
assert ghosts == []
650+
651+
# --- find_broken_links ------------------------------------------------
652+
653+
def test_heading_link_to_existing_page_not_broken(self, tmp_path):
654+
wiki = _make_wiki(tmp_path)
655+
(wiki / "concepts" / "attention.md").write_text("# Attention", encoding="utf-8")
656+
(wiki / "summaries" / "paper.md").write_text(
657+
"See [[concepts/attention#Scores]] and [[concepts/attention#^b1]].",
658+
encoding="utf-8",
659+
)
660+
661+
assert find_broken_links(wiki) == []
662+
663+
def test_embed_and_attachment_links_not_reported_broken(self, tmp_path):
664+
wiki = _make_wiki(tmp_path)
665+
(wiki / "summaries" / "paper.md").write_text(
666+
"![[images/doc/fig.png]] and [[scan.pdf]] and [[#Local]].",
667+
encoding="utf-8",
668+
)
669+
670+
assert find_broken_links(wiki) == []
671+
672+
def test_heading_link_to_missing_page_still_broken(self, tmp_path):
673+
wiki = _make_wiki(tmp_path)
674+
(wiki / "summaries" / "paper.md").write_text(
675+
"See [[concepts/ghost#Section]].", encoding="utf-8"
676+
)
677+
678+
result = find_broken_links(wiki)
679+
680+
assert len(result) == 1
681+
assert "ghost" in result[0]
682+
683+
def test_note_embed_validated_as_page_link(self, tmp_path):
684+
# ![[page]] note embeds participate in lint: a broken one is
685+
# reported, an existing one is not.
686+
wiki = _make_wiki(tmp_path)
687+
(wiki / "concepts" / "attention.md").write_text("# Attention", encoding="utf-8")
688+
(wiki / "summaries" / "paper.md").write_text(
689+
"![[concepts/attention]] and ![[concepts/ghost]].", encoding="utf-8"
690+
)
691+
692+
result = find_broken_links(wiki)
693+
694+
assert len(result) == 1
695+
assert "ghost" in result[0]
696+
697+
def test_note_embed_counts_as_incoming_link_for_orphans(self, tmp_path):
698+
# A page referenced only via ![[embed]] is linked, not an orphan.
699+
wiki = _make_wiki(tmp_path)
700+
(wiki / "concepts" / "embedded.md").write_text("# Embedded", encoding="utf-8")
701+
(wiki / "summaries" / "host.md").write_text("![[concepts/embedded]]", encoding="utf-8")
702+
703+
result = find_orphans(wiki)
704+
705+
assert "concepts/embedded" not in result
706+
707+
# --- fix_broken_links regression on explorations/ ---------------------
708+
709+
def test_fix_is_idempotent_on_handwritten_exploration(self, tmp_path):
710+
"""A manually written note in explorations/ using the full Obsidian
711+
syntax must survive a wiki-wide ``lint --fix`` sweep unchanged —
712+
this was the data-loss path: heading/block/embed links were being
713+
demoted to plain text."""
714+
wiki = _make_wiki(tmp_path)
715+
(wiki / "concepts" / "attention.md").write_text("# Attention", encoding="utf-8")
716+
(wiki / "explorations").mkdir()
717+
note = wiki / "explorations" / "notas-manuales.md"
718+
original = (
719+
"# Notas\n\n"
720+
"Ver [[concepts/attention#Scaled Dot-Product]] y "
721+
"[[concepts/attention#^bloque1|el bloque]].\n\n"
722+
"![[images/doc/p1_img1.png]]\n\n"
723+
"Nota embebida: ![[concepts/attention]]\n\n"
724+
"Adjunto: [[informe.pdf]] — y volver a [[#Notas]].\n"
725+
)
726+
note.write_text(original, encoding="utf-8")
727+
728+
files_changed, ghosts = fix_broken_links(wiki)
729+
730+
assert note.read_text(encoding="utf-8") == original
731+
assert files_changed == 0
732+
assert ghosts == 0
733+
734+
548735
class TestBuildNormIndex:
549736
def test_returns_normalized_to_canonical_map(self):
550737
idx = build_norm_index({"concepts/Gist_Memory", "summaries/Paper"})

0 commit comments

Comments
 (0)