feat(wiki): port upstream knowledge quality improvements
setup / install-leaves-tree-clean (push) Waiting to run

Selectively port source coverage and redirect lint, ignore-aware provenance, staging and trust checks from upstream 055d720. Preserve local archive metadata and adapt source-date and defuddle skill guidance.

Keep the existing skills directory, file index and MCP interfaces. Record per-module upstream baselines and exclusions without migrating vault data or changing services.

Validation: 625 tests passed; git diff --check passed.
This commit is contained in:
Codex
2026-11-07 21:49:41 +00:00
parent 82890606aa
commit e943bb329e
17 changed files with 437 additions and 29 deletions
+1 -1
View File
@@ -59,7 +59,7 @@ Before fetching, check whether the `defuddle` CLI is available:
which defuddle
```
- **If available:** Use `defuddle <url>` (via Bash) to retrieve a clean, stripped-down markdown version of the page. This removes ads, navbars, cookie banners, and related-content sidebars — reducing token usage by ~40-60% on typical articles. Use the `defuddle` output as your content source for Step 4 instead of the raw WebFetch result.
- **If available:** Use `defuddle parse <url> --markdown` (via Bash) to retrieve a clean, stripped-down markdown version of the page. This removes ads, navbars, cookie banners, and related-content sidebars — reducing token usage by ~40-60% on typical articles. Use the `defuddle` output as your content source for Step 4 instead of the raw WebFetch result.
- **If not available:** Fall back to `WebFetch` as normal. No action needed.
## Step 1: Fetch the URL
+8
View File
@@ -408,6 +408,14 @@ For each page in your plan:
- Add the new source to the `sources` list
- Resolve any contradictions between old and new information (note them if unresolvable)
**Source dates (applies to live and staged pages).** Keep `created`/`updated` as ingestion timestamps from `obsidian-wiki memory now`. For a new page, set optional `valid_from: YYYY-MM-DD` from the source’s own date, taking the first available value in this order:
1. Source frontmatter `created:` or `date:` (including a raw note’s capture date).
2. A leading ISO date in the original filename, such as `2026-03-14-topic.md`. For archived files, consult the original filename in registry/manifest metadata; the archive directory date and hash prefix are not event dates.
3. A dated origin such as `conversation:2026-03-14` or `<project> session (2026-03-14)`.
Use only a valid date no later than today (`obsidian-wiki memory now --date`). If absent, invalid or future-dated, omit `valid_from`; never guess from file mtime or the archive month. For an existing page, only lower an existing `valid_from` when the new source has an earlier valid date; never raise it or add it to a page that lacks it. Do not set `valid_until` during ingest. These fields preserve temporal provenance; this fork does not include upstream’s GraphRAG `query --as-of` command. Keep the local archive citations and curated registry fields unchanged.
**Populate `relationships:` when context is clear** — if Step 2 identified typed relationships between this page and another, add a `relationships:` block to the frontmatter (defined in `llm-wiki/SKILL.md`, Typed Relationships section). Only add entries where the source text makes the direction and type unambiguous. When in doubt, use `related_to` or omit the block. Example:
```yaml
+6
View File
@@ -26,6 +26,12 @@ You are performing a health check on an Obsidian wiki. Your goal is to find and
First run `obsidian-wiki lint "$OBSIDIAN_VAULT_PATH" --json` using this repository's CLI. Use its findings for structural checks below, then inspect only implicated pages and perform semantic contradiction review. It resolves project-relative and duplicate-name links, ignores code examples, and excludes source archives from knowledge pages. Trust-ledger gaps are warnings in legacy mode; do not invent reviews or change scores to silence them. Follow `llm-wiki/references/CORE-MAINTENANCE.md` for any authorized repairs and tracking-file updates. If the CLI is unavailable, use the manual checks below with the same source-directory exclusions.
Interpret the additional CLI findings before manual repairs:
- `uncovered_sources`: a manifest source has an explicit empty page list, or all listed pages are missing. Check renames, dedup merges and unfinished ingest; repair the actual association or re-ingest instead of inventing pages or dropping source records. Legacy count-only entries are not evidence of missing pages. This check reports complete loss of coverage, not every missing page in a partially surviving list.
- `redirect_issues`: a `redirects_to` stub has a missing target, points to itself, or points to another stub. Resolve it to a real canonical page. Redirect stubs are excluded from duplicate-title/stem findings; actual duplicate content pages still appear.
- `.okignore` also applies to archive snapshot checks and staged/live listing. An ignored source is not automatically a deleted source; preserve the local source registry and archive.
Run these checks in order. Report findings as you go.
### 1. Orphaned Pages
+1 -1
View File
@@ -38,7 +38,7 @@ If the file doesn't exist, proceed with defaults.
1. Decompose the topic into **3-5 distinct angles** (e.g., for "vector databases": what they are, when to use them, leading implementations, trade-offs, production gotchas)
2. For each angle, run **2-3 `WebSearch` queries** using varied phrasing
3. For the top 2-3 results per angle, use `WebFetch` (or `defuddle <url>` if available — cleaner extraction) to get content
3. For the top 2-3 results per angle, use `WebFetch` (or `defuddle parse <url> --markdown` if available — cleaner extraction) to get content
4. From each fetched page, extract:
- **Key claims** — what the source explicitly states
- **Concepts** — ideas, terms, frameworks introduced
+1 -1
View File
@@ -19,7 +19,7 @@ We took that and built a framework around it. Markdown skill files guide AI codi
## Quick Start
This fork selectively includes the core maintenance updates from upstream **v2026.10.4**: hash-based source tracking, locked memory writes, path-aware lint/graph analysis, and revision-checked staging. See [the sync scope and migration notes](docs/upstream-sync.md). Existing source-file lookup and MCP deployment conventions remain local; upgrading the code does not migrate vault contents or enable services.
This fork selectively includes the core maintenance updates from upstream **v2026.10.4**: hash-based source tracking, locked memory writes, path-aware lint/graph analysis, and revision-checked staging. A further quality-only update adds source-coverage and redirect checks, consistent ignore rules, and source-date guidance. See [the sync scope and migration notes](docs/upstream-sync.md). Existing source-file lookup and MCP deployment conventions remain local; upgrading the code does not migrate vault contents or enable services.
### Install via pip (recommended)
+28 -1
View File
@@ -109,5 +109,32 @@
"Resolve _sources/archive and _sources/catalog citations without changing file extensions",
"Preserve pages_produced when cache-update omits --pages",
"Keep local CLI, MCP dependency range, service gating and curated source-registry metadata"
]
],
"quality_sync": {
"date": "2026-11-07",
"commit": "055d720950f7137954e36633b1a79c641ea5e735",
"base_commit": "4f457e154653756b04802d5aeb25c20f42b4770c",
"scope": "Wiki quality fixes only; no skill-directory migration or server changes",
"source_sha256": {
"obsidian_wiki/vault.py": "12e76c819bbed6514c6370df47c001b97cd63f6b13f90875d23c819ec8b185ab",
"obsidian_wiki/lint.py": "e6c6592a000487f4f8a67fa45d72a3ff528af90b70c1d61431bcc7c43b7db5df",
"obsidian_wiki/provenance.py": "c1cb69296aed2b7a5669191340c6d2e66819f595b1bc202eaeb88ca6c859725a",
"obsidian_wiki/trust.py": "3acc4899a2dfe28dfbebf518031fb68cd1e3f873c3244f17967a3713a08ea285",
"obsidian_wiki/staging.py": "ab1b1d7dbe8a024d2bdf9e17541b444e7bc6d5e06806334418d22ae4f587a8ee"
},
"skill_changes": [
"source event dates with local archive metadata adaptation",
"defuddle parse <url> --markdown",
"lint source coverage and redirect guidance"
],
"test_exclusions_outside_scope": [
"test_recording_one_page_prunes_ignored_entries",
"test_recording_all_reports_the_ignored_entries_it_dropped"
],
"notes": [
"Excluded tests require upstream trust-record/trust-check CLI commands not ported locally; partial ledger pruning covered through the local Python API.",
"Original commit/source_sha256 remain the initial baseline; quality_sync.source_sha256 supersedes that baseline for its five listed modules.",
"Preserve local archive/catalog citations, registry identifiers, MCP implementation and .skills layout."
]
}
}
+19 -1
View File
@@ -1,5 +1,23 @@
# 上游关键更新同步
## 2026-11-07:Wiki 质量增量同步
基于上次 v2026.10.4 移植,选择性同步到上游提交 [`055d720`](https://github.com/Ar9av/obsidian-wiki/commit/055d720950f7137954e36633b1a79c641ea5e735) 中的质量相关改动,不表示追平整个主分支。
- 来源覆盖检查:发现 manifest 中明确没有产出页面、或产出页面已全部丢失的来源;兼容列表/字典清单及旧版仅记录数量的条目。
- 重定向检查:合并后留下的跳转页不再误报重复标题/文件名;报告目标丢失、自引用和重定向链。
- 忽略规则:归档快照核对、URL 来源索引、暂存列表及信任记录检查遵守 `.okignore`;保留本地 `_sources/archive` / `_sources/catalog` 引用适配。
- 入库时间信息:新增页面可从来源自身日期填写 `valid_from`;本地归档资料使用原文件名/元数据,避免误用归档月份、mtime 或哈希前缀。已有页面只允许提前已有的有效日期,不批量回填历史知识。
- 网页正文提取:修正本地 ingest-url 与 wiki-research 中的 `defuddle parse <url> --markdown` 调用。
按本次范围保留 `.skills/`、安装配置、文件索引、MCP 和现有服务。上游自身仍有 HTTP/MCP 服务,本地 MCP 在订单功能剥离后仍支持文件检索、Wiki 入库与问答,因此“上游没有 MCP 时删除”的条件不成立。本次不引入上游服务、GraphRAG、会话 hooks 或新增技能,也不执行实际知识库迁移。
新增回归覆盖来源丢失、重定向、忽略规则、暂存目录软链接、本地归档字段保留和 CLI 严格模式。完整回归结果:**625 项测试通过**(`PYTHONPATH="$PWD" .venv/bin/python -m pytest tests -q`);`git diff --check` 通过。测试使用临时知识库,本次没有写入实际 vault,也未启动、停止或重新配置服务。
后续比较采用 `upstream-sync.json` 的 `quality_sync` 五个模块哈希;未变化模块沿用最初基线。技能为按语义移植,不可整体覆盖。
## 2026-10-31:初始核心维护同步
本次选择性同步 [Ar9av/obsidian-wiki v2026.10.4](https://github.com/Ar9av/obsidian-wiki/releases/tag/v2026.10.4),固定提交 `4f457e154653756b04802d5aeb25c20f42b4770c`。这是核心维护能力的移植,不表示本分支完整等同于该版本。来源哈希、移植的 CLI 函数和未纳入的测试范围记录在 `upstream-sync.json`。
| 纳入能力 | 实际作用 |
@@ -46,4 +64,4 @@ PYTHONPATH="$PWD" .venv/bin/python -m pytest tests -q
本次验证结果:**609 项测试通过**。实际知识库只执行了 `memory status` 只读检查,index/hot/log、manifest、registry、SQLite 的前后 SHA-256 一致。Wiki MCP 保持 inactive,独立订单 MCP 保持 active。
下次以 `upstream-sync.json` 的提交和源文件哈希作比较基线,逐模块检查新提交,再重放列出的本地适配。不要直接覆盖 `.skills`、运行上游 setup、切换服务依赖或批量改写知识库。上游未纳入模块的测试明确列入排除清单,不能把它们的缺席解释为整版上游已通过验证。
初始同步以 `upstream-sync.json` 的提交和源文件哈希作比较基线,逐模块检查新提交,再重放列出的本地适配。不要直接覆盖 `.skills`、运行上游 setup、切换服务依赖或批量改写知识库。上游未纳入模块的测试明确列入排除清单,不能把它们的缺席解释为整版上游已通过验证。
+94 -12
View File
@@ -20,7 +20,7 @@ from obsidian_wiki.provenance import (
parse_snapshots_field,
)
from obsidian_wiki.vault import SKIP_DIRS as VAULT_SKIP_DIRS
from obsidian_wiki.vault import iter_instruction_files, iter_md, split_frontmatter
from obsidian_wiki.vault import iter_instruction_files, iter_md, okignore_patterns, okignored, split_frontmatter
from obsidian_wiki.temporal import (
SUPERSEDED_FIELD,
superseded_target,
@@ -350,6 +350,7 @@ def _parse_page(path: Path, vault: Path) -> dict[str, Any]:
"slug": _slug(path.stem),
"title": values.get("title", "").strip() or path.stem,
"summary": values.get("summary", "").strip(),
"redirects_to": values.get("redirects_to", "").strip(),
"fields": fields,
"links": links,
"relationships": _parse_relationships(frontmatter),
@@ -361,6 +362,11 @@ def _parse_page(path: Path, vault: Path) -> dict[str, Any]:
}
# Manifest fields that list the pages a source produced (provenance.py reads
# the first two; pages_updated is the same list for an update run).
_COVERAGE_PAGE_KEYS = ("pages_produced", "pages_created", "pages_updated")
def lint_vault(
vault: Path,
*,
@@ -432,11 +438,18 @@ def lint_vault(
# `Vector Search.md` beside `vector-search.md` still share one, since
# nothing distinguishes them. Key this check with the graph's own page
# selection so it reports exactly the pages the graph sees.
# Redirect stubs (wiki-dedup): a merged page keeps the secondary page's
# title and filename so old links stay usable, but it is not a content
# page. Stub paths are excluded from the duplicate checks below, and the
# pointers themselves are validated further down instead.
stub_paths = {page["path"] for page in pages if page["redirects_to"]}
stem_index: dict[str, list[str]] = defaultdict(list)
for graph_page in iter_graph_pages(vault):
stem_index[graph_page_slug(graph_page, vault)].append(
graph_page.relative_to(vault).as_posix()
)
graph_rel = graph_page.relative_to(vault).as_posix()
if graph_rel in stub_paths:
continue
stem_index[graph_page_slug(graph_page, vault)].append(graph_rel)
duplicate_stems = [
{"stem": stem, "pages": sorted(paths)}
for stem, paths in sorted(stem_index.items())
@@ -445,6 +458,8 @@ def lint_vault(
title_index: dict[str, list[str]] = defaultdict(list)
for page in pages:
if page["path"] in stub_paths:
continue
title_index[page["title"].strip().lower()].append(page["path"])
duplicate_titles = [
{"title": title, "pages": paths}
@@ -491,20 +506,27 @@ def lint_vault(
(key or "").startswith("url:") for key, _ in _iter_entries(manifest_sources)
)
url_index = clip_url_index(vault) if need_urls else None
okignore = okignore_patterns(vault)
snapshot_mismatch = []
for page in pages:
if page["slug"] in RESERVED_PAGE_STEMS:
continue
expected = expected_snapshots_for_page(
vault,
page["path"],
manifest_sources,
inverted=inverted,
url_index=url_index,
)
# An ignored archive neither appears in the report nor counts as a mismatch,
# whether the manifest or the page is the side still naming it.
expected = [
rel
for rel in expected_snapshots_for_page(
vault,
page["path"],
manifest_sources,
inverted=inverted,
url_index=url_index,
)
if not okignored(Path(rel), okignore)
]
if not expected:
continue
actual = page["snapshots"]
actual = [rel for rel in page["snapshots"] if not okignored(Path(rel), okignore)]
if set(expected) != set(actual):
snapshot_mismatch.append(
{
@@ -515,6 +537,39 @@ def lint_vault(
)
snapshot_mismatch.sort(key=lambda item: item["page"])
# Source coverage: every ingested source should still map to a page that
# exists. A source whose page list is empty or points only at missing
# files was dropped somewhere between ingest and now (a failed write, a
# rename, a dedup), and nothing else would surface it. Entries with no
# page list at all (older count-only shapes) are skipped, not guessed at.
uncovered_sources: list[dict[str, Any]] = []
for key, entry in _iter_entries(manifest_sources):
listed = [
str(raw).strip().strip("[]")
for field in _COVERAGE_PAGE_KEYS
if isinstance(entry.get(field), list)
for raw in entry[field]
if raw
]
if not any(isinstance(entry.get(field), list) for field in _COVERAGE_PAGE_KEYS):
continue
missing = sorted(
{
page
for page in listed
if not (vault / page).is_file() and not (vault / f"{page}.md").is_file()
}
)
if not listed or len(missing) == len(set(listed)):
uncovered_sources.append(
{
"source": key or "",
"issue": "no_pages" if not listed else "pages_missing",
"missing": missing,
}
)
uncovered_sources.sort(key=lambda item: item["source"])
typed_relationship_issues: list[dict[str, Any]] = []
for page in pages:
for index, relationship in enumerate(page["relationships"]):
@@ -601,6 +656,29 @@ def lint_vault(
{"page": page["path"], "target": successor, "issue": "missing_target"}
)
# Redirect pointers get the same treatment as `superseded_by`: a stub
# whose target is gone strands every old link that relied on it, and a
# stub pointing at another stub (or itself) never reaches content.
redirect_issues: list[dict[str, str]] = []
pages_by_id = {page["id"]: page for page in pages}
for page in pages:
if page["path"] not in stub_paths:
continue
target = _relationship_link(page["redirects_to"])
resolved_redirect = page_index.resolve(target, page["path"]) if target else None
if resolved_redirect is None:
redirect_issues.append(
{"page": page["path"], "target": target, "issue": "missing_target"}
)
elif resolved_redirect == page["id"]:
redirect_issues.append(
{"page": page["path"], "target": target, "issue": "self_reference"}
)
elif pages_by_id[resolved_redirect]["path"] in stub_paths:
redirect_issues.append(
{"page": page["path"], "target": target, "issue": "redirect_chain"}
)
ledger_path = vault / TRUST_LEDGER_RELATIVE_PATH
trust_report = (
check_trust_ledger(
@@ -632,12 +710,14 @@ def lint_vault(
"missing_summaries": sorted(missing_summaries),
"machine_path_sources": machine_path_sources,
"snapshot_mismatch": snapshot_mismatch,
"uncovered_sources": uncovered_sources,
"orphan_pages": sorted(orphan_pages),
"undated_claims": undated_claims,
"typed_relationship_issues": typed_relationship_issues,
# Sorted by page: these findings get diffed between CI runs.
"temporal_errors": sorted(temporal_errors, key=lambda item: item["page"]),
"superseded_dangling": sorted(superseded_dangling, key=lambda item: item["page"]),
"redirect_issues": sorted(redirect_issues, key=lambda item: item["page"]),
"confidence_missing_fields": confidence_missing_fields,
"trust_metadata_errors": trust_metadata_errors,
"confidence_review_stale": trust_report["stale"] if trust_report else [],
@@ -690,10 +770,12 @@ def lint_vault(
"missing_summaries",
"machine_path_sources",
"snapshot_mismatch",
"uncovered_sources",
"orphan_pages",
"undated_claims",
"typed_relationship_issues",
"superseded_dangling",
"redirect_issues",
)
)
or trust_findings_present
+2 -2
View File
@@ -8,7 +8,7 @@ from pathlib import Path
from typing import Any
from obsidian_wiki.cache import _is_file_key, _iter_entries
from obsidian_wiki.vault import split_frontmatter
from obsidian_wiki.vault import iter_md_under, split_frontmatter
_PAGE_LIST_KEYS = ("pages_produced", "pages_created")
_URL_FIELDS = ("url", "source", "source_url")
@@ -62,7 +62,7 @@ def clip_url_index(vault: Path) -> dict[str, list[str]]:
root = _archived_dir(vault)
if not root.is_dir():
return {}
for path in sorted(root.rglob("*.md")):
for path in iter_md_under(vault, root):
text = path.read_text(encoding="utf-8", errors="replace")
frontmatter = split_frontmatter(text)[0]
rel = path.relative_to(vault).as_posix()
+14 -7
View File
@@ -21,6 +21,8 @@ from datetime import date
from pathlib import Path
from typing import Any
from obsidian_wiki.vault import iter_md_under, okignore_patterns, okignored
STAGING_DIR = "_staging"
RAW_DIR = "_raw"
PATCH_SUFFIX = ".patch.md"
@@ -92,7 +94,7 @@ def _live_relative(staged_rel: Path) -> str:
if name.endswith(PATCH_SUFFIX):
# `concepts/foo.patch.md` describes an edit to `concepts/foo.md`.
name = name[: -len(PATCH_SUFFIX)] + ".md"
return str(staged_rel.with_name(name))
return staged_rel.with_name(name).as_posix()
def list_staged(vault: Path) -> list[StagedEntry]:
@@ -102,10 +104,15 @@ def list_staged(vault: Path) -> list[StagedEntry]:
if not root.is_dir():
return []
patterns = okignore_patterns(vault)
entries: list[StagedEntry] = []
for staged in sorted(root.rglob("*.md")):
for staged in iter_md_under(vault, root):
staged_rel = staged.relative_to(root)
live_rel = _live_relative(staged_rel)
# The server diffs against the live page, which would print an ignored
# page's text for anything staged over it.
if okignored(Path(live_rel), patterns):
continue
live = vault / live_rel
if staged.name.endswith(PATCH_SUFFIX):
kind = "patch"
@@ -114,7 +121,7 @@ def list_staged(vault: Path) -> list[StagedEntry]:
stat = staged.stat()
entries.append(
StagedEntry(
staged_path=str(staged.relative_to(vault)),
staged_path=staged.relative_to(vault).as_posix(),
live_path=live_rel,
kind=kind,
staged_revision=revision(staged),
@@ -189,7 +196,7 @@ def promote(
staged = _staged_file(vault, rel)
if staged.name.endswith(PATCH_SUFFIX):
raise StagingError(
f"{staged.relative_to(vault)} is a patch, not a whole page — "
f"{staged.relative_to(vault).as_posix()} is a patch, not a whole page — "
"merge it with the wiki-stage-commit skill, then promote the result"
)
@@ -209,7 +216,7 @@ def promote(
return {
"action": "promote",
"staged_path": str(staged.relative_to(vault)),
"staged_path": staged.relative_to(vault).as_posix(),
"live_path": live_rel,
"kind": "update" if replaced else "new",
"revision": revision(live),
@@ -243,8 +250,8 @@ def discard(vault: Path, rel: str) -> dict[str, Any]:
return {
"action": "discard",
"staged_path": str(staged.relative_to(vault)),
"raw_path": str(raw.relative_to(vault)),
"staged_path": staged.relative_to(vault).as_posix(),
"raw_path": raw.relative_to(vault).as_posix(),
}
+16 -3
View File
@@ -22,7 +22,7 @@ from pathlib import Path, PurePosixPath
from typing import Any
from obsidian_wiki.vault import FRONTMATTER_RE as _FRONTMATTER_RE
from obsidian_wiki.vault import SKIP_DIRS, iter_md
from obsidian_wiki.vault import SKIP_DIRS, iter_md, okignore_patterns, okignored
TRUST_LEDGER_RELATIVE_PATH = Path("_meta/trust-ledger.json")
TRUST_LEDGER_SCHEMA_VERSION = 1
@@ -432,6 +432,10 @@ def update_trust_ledger(
removed_not_applicable = sorted(set(ledger["pages"]) & set(not_applicable))
for rel in removed_not_applicable:
del ledger["pages"][rel]
patterns = okignore_patterns(vault)
removed_ignored = [rel for rel in ledger["pages"] if okignored(Path(rel), patterns)]
for rel in removed_ignored:
del ledger["pages"][rel]
selected: list[str] = []
for raw in page_paths:
@@ -455,6 +459,7 @@ def update_trust_ledger(
ledger["reviewed_at"] = reviewed_at
ledger["not_applicable"] = sorted(not_applicable)
ledger["removed_not_applicable"] = removed_not_applicable
ledger["removed_ignored"] = len(removed_ignored)
return ledger
@@ -581,6 +586,7 @@ def _empty_report(
"score_mismatches": [],
"missing_pages": [],
"errors": [],
"ignored_entries": 0,
"counts": {},
}
@@ -711,8 +717,14 @@ def check_trust_ledger(
}
)
patterns = okignore_patterns(vault)
for rel in sorted(set(entries) - set(current)):
report["missing_pages"].append({"page": rel, "reason": "reviewed_page_missing"})
# Counted, never named: the page must stay out of the output, but its
# leaving the review gate must not go unnoticed.
if okignored(Path(rel), patterns):
report["ignored_entries"] += 1
else:
report["missing_pages"].append({"page": rel, "reason": "reviewed_page_missing"})
return _finalise_report(report)
@@ -727,9 +739,10 @@ def _finalise_report(report: dict[str, Any]) -> dict[str, Any]:
"errors",
)
report["counts"] = {key: len(report[key]) for key in keys}
report["counts"]["ignored_entries"] = report["ignored_entries"]
if report["errors"] or report["score_mismatches"]:
report["status"] = "fail"
elif report["stale"] or report["unreviewed"] or report["missing_pages"]:
elif report["stale"] or report["unreviewed"] or report["missing_pages"] or report["ignored_entries"]:
report["status"] = "warn"
else:
report["status"] = "pass"
+11
View File
@@ -115,6 +115,17 @@ def iter_instruction_files(vault: Path, skip_dirs: Iterable[str] = SKIP_DIRS) ->
)
def iter_md_under(vault: Path, root: Path) -> list[Path]:
"""Every `.md` under `root` (a directory in `vault`), sorted, minus `.okignore`.
For the tool-owned subtrees `iter_md` skips, such as `_staging/` and `_raw/`.
`rglob` rather than `_walk_md`: a symlinked folder here isn't part of the vault
the way a mounted project folder is, and it could lead out of the vault.
"""
patterns = okignore_patterns(vault)
return sorted(path for path in root.rglob("*.md") if not okignored(path.relative_to(vault), patterns))
def iter_md(vault: Path, skip_dirs: Iterable[str] = SKIP_DIRS) -> list[Path]:
"""Every `.md` page under `vault`, sorted, minus hidden/skipped dirs,
agent-instruction files, and `.okignore`."""
+124
View File
@@ -1065,3 +1065,127 @@ def test_lint_vault_warns_on_undated_counts_and_skips_dated_ones(tmp_path: Path)
lines = page.read_text(encoding="utf-8").splitlines()
assert all(lines[claim["line"] - 1].strip() == claim["text"] for claim in claims)
assert report["status"] == "warn"
def _write_ignored_archive(vault: Path) -> None:
"""`_write_archive_and_manifest`, plus a second archive `.okignore` covers."""
_write_archive_and_manifest(vault)
hidden = vault / "_raw" / "_archived" / "private" / "hidden.md"
hidden.parent.mkdir(parents=True, exist_ok=True)
hidden.write_text("# clip\n", encoding="utf-8")
manifest = json.loads((vault / ".manifest.json").read_text(encoding="utf-8"))
manifest["sources"]["_raw/_archived/private/hidden.md"] = {"pages_produced": ["concepts/alpha.md"]}
(vault / ".manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
(vault / ".okignore").write_text("_raw/_archived/private\n", encoding="utf-8")
def test_snapshot_mismatch_does_not_name_an_ignored_archive(tmp_path: Path) -> None:
vault = tmp_path / "vault"
_clean_pair(vault)
_write_ignored_archive(vault)
rows = lint_vault(vault)["findings"]["snapshot_mismatch"]
assert [row["expected"] for row in rows] == [["_raw/_archived/a.md"]]
def test_a_page_citing_an_ignored_archive_is_not_a_mismatch(tmp_path: Path) -> None:
vault = tmp_path / "vault"
_clean_pair(vault, alpha_snapshots="[_raw/_archived/a.md, _raw/_archived/private/hidden.md]")
_write_ignored_archive(vault)
assert lint_vault(vault)["findings"]["snapshot_mismatch"] == []
def test_uncovered_sources_reports_sources_with_no_surviving_page(tmp_path: Path) -> None:
vault = tmp_path / "vault"
_clean_pair(vault)
(vault / ".manifest.json").write_text(
json.dumps(
{
"sources": [
{"path": "docs/kept.md", "pages_produced": ["concepts/alpha.md"]},
{"path": "docs/partly.md", "pages_produced": ["concepts/beta", "concepts/gone.md"]},
{"path": "docs/empty.md", "pages_produced": []},
{"path": "docs/lost.md", "pages_created": ["concepts/gone.md"]},
{"path": "docs/counts-only.md", "pages_created": 3},
]
}
),
encoding="utf-8",
)
report = lint_vault(vault)
assert report["findings"]["uncovered_sources"] == [
{"source": "docs/empty.md", "issue": "no_pages", "missing": []},
{"source": "docs/lost.md", "issue": "pages_missing", "missing": ["concepts/gone.md"]},
]
assert report["status"] == "warn"
def _redirect_stub(vault: Path, relpath: str, target: str, *, title: str | None = None) -> Path:
"""A wiki-dedup merge leftover: same title/stem as the canonical page,
with a `redirects_to:` pointer in its frontmatter."""
path = _page(vault, relpath, title=title)
text = path.read_text(encoding="utf-8")
path.write_text(
text.replace("\n---\n", f'\nredirects_to: "[[{target}]]"\n---\n', 1),
encoding="utf-8",
)
return path
def test_lint_redirect_stub_is_not_a_duplicate(tmp_path: Path) -> None:
vault = tmp_path / "vault"
_page(vault, "concepts/example.md", title="Example")
_redirect_stub(vault, "entities/example.md", "concepts/example", title="Example")
report = lint_vault(vault, require_trust_ledger=False)
assert report["findings"]["duplicate_titles"] == []
assert report["findings"]["duplicate_stems"] == []
assert report["findings"]["redirect_issues"] == []
def test_lint_redirect_stub_with_missing_target_is_reported(tmp_path: Path) -> None:
vault = tmp_path / "vault"
_page(vault, "concepts/example.md", title="Example")
_redirect_stub(vault, "entities/example.md", "concepts/gone", title="Example")
report = lint_vault(vault, require_trust_ledger=False)
assert report["findings"]["redirect_issues"] == [
{"page": "entities/example.md", "target": "concepts/gone", "issue": "missing_target"}
]
# A broken stub still stays out of the duplicate checks.
assert report["findings"]["duplicate_titles"] == []
assert report["findings"]["duplicate_stems"] == []
def test_lint_redirect_chain_is_reported(tmp_path: Path) -> None:
vault = tmp_path / "vault"
_page(vault, "concepts/example.md", title="Example")
_redirect_stub(vault, "entities/example.md", "concepts/example", title="Example")
_redirect_stub(vault, "references/example.md", "entities/example", title="Example")
report = lint_vault(vault, require_trust_ledger=False)
assert report["findings"]["redirect_issues"] == [
{"page": "references/example.md", "target": "entities/example", "issue": "redirect_chain"}
]
def test_lint_two_real_pages_with_same_title_still_duplicate(tmp_path: Path) -> None:
vault = tmp_path / "vault"
_page(vault, "concepts/example.md", title="Example")
_page(vault, "entities/example.md", title="Example")
report = lint_vault(vault, require_trust_ledger=False)
assert report["findings"]["duplicate_titles"] == [
{"title": "example", "pages": ["concepts/example.md", "entities/example.md"]}
]
assert report["findings"]["duplicate_stems"] == [
{"stem": "example", "pages": ["concepts/example.md", "entities/example.md"]}
]
+11
View File
@@ -4,6 +4,7 @@ from pathlib import Path
from obsidian_wiki.provenance import (
archive_wikilink_relpath,
clip_url_index,
expected_snapshots_for_page,
invert_pages,
parse_snapshots_field,
@@ -218,3 +219,13 @@ def test_archive_wikilink_relpath_rejects_parent_traversal(tmp_path: Path) -> No
vault = tmp_path / "vault"
vault.mkdir()
assert archive_wikilink_relpath(vault, "_raw/_archived/../attention") is None
def test_clip_url_index_honors_okignore(tmp_path: Path) -> None:
for rel, url in (("kept", "https://example.test/kept"), ("private/hidden", "https://example.test/hidden")):
clip = tmp_path / "_raw" / "_archived" / f"{rel}.md"
clip.parent.mkdir(parents=True, exist_ok=True)
clip.write_text(f"---\nurl: {url}\n---\n\nclip\n", encoding="utf-8")
(tmp_path / ".okignore").write_text("_raw/_archived/private\n", encoding="utf-8")
assert clip_url_index(tmp_path) == {"https://example.test/kept": ["_raw/_archived/kept.md"]}
+25
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
from pathlib import Path
import pytest
from obsidian_wiki.staging import (
@@ -200,3 +202,26 @@ def test_log_records_one_line_per_batch(vault):
def test_log_is_silent_when_nothing_happened(vault):
log_decisions(vault, [])
assert not (vault / "log.md").exists()
def test_list_staged_honors_okignore(tmp_path):
write(tmp_path, "_staging/concepts/kept.md", "kept\n")
write(tmp_path, "_staging/private/hidden.md", "hidden\n")
# Staged over an ignored live page: listing it would diff against that page.
write(tmp_path, "notes/old/live.md", "secret\n")
write(tmp_path, "_staging/notes/old/live.md", "revised\n")
write(tmp_path, ".okignore", "_staging/private\n/notes/old\n")
assert [Path(entry.live_path).as_posix() for entry in list_staged(tmp_path)] == ["concepts/kept.md"]
def test_list_staged_does_not_follow_a_symlinked_directory(tmp_path):
vault = tmp_path / "vault"
write(vault, "_staging/concepts/kept.md", "kept\n")
write(tmp_path, "outside/secret.md", "secret\n")
try:
(vault / "_staging" / "link").symlink_to(tmp_path / "outside", target_is_directory=True)
except (OSError, NotImplementedError):
pytest.skip("symlinks unavailable on this platform")
assert [Path(entry.live_path).as_posix() for entry in list_staged(vault)] == ["concepts/kept.md"]
+48
View File
@@ -86,6 +86,7 @@ def test_reviewed_ledger_is_authoritative_instead_of_reclassifying_sources(tmp_p
"score_mismatches": 0,
"missing_pages": 0,
"errors": 0,
"ignored_entries": 0,
}
assert {item["page"] for item in report["reviewed"]} == {
"concepts/alpha.md",
@@ -881,3 +882,50 @@ def test_required_trust_ledger_fails_closed_when_missing(tmp_path: Path) -> None
strict_report = lint_vault(vault, require_trust_ledger=True, strict_trust=True)
assert strict_report["status"] == "fail"
def _reviewed_then_ignored(tmp_path: Path) -> Path:
vault = tmp_path / "vault"
_page(vault, "concepts/alpha.md")
_page(vault, "private/hidden.md")
_write_ledger(vault)
(vault / ".okignore").write_text("private/\n", encoding="utf-8")
return vault
def test_check_counts_a_reviewed_page_ignored_later_without_naming_it(tmp_path: Path) -> None:
vault = _reviewed_then_ignored(tmp_path)
report = check_trust_ledger(vault)
# Still a warning: a page leaving the review gate has to stay visible.
assert report["status"] == "warn"
assert report["ignored_entries"] == 1
assert report["counts"]["ignored_entries"] == 1
assert report["missing_pages"] == []
assert "hidden" not in json.dumps(report)
def test_a_deleted_page_is_still_reported_missing_by_name(tmp_path: Path) -> None:
vault = _reviewed_then_ignored(tmp_path)
(vault / "concepts" / "alpha.md").unlink()
report = check_trust_ledger(vault)
assert report["missing_pages"] == [{"page": "concepts/alpha.md", "reason": "reviewed_page_missing"}]
assert report["ignored_entries"] == 1
def test_partial_review_prunes_ignored_entries_without_changing_other_reviews(tmp_path):
vault = _reviewed_then_ignored(tmp_path)
ledger_path = vault / "_meta/trust-ledger.json"
before = json.loads(ledger_path.read_text())
updated = update_trust_ledger(
vault, ledger_path, reviewed_at="2026-11-07T00:00:00Z",
page_paths=["concepts/alpha.md"],
)
assert updated["removed_ignored"] == 1
assert set(updated["pages"]) == {"concepts/alpha.md"}
assert updated["pages"]["concepts/alpha.md"]["material_fingerprint"] == before["pages"]["concepts/alpha.md"]["material_fingerprint"]
write_trust_ledger(ledger_path, updated)
assert check_trust_ledger(vault)["ignored_entries"] == 0
+28
View File
@@ -83,3 +83,31 @@ def test_staging_cli_conflict_keeps_reviewed_and_live_files(tmp_path, capsys):
]) == 9
assert live.read_text() == "Concurrent live update"
assert staged.read_text() == "Reviewed proposal"
@pytest.mark.parametrize("shape", ["dict", "list"])
def test_source_coverage_accepts_local_archive_metadata_and_preserves_manifest(tmp_path, shape):
page = tmp_path / "concepts/a.md"
page.parent.mkdir()
page.write_text("---\ntitle: A\n---\n")
entries = [
{"path": "original.pdf", "archive_path": "_sources/archive/a.pdf",
"pages_produced": ["concepts/a.md"], "bjd_part_numbers": ["BJD-123"]},
{"path": "lost.pdf", "pages_updated": ["concepts/deleted.md"]},
{"path": "legacy.pdf", "pages_created": 2},
]
manifest = tmp_path / ".manifest.json"
manifest.write_text(json.dumps({"sources": {e["path"]: e for e in entries} if shape == "dict" else entries}))
before = manifest.read_bytes()
report = lint_vault(tmp_path, require_trust_ledger=False)
assert report["findings"]["uncovered_sources"] == [
{"source": "lost.pdf", "issue": "pages_missing", "missing": ["concepts/deleted.md"]}
]
assert manifest.read_bytes() == before
def test_lint_cli_exposes_source_coverage_in_json_and_strict_exit(tmp_path, capsys):
(tmp_path / ".manifest.json").write_text(json.dumps({"sources": {"lost.pdf": {"pages_produced": []}}}))
assert main(["lint", str(tmp_path), "--json", "--strict"]) == 1
report = json.loads(capsys.readouterr().out)
assert report["stats"]["findings"]["uncovered_sources"] == 1