Skip to content

fix(lint): preserve Obsidian heading, block, and embed link syntax - #180

Open
Aldominguez12 wants to merge 3 commits into
VectifyAI:mainfrom
Aldominguez12:fix/lint-obsidian-link-syntax
Open

fix(lint): preserve Obsidian heading, block, and embed link syntax#180
Aldominguez12 wants to merge 3 commits into
VectifyAI:mainfrom
Aldominguez12:fix/lint-obsidian-link-syntax

Conversation

@Aldominguez12

Copy link
Copy Markdown
Contributor

Problem

The wikilink regex in openkb/lint.py captured the whole [[...]] body as a single page target. Valid Obsidian syntax therefore never matched a known target:

  • [[page#Heading]] / [[page#^block]] — heading and block links
  • ![[file.png]] / [[report.pdf]] — attachment embeds and links
  • [[#Heading]] — same-page fragment links
  • ![[concepts/x]] — note embeds

Consequences:

  • openkb lint --fix destroyed valid links: strip_ghost_wikilinks demoted them to plain text. A wiki-wide sweep silently mangled hand-written notes in explorations/ — a data-loss path.
  • openkb lint reported them all as broken links (false positives).
  • Note embeds created no graph edges in visualize and could produce false orphans.

Fix

  • Parse the embed marker, target, fragment, and alias as named regex 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 !, counted as incoming links for orphan detection, and picked up as graph edges by visualize (which reuses _extract_wikilinks).

Verification

  • New TestObsidianSyntax coverage: heading/block/fragment preservation through direct and fuzzy matches, ghost demotion without ! residue, attachment/note embed handling, and a regression test asserting a hand-written explorations/ note survives a wiki-wide lint --fix byte-for-byte.
  • pytest tests/test_lint.py tests/test_lint_cli.py tests/test_visualize.py tests/test_chat_session.py tests/test_remove.py green; ruff check/format and mypy openkb/lint.py clean.

🤖 Generated with Claude Code

@Aldominguez12
Aldominguez12 force-pushed the fix/lint-obsidian-link-syntax branch from 9d33a56 to 2775a3a Compare July 12, 2026 05:30
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>
@Aldominguez12
Aldominguez12 force-pushed the fix/lint-obsidian-link-syntax branch from 2775a3a to e8900ac Compare July 12, 2026 05:49
@KylinMountain

Copy link
Copy Markdown
Collaborator

Nice fix — this closes a real data-loss path and the test coverage is thorough.

One thing worth addressing before merge: _WIKILINK_RE backtracks O(n²) on long runs of unmatched [, and the new optional frag/alias groups make it ~10× slower than the old regex — a doc with [×20000 takes ~12s per scan (and lint scans each file twice), so a malformed/pathological doc (ASCII art, code fence, base64, stray LLM output) can hang the linter for seconds.

Excluding [ from the target class — (?P<target>[^\[\]|#]*) — makes it linear again, and as a bonus fixes [[[a]]] capturing [a as the target. The other edge cases I noticed are minor.

Exclude [ from the target character class. Allowing it made the scan
quadratic on long runs of unmatched brackets (20k chars took ~10s per
pass, and lint scans each file twice), and also let [[[a]]] capture
[a as the target instead of matching the inner [[a]].

Obsidian forbids [ in note names, so no legitimate link changes
behavior. Addresses review feedback on VectifyAI#180.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Aldominguez12

Copy link
Copy Markdown
Contributor Author

Good catch — confirmed the quadratic behavior locally (20k unmatched [ took ~9.5s per scan, linear after the change) and that [[[a]]] now matches the inner [[a]] instead of capturing [a as the target. Fixed in e03e322 by excluding [ from the target class, with regression tests for both the pathological input and the nested-bracket case. All lint tests pass (88/88), ruff and mypy clean.

@KylinMountain

Copy link
Copy Markdown
Collaborator

Small follow-up on the same regex: e03e322 fixed the [-run case by excluding [ from target, but the two new groups still include [frag is #[^\]|]* and alias is [^\]]+ — so the same O(n²) backtracking survives; it just moves to unclosed [[…#… / [[…|… runs.

Repro against the PR head, calling the real strip_ghost_wikilinks + _extract_wikilinks (lint scans each file twice):

  • "[[a#" * 10000 (a long run with no ]) hangs for seconds; doubling the count ~4×s the time.
  • The trigger is narrow, though: put a single ] roughly every ~10 links and it drops back to ~20ms. So it only bites on a long ]-free region densely seeded with [[…#/[[…| — corrupted/truncated or adversarial input, not normal notes (a 175KB note full of closed [[a#b|c]] links is ~9ms). Low severity — just the same class e03e322 set out to close.

One-liner — extend the same [-exclusion to both inner classes:

r"(?P<embed>!)?\[\[(?P<target>[^\[\]|#]*)(?P<frag>#[^\[\]|]*)?(?:\|(?P<alias>[^\[\]]+))?\]\]"

(#[^\]|]*#[^\[\]|]*, and [^\]]+[^\[\]]+)

Verified against the real functions: every pathological shape goes linear ([[a#×10000 → ~15ms), and matching on valid Obsidian syntax is byte-for-byte unchanged — heading/block/fragment rewrites, note/attachment embeds, [[#Heading]], aliases, and ghost demotion all identical. Might be worth a regression test on the frag/alias shapes alongside the existing [-run one.

Follow-up to the target-class fix (e03e322): the frag class #[^\]|]* and
alias class [^\]]+ still allowed [, so the same O(n^2) backtracking
survived on long ]-free runs seeded with [[a#... or [[a|... (a bare
[[a#*10000 hung ~15s per pass, and lint scans each file twice).

Extend the same [-exclusion to both inner classes:
  frag   #[^\]|]*  ->  #[^\[\]|]*
  alias  [^\]]+    ->  [^\[\]]+

Every pathological shape is now linear (20k -> <20ms), and matching on
valid Obsidian syntax is byte-for-byte unchanged (headings, block refs,
aliases, note/attachment embeds, same-page fragments). Adds regression
tests for the frag/alias runs alongside the existing [-run one.

Addresses review follow-up on VectifyAI#180.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Aldominguez12

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough follow-up — you're exactly right. e03e322 only bounded target; frag (#[^\]|]*) and alias ([^\]]+) still admitted [, so the same O(n²) just moved to [[…#… / [[…|… runs.

Reproduced against the real strip_ghost_wikilinks + _extract_wikilinks: [[a#×10000 ran ~15.5s per pass (≈4× on doubling — quadratic), and your one-liner takes it to ~7ms (linear). Applied it verbatim in 3ee0da7:

  • frag: #[^\]|]*#[^\[\]|]*
  • alias: [^\]]+[^\[\]]+
  • widened the class comment to cover all three inner classes, not just target
  • added the two regression tests you suggested (test_unmatched_fragment_run_scans_in_linear_time / …_alias_…) alongside the existing [-run one — full lint suite 90/90.

Matching on valid Obsidian syntax is unchanged across the corpus (headings, block refs, aliases, note/attachment embeds, [[#Heading]], ghost demotion). The only shapes that stop matching are fragments/aliases containing an unmatched [ — malformed input, and the same trade-off already accepted for target. Thanks again!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

2 participants