Skip to content

Commit e8900ac

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 e8900ac

2 files changed

Lines changed: 296 additions & 22 deletions

File tree

openkb/lint.py

Lines changed: 79 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,33 @@
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+
# Extension-like suffix on a wikilink target: 1-8 alphanumeric chars with at
32+
# least one letter (".txt", ".flac", ".drawio", ".md" — but not the ".11420"
33+
# of a dotted page name like "2509.11420"). Obsidian accepts arbitrary
34+
# attachment formats, so no closed extension list can be complete; targets
35+
# that look like files are simply never demoted. Keeping a genuinely dead
36+
# link is visible and harmless — demoting a valid attachment embed to plain
37+
# text is silent data loss.
38+
_EXTENSION_RE = re.compile(r"\.(?=[A-Za-z0-9]{0,7}[A-Za-z])[A-Za-z0-9]{1,8}$")
39+
40+
41+
def _is_attachment(target: str) -> bool:
42+
"""True when a wikilink target looks like a file attachment, not a page.
43+
44+
Checked only *after* page-target validation fails, so a real page whose
45+
name happens to carry an extension-like suffix (``concepts/node.js``)
46+
still validates and rewrites normally.
47+
"""
48+
return bool(_EXTENSION_RE.search(target.rsplit("/", 1)[-1]))
49+
2550

2651
# Files to exclude from lint scanning (schema, logs, etc.)
2752
_EXCLUDED_FILES = {"AGENTS.md", "SCHEMA.md", "log.md"}
@@ -68,15 +93,28 @@ def strip_ghost_wikilinks(
6893
) -> tuple[str, list[str]]:
6994
"""Strip [[wikilinks]] whose targets do not exist in ``known_targets``.
7095
71-
For each ``[[X]]`` or ``[[X|alias]]`` in ``content``:
96+
For each ``[[X]]``, ``[[X|alias]]``, ``[[X#frag]]``, or
97+
``[[X#frag|alias]]`` in ``content``:
7298
7399
- If ``X`` is in ``known_targets`` exactly, the link is kept as-is.
74100
- Otherwise, ``X`` is normalized (see :func:`_normalize_target`) and
75101
matched against the normalized form of each known target. On a hit,
76-
the link is rewritten to the canonical target form.
102+
the link is rewritten to the canonical target form, preserving any
103+
``#Heading`` / ``#^block`` fragment and alias.
77104
- Otherwise, the brackets are removed and the link becomes plain text
78105
(the alias if provided, otherwise the slug rendered as words).
79106
107+
Obsidian syntax that never targets a wiki page is passed through
108+
untouched: same-page fragment links (``[[#Heading]]``) and attachment
109+
embeds/links — any unknown target with an extension-like suffix
110+
(``![[file.png]]``, ``[[report.pdf]]``, ``![[audio.flac]]``,
111+
``![[diagram.drawio]]``). Obsidian accepts arbitrary attachment
112+
formats, so classification is by shape, not by a closed extension
113+
list; page validation runs first, so a real page named like a file
114+
(``concepts/node.js``) still validates and rewrites normally. Note
115+
embeds (``![[concepts/attention]]``) target pages like any other
116+
link and go through the same validation, keeping the ``!`` on rewrite.
117+
80118
Args:
81119
content: Markdown text containing zero or more ``[[wikilinks]]``.
82120
known_targets: Valid link targets, e.g.
@@ -96,25 +134,33 @@ def strip_ghost_wikilinks(
96134
ghosts: list[str] = []
97135

98136
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
107-
108-
# Direct hit
137+
embed = m.group("embed") or ""
138+
target = (m.group("target") or "").strip()
139+
frag = m.group("frag") or ""
140+
alias = (m.group("alias") or "").strip() or None
141+
142+
# [[#Heading]] links within the same page carry no target to check.
143+
if not target:
144+
return m.group(0)
145+
146+
# Direct hit — page targets validate first, so a page whose name
147+
# carries an extension-like suffix still resolves normally.
109148
if target in known_targets:
110149
return m.group(0)
111150

112-
# Fuzzy normalized hit → rewrite to canonical
151+
# Fuzzy normalized hit → rewrite to canonical, keeping the
152+
# fragment and the embed marker
113153
canonical = norm_index.get(_normalize_target(target))
114154
if canonical is not None:
115155
if alias:
116-
return f"[[{canonical}|{alias}]]"
117-
return f"[[{canonical}]]"
156+
return f"{embed}[[{canonical}{frag}|{alias}]]"
157+
return f"{embed}[[{canonical}{frag}]]"
158+
159+
# Unknown target that looks like a file (![[audio.flac]],
160+
# [[report.pdf]], ![[diagram.drawio]]): an attachment, not a page —
161+
# keep verbatim rather than risk demoting a valid embed.
162+
if _is_attachment(target):
163+
return m.group(0)
118164

119165
# Ghost — strip brackets, leave readable display
120166
ghosts.append(target)
@@ -152,12 +198,23 @@ 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 (any extension-like target —
206+
``![[fig.png]]``, ``[[report.pdf]]``, ``![[audio.flac]]``) and
207+
same-page fragment links (``[[#Heading]]``) are skipped — they never
208+
target a wiki page, so reporting them as broken would be a false
209+
positive.
158210
"""
159-
raw = _WIKILINK_RE.findall(text)
160-
return [link.split("|")[0].strip() for link in raw]
211+
targets: list[str] = []
212+
for m in _WIKILINK_RE.finditer(text):
213+
target = (m.group("target") or "").strip()
214+
if not target or _is_attachment(target):
215+
continue
216+
targets.append(target)
217+
return targets
161218

162219

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

tests/test_lint.py

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -545,6 +545,223 @@ 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_uncommon_attachment_extensions_left_untouched(self):
609+
# Obsidian accepts arbitrary attachment formats — classification is
610+
# by extension *shape*, not a closed list (the P1 regression: these
611+
# were silently demoted to plain text).
612+
text = "![[notes.txt]] ![[audio.flac]] ![[diagram.drawio]] [[note.md]]"
613+
out, ghosts = strip_ghost_wikilinks(text, set())
614+
assert out == text
615+
assert ghosts == []
616+
617+
def test_dotted_page_name_still_ghosted(self):
618+
# A numeric dotted suffix is not extension-like — an unknown page
619+
# target such as an unsanitized arXiv id still gets demoted.
620+
out, ghosts = strip_ghost_wikilinks(
621+
"See [[summaries/2509.11420]].",
622+
{"summaries/other"},
623+
)
624+
assert out == "See 2509.11420."
625+
assert ghosts == ["summaries/2509.11420"]
626+
627+
def test_page_named_like_file_validates_before_attachment_check(self):
628+
# Page validation runs first: a real page whose name carries an
629+
# extension-like suffix still direct-matches and fuzzy-rewrites.
630+
out, ghosts = strip_ghost_wikilinks(
631+
"See [[concepts/node.js]] and [[concepts/Node.js]].",
632+
{"concepts/node.js"},
633+
)
634+
assert out == "See [[concepts/node.js]] and [[concepts/node.js]]."
635+
assert ghosts == []
636+
637+
def test_attachment_embed_left_untouched(self):
638+
text = "Figure: ![[images/doc/p1_img1.png]] here."
639+
out, ghosts = strip_ghost_wikilinks(text, set())
640+
assert out == text
641+
assert ghosts == []
642+
643+
def test_note_embed_kept_on_direct_match(self):
644+
# ![[page]] embeds a note — a page link like any other, keep the "!".
645+
out, ghosts = strip_ghost_wikilinks(
646+
"Embedded: ![[concepts/attention]].",
647+
{"concepts/attention"},
648+
)
649+
assert out == "Embedded: ![[concepts/attention]]."
650+
assert ghosts == []
651+
652+
def test_note_embed_fuzzy_rewrite_preserves_embed_marker(self):
653+
out, ghosts = strip_ghost_wikilinks(
654+
"Embedded: ![[concepts/Gist_Memory#Notes]].",
655+
{"concepts/gist-memory"},
656+
)
657+
assert out == "Embedded: ![[concepts/gist-memory#Notes]]."
658+
assert ghosts == []
659+
660+
def test_ghost_note_embed_demoted_without_bang_residue(self):
661+
out, ghosts = strip_ghost_wikilinks(
662+
"Embedded: ![[concepts/missing]].",
663+
{"concepts/attention"},
664+
)
665+
assert out == "Embedded: missing."
666+
assert ghosts == ["concepts/missing"]
667+
668+
def test_attachment_link_left_untouched(self):
669+
text = "Original: [[report.pdf]]."
670+
out, ghosts = strip_ghost_wikilinks(text, set())
671+
assert out == text
672+
assert ghosts == []
673+
674+
def test_same_page_fragment_left_untouched(self):
675+
text = "Jump to [[#Conclusiones]]."
676+
out, ghosts = strip_ghost_wikilinks(text, set())
677+
assert out == text
678+
assert ghosts == []
679+
680+
# --- find_broken_links ------------------------------------------------
681+
682+
def test_heading_link_to_existing_page_not_broken(self, tmp_path):
683+
wiki = _make_wiki(tmp_path)
684+
(wiki / "concepts" / "attention.md").write_text("# Attention", encoding="utf-8")
685+
(wiki / "summaries" / "paper.md").write_text(
686+
"See [[concepts/attention#Scores]] and [[concepts/attention#^b1]].",
687+
encoding="utf-8",
688+
)
689+
690+
assert find_broken_links(wiki) == []
691+
692+
def test_embed_and_attachment_links_not_reported_broken(self, tmp_path):
693+
wiki = _make_wiki(tmp_path)
694+
(wiki / "summaries" / "paper.md").write_text(
695+
"![[images/doc/fig.png]] and [[scan.pdf]] and [[#Local]] "
696+
"and ![[audio.flac]] and ![[diagram.drawio]] and [[note.md]].",
697+
encoding="utf-8",
698+
)
699+
700+
assert find_broken_links(wiki) == []
701+
702+
def test_heading_link_to_missing_page_still_broken(self, tmp_path):
703+
wiki = _make_wiki(tmp_path)
704+
(wiki / "summaries" / "paper.md").write_text(
705+
"See [[concepts/ghost#Section]].", encoding="utf-8"
706+
)
707+
708+
result = find_broken_links(wiki)
709+
710+
assert len(result) == 1
711+
assert "ghost" in result[0]
712+
713+
def test_note_embed_validated_as_page_link(self, tmp_path):
714+
# ![[page]] note embeds participate in lint: a broken one is
715+
# reported, an existing one is not.
716+
wiki = _make_wiki(tmp_path)
717+
(wiki / "concepts" / "attention.md").write_text("# Attention", encoding="utf-8")
718+
(wiki / "summaries" / "paper.md").write_text(
719+
"![[concepts/attention]] and ![[concepts/ghost]].", encoding="utf-8"
720+
)
721+
722+
result = find_broken_links(wiki)
723+
724+
assert len(result) == 1
725+
assert "ghost" in result[0]
726+
727+
def test_note_embed_counts_as_incoming_link_for_orphans(self, tmp_path):
728+
# A page referenced only via ![[embed]] is linked, not an orphan.
729+
wiki = _make_wiki(tmp_path)
730+
(wiki / "concepts" / "embedded.md").write_text("# Embedded", encoding="utf-8")
731+
(wiki / "summaries" / "host.md").write_text("![[concepts/embedded]]", encoding="utf-8")
732+
733+
result = find_orphans(wiki)
734+
735+
assert "concepts/embedded" not in result
736+
737+
# --- fix_broken_links regression on explorations/ ---------------------
738+
739+
def test_fix_is_idempotent_on_handwritten_exploration(self, tmp_path):
740+
"""A manually written note in explorations/ using the full Obsidian
741+
syntax must survive a wiki-wide ``lint --fix`` sweep unchanged —
742+
this was the data-loss path: heading/block/embed links were being
743+
demoted to plain text."""
744+
wiki = _make_wiki(tmp_path)
745+
(wiki / "concepts" / "attention.md").write_text("# Attention", encoding="utf-8")
746+
(wiki / "explorations").mkdir()
747+
note = wiki / "explorations" / "notas-manuales.md"
748+
original = (
749+
"# Notas\n\n"
750+
"Ver [[concepts/attention#Scaled Dot-Product]] y "
751+
"[[concepts/attention#^bloque1|el bloque]].\n\n"
752+
"![[images/doc/p1_img1.png]]\n\n"
753+
"Nota embebida: ![[concepts/attention]]\n\n"
754+
"Adjunto: [[informe.pdf]] — y volver a [[#Notas]].\n"
755+
)
756+
note.write_text(original, encoding="utf-8")
757+
758+
files_changed, ghosts = fix_broken_links(wiki)
759+
760+
assert note.read_text(encoding="utf-8") == original
761+
assert files_changed == 0
762+
assert ghosts == 0
763+
764+
548765
class TestBuildNormIndex:
549766
def test_returns_normalized_to_canonical_map(self):
550767
idx = build_norm_index({"concepts/Gist_Memory", "summaries/Paper"})

0 commit comments

Comments
 (0)