* fix(lint): stop flagging embeds and normalise .md/escaped-pipe wikilink targets
broken_links harvests every [[wikilink]] target, including embeds
(![[diagram.png]], [[board.canvas]], [[dashboard.base]]) that by_slug can
never hold since _iter_pages globs only *.md. An explicit .md suffix
([[alpha.md]]) and a table-escaped pipe ([[beta\|B]]) were also compared
raw against the slugged index instead of being normalised the way
_normalise_node_id already normalises a page's own id, so both never
resolved either.
_wikilink_page_target() returns None for a non-.md-extension target
(skipping attachment embeds) and otherwise strips an explicit .md suffix
and a trailing backslash before _parse_page slugs it. On the issue's own
repro vault this drops broken_links from 7 flagged (6 false positives) to
the one genuinely broken link.
Fixes#176
* fix(lint): only skip attachment extensions, not every dotted page name
Path("Node.js").suffix is ".js", so the previous `if suffix: return None`
dropped links to any page whose name contains a dot. Those links feed orphan
detection and link_count as well as broken_links, so "Node.js", "Next.js",
"v1.2 release notes" and friends were reported as orphans.
Skip only Obsidian's attachment extensions instead.
---------
Co-authored-by: ar9av <ar9avg@gmail.com>
`graph_analysis` identifies a page by its slugged filename stem, so two pages
whose stems slug alike are one node — and one entry in the graphrag index.
On a vault holding concepts/vector-search.md and entities/vector-search.md,
build_index keeps only the entities page; the concepts page is absent from
the index that memory_search and graph-query read.
duplicate_titles does not cover this: it keys on the title: value, and stems
can collide while titles differ.
Add duplicate_stems, built over graph_analysis.iter_pages with
graph_analysis._page_slug so the report keys as the merge does. It reads
pages rather than links, so a collision nothing references is still reported.
Severity warn, beside duplicate_titles.
Borrowing the graph's page selection means a collision inside _bootstrap/ is
now visible to this check, where lint's own SKIP_DIRS excludes that
directory. The existing bootstrap exclusion test still passes; the tradeoff
is called out in the pull request rather than settled here.
* fix(cache): lock and atomically write .manifest.json
update_source was a plain read-modify-write with no mutual exclusion, so
any two overlapping writers lost an entry silently: the second reader's
snapshot predated the first writer's write. This fires in three real
configurations — batch-plan fan-out dispatching parallel ingest subagents,
the Dockerized server writing a vault a local skill is also using, and a
scheduled daily-update overlapping an interactive session.
Reproduced with 12 concurrent update_source processes against one vault:
6 of 12 entries recorded before, 12 of 12 after.
Writes now take an advisory lockfile and replace the manifest atomically
via a temp file plus os.replace. O_CREAT|O_EXCL rather than fcntl because
this ships on Windows. Hashing stays outside the lock — it can take
seconds on a large source tree and nothing in the manifest depends on it.
The contention test spawns real subprocesses; a single-process test
cannot observe this race.
* feat(trust): flag illegal lifecycle transitions in lint
The lifecycle field is a state machine, but nothing enforced its edges.
Ledger entries now record the lifecycle at review time, giving lint a
baseline to compare the current frontmatter against.
Only edges that are illegal for every possible sequence of unobserved
intermediate states are flagged: falling back to draft (only ingest sets
draft) and exiting archived (terminal). draft -> verified is deliberately
not flagged — ledger snapshots are sparse, so a legitimate intermediate
reviewed may simply never have been recorded, and flagging that pair
would fire on valid history.
Additive: entries predating the lifecycle field carry no baseline and are
skipped, so existing vaults behave identically until their next
trust-record. Owner-extended lifecycle states never match the illegal
pairs, so the check degrades to a no-op rather than firing on states it
does not understand. Warns by default, fails under --strict-trust,
matching how the surrounding trust checks were already staged.
* feat(wiki-status): add vault equilibrium mode
The maintenance skills each optimize one shared vault for a different
objective, so any of them can undo another's work and no single skill
reporting clean means maintenance is done.
Equilibrium mode runs every maintenance audit in report-only form and
reports whether any skill still has a pending change. The vault is
converged only when all of them propose nothing.
It also detects oscillation via a snapshot in _insights.md, alongside the
existing GRAPH_SNAPSHOT: a skill whose pending count keeps returning
after being driven to zero is being reversed by another skill, and the
vault can never settle. Reported, never auto-fixed — resolving it is a
decision about whose objective wins.
Pending moves are attributed back to their manifest source, so a source
that repeatedly produces pages needing repair surfaces as a suggestion to
review its source_quality. Report-only; the owner still decides through
the existing trust flow.
Skill-only, no Python: lint already emits JSON counts and the other three
audits are LLM passes.
* docs: document manifest write safety, lifecycle checks, and equilibrium
Covers the advisory lock and atomic manifest write (including the
parallel-run contract), the illegal_lifecycle_transitions check and why
draft -> verified is excluded, and equilibrium mode in the architecture
feature list.
* fix(tests): isolate CLI subprocess tests, and run the suite in CI
12 tests failed on a clean checkout. Neither was a product bug — both were
test-harness isolation gaps, and CI never caught them because the suite was
never run there.
1. Wrong package under test. The 18 test files that spawn
`python -m obsidian_wiki.cli` each build their own env from
os.environ.copy() and never set PYTHONPATH, so sys.path[0] was the
subprocess cwd. Any test running from a temp dir imported whatever
obsidian_wiki happened to be pip-installed instead of the working tree —
here a stale build predating `code-understand`, so all 8
test_code_understand_cli tests died at argparse with "invalid choice",
plus 4 in test_context_pack_cli. One autouse session fixture in
conftest.py now pins PYTHONPATH for every helper at once.
2. Developer .env leaking in. test_lint's `_run` and test_context_pack's
`run_cli` inherited pytest's cwd (the repo), so config resolution walked
up and found a developer's own untracked .env, pointing the run at their
real vault instead of the fixture. Both now run from the fake HOME.
Verified the fix isn't masking anything: sabotaging the config resolver makes
the relevant tests fail, and the suite passes in a venv with no obsidian_wiki
installed at all — so PYTHONPATH is genuinely doing the work.
Adds .github/workflows/tests.yml to run pytest on PRs and main. The workflows
only ran setup.sh, wheel publish, and the README-sync checker, so nothing
executed tests/ — the real reason this drifted.
Also drops the stale "RED by design / command does not exist yet" docstring
in test_code_understand_cli.py; the wiring landed in fd30720.
566 passed, 1 skipped (was 554 passed / 12 failed).
* fix(tests): make three test modules importable on Python 3.9
test_batch, test_graph_analysis and test_graphrag annotate helpers with
`int | None` / `list[str] | None` but lack `from __future__ import
annotations`, so 3.9 evaluates the union at import time and collection dies
with "unsupported operand type(s) for |". Every module under obsidian_wiki/
already has the future import; only these three tests were missing it.
Surfaced by the new pytest CI job — the 3.9 leg (the requires-python floor)
failed at collection while 3.12 passed.
Legacy vaults fail-closed on every pre-existing page after the trust-ledger
rollout: missing base_confidence/lifecycle and a missing ledger became
mandatory failures with no opt-out, skipping the staged migration from #28.
Split confidence/lifecycle out of the core required-frontmatter check into a
separate confidence_missing_fields finding, and add a strict_trust switch to
lint_vault (default off): missing trust fields, ledger errors, stale reviews,
and score mismatches now warn by default and only fail once a vault owner
opts in via --strict-trust or OBSIDIAN_TRUST_STRICT=1. Ledger presence alone
no longer silently enables strict mode.
Reject symlinked writes and malformed or ambiguous trust data, enforce complete page coverage and strict timestamps/lifecycle values, and parse typed relationships independent of key order.