| name | obsidian-wiki-lint |
| description | Scan an Obsidian vault for broken wikilinks and orphan pages. Distinguishes real broken links from false positives (syntax examples in documentation). |
| triggers | ["lint obsidian","validate wiki","broken wikilinks","check for orphan pages","after bulk wiki import"] |
Obsidian Wiki Lint Skill
Verify an Obsidian vault for broken wikilinks and orphan pages.
When to Use
After any bulk wiki operation (import, migration, refactor) or when wikilinks appear broken in Obsidian.
Lint Output Format
Critical: wiki-lint.mjs always exits with code 0, even when errors are found. You MUST parse the summary line, not rely on exit code.
The first line of output is the summary:
Wiki lint: N error(s), N warning(s), N tracked page(s)
After the summary, errors are listed under ── Errors (N) ── section with types:
BROKEN LINK: file -> [[target]] — wikilink target doesn't exist
DUPLICATE index entry: [[slug]] — same entry appears multiple times in index.md or index-sources.md
GHOST: index entry [[slug]] has no file on disk — entry in index.md/index-sources.md but file missing
MISSING from index: entity/slug — file exists on disk but no entry in index.md
Parsing pattern for scripts:
OUTPUT=$(node scripts/wiki-lint.mjs /path/to/wiki 2>&1)
ERRORS=$(echo "$OUTPUT" | head -1 | grep -oE '[0-9]+ error' | grep -oE '[0-9]+')
WARNINGS=$(echo "$OUTPUT" | head -1 | grep -oE '[0-9]+ warning' | grep -oE '[0-9]+')
PAGES=$(echo "$OUTPUT" | head -1 | grep -oE '[0-9]+ tracked page' | grep -oE '[0-9]+')
Do NOT use grep "^✖" or grep "^ERROR" — these patterns don't match the actual output format.
How to Lint
Use this Python pattern to scan for broken wikilinks and orphan pages:
import os, re
VAULT = os.path.expanduser("<wiki_root>")
VAULT_ABS = os.path.abspath(VAULT)
SLUG_RE = re.compile(r'\[\[([^|\]]+?)\]\]')
SKIP_SLUGS = {"page-slug", "wikilinks", "basename", "..."}
SUBDIRS = ["", "entities/", "raw/articles/", "concepts/", "comparisons/"]
ACTIVE = set()
for dirpath, _, files in os.walk(VAULT):
if "_archive" in dirpath or "copilot" in dirpath:
continue
for f in files:
if f.endswith(".md"):
ACTIVE.add(os.path.join(dirpath, f))
BAD_LINKS = []
for dirpath, _, files in os.walk(VAULT):
if "_archive" in dirpath or "copilot" in dirpath:
continue
for f in files:
if not f.endswith(".md"):
continue
path = os.path.join(dirpath, f)
with open(path) as fh:
content = fh.read()
for m in SLUG_RE.finditer(content):
slug = m.group(1).split()[].strip()
slug SKIP_SLUGS:
basename = slug.split()[-]
found = (os.path.exists(os.path.join(VAULT, d, basename + ))
d SUBDIRS)
found:
rel = path.replace(VAULT_ABS + , )
BAD_LINKS.append((rel, slug))
raw_n = ([f f os.listdir(os.path.join(VAULT, )) f.endswith()])
ent_n = ([f f os.listdir(os.path.join(VAULT, )) f.endswith()])
con_n = ([f f os.listdir(os.path.join(VAULT, )) f.endswith()])
actual_total = raw_n + ent_n + con_n
()
()
()
ctx, slug BAD_LINKS:
()
Critical scanner bug this fixes: The naive approach checks VAULT/slug + ".md" directly. For [[entities/openclaw-architecture]] this looks for VAULT/entities/openclaw-architecture.md which EXISTS — so the naive scanner would incorrectly flag it as broken. The fix: always try the basename (extracted via slug.split("/")[-1]) in each known subdir, not the full slug as a relative path.
Always skip copilot/ and _archive/ directories — these are not part of the active knowledge base.
Orphan pages: The original scanner's ORPHANS logic is also buggy (it incorrectly flags valid subdir files as orphans). Do NOT trust the orphan count from naive implementations. A page is only a true orphan if it is linked to by NO other active page AND nothing links to it.
Critical Distinction: False Positives vs Real Broken Links
The scanner flags [[wikilinks]] and [[page-slug]] patterns even when they appear as syntax examples in documentation (e.g., SCHEMA.md showing Obsidian link syntax, or documentation describing how wikilinks work). These are NOT real broken links.
Examples that are FALSE POSITIVES (syntax descriptions, not real links):
[[page-slug]] in SCHEMA.md explaining the naming convention
[[wikilinks]] describing Obsidian link syntax
- Any
[[...]] appearing inside code blocks, backticks, or documentation prose
Examples that ARE REAL broken links:
[[entities/notion-ai]] pointing to a file that doesn't exist in entities/
[[concepts/llm]] pointing to a file that doesn't exist in concepts/
[[raw/articles/some-article]] pointing to a file that doesn't exist in raw/articles/
- Bare wikilinks like
[[notion-ai]] when the file lives in a subdirectory (e.g., entities/notion-ai.md) — Obsidian's basename resolution fails for subdirectory vaults
The old scanner bug: it checked VAULT/slug.md for ALL wikilinks, so [[entities/openclaw-architecture]] was falsely reported as broken because it looked for VAULT/openclaw-architecture.md instead of VAULT/entities/openclaw-architecture.md. Always try the basename in all known subdirs.
The "backticks protect wikilinks" myth (validated 2026-06-02): It is commonly assumed that wrapping [[entities/foo]] in backticks (`[[entities/foo]]`) prevents the linter from parsing it as a real wikilink. It does not. The wiki-lint.mjs regex matches [[...]] anywhere in the file body, including inside backticks and (in some implementations) even inside fenced code blocks. Concrete failure: writing `[[entities/...]]` in prose to illustrate the syntax triggered a real BROKEN LINK lint error because no entity named ... exists.
Fixes (in source files, ordered by preference):
-
Shell code in fenced code blocks — use ⟦ (U+27E6 MATHEMATICAL LEFT WHITE SQUARE BRACKET) instead of [[:
if ⟦ condition ⟧; then echo "ok"; fi
The linter's regex [[...]] won't match ⟦...⟧. The ⟦ character is visually similar to [[ in most fonts (double-bracket appearance) but is a single Unicode scalar that bypasses the wikilink pattern. Verified 2026-07-04 on drafts/qmd-implementation-plan.md and queries/wiki-quality-dashboard.md — both had shell code with [[$(uname)]] being parsed as BROKEN LINK wikilinks. Using ⟦$(uname)⟧ eliminated all false positives.
-
Wikilink syntax illustration in prose — use a non-bracket placeholder: [entities/<slug>] or [[ entities/foo ]] (space inside brackets breaks the [[...]] pattern).
Fix in lint script (if you have access): make the regex require that the match NOT be inside a code span (lookbehind for unescaped backtick pairs is fragile — better: strip code blocks/fences before matching). Always run node scripts/wiki-lint.mjs <wiki_root> and check whether the flagged wikilink is a real broken link or a syntax illustration before fixing.
Wikilink Format Convention
In subdirectory vaults (entities/, concepts/, comparisons/, etc.), wikilinks MUST use the subdirectory prefix:
- ✅
[[entities/notion-ai|Notion AI]] — works reliably
- ✅
[[comparisons/ai-tools|Comparison]] — works reliably
- ❌
[[notion-ai]] — Obsidian's basename resolution fails when files are in subdirectories
Always use the [[subdir/basename]] format for all non-root links.
Wikilink Escape Bug (\\|)
Pitfall discovered 2026-06-29: Wikilinks with \\| instead of | cause broken link errors. The backslash before the pipe makes lint interpret the link target as slug\ instead of slug.
Detection:
grep -rn '\\\]\]' wiki_root/ --include="*.md"
Fix: Replace \\| with | in all wikilinks:
import re
content = re.sub(r'\[\[([^]]+?)\\\|([^]]*?)\]\]', r'[[\1|\2]]', content)
Common cause: Copy-pasting from markdown renderers or text editors that escape special characters.
Assets Directory Exclusion
Critical: The assets/ directory is NOT in the lint's active page set. Wikilinks to assets/ files (e.g., [[assets/c4/some-diagram]]) will ALWAYS be reported as broken, even if the files exist.
Active page set (per AGENTS.md): entities/, concepts/, comparisons/, queries/, moc/, drafts/, raw/articles/
Fix for assets/ references: Convert from wikilinks to regular markdown links:
content = re.sub(r'\[\[assets/(c4/[^|\]]+)\|([^\]]+)\]\]',
lambda m: f"[{m.group(2)}](assets/{m.group(1)}.html)", content)
Do NOT try to fix by adding assets/ to the lint's active page set — AGENTS.md explicitly excludes it because assets are supplementary content, not core wiki pages.
Post-Import Verification Checklist
After any wiki write operation (ingest, patch, create):
- Broken wikilinks: Run
node scripts/wiki-lint.mjs <wiki_root> — fix any true broken links
- Lint
.md suffix stripping bug: The lint script's resolveWikilink() function MUST strip .md from link targets before checking the ACTIVE map. If it doesn't, ALL links with .md extension (e.g., [[raw/articles/slug.md|Title]]) will be falsely reported as broken even though the file exists. The fix is in scripts/wiki-lint.mjs line ~65 — ensure resolveWikilink() does let target = linkTarget; if (target.endsWith('.md')) target = target.slice(0, -3); before any ACTIVE.has() check. If lint reports 1000+ "BROKEN LINK" errors on files that clearly exist, this is the bug.
- Sync index.md header to lint's count: After running lint, read the line that says
N tracked page(s). Update index.md header's Total pages: to match that exact number. Do NOT calculate by adding +2 per new file — lint maintains its own internal state and the header must track lint's reported count. This is the single most common source of lint failures.
- Re-run lint after header sync to confirm 0 errors before calling the operation complete.
- New concept/entity files: If lint reports broken links to missing
concepts/*.md files, create stub entries rather than removing the links — missing concepts should be created, not deleted
The authoritative page count is always lint's reported number, not the index.md header. The header is a human-readable display that must be kept in sync. On every ingest, run lint → copy lint's count into header → re-run lint to confirm clean.
index.md Corruption Patterns and Recovery
Pattern 1: |- Prefix Corruption → Section Header Merger
The Hermes patch tool can produce |- list item prefixes. Running sed -i '' 's/^|- /- /' to fix these can DESTROY newlines when applied to already-corrupted lines, causing section headers to merge into the previous line's content:
- Symptom:
value=8×8=64## Entities — a content line ending with 64 directly followed by ## Entities on the same line
- Symptom:
wechat## Queries — same pattern with ## Queries
- Detection:
grep -n '## Comparisons\|## Entities\|## Concepts\|## Queries' index.md — if a section header appears embedded in another line, its line number will be one less than expected (the header is merged into the previous line)
- Fix: Python script to split merged lines:
content = re.sub(r'(value=\d+)-\s+\[\[entities/', r'\1\n- [[entities/', content)
content = re.sub(r'\]\]\s*—\s*([^\n-]*)-\s+\[\[', lambda m: m.group().replace('- [[', '\n- [[', 1), content)
- Prevention: After any
sed -i '' 's/^|- /- /' on index.md, always re-run lint to catch merge artifacts
Pattern 2: wiki-hot-context.mjs Wrong Index Count
The indexDeclares variable was computed as grep -cE '\\[' — counting ALL [ characters in index.md. This is meaningless.
Fix:
const indexDeclares = sh("grep -cE '\\[' index.md 2>/dev/null || echo '0'");
const indexDeclares = sh("grep '^Total pages:' index.md | grep -oE '[0-9]+' | head -1 || echo '0'");
Pattern 3: Duplicate index.md Entries
Multiple scripts/processes appending to index.md without deduplication creates duplicate entries. Detection:
slugs = re.findall(r'\[\[raw/articles/([^|\]]+)', content)
seen = set()
for slug in slugs:
if slug in seen:
print(f"DUPLICATE: {slug}")
seen.add(slug)
Note: Lint also checks index-sources.md for ghost entries. If you see GHOST: index entry [[raw/articles/slug]] but the entry isn't in index.md, check index-sources.md — it may have a stale reference to a deleted raw article. Fix with sed -i '' 'NLINE d' index-sources.md to remove the ghost line.
Pattern 11: Entity Suffix Drift in index.md
Symptom: Lint reports both GHOST entries for suffixed entity slugs (e.g., entities/foo-1, entities/bar-2, entities/baz-2026) AND BROKEN LINK errors from other files referencing the same suffixed slugs. But the entity files exist WITHOUT the suffix (entities/foo.md, not entities/foo-1.md).
Root cause: Entity files were created with clean slugs, but index entries and wikilinks in other files were written using suffixed versions (-1, -2, -2026, -569278, -1778979924, -1778979925). The suffix is a stale artifact from dedup renaming during a bulk import.
Detection: Check if base slug (without suffix) exists:
import os, re
entities_dir = '/Users/jinguo/wiki/entities'
suffix_pattern = re.compile(r'^- \[\[entities/([^\]|]+?-(?:1|2|2026|569278|1778979924|1778979925))')
Diagnosis command:
grep -n '^- \[\[entities/.*-[12]\]' index.md
Common suffixed slugs seen in production:
-1 (most common: dedup collision)
-2 (second collision)
-2026 (year marker that was dropped)
-569278, -1778979924, -1778979925 (numeric hash suffixes)
Fix strategy: See wiki-audit-and-repair skill → references/entity-suffix-drift-repair.md
Pattern 10: Missing Concept Redirects (original)
When lint reports broken links to missing concepts/ files (e.g., concepts/agent-harness), check if a similar concept exists under a different name. Redirect rather than create stubs:
redirects = {
"concepts/agent-harness": "concepts/harness-engineering-framework",
"concepts/agentic-architecture": "concepts/agentic-engineering-paradigm",
"concepts/mcp-model-context-protocol": "concepts/model-context-protocol-mcp",
}
for old, new in redirects.items():
content = content.replace(f"[[{old}]]", f"[[{new}]]")
content = re.sub(r'\[\[' + re.escape(old) + r'\|([^\]]+)\]\]',
lambda m: f"[[{new}|{m.group(1)}]]", content)
When to redirect vs create stub:
- Redirect: concept has a clear existing equivalent (e.g.,
agent-harness → harness-engineering-framework)
- Create stub: concept is heavily referenced (10+ files) and has no equivalent — create a minimal hub page with
## 相关实体 links
Pattern 4: Entity Source Entry Mismatch
Entity pages exist (entities/*.md) but corresponding [[raw/articles/...]] source entries are missing from index.md. Lint reports "MISSING from index Sources". Recovery: scan entity files, check if source entry exists, append missing ones.
Pattern 5: .md Suffix in Index Entries (Lint Key Mismatch)
The lint script builds its ACTIVE dict by stripping .md from filenames. So raw/articles/foo.html.md becomes key raw/articles/foo.html. Index entries MUST NOT include .md suffix — write [[raw/articles/foo.html]] not [[raw/articles/foo.html.md]]. If a new raw file has .html.md or similar double-extension, the index entry path must match the slug (filename minus .md), not the full filename.
Pattern 6: Ghost Entity Files and Broken Link Cascades
When creating new entity pages (e.g., entities/cloudsectidbits.md), old entity files with different names (e.g., entities/cloudsectidbits-masso-cognito-ssohtml.md) become ghost files. They appear in index.md as entries pointing to non-existent files, AND other files may have broken links pointing to them. Fix: (1) delete old ghost entity/raw files, (2) update all broken links in other files to point to the new entity slug, (3) verify with lint. Use grep -rl 'old-slug' to find all files referencing the old slug before replacing.
Pattern 7: Leading Spaces in New Index Entries
When appending entries to index.md, new entries must NOT have leading spaces. The lint regex - \[\[ requires the line to start with - . Entries with - [[...]] (2-space indent) are invisible to lint and reported as GHOST. Always strip leading whitespace from new entries before writing.
Pattern 8: Source + Query Entries on Same Line (Missing Newline Separator)
When appending entries to a section, if the last existing line has no trailing newline, new entries get concatenated onto the same line. Lint regex ^- \[\[ only matches entries at line start, so entries on the same line are invisible to lint and reported as GHOST/MISSING.
Pattern 9: Hidden .md File as Duplicate Raw Article
A file named .md (hidden file) in raw/articles/ can be a duplicate raw article saved with the wrong filename. The lint counts it because .md.endswith('.md') is True.
- Symptom: INDEX DRIFT — header says N but actual is N+1 (or similar off-by-one)
- Detection:
ls -la raw/articles/ | grep '^\.' — look for hidden files
- Fix: Compare the
.md file's URL against existing articles. If duplicate, remove it. If unique, rename to proper slug.
- Prevention: In ingestion scripts, always validate filename is not just
.md or starts with .
Known Lint False Positives
"MISSING sha256" is a false positive when sha256 is trapped outside frontmatter
When entity files have sha256: in the body section (between body and --- separator), lint scans only the frontmatter block and reports MISSING sha256. The sha256 exists but is in the wrong location. Fix by moving it inside the frontmatter block. See wiki-audit-and-repair skill Pattern 4.
"MISSING from index" — 211 raw orphans are design-excluded
Lint reports raw articles with no entity as "MISSING from index". This is expected — 211 of 1346 raw articles have no entity, which is normal backlog. These should NOT all be force-created as stub entities. The MISSING count includes both raw orphans AND entity orphans. Only fix MISSING when the entity has incoming links but no index entry.
EXCESS INFERRED — False Positive for Synthesis Directories
When lint reports 1000+ EXCESS INFERRED warnings in entity files, this is almost always a false positive caused by design mismatch, not a real quality problem.
Root cause: Entity files (in entities/) are written as essence summaries — the body IS the original article content, densely presented. The EXCESS check flags paragraphs that start with ^[raw/...] as "inferred from source." But for entities, the body IS the source (not an annotation of it), so every paragraph looks like an "inference." The detection threshold inferredRatio > 0.3 fires on content-dense entity pages regardless of actual quality.
Synthesis directories are always false positives: concepts/, queries/, and comparisons/ contain synthesized content written FROM entities — not sourced FROM raw articles. They have near-zero ^[raw/...] annotations by design, but their content is fully derived. The EXCESS check does not apply to them at all.
Correct fix — patch the lint script, never the source files:
In scripts/wiki-lint.mjs, add an exemption around the EXCESS check:
Where isSynthesisDir is true when the file's path contains concepts/ or queries/ or comparisons/. After patching, re-run lint — warnings should drop significantly (e.g., 1298 → 1242).
Do NOT try to "fix" EXCESS by adding ^[raw/...] annotations to entity bodies. This ruins the prose flow of essence summaries and is the wrong direction entirely. The lint script is the right place to handle this class of design-expectation mismatch.
Verification: After patching, lint 0 errors + N warnings (all EXCESS) + tracked pages = expected count means the vault is clean.
Case-mismatch wikilinks (macOS)
The lint script resolves links case-insensitively on macOS (APFS/HFS+). A wikilink [[entities/KIMI-attention]] resolving to file entities/kimi-attention.md passes lint but may break in Obsidian or on case-sensitive filesystems. After bulk operations, scan for case mismatches:
entity_files = {f.stem.lower(): f.stem for f in Path("entities").glob("*.md")}
Move old/isolated pages to _archive/ rather than deleting them. This preserves history and prevents orphan alerts from deleted-but-once-linked content.
Cron Job Setup
A cron script is available at scripts/cron-wiki-lint.sh. It runs lint daily and reports only when errors are found (silent when clean).
Usage: Schedule with no_agent: true and script: bash ~/wiki/scripts/cron-wiki-lint.sh
Key behavior:
- Parses
Wiki lint: N error(s)... summary line (not exit code)
- Shows first 20 broken links if errors found
- Stays silent when clean (empty stdout = no notification)
- Recommended schedule:
0 5 * * * (after provenance enforcer, before dashboard refresh)