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
Aldominguez12force-pushed the fix/lint-obsidian-link-syntax branch from 9d33a56 to 2775a3aCompareJuly 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
Aldominguez12force-pushed the fix/lint-obsidian-link-syntax branch from 2775a3a to e8900acCompareJuly 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
ContributorAuthor

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
ContributorAuthor

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 freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@Aldominguez12@KylinMountain
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
Aldominguez12force-pushed the fix/lint-obsidian-link-syntax branch from 9d33a56 to 2775a3aCompareJuly 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
Aldominguez12force-pushed the fix/lint-obsidian-link-syntax branch from 2775a3a to e8900acCompareJuly 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
ContributorAuthor

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
ContributorAuthor

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 freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@Aldominguez12@KylinMountain
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
Aldominguez12force-pushed the fix/lint-obsidian-link-syntax branch from 9d33a56 to 2775a3aCompareJuly 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
Aldominguez12force-pushed the fix/lint-obsidian-link-syntax branch from 2775a3a to e8900acCompareJuly 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
ContributorAuthor

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
ContributorAuthor

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 freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@Aldominguez12@KylinMountain
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
Aldominguez12force-pushed the fix/lint-obsidian-link-syntax branch from 9d33a56 to 2775a3aCompareJuly 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
Aldominguez12force-pushed the fix/lint-obsidian-link-syntax branch from 2775a3a to e8900acCompareJuly 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
ContributorAuthor

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
ContributorAuthor

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 freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@Aldominguez12@KylinMountain
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
Aldominguez12force-pushed the fix/lint-obsidian-link-syntax branch from 9d33a56 to 2775a3aCompareJuly 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
Aldominguez12force-pushed the fix/lint-obsidian-link-syntax branch from 2775a3a to e8900acCompareJuly 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
ContributorAuthor

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
ContributorAuthor

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 freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@Aldominguez12@KylinMountain
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
Aldominguez12force-pushed the fix/lint-obsidian-link-syntax branch from 9d33a56 to 2775a3aCompareJuly 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
Aldominguez12force-pushed the fix/lint-obsidian-link-syntax branch from 2775a3a to e8900acCompareJuly 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
ContributorAuthor

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
ContributorAuthor

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 freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@Aldominguez12@KylinMountain
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
Aldominguez12force-pushed the fix/lint-obsidian-link-syntax branch from 9d33a56 to 2775a3aCompareJuly 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
Aldominguez12force-pushed the fix/lint-obsidian-link-syntax branch from 2775a3a to e8900acCompareJuly 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
ContributorAuthor

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
ContributorAuthor

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 freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@Aldominguez12@KylinMountain
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
Aldominguez12force-pushed the fix/lint-obsidian-link-syntax branch from 9d33a56 to 2775a3aCompareJuly 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
Aldominguez12force-pushed the fix/lint-obsidian-link-syntax branch from 2775a3a to e8900acCompareJuly 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
ContributorAuthor

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
ContributorAuthor

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 freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@Aldominguez12@KylinMountain