feat(wiki): port upstream knowledge quality improvements
setup / install-leaves-tree-clean (push) Waiting to run
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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`."""
|
||||
|
||||
@@ -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"]}
|
||||
]
|
||||
|
||||
@@ -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"]}
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user