Selectively port source hashing, locked manifest/memory writes, path-aware link lint and graph analysis, and revision-checked staging from upstream 4f457e154653756b04802d5aeb25c20f42b4770c. Record source hashes and exclusions. Preserve local archive/catalog citations, curated manifest metadata, config location, file-index and MCP behavior. Wire maintenance guidance into four skills without migrating vault contents or changing running services. Validation: 609 tests passed, including existing MCP/file-asset regressions and imported upstream maintenance tests. Read-only vault status left shared metadata hashes unchanged. Wiki MCP remains inactive; orders MCP is active.
1068 lines
39 KiB
Python
1068 lines
39 KiB
Python
"""Tests for vault linting."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from obsidian_wiki.lint import _parse_page, lint_vault
|
|
from obsidian_wiki.trust import build_trust_ledger, write_trust_ledger
|
|
|
|
|
|
def _page(
|
|
vault: Path,
|
|
relpath: str,
|
|
*,
|
|
title: str | None = None,
|
|
summary: str | None = "Short summary.",
|
|
tags: str = "[test]",
|
|
sources: str = "[manual]",
|
|
created: str = "2026-07-01",
|
|
updated: str = "2026-07-01",
|
|
links: list[str] | None = None,
|
|
include_frontmatter: bool = True,
|
|
include_trust_fields: bool = True,
|
|
snapshots: str | None = None,
|
|
) -> Path:
|
|
path = vault / relpath
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
lines: list[str] = []
|
|
if include_frontmatter:
|
|
lines.extend(
|
|
[
|
|
"---",
|
|
f"title: {title or path.stem}",
|
|
"category: concepts",
|
|
f"tags: {tags}",
|
|
f"sources: {sources}",
|
|
f"created: {created}",
|
|
f"updated: {updated}",
|
|
]
|
|
)
|
|
if include_trust_fields:
|
|
lines.extend(["base_confidence: 0.80", "lifecycle: reviewed"])
|
|
if summary is not None:
|
|
lines.append(f"summary: {summary}")
|
|
if snapshots is not None:
|
|
lines.append(f"snapshots: {snapshots}")
|
|
lines.append("---")
|
|
lines.append(f"# {title or path.stem}")
|
|
for link in links or []:
|
|
lines.append(f"[[{link}]]")
|
|
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
return path
|
|
|
|
|
|
def _run(home: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
|
env = os.environ.copy()
|
|
env["HOME"] = str(home)
|
|
# Run from the fake HOME, not the repo: config resolution walks up from cwd
|
|
# looking for a `.env`, and a developer's own .env at the repo root would
|
|
# otherwise override the fixture config and point at their real vault.
|
|
home.mkdir(parents=True, exist_ok=True)
|
|
return subprocess.run(
|
|
[sys.executable, "-m", "obsidian_wiki.cli", *args],
|
|
capture_output=True,
|
|
check=False,
|
|
text=True,
|
|
env=env,
|
|
cwd=home,
|
|
)
|
|
|
|
|
|
def _run_at(home: Path, cwd: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
|
env = os.environ.copy()
|
|
env["HOME"] = str(home)
|
|
env["PYTHONPATH"] = str(Path(__file__).parents[1])
|
|
return subprocess.run(
|
|
[sys.executable, "-m", "obsidian_wiki.cli", *args],
|
|
capture_output=True,
|
|
check=False,
|
|
text=True,
|
|
env=env,
|
|
cwd=cwd,
|
|
)
|
|
|
|
|
|
def test_lint_vault_passes_clean_graph(tmp_path: Path) -> None:
|
|
vault = tmp_path / "vault"
|
|
_page(vault, "index.md", links=["alpha"])
|
|
_page(vault, "log.md", links=["alpha"])
|
|
_page(vault, "hot.md", links=["alpha"])
|
|
_page(vault, "concepts/alpha.md", links=["beta"])
|
|
_page(vault, "concepts/beta.md", links=["alpha"])
|
|
ledger = build_trust_ledger(vault, reviewed_at="2026-07-12T17:38:39+07:00")
|
|
write_trust_ledger(vault / "_meta" / "trust-ledger.json", ledger, vault=vault)
|
|
|
|
report = lint_vault(vault)
|
|
|
|
assert report["status"] == "pass"
|
|
assert report["findings"]["broken_links"] == []
|
|
assert report["findings"]["missing_frontmatter"] == []
|
|
|
|
|
|
def _clean_pair(vault: Path, *, alpha_snapshots: str | None = None) -> None:
|
|
"""Write a two-page graph and trust ledger from the **final** page bytes.
|
|
|
|
Never rewrite a page after `write_trust_ledger`: that marks reviewed
|
|
pages stale (`confidence_review_stale`) and pollutes `status`.
|
|
"""
|
|
_page(
|
|
vault,
|
|
"concepts/alpha.md",
|
|
links=["beta"],
|
|
snapshots=alpha_snapshots,
|
|
)
|
|
_page(vault, "concepts/beta.md", links=["alpha"])
|
|
ledger = build_trust_ledger(vault, reviewed_at="2026-07-12T17:38:39+07:00")
|
|
write_trust_ledger(vault / "_meta" / "trust-ledger.json", ledger, vault=vault)
|
|
|
|
|
|
def _write_archive_and_manifest(vault: Path, page: str = "concepts/alpha.md") -> None:
|
|
archived = vault / "_raw" / "_archived" / "a.md"
|
|
archived.parent.mkdir(parents=True, exist_ok=True)
|
|
archived.write_text("# clip\n", encoding="utf-8")
|
|
(vault / ".manifest.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"sources": {
|
|
"_raw/_archived/a.md": {"pages_produced": [page]},
|
|
}
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
def test_snapshot_mismatch_warns_when_field_missing(tmp_path: Path) -> None:
|
|
vault = tmp_path / "vault"
|
|
_clean_pair(vault)
|
|
_write_archive_and_manifest(vault)
|
|
|
|
report = lint_vault(vault)
|
|
|
|
assert report["status"] == "warn"
|
|
assert report["findings"]["snapshot_mismatch"] == [
|
|
{
|
|
"page": "concepts/alpha.md",
|
|
"expected": ["_raw/_archived/a.md"],
|
|
"actual": [],
|
|
}
|
|
]
|
|
assert "sources: [manual]" in (vault / "concepts" / "alpha.md").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
|
|
|
|
def test_snapshot_mismatch_empty_when_field_matches(tmp_path: Path) -> None:
|
|
vault = tmp_path / "vault"
|
|
_clean_pair(vault, alpha_snapshots="[_raw/_archived/a.md]")
|
|
_write_archive_and_manifest(vault)
|
|
|
|
report = lint_vault(vault)
|
|
|
|
assert report["findings"]["snapshot_mismatch"] == []
|
|
assert report["status"] == "pass"
|
|
|
|
|
|
def test_snapshot_mismatch_when_field_disagrees(tmp_path: Path) -> None:
|
|
vault = tmp_path / "vault"
|
|
_clean_pair(vault, alpha_snapshots="[_raw/_archived/wrong.md]")
|
|
_write_archive_and_manifest(vault)
|
|
|
|
report = lint_vault(vault)
|
|
|
|
assert report["findings"]["snapshot_mismatch"] == [
|
|
{
|
|
"page": "concepts/alpha.md",
|
|
"expected": ["_raw/_archived/a.md"],
|
|
"actual": ["_raw/_archived/wrong.md"],
|
|
}
|
|
]
|
|
|
|
|
|
def test_snapshot_mismatch_absent_when_invert_empty(tmp_path: Path) -> None:
|
|
vault = tmp_path / "vault"
|
|
_clean_pair(vault)
|
|
|
|
report = lint_vault(vault)
|
|
|
|
assert report["findings"]["snapshot_mismatch"] == []
|
|
|
|
|
|
def test_lint_vault_honors_okignore(tmp_path: Path) -> None:
|
|
vault = tmp_path / "vault"
|
|
_page(vault, "concepts/alpha.md", links=["beta"])
|
|
_page(vault, "concepts/beta.md", links=["alpha"])
|
|
_page(vault, "_chatwithgpt/export.md", include_frontmatter=False, links=["ghost"])
|
|
_page(vault, "notes/old/stale.md", include_frontmatter=False)
|
|
_page(vault, "notes/keep.md", title="Keep", links=["alpha"])
|
|
(vault / ".okignore").write_text("# inboxes\n_chatwithgpt/\n/notes/old\n", encoding="utf-8")
|
|
|
|
report = lint_vault(vault)
|
|
flagged = json.dumps(report["findings"])
|
|
|
|
assert "_chatwithgpt" not in flagged
|
|
assert "notes/old" not in flagged
|
|
assert report["findings"]["broken_links"] == []
|
|
|
|
|
|
def test_lint_vault_fails_on_broken_links_and_missing_frontmatter(tmp_path: Path) -> None:
|
|
vault = tmp_path / "vault"
|
|
_page(vault, "concepts/alpha.md", links=["ghost"])
|
|
_page(vault, "concepts/beta.md", include_frontmatter=False)
|
|
|
|
report = lint_vault(vault)
|
|
|
|
assert report["status"] == "fail"
|
|
assert report["findings"]["broken_links"] == [{"page": "concepts/alpha.md", "target": "ghost"}]
|
|
assert any(item["page"] == "concepts/beta.md" for item in report["findings"]["missing_frontmatter"])
|
|
|
|
|
|
def test_lint_vault_broken_links_ignores_embeds_and_normalises_md_and_escaped_pipe(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
vault = tmp_path / "vault"
|
|
_page(vault, "concepts/alpha.md")
|
|
_page(vault, "concepts/beta.md")
|
|
(vault / "diagram.png").write_text("not a real png\n", encoding="utf-8")
|
|
_page(
|
|
vault,
|
|
"concepts/index.md",
|
|
links=["alpha.md", "diagram.png", "ghost"],
|
|
)
|
|
# A Markdown-table cell escapes a literal pipe, which _WIKILINK_RE's own
|
|
# alternation consumes into the alias group it does not capture.
|
|
index = vault / "concepts" / "index.md"
|
|
index.write_text(index.read_text(encoding="utf-8") + "| [[beta\\|B]] | note |\n", encoding="utf-8")
|
|
|
|
report = lint_vault(vault)
|
|
|
|
assert report["findings"]["broken_links"] == [{"page": "concepts/index.md", "target": "ghost"}]
|
|
|
|
|
|
def test_lint_vault_keeps_links_to_pages_whose_name_contains_a_dot(tmp_path: Path) -> None:
|
|
"""A dot in a page name ("Node.js", "v1.2 notes") is not a file extension —
|
|
dropping those links would report the target as an orphan."""
|
|
vault = tmp_path / "vault"
|
|
for name in ("Node.js", "v1.2 release notes"):
|
|
_page(vault, f"entities/{name}.md")
|
|
_page(vault, "concepts/index.md", links=["Node.js", "v1.2 release notes"])
|
|
|
|
report = lint_vault(vault)
|
|
|
|
assert report["findings"]["broken_links"] == []
|
|
assert report["findings"]["orphan_pages"] == []
|
|
assert report["stats"]["link_count"] == 2
|
|
|
|
|
|
def test_lint_vault_warns_on_duplicates_missing_summaries_and_orphans(tmp_path: Path) -> None:
|
|
vault = tmp_path / "vault"
|
|
_page(vault, "concepts/alpha.md", title="Same Title", summary=None)
|
|
_page(vault, "references/beta.md", title="Same Title")
|
|
ledger = build_trust_ledger(vault, reviewed_at="2026-07-12T17:38:39+07:00")
|
|
write_trust_ledger(vault / "_meta" / "trust-ledger.json", ledger, vault=vault)
|
|
|
|
report = lint_vault(vault)
|
|
|
|
assert report["status"] == "warn"
|
|
assert report["findings"]["duplicate_titles"]
|
|
assert "concepts/alpha.md" in report["findings"]["missing_summaries"]
|
|
assert "references/beta.md" in report["findings"]["orphan_pages"]
|
|
|
|
|
|
def test_lint_vault_ignores_tool_owned_directories(tmp_path: Path) -> None:
|
|
"""A `.venv` (or any dot-dir / dependency tree) inside the vault must not
|
|
enter the findings — one `uv sync` in a project-vault would otherwise
|
|
sweep thousands of dependency READMEs into the CI baseline."""
|
|
vault = tmp_path / "vault"
|
|
_page(vault, "concepts/alpha.md", links=["beta"])
|
|
_page(vault, "references/beta.md")
|
|
junk = [
|
|
".venv/lib/python3.12/site-packages/somepkg/README.md",
|
|
".trash/draft.md",
|
|
"node_modules/pkg/README.md",
|
|
"venv/readme.md",
|
|
"__pycache__/x.md",
|
|
]
|
|
for rel in junk:
|
|
path = vault / rel
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text("# dependency readme\nno frontmatter\n", encoding="utf-8")
|
|
|
|
report = lint_vault(vault, require_trust_ledger=False)
|
|
|
|
assert report["status"] == "pass"
|
|
assert report["stats"]["pages"] == 2
|
|
assert report["findings"]["missing_frontmatter"] == []
|
|
assert report["findings"]["orphan_pages"] == []
|
|
assert report["findings"]["missing_summaries"] == []
|
|
|
|
|
|
def test_lint_cli_uses_configured_vault_and_strict_mode(tmp_path: Path) -> None:
|
|
home = tmp_path / "home"
|
|
vault = tmp_path / "vault"
|
|
_page(vault, "concepts/alpha.md", summary=None)
|
|
|
|
config_dir = home / ".obsidian-wiki"
|
|
config_dir.mkdir(parents=True, exist_ok=True)
|
|
(config_dir / "config").write_text(f'OBSIDIAN_VAULT_PATH="{vault}"\n', encoding="utf-8")
|
|
ledger = build_trust_ledger(vault, reviewed_at="2026-07-12T17:38:39+07:00")
|
|
write_trust_ledger(vault / "_meta" / "trust-ledger.json", ledger)
|
|
|
|
proc = _run(home, "lint", "--json", "--strict")
|
|
|
|
assert proc.returncode == 1
|
|
data = json.loads(proc.stdout)
|
|
assert data["status"] == "warn"
|
|
assert "concepts/alpha.md" in data["findings"]["missing_summaries"]
|
|
|
|
|
|
def test_lint_cli_strict_ignores_snapshot_mismatch_alone(tmp_path: Path) -> None:
|
|
home = tmp_path / "home"
|
|
vault = tmp_path / "vault"
|
|
_clean_pair(vault)
|
|
_write_archive_and_manifest(vault)
|
|
config_dir = home / ".obsidian-wiki"
|
|
config_dir.mkdir(parents=True)
|
|
(config_dir / "config").write_text(f'OBSIDIAN_VAULT_PATH="{vault}"\n', encoding="utf-8")
|
|
proc = _run(home, "lint", "--json", "--strict")
|
|
data = json.loads(proc.stdout)
|
|
assert data["status"] == "warn"
|
|
assert data["findings"]["snapshot_mismatch"]
|
|
assert proc.returncode == 0
|
|
|
|
|
|
def test_lint_cli_strict_still_fails_other_warnings(tmp_path: Path) -> None:
|
|
home = tmp_path / "home"
|
|
vault = tmp_path / "vault"
|
|
_page(vault, "concepts/alpha.md", summary=None)
|
|
config_dir = home / ".obsidian-wiki"
|
|
config_dir.mkdir(parents=True)
|
|
(config_dir / "config").write_text(f'OBSIDIAN_VAULT_PATH="{vault}"\n', encoding="utf-8")
|
|
ledger = build_trust_ledger(vault, reviewed_at="2026-07-12T17:38:39+07:00")
|
|
write_trust_ledger(vault / "_meta" / "trust-ledger.json", ledger)
|
|
proc = _run(home, "lint", "--json", "--strict")
|
|
assert proc.returncode == 1
|
|
assert json.loads(proc.stdout)["findings"]["missing_summaries"]
|
|
|
|
|
|
def test_lint_vault_legacy_pages_without_trust_schema_warn_by_default(tmp_path: Path) -> None:
|
|
vault = tmp_path / "vault"
|
|
_page(vault, "concepts/alpha.md", include_trust_fields=False)
|
|
|
|
report = lint_vault(vault)
|
|
|
|
assert report["status"] == "warn"
|
|
assert report["findings"]["missing_frontmatter"] == []
|
|
assert report["findings"]["confidence_missing_fields"] == [
|
|
{"page": "concepts/alpha.md", "missing": ["base_confidence", "lifecycle"]}
|
|
]
|
|
assert any(item["issue"] == "ledger_missing" for item in report["findings"]["confidence_ledger_errors"])
|
|
|
|
|
|
def test_lint_vault_missing_ledger_is_warning_not_failure_by_default(tmp_path: Path) -> None:
|
|
vault = tmp_path / "vault"
|
|
_page(vault, "concepts/alpha.md")
|
|
|
|
report = lint_vault(vault)
|
|
|
|
assert report["status"] == "warn"
|
|
assert report["findings"]["confidence_ledger_errors"]
|
|
|
|
|
|
def test_lint_vault_strict_trust_fails_on_missing_fields_and_ledger(tmp_path: Path) -> None:
|
|
vault = tmp_path / "vault"
|
|
_page(vault, "concepts/alpha.md", include_trust_fields=False)
|
|
|
|
report = lint_vault(vault, strict_trust=True)
|
|
|
|
assert report["status"] == "fail"
|
|
|
|
|
|
def test_lint_vault_strict_trust_still_passes_clean_reviewed_vault(tmp_path: Path) -> None:
|
|
vault = tmp_path / "vault"
|
|
_page(vault, "concepts/alpha.md", links=["beta"])
|
|
_page(vault, "concepts/beta.md", links=["alpha"])
|
|
ledger = build_trust_ledger(vault, reviewed_at="2026-07-12T17:38:39+07:00")
|
|
write_trust_ledger(vault / "_meta" / "trust-ledger.json", ledger, vault=vault)
|
|
|
|
report = lint_vault(vault, strict_trust=True)
|
|
|
|
assert report["status"] == "pass"
|
|
|
|
|
|
def test_lint_cli_strict_trust_flag_fails_legacy_vault(tmp_path: Path) -> None:
|
|
home = tmp_path / "home"
|
|
vault = tmp_path / "vault"
|
|
_page(vault, "concepts/alpha.md", include_trust_fields=False)
|
|
|
|
config_dir = home / ".obsidian-wiki"
|
|
config_dir.mkdir(parents=True, exist_ok=True)
|
|
(config_dir / "config").write_text(f'OBSIDIAN_VAULT_PATH="{vault}"\n', encoding="utf-8")
|
|
|
|
default_proc = _run(home, "lint", "--json")
|
|
assert default_proc.returncode == 0
|
|
assert json.loads(default_proc.stdout)["status"] == "warn"
|
|
|
|
strict_proc = _run(home, "lint", "--json", "--strict-trust")
|
|
assert strict_proc.returncode == 1
|
|
assert json.loads(strict_proc.stdout)["status"] == "fail"
|
|
|
|
|
|
def test_owner_schema_accepts_extensions_optional_trust_and_reports_source(tmp_path: Path) -> None:
|
|
vault = tmp_path / "vault"
|
|
extensions = [
|
|
("active", "synthesizes"),
|
|
("confirmed", "builds_on"),
|
|
("stub", "complements"),
|
|
("active", "refines"),
|
|
("confirmed", "contrasts_with"),
|
|
]
|
|
names = [f"page-{index}" for index in range(len(extensions))]
|
|
for index, ((lifecycle, relationship), name) in enumerate(zip(extensions, names)):
|
|
target = names[(index + 1) % len(names)]
|
|
page = _page(vault, f"concepts/{name}.md", links=[target])
|
|
text = page.read_text()
|
|
text = text.replace("base_confidence: 0.80\n", "")
|
|
text = text.replace(
|
|
"lifecycle: reviewed",
|
|
f'lifecycle: {lifecycle}\nrelationships:\n - type: {relationship}\n target: "[[concepts/{target}]]"',
|
|
)
|
|
page.write_text(text)
|
|
|
|
report = lint_vault(
|
|
vault,
|
|
require_trust_ledger=False,
|
|
allowed_lifecycles={"active", "confirmed", "stub"},
|
|
allowed_relationship_types={item[1] for item in extensions},
|
|
required_trust_fields=("updated",),
|
|
schema_source="wiki/AGENTS.md",
|
|
)
|
|
|
|
assert report["findings"]["confidence_missing_fields"] == []
|
|
assert report["findings"]["typed_relationship_issues"] == []
|
|
assert report["schema"] == {
|
|
"source": "wiki/AGENTS.md",
|
|
"allowed_lifecycles": ["active", "confirmed", "stub"],
|
|
"allowed_relationship_types": sorted(item[1] for item in extensions),
|
|
"required_trust_fields": ["updated"],
|
|
}
|
|
|
|
|
|
|
|
|
|
def test_empty_relationship_cli_extension_cannot_hide_missing_relation_type(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
home = tmp_path / "home"
|
|
vault = tmp_path / "vault"
|
|
alpha = _page(vault, "concepts/alpha.md", links=["beta"])
|
|
_page(vault, "concepts/beta.md", links=["alpha"])
|
|
alpha.write_text(
|
|
alpha.read_text().replace(
|
|
"summary: Short summary.",
|
|
'summary: Short summary.\nrelationships:\n - type:\n target: "[[concepts/beta]]"',
|
|
)
|
|
)
|
|
|
|
baseline = _run(home, "lint", str(vault), "--json")
|
|
assert baseline.returncode == 0
|
|
assert json.loads(baseline.stdout)["findings"]["typed_relationship_issues"] == [
|
|
{"page": "concepts/alpha.md", "index": 0, "issue": "invalid_type", "type": ""}
|
|
]
|
|
|
|
for value in ("", " "):
|
|
invalid = _run(
|
|
home,
|
|
"lint",
|
|
str(vault),
|
|
"--json",
|
|
"--allow-relationship-type",
|
|
value,
|
|
)
|
|
assert invalid.returncode == 1
|
|
assert invalid.stdout == ""
|
|
assert "error: invalid --allow-relationship-type value: must not be empty" in invalid.stderr
|
|
assert "Traceback" not in invalid.stderr
|
|
|
|
|
|
def test_empty_cli_lifecycle_and_required_field_overrides_fail_closed(tmp_path: Path) -> None:
|
|
home = tmp_path / "home"
|
|
vault = tmp_path / "vault"
|
|
_page(vault, "concepts/alpha.md")
|
|
|
|
for value in ("", " "):
|
|
lifecycle = _run(
|
|
home,
|
|
"lint",
|
|
str(vault),
|
|
"--json",
|
|
"--allow-lifecycle",
|
|
value,
|
|
)
|
|
assert lifecycle.returncode == 1
|
|
assert lifecycle.stdout == ""
|
|
assert "error: invalid --allow-lifecycle value: must not be empty" in lifecycle.stderr
|
|
assert "Traceback" not in lifecycle.stderr
|
|
|
|
required = _run(
|
|
home,
|
|
"lint",
|
|
str(vault),
|
|
"--json",
|
|
"--required-trust-field",
|
|
"",
|
|
)
|
|
assert required.returncode == 2
|
|
assert required.stdout == ""
|
|
assert "invalid choice" in required.stderr
|
|
assert "Traceback" not in required.stderr
|
|
|
|
for value in ("", " "):
|
|
source = _run(
|
|
home,
|
|
"lint",
|
|
str(vault),
|
|
"--json",
|
|
"--schema-source",
|
|
value,
|
|
)
|
|
assert source.returncode == 1
|
|
assert source.stdout == ""
|
|
assert "error: invalid --schema-source value: must not be empty" in source.stderr
|
|
assert "Traceback" not in source.stderr
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_owner_schema_still_rejects_unknown_typos(tmp_path: Path) -> None:
|
|
vault = tmp_path / "vault"
|
|
page = _page(vault, "concepts/alpha.md", links=["beta"])
|
|
_page(vault, "concepts/beta.md", links=["alpha"])
|
|
page.write_text(
|
|
page.read_text()
|
|
.replace("lifecycle: reviewed", "lifecycle: activ")
|
|
.replace(
|
|
"---\n# alpha",
|
|
'relationships:\n - type: synthesizez\n target: "[[concepts/beta]]"\n---\n# alpha',
|
|
)
|
|
)
|
|
|
|
report = lint_vault(
|
|
vault,
|
|
require_trust_ledger=False,
|
|
allowed_lifecycles={"active", "confirmed", "stub"},
|
|
allowed_relationship_types={"synthesizes"},
|
|
required_trust_fields=("updated",),
|
|
schema_source="wiki/AGENTS.md",
|
|
)
|
|
|
|
assert report["findings"]["typed_relationship_issues"][0]["issue"] == "invalid_type"
|
|
|
|
|
|
def test_default_schema_remains_framework_compatible(tmp_path: Path) -> None:
|
|
vault = tmp_path / "vault"
|
|
_page(vault, "concepts/alpha.md")
|
|
|
|
report = lint_vault(vault, require_trust_ledger=False)
|
|
|
|
assert report["schema"]["source"] == "framework-defaults"
|
|
assert report["schema"]["required_trust_fields"] == ["base_confidence", "lifecycle"]
|
|
assert "related_to" in report["schema"]["allowed_relationship_types"]
|
|
|
|
|
|
def test_lifecycle_typo_fails_without_ledger_when_ledger_is_not_required(tmp_path: Path) -> None:
|
|
vault = tmp_path / "vault"
|
|
page = _page(vault, "concepts/alpha.md")
|
|
page.write_text(page.read_text().replace("lifecycle: reviewed", "lifecycle: reveiwed"))
|
|
|
|
report = lint_vault(vault, require_trust_ledger=False)
|
|
|
|
assert report["status"] == "fail"
|
|
assert report["findings"]["trust_metadata_errors"] == [
|
|
{"page": "concepts/alpha.md", "issue": "invalid lifecycle: reveiwed"}
|
|
]
|
|
|
|
|
|
def test_present_invalid_confidence_fails_without_ledger_when_not_required(tmp_path: Path) -> None:
|
|
vault = tmp_path / "vault"
|
|
page = _page(vault, "concepts/alpha.md")
|
|
page.write_text(page.read_text().replace("base_confidence: 0.80", "base_confidence: 1.4"))
|
|
|
|
report = lint_vault(vault, require_trust_ledger=False)
|
|
|
|
assert report["status"] == "fail"
|
|
assert report["findings"]["trust_metadata_errors"] == [
|
|
{"page": "concepts/alpha.md", "issue": "base_confidence is outside [0.0, 1.0]"}
|
|
]
|
|
|
|
|
|
def test_schema_config_is_scoped_to_explicit_cwd_and_named_vaults(tmp_path: Path) -> None:
|
|
home = tmp_path / "home"
|
|
global_vault = tmp_path / "global-vault"
|
|
local_vault = tmp_path / "local-vault"
|
|
explicit_vault = tmp_path / "explicit-vault"
|
|
for vault in (global_vault, local_vault, explicit_vault):
|
|
page = _page(vault, "concepts/alpha.md")
|
|
page.write_text(page.read_text().replace("lifecycle: reviewed", "lifecycle: active"))
|
|
|
|
config_dir = home / ".obsidian-wiki"
|
|
config_dir.mkdir(parents=True)
|
|
(config_dir / "config").write_text(
|
|
f'OBSIDIAN_VAULT_PATH="{global_vault}"\nOBSIDIAN_ALLOWED_LIFECYCLES=active\n',
|
|
encoding="utf-8",
|
|
)
|
|
(config_dir / "config.owner").write_text(
|
|
f'OBSIDIAN_VAULT_PATH="{local_vault}"\nOBSIDIAN_ALLOWED_LIFECYCLES=active\n',
|
|
encoding="utf-8",
|
|
)
|
|
project = tmp_path / "project"
|
|
project.mkdir()
|
|
(project / ".env").write_text(
|
|
f'OBSIDIAN_VAULT_PATH="{local_vault}"\nOBSIDIAN_ALLOWED_LIFECYCLES=active\n',
|
|
encoding="utf-8",
|
|
)
|
|
|
|
explicit = _run_at(home, project, "lint", str(explicit_vault), "--json")
|
|
assert explicit.returncode == 1
|
|
assert json.loads(explicit.stdout)["findings"]["trust_metadata_errors"][0]["issue"] == "invalid lifecycle: active"
|
|
|
|
local = _run_at(home, project, "lint", "--json")
|
|
assert local.returncode == 0, local.stderr
|
|
local_source = json.loads(local.stdout)["schema"]["source"]
|
|
assert Path(local_source.removeprefix("config:")).resolve() == (project / ".env").resolve()
|
|
|
|
named = _run_at(home, tmp_path, "lint", "@owner", "--json")
|
|
assert named.returncode == 0, named.stderr
|
|
named_source = json.loads(named.stdout)["schema"]["source"]
|
|
assert Path(named_source.removeprefix("config:")).resolve() == (config_dir / "config.owner").resolve()
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lifecycle transition validation (state machine enforcement)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _vault_with_ledger(tmp_path: Path, lifecycle: str = "reviewed") -> Path:
|
|
"""A clean two-page vault whose ledger records *lifecycle* for alpha."""
|
|
vault = tmp_path / "vault"
|
|
_page(vault, "index.md", links=["alpha"])
|
|
_page(vault, "concepts/alpha.md", links=["beta"])
|
|
_page(vault, "concepts/beta.md", links=["alpha"])
|
|
if lifecycle != "reviewed":
|
|
_set_lifecycle(vault / "concepts" / "alpha.md", lifecycle)
|
|
ledger = build_trust_ledger(vault, reviewed_at="2026-07-12T17:38:39+07:00")
|
|
write_trust_ledger(vault / "_meta" / "trust-ledger.json", ledger, vault=vault)
|
|
return vault
|
|
|
|
|
|
def _set_lifecycle(page: Path, lifecycle: str) -> None:
|
|
text = page.read_text(encoding="utf-8")
|
|
for existing in ("draft", "reviewed", "verified", "disputed", "archived"):
|
|
if f"lifecycle: {existing}" in text:
|
|
page.write_text(
|
|
text.replace(f"lifecycle: {existing}", f"lifecycle: {lifecycle}"),
|
|
encoding="utf-8",
|
|
)
|
|
return
|
|
raise AssertionError("page has no lifecycle field")
|
|
|
|
|
|
def test_ledger_records_lifecycle(tmp_path: Path) -> None:
|
|
vault = _vault_with_ledger(tmp_path)
|
|
ledger = json.loads(
|
|
(vault / "_meta" / "trust-ledger.json").read_text(encoding="utf-8")
|
|
)
|
|
assert ledger["pages"]["concepts/alpha.md"]["lifecycle"] == "reviewed"
|
|
|
|
|
|
def test_demotion_to_draft_is_flagged(tmp_path: Path) -> None:
|
|
vault = _vault_with_ledger(tmp_path, lifecycle="verified")
|
|
_set_lifecycle(vault / "concepts" / "alpha.md", "draft")
|
|
|
|
report = lint_vault(vault)
|
|
|
|
findings = report["findings"]["illegal_lifecycle_transitions"]
|
|
assert findings == [{"page": "concepts/alpha.md", "from": "verified", "to": "draft"}]
|
|
assert report["status"] == "warn"
|
|
|
|
|
|
def test_exit_from_archived_is_flagged(tmp_path: Path) -> None:
|
|
vault = _vault_with_ledger(tmp_path, lifecycle="archived")
|
|
_set_lifecycle(vault / "concepts" / "alpha.md", "reviewed")
|
|
|
|
report = lint_vault(vault)
|
|
|
|
assert report["findings"]["illegal_lifecycle_transitions"] == [
|
|
{"page": "concepts/alpha.md", "from": "archived", "to": "reviewed"}
|
|
]
|
|
|
|
|
|
def test_draft_to_verified_is_not_flagged(tmp_path: Path) -> None:
|
|
"""Sparse ledger snapshots may hide a legitimate intermediate `reviewed`."""
|
|
vault = _vault_with_ledger(tmp_path, lifecycle="draft")
|
|
_set_lifecycle(vault / "concepts" / "alpha.md", "verified")
|
|
|
|
report = lint_vault(vault)
|
|
|
|
assert report["findings"]["illegal_lifecycle_transitions"] == []
|
|
|
|
|
|
def test_illegal_transition_fails_under_strict_trust(tmp_path: Path) -> None:
|
|
vault = _vault_with_ledger(tmp_path, lifecycle="verified")
|
|
_set_lifecycle(vault / "concepts" / "alpha.md", "draft")
|
|
|
|
assert lint_vault(vault, strict_trust=True)["status"] == "fail"
|
|
|
|
|
|
def test_legacy_ledger_without_lifecycle_is_skipped(tmp_path: Path) -> None:
|
|
"""Old ledgers carry no baseline — the check stays silent, not noisy."""
|
|
vault = _vault_with_ledger(tmp_path, lifecycle="verified")
|
|
ledger_path = vault / "_meta" / "trust-ledger.json"
|
|
ledger = json.loads(ledger_path.read_text(encoding="utf-8"))
|
|
for entry in ledger["pages"].values():
|
|
entry.pop("lifecycle", None)
|
|
ledger_path.write_text(json.dumps(ledger, indent=2), encoding="utf-8")
|
|
_set_lifecycle(vault / "concepts" / "alpha.md", "draft")
|
|
|
|
report = lint_vault(vault)
|
|
|
|
assert report["findings"]["illegal_lifecycle_transitions"] == []
|
|
|
|
|
|
def test_no_ledger_means_no_transition_findings(tmp_path: Path) -> None:
|
|
vault = tmp_path / "vault"
|
|
_page(vault, "index.md", links=["alpha"])
|
|
_page(vault, "concepts/alpha.md", links=["index"])
|
|
|
|
report = lint_vault(vault, require_trust_ledger=False)
|
|
|
|
assert report["findings"]["illegal_lifecycle_transitions"] == []
|
|
|
|
|
|
def test_lint_vault_warns_on_pages_sharing_a_stem(tmp_path: Path) -> None:
|
|
"""`graph_analysis` keys a page by its bare stem, so these two are one node.
|
|
|
|
Nothing in the vault references the stem: the collision is the defect, not
|
|
the reference to it.
|
|
"""
|
|
vault = tmp_path / "vault"
|
|
_page(vault, "concepts/mercury.md", title="Mercury the concept", links=["alpha"])
|
|
_page(vault, "entities/mercury.md", title="Mercury the planet", links=["beta"])
|
|
_page(vault, "concepts/alpha.md", title="Alpha")
|
|
_page(vault, "concepts/beta.md", title="Beta")
|
|
|
|
report = lint_vault(vault, require_trust_ledger=False)
|
|
|
|
assert report["status"] == "warn"
|
|
assert report["findings"]["duplicate_stems"] == [
|
|
{"stem": "mercury", "pages": ["concepts/mercury.md", "entities/mercury.md"]}
|
|
]
|
|
|
|
|
|
def test_duplicate_stems_ignores_how_the_link_was_written(tmp_path: Path) -> None:
|
|
"""Both documented link formats carry a folder; neither creates a collision."""
|
|
vault = tmp_path / "vault"
|
|
_page(vault, "concepts/vector-search.md", title="Vector Search")
|
|
_page(
|
|
vault,
|
|
"projects/renewal.md",
|
|
title="Renewal",
|
|
links=["concepts/vector-search|Vector Search"],
|
|
)
|
|
_page(vault, "projects/rollout.md", title="Rollout")
|
|
(vault / "projects/rollout.md").write_text(
|
|
(vault / "projects/rollout.md").read_text(encoding="utf-8")
|
|
+ "\n[Vector Search](../concepts/vector-search.md)\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
report = lint_vault(vault, require_trust_ledger=False)
|
|
|
|
assert report["findings"]["duplicate_stems"] == []
|
|
assert report["status"] == "pass"
|
|
|
|
|
|
def test_external_urls_containing_md_are_not_broken_links(tmp_path: Path) -> None:
|
|
"""Issue #263: `.md` inside a URL (`www.mdpi.com`, `README.md`) is not a page."""
|
|
vault = tmp_path / "vault"
|
|
_page(vault, "concepts/vector-search.md", title="Vector Search")
|
|
_page(vault, "projects/rollout.md", title="Rollout")
|
|
(vault / "projects/rollout.md").write_text(
|
|
(vault / "projects/rollout.md").read_text(encoding="utf-8")
|
|
+ "\n[Study](https://www.mdpi.com/2304-8158/13/11/1691)"
|
|
+ "\n[Readme](https://github.com/owner/repo/blob/main/README.md)"
|
|
+ "\n[Vector Search](../concepts/vector-search.md#intro)\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
report = lint_vault(vault, require_trust_ledger=False)
|
|
|
|
assert report["findings"]["broken_links"] == []
|
|
|
|
|
|
def test_duplicate_stems_follows_the_graph_page_selection(tmp_path: Path) -> None:
|
|
"""Root `index.md` is not a graph page; `_bootstrap/` is."""
|
|
vault = tmp_path / "vault"
|
|
_page(vault, "index.md", title="Index")
|
|
_page(vault, "concepts/index.md", title="Concepts index")
|
|
_page(vault, "_bootstrap/vector-search.md", title="Bootstrap copy")
|
|
_page(vault, "concepts/vector-search.md", title="Vector Search")
|
|
|
|
report = lint_vault(vault, require_trust_ledger=False)
|
|
|
|
assert report["findings"]["duplicate_stems"] == [
|
|
{
|
|
"stem": "vector-search",
|
|
"pages": ["_bootstrap/vector-search.md", "concepts/vector-search.md"],
|
|
}
|
|
]
|
|
|
|
|
|
def test_duplicate_stems_rows_and_pages_are_sorted(tmp_path: Path) -> None:
|
|
"""Neither ordering follows the page walk: `concepts/` precedes `concepts-x/`
|
|
by path but not by string, and `zebra` is walked before `dup`."""
|
|
vault = tmp_path / "vault"
|
|
_page(vault, "a-dir/zebra.md", title="Zebra one")
|
|
_page(vault, "b-dir/zebra.md", title="Zebra two")
|
|
_page(vault, "concepts/dup.md", title="Dup one")
|
|
_page(vault, "concepts-x/dup.md", title="Dup two")
|
|
|
|
report = lint_vault(vault, require_trust_ledger=False)
|
|
|
|
assert report["findings"]["duplicate_stems"] == [
|
|
{"stem": "dup", "pages": ["concepts-x/dup.md", "concepts/dup.md"]},
|
|
{"stem": "zebra", "pages": ["a-dir/zebra.md", "b-dir/zebra.md"]},
|
|
]
|
|
|
|
|
|
def test_duplicate_stems_slugs_the_filename(tmp_path: Path) -> None:
|
|
"""`Vector Search.md` and `vector-search.md` are the same node; a raw
|
|
`Path.stem` would not see it."""
|
|
vault = tmp_path / "vault"
|
|
_page(vault, "concepts/Vector Search.md", title="Vector Search")
|
|
_page(vault, "entities/vector-search.md", title="The Pinecone product")
|
|
|
|
report = lint_vault(vault, require_trust_ledger=False)
|
|
|
|
assert report["findings"]["duplicate_stems"] == [
|
|
{
|
|
"stem": "vector-search",
|
|
"pages": ["concepts/Vector Search.md", "entities/vector-search.md"],
|
|
}
|
|
]
|
|
|
|
|
|
def test_duplicate_stems_reports_a_collision_inside_one_folder(tmp_path: Path) -> None:
|
|
"""A hand-named page beside a tool-written one: same folder, same node."""
|
|
vault = tmp_path / "vault"
|
|
_page(vault, "concepts/Vector Search.md", title="Vector Search")
|
|
_page(vault, "concepts/vector-search.md", title="Vector search notes")
|
|
|
|
report = lint_vault(vault, require_trust_ledger=False)
|
|
|
|
assert report["findings"]["duplicate_stems"] == [
|
|
{
|
|
"stem": "vector-search",
|
|
"pages": ["concepts/Vector Search.md", "concepts/vector-search.md"],
|
|
}
|
|
]
|
|
|
|
|
|
def test_machine_path_in_sources_is_reported(tmp_path: Path) -> None:
|
|
"""A `sources:` entry holding a machine absolute path cannot resolve on
|
|
another machine — read-only report, the page is not modified."""
|
|
vault = tmp_path / "vault"
|
|
_page(
|
|
vault, "references/alpha.md",
|
|
sources='["/DATA/disk1/me/wiki/Raw/a.pdf"]', links=["beta"],
|
|
)
|
|
_page(vault, "references/beta.md", sources='["~/docs/b.pdf"]', links=["alpha"])
|
|
|
|
report = lint_vault(vault, require_trust_ledger=False)
|
|
|
|
assert report["findings"]["machine_path_sources"] == [
|
|
{"page": "references/alpha.md", "sources": ["/DATA/disk1/me/wiki/Raw/a.pdf"]}
|
|
]
|
|
# The only thing wrong with this vault is the machine path, and it warns.
|
|
assert report["findings"]["orphan_pages"] == []
|
|
assert report["findings"]["missing_summaries"] == []
|
|
assert report["status"] == "warn"
|
|
|
|
|
|
def test_machine_path_in_block_style_sources_is_reported(tmp_path: Path) -> None:
|
|
vault = tmp_path / "vault"
|
|
path = vault / "references" / "alpha.md"
|
|
path.parent.mkdir(parents=True)
|
|
path.write_text(
|
|
"---\n"
|
|
"title: alpha\n"
|
|
"category: references\n"
|
|
"tags: [test]\n"
|
|
"sources:\n"
|
|
" - /home/other/notes/wiki/Raw/a.pdf\n"
|
|
" - Raw/local.pdf\n"
|
|
"created: 2026-07-01\n"
|
|
"updated: 2026-07-01\n"
|
|
"---\n# alpha\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
report = lint_vault(vault, require_trust_ledger=False)
|
|
|
|
assert report["findings"]["machine_path_sources"] == [
|
|
{"page": "references/alpha.md", "sources": ["/home/other/notes/wiki/Raw/a.pdf"]}
|
|
]
|
|
|
|
|
|
def test_machine_path_in_scalar_sources_is_reported(tmp_path: Path) -> None:
|
|
"""`sources:` may be written as a scalar; that is still a stored source key."""
|
|
vault = tmp_path / "vault"
|
|
_page(vault, "references/alpha.md", sources="/abs/only.md")
|
|
|
|
report = lint_vault(vault, require_trust_ledger=False)
|
|
|
|
assert report["findings"]["machine_path_sources"] == [
|
|
{"page": "references/alpha.md", "sources": ["/abs/only.md"]}
|
|
]
|
|
|
|
|
|
def test_lint_archive_wikilink_does_not_attach_to_concept_slug(tmp_path: Path) -> None:
|
|
vault = tmp_path / "vault"
|
|
_page(vault, "concepts/attention.md", links=["beta"])
|
|
_page(vault, "concepts/beta.md", links=["attention"])
|
|
archived = vault / "_raw" / "_archived" / "attention.md"
|
|
archived.parent.mkdir(parents=True)
|
|
archived.write_text("# clip\n", encoding="utf-8")
|
|
_page(
|
|
vault,
|
|
"concepts/alpha.md",
|
|
links=["beta"],
|
|
snapshots="\n - [[_raw/_archived/attention]]",
|
|
)
|
|
ledger = build_trust_ledger(vault, reviewed_at="2026-07-12T17:38:39+07:00")
|
|
write_trust_ledger(vault / "_meta" / "trust-ledger.json", ledger, vault=vault)
|
|
parsed = _parse_page(vault / "concepts/alpha.md", vault)
|
|
assert "attention" not in parsed["links"]
|
|
assert "beta" in parsed["links"]
|
|
report = lint_vault(vault)
|
|
assert report["findings"]["broken_links"] == []
|
|
assert not any(
|
|
item.get("target") == "attention" and item.get("page") == "concepts/alpha.md"
|
|
for item in report["findings"]["broken_links"]
|
|
)
|
|
|
|
|
|
def test_lint_archive_markdown_snapshot_does_not_attach_to_concept_slug(tmp_path: Path) -> None:
|
|
vault = tmp_path / "vault"
|
|
_page(vault, "concepts/attention.md", links=["beta"])
|
|
_page(vault, "concepts/beta.md", links=["attention"])
|
|
archived = vault / "_raw" / "_archived" / "attention.md"
|
|
archived.parent.mkdir(parents=True)
|
|
archived.write_text("# clip\n", encoding="utf-8")
|
|
_page(
|
|
vault,
|
|
"concepts/alpha.md",
|
|
links=["beta"],
|
|
snapshots='\n - "[attention](_raw/_archived/attention.md)"',
|
|
)
|
|
ledger = build_trust_ledger(vault, reviewed_at="2026-07-12T17:38:39+07:00")
|
|
write_trust_ledger(vault / "_meta" / "trust-ledger.json", ledger, vault=vault)
|
|
parsed = _parse_page(vault / "concepts/alpha.md", vault)
|
|
assert "attention" not in parsed["links"]
|
|
assert "beta" in parsed["links"]
|
|
report = lint_vault(vault)
|
|
assert report["findings"]["broken_links"] == []
|
|
|
|
|
|
def test_lint_missing_archive_markdown_snapshot_is_broken_path(tmp_path: Path) -> None:
|
|
vault = tmp_path / "vault"
|
|
_page(
|
|
vault,
|
|
"concepts/alpha.md",
|
|
links=["beta"],
|
|
snapshots='\n - "[ghost](_raw/_archived/ghost.md)"',
|
|
)
|
|
_page(vault, "concepts/beta.md", links=["alpha"])
|
|
ledger = build_trust_ledger(vault, reviewed_at="2026-07-12T17:38:39+07:00")
|
|
write_trust_ledger(vault / "_meta" / "trust-ledger.json", ledger, vault=vault)
|
|
report = lint_vault(vault)
|
|
assert {
|
|
"page": "concepts/alpha.md",
|
|
"target": "_raw/_archived/ghost.md",
|
|
} in report["findings"]["broken_links"]
|
|
|
|
|
|
def test_lint_missing_archive_wikilink_is_broken_path(tmp_path: Path) -> None:
|
|
vault = tmp_path / "vault"
|
|
_page(
|
|
vault,
|
|
"concepts/alpha.md",
|
|
links=["beta"],
|
|
snapshots="\n - [[_raw/_archived/ghost]]",
|
|
)
|
|
_page(vault, "concepts/beta.md", links=["alpha"])
|
|
ledger = build_trust_ledger(vault, reviewed_at="2026-07-12T17:38:39+07:00")
|
|
write_trust_ledger(vault / "_meta" / "trust-ledger.json", ledger, vault=vault)
|
|
report = lint_vault(vault)
|
|
assert {
|
|
"page": "concepts/alpha.md",
|
|
"target": "_raw/_archived/ghost.md",
|
|
} in report["findings"]["broken_links"]
|
|
|
|
|
|
|
|
def test_lint_vault_warns_on_undated_counts_and_skips_dated_ones(tmp_path: Path) -> None:
|
|
page = _page(tmp_path, "projects/alpha.md", links=["journal/today"])
|
|
page.write_text(
|
|
page.read_text(encoding="utf-8")
|
|
+ "\n".join(
|
|
[
|
|
"The repo has 23 stars, Apache-2.0.",
|
|
"Chrome Web Store: 69 users.",
|
|
"Traction: 291★ and 55+ customers.",
|
|
"MRR is $4,000.",
|
|
"GitHub: 1,754 stars (as of 2026-07-13).",
|
|
"3. Installs global skills.",
|
|
"> Quoted: 10 users.",
|
|
"Run `seed --users 5 users` once.",
|
|
"```",
|
|
"40 stars",
|
|
"```",
|
|
"## 2026-07-01 snapshot",
|
|
"12 open deals.",
|
|
"## Later",
|
|
"8 subscribers.",
|
|
]
|
|
)
|
|
+ "\n",
|
|
encoding="utf-8",
|
|
)
|
|
journal = _page(tmp_path, "journal/today.md", links=["projects/alpha"])
|
|
journal.write_text(journal.read_text(encoding="utf-8") + "500 downloads.\n", encoding="utf-8")
|
|
|
|
report = lint_vault(tmp_path, require_trust_ledger=False)
|
|
claims = report["findings"]["undated_claims"]
|
|
|
|
assert [claim["text"] for claim in claims] == [
|
|
"The repo has 23 stars, Apache-2.0.",
|
|
"Chrome Web Store: 69 users.",
|
|
"Traction: 291★ and 55+ customers.",
|
|
"MRR is $4,000.",
|
|
"8 subscribers.",
|
|
]
|
|
assert all(claim["page"] == "projects/alpha.md" for claim in claims)
|
|
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"
|