@@ -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+
548735class TestBuildNormIndex :
549736 def test_returns_normalized_to_canonical_map (self ):
550737 idx = build_norm_index ({"concepts/Gist_Memory" , "summaries/Paper" })
0 commit comments