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