b138fd8e64
Files changed: - CHANGES.md - VERSION - instructions/dev/corpus-policy.md - instructions/wiki-ingest/SKILL.md - kb/index.md - kb/log.md - kb/sources/COLLECTION.md - kb/sources/INDEX.md - kb/sources/Source - AMD Powermanagement CPU.md - kb/sources/Source - Arch Linux Cheat Sheet.md - kb/sources/Source - Conversation - AGENTS.md Skill Restructuring Session 2026-08-04.md - kb/sources/Source - Conversation - Auto Mode and Tool Choice Session 2026-08-31.md - kb/sources/Source - Conversation - Comma Bug Budget Refund and Lint Report Path Session 2026-08-31.md - kb/sources/Source - Conversation - ENVIRONMENT.md as an Optional Third Session-Level File Session 2026-08-31.md - kb/sources/Source - Conversation - Gate Counting and Measured Calibration Session 2026-08-31.md - kb/sources/Source - Conversation - Hardening the Test Suite Against Silent Environment Dependencies Session 2026-08-31.md - kb/sources/Source - Conversation - Issue Triage Labels and TODO Retirement Session 2026-08-31.md - kb/sources/Source - Conversation - Nightly Drift-Check Workflow and doctor's Bootstrap Gap Session 2026-08-31.md - kb/sources/Source - Conversation - Two Round-Trip Defects Found by an Ingest Session 2026-08-31.md - kb/sources/Source - Conversation - Versioning CI-CD and Content Migration Session 2026-08-30.md - kb/sources/Source - Conversation - Write-Once Frontmatter Fields and touch --set Session 2026-08-31.md - kb/sources/Source - Copilot Skill Restructure Instructions.md - kb/sources/Source - Docker Cheatsheet.md - kb/sources/Source - Gitea Issue 41 - Issue Management and Label Scheme 2026-09-02.md - kb/sources/Source - Gitea Issues 62-63 - status-incoming Label Introduction 2026-09-04.md - kb/sources/Source - LLM Improvements Codex Analysis.md - kb/sources/Source - LLM Improvements Production Agent Gaps 2026.md - kb/sources/Source - LLM Improvements Sonnet Analysis.md - kb/sources/Source - LLM Wiki Pattern.md - kb/sources/Source - LLM Wiki v2.md - kb/sources/Source - MCP Read Server Implementation Session 2026-09-02.md - kb/sources/Source - Private-Instance Merge Correction and Issue 30 Session 2026-09-01.md - kb/sources/Source - Public Release, Corpus Purge and History Squash Session 2026-09-01.md - kb/sources/Source - Publish-Remote Gate and Issue Triage Session 2026-09-01.md - kb/sources/Source - Version Part Nomenclature and Breaking Change Gate Session 2026-09-02.md - kb/sources/Source - Wine.md - kb/sources/Source - qmd - GitHub Repository.md - kb/sources/analyses/Source - Copilot Skill Restructure Instructions.md - kb/sources/analyses/Source - LLM Improvements Codex Analysis.md - kb/sources/analyses/Source - LLM Improvements Production Agent Gaps 2026.md - kb/sources/analyses/Source - LLM Improvements Sonnet Analysis.md - kb/sources/articles/Source - AMD Powermanagement CPU.md - kb/sources/articles/Source - LLM Wiki Pattern.md - kb/sources/articles/Source - LLM Wiki v2.md - kb/sources/documents/Source - qmd - GitHub Repository.md - kb/sources/notes/Source - Arch Linux Cheat Sheet.md - kb/sources/notes/Source - Docker Cheatsheet.md - kb/sources/notes/Source - Wine.md - kb/sources/trackers/Source - Gitea Issue 41 - Issue Management and Label Scheme 2026-09-02.md - kb/sources/trackers/Source - Gitea Issues 62-63 - status-incoming Label Introduction 2026-09-04.md - kb/sources/transcripts/Source - Conversation - AGENTS.md Skill Restructuring Session 2026-08-04.md - kb/sources/transcripts/Source - Conversation - Auto Mode and Tool Choice Session 2026-08-31.md - kb/sources/transcripts/Source - Conversation - Comma Bug Budget Refund and Lint Report Path Session 2026-08-31.md - kb/sources/transcripts/Source - Conversation - ENVIRONMENT.md as an Optional Third Session-Level File Session 2026-08-31.md - kb/sources/transcripts/Source - Conversation - Gate Counting and Measured Calibration Session 2026-08-31.md - kb/sources/transcripts/Source - Conversation - Hardening the Test Suite Against Silent Environment Dependencies Session 2026-08-31.md - kb/sources/transcripts/Source - Conversation - Issue Triage Labels and TODO Retirement Session 2026-08-31.md - kb/sources/transcripts/Source - Conversation - Nightly Drift-Check Workflow and doctor's Bootstrap Gap Session 2026-08-31.md - kb/sources/transcripts/Source - Conversation - Two Round-Trip Defects Found by an Ingest Session 2026-08-31.md - kb/sources/transcripts/Source - Conversation - Versioning CI-CD and Content Migration Session 2026-08-30.md - kb/sources/transcripts/Source - Conversation - Write-Once Frontmatter Fields and touch --set Session 2026-08-31.md - kb/sources/transcripts/Source - MCP Read Server Implementation Session 2026-09-02.md - kb/sources/transcripts/Source - Private-Instance Merge Correction and Issue 30 Session 2026-09-01.md - kb/sources/transcripts/Source - Public Release, Corpus Purge and History Squash Session 2026-09-01.md - kb/sources/transcripts/Source - Publish-Remote Gate and Issue Triage Session 2026-09-01.md - kb/sources/transcripts/Source - Version Part Nomenclature and Breaking Change Gate Session 2026-09-02.md - tools/CONTRACT.md - tools/chemenu/lint_core.py - tools/chemenu/tests/conftest.py - tools/chemenu/tests/test_lint.py - tools/chemenu/tests/test_new_page.py - tools/chemenu/tests/test_provenance.py - tools/chemenu/tests/test_touch.py - tools/chemenu/tests/test_type_resolver.py - tools/chemenu/tests/test_xref.py - types/source.md - types/source.schema.yaml - types/type-spec.md - work/reclassify-source-types/README.md - work/reclassify-source-types/plan.md
895 lines
41 KiB
Python
895 lines
41 KiB
Python
import json
|
|
import shutil
|
|
from datetime import date
|
|
|
|
import pytest
|
|
|
|
from chemenu import config, kb_state
|
|
from chemenu.type_resolver import resolver
|
|
from chemenu.commands.lint import (
|
|
HARD_ERROR_KEYS,
|
|
hard_error_keys,
|
|
has_hard_errors,
|
|
lint_command,
|
|
render_markdown,
|
|
render_summary,
|
|
run_lint,
|
|
)
|
|
from chemenu.frontmatter_io import write_page
|
|
from chemenu.provenance import cite_id, render_cite_block
|
|
from chemenu.version import Version
|
|
|
|
|
|
def test_lint_detects_unparsable_frontmatter(kb_dir):
|
|
"""A page whose YAML is malformed reads back as `{}` everywhere else, so
|
|
without this check it would slip past every frontmatter-driven finding
|
|
and only surface as an 'Other / Unclassified' index entry."""
|
|
(kb_dir / "entities/tools/broken.md").write_text(
|
|
"---\ntype: types/entity.md\ntags: [unclosed\n---\n\n# broken\n",
|
|
encoding="utf-8",
|
|
)
|
|
report = run_lint(kb_dir)
|
|
issue = next(i for i in report["frontmatter_errors"] if i["page"] == "broken")
|
|
assert "invalid YAML" in issue["error"]
|
|
|
|
|
|
def test_lint_detects_missing_frontmatter_block(kb_dir):
|
|
(kb_dir / "concepts/No Frontmatter.md").write_text(
|
|
"# No Frontmatter\n\nJust prose.\n", encoding="utf-8"
|
|
)
|
|
report = run_lint(kb_dir)
|
|
issue = next(i for i in report["frontmatter_errors"] if i["page"] == "No Frontmatter")
|
|
assert "frontmatter block" in issue["error"]
|
|
|
|
|
|
def test_lint_reports_most_linked_pages(kb_dir):
|
|
"""wiki-status reads this instead of re-deriving the link graph."""
|
|
report = run_lint(kb_dir)
|
|
assert report["inbound_counts"]["aurora"] >= 1
|
|
hubs = {entry["page"] for entry in report["most_linked"]}
|
|
assert "aurora" in hubs
|
|
assert all(entry["inbound"] > 0 for entry in report["most_linked"])
|
|
|
|
|
|
def test_lint_detects_broken_wikilink(kb_dir):
|
|
path = kb_dir / "entities/tools/gdeploy.md"
|
|
write_page(
|
|
path,
|
|
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-07-25",
|
|
"modified": "2026-07-25", "related": [], "sources": [], "confidence": 0.8},
|
|
"\n# gdeploy\n\n## Description\n\nSee [[Nonexistent Page]] for details.\n",
|
|
)
|
|
report = run_lint(kb_dir)
|
|
assert {"page": "gdeploy", "target": "Nonexistent Page"} in report["broken_links"]
|
|
|
|
|
|
def test_lint_fixture_has_no_dangling_frontmatter_refs(kb_dir):
|
|
assert run_lint(kb_dir)["dangling_frontmatter_refs"] == []
|
|
|
|
|
|
def test_lint_detects_dangling_related_ref(kb_dir):
|
|
"""`related:` names a page title, but `broken_links` only walks body
|
|
wikilinks - so before this check a rename left the array pointing at
|
|
nothing and every lint still came back clean."""
|
|
write_page(
|
|
kb_dir / "entities/tools/gdeploy.md",
|
|
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-07-25",
|
|
"modified": "2026-07-25", "related": ["Renamed Away"], "sources": [], "confidence": 0.8},
|
|
"\n# gdeploy\n\n## Description\n\nDeploy tool.\n",
|
|
)
|
|
report = run_lint(kb_dir)
|
|
assert {"page": "gdeploy", "field": "related", "target": "Renamed Away"} in report[
|
|
"dangling_frontmatter_refs"
|
|
]
|
|
|
|
|
|
def test_lint_detects_url_pasted_into_sources(kb_dir):
|
|
"""A URL is not a page title; it belongs in the source page's `source_url:`."""
|
|
write_page(
|
|
kb_dir / "entities/tools/gdeploy.md",
|
|
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-07-25",
|
|
"modified": "2026-07-25", "related": [], "sources": ["https://example.com/x/"],
|
|
"confidence": 0.8},
|
|
"\n# gdeploy\n\n## Description\n\nDeploy tool.\n",
|
|
)
|
|
targets = {i["target"] for i in run_lint(kb_dir)["dangling_frontmatter_refs"]}
|
|
assert "https://example.com/x/" in targets
|
|
|
|
|
|
def test_lint_detects_dangling_source_page_entity_ref(kb_dir):
|
|
"""Source pages declare `entities:`/`concepts:`, not `related:`/`sources:` -
|
|
the field list comes from each type-spec's own `page_ref_fields:`."""
|
|
write_page(
|
|
kb_dir / "sources/notes/Source - Aurora.md",
|
|
{"type": "types/source.md", "source_type": "notes", "author": "Torben",
|
|
"source": "raw/notes/Aurora.md", "date": "2026-08-02", "tags": [],
|
|
"entities": ["aurora", "ghost-entity"], "concepts": []},
|
|
"\n# Source: Aurora\n\n## Summary\n\nNotes.\n",
|
|
)
|
|
report = run_lint(kb_dir)
|
|
assert {
|
|
"page": "Source - Aurora", "field": "entities", "target": "ghost-entity"
|
|
} in report["dangling_frontmatter_refs"]
|
|
|
|
|
|
def test_lint_ignores_tags_and_raw_files_as_page_refs(kb_dir):
|
|
"""`tags` are free-form labels and `raw_files` are filesystem paths; only
|
|
`raw_files` has its own check (broken_raw_refs)."""
|
|
write_page(
|
|
kb_dir / "entities/tools/gdeploy.md",
|
|
{"type": "types/entity.md", "entity_type": "tool", "tags": ["not-a-page"],
|
|
"created": "2026-07-25", "modified": "2026-07-25", "related": [], "sources": [],
|
|
"confidence": 0.8},
|
|
"\n# gdeploy\n\n## Description\n\nDeploy tool.\n",
|
|
)
|
|
targets = {i["target"] for i in run_lint(kb_dir)["dangling_frontmatter_refs"]}
|
|
assert "not-a-page" not in targets
|
|
|
|
|
|
def test_lint_detects_orphan_page(kb_dir):
|
|
write_page(
|
|
kb_dir / "entities/tools/isolated.md",
|
|
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-07-25",
|
|
"modified": "2026-07-25", "related": [], "sources": [], "confidence": 0.8},
|
|
"\n# isolated\n\n## Description\n\nNothing links here.\n",
|
|
)
|
|
report = run_lint(kb_dir)
|
|
assert "isolated" in report["orphan_pages"]
|
|
# aurora and Borealis link to each other, so they must not be reported as orphans.
|
|
assert "aurora" not in report["orphan_pages"]
|
|
assert "Borealis" not in report["orphan_pages"]
|
|
|
|
|
|
def test_lint_detects_missing_frontmatter_fields(kb_dir):
|
|
"""Missing-field detection now comes solely from the type's schema (its
|
|
`required:` list), not a separately hand-maintained REQUIRED_FIELDS dict -
|
|
so only fields the schema actually requires (created/modified/provenance/
|
|
summary) are flagged, not schema-optional ones like tags/confidence."""
|
|
write_page(
|
|
kb_dir / "entities/tools/incomplete.md",
|
|
{"type": "types/entity.md", "entity_type": "tool"},
|
|
"\n# incomplete\n",
|
|
)
|
|
report = run_lint(kb_dir)
|
|
issue = next(i for i in report["schema_validation_errors"] if i["page"] == "incomplete")
|
|
assert "created" in issue["error"]
|
|
assert "summary" in issue["error"]
|
|
|
|
|
|
def test_lint_detects_duplicate_titles(kb_dir):
|
|
write_page(
|
|
kb_dir / "concepts/gdeploy.md",
|
|
{"type": "types/concept.md", "concept_type": "pattern", "tags": [], "created": "2026-07-25",
|
|
"modified": "2026-07-25", "related": [], "sources": [], "confidence": 0.5},
|
|
"\n# gdeploy\n\nDuplicate stem with the tool page.\n",
|
|
)
|
|
report = run_lint(kb_dir)
|
|
entry = next(i for i in report["duplicate_titles"] if i["stem"] == "gdeploy")
|
|
assert len(entry["paths"]) == 2
|
|
|
|
|
|
def test_lint_detects_title_mismatch(kb_dir):
|
|
write_page(
|
|
kb_dir / "entities/tools/mismatched.md",
|
|
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-07-25",
|
|
"modified": "2026-07-25", "related": [], "sources": [], "confidence": 0.8},
|
|
"\n# Totally Different Title\n",
|
|
)
|
|
report = run_lint(kb_dir)
|
|
entry = next(i for i in report["title_mismatches"] if i["page"] == "mismatched")
|
|
assert entry["h1"] == "Totally Different Title"
|
|
|
|
|
|
def test_clean_wiki_has_no_hard_errors(kb_dir):
|
|
report = run_lint(kb_dir)
|
|
assert report["broken_links"] == []
|
|
assert report["duplicate_titles"] == []
|
|
|
|
|
|
def test_lint_detects_a_misplaced_page(kb_dir):
|
|
write_page(
|
|
kb_dir / "entities/systems/misplaced-tool.md",
|
|
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-07-25",
|
|
"modified": "2026-07-25", "related": [], "sources": [], "confidence": 0.8},
|
|
"\n# misplaced-tool\n",
|
|
)
|
|
report = run_lint(kb_dir)
|
|
entry = next(i for i in report["misplaced_pages"] if i["page"] == "misplaced-tool")
|
|
assert entry["at"].endswith("entities/systems")
|
|
assert entry["should_be"].endswith("entities/tools")
|
|
# Advisory, not a hard error - a corpus whose only finding is this one
|
|
# must stay green (see test_a_lint_run_with_only_a_misplaced_page_is_green).
|
|
assert "misplaced_pages" not in HARD_ERROR_KEYS
|
|
|
|
|
|
def test_a_report_with_only_a_misplaced_page_stays_green():
|
|
report = {key: [] for key in HARD_ERROR_KEYS}
|
|
report["misplaced_pages"] = [{"page": "x", "at": "a", "should_be": "b"}]
|
|
assert not has_hard_errors(report)
|
|
|
|
|
|
def test_lint_is_silent_about_correctly_placed_pages(kb_dir):
|
|
"""The fixture wiki's own pages (aurora under entities/systems/,
|
|
gdeploy under entities/tools/, ...) are all at their computed
|
|
location - the check must not fire on them."""
|
|
assert run_lint(kb_dir)["misplaced_pages"] == []
|
|
|
|
|
|
def test_lint_detects_a_nested_page_as_a_hard_error(kb_dir):
|
|
"""Gitea #57: a page sitting a level below its area (like the three real
|
|
`kb/entities/projects/<owner>/*.md` pages that prompted this) is not just
|
|
misplaced - the generated catalog folds it into the area silently, so
|
|
this is hard rather than advisory."""
|
|
write_page(
|
|
kb_dir / "entities/projects/someowner/nested-tool.md",
|
|
{"type": "types/entity.md", "entity_type": "project", "tags": [], "created": "2026-07-25",
|
|
"modified": "2026-07-25", "related": [], "sources": [], "confidence": 0.8},
|
|
"\n# nested-tool\n",
|
|
)
|
|
report = run_lint(kb_dir)
|
|
entry = next(i for i in report["nested_pages"] if i["page"] == "nested-tool")
|
|
assert entry["at"].endswith("entities/projects/someowner")
|
|
assert entry["depth"] == 2
|
|
assert "nested_pages" in HARD_ERROR_KEYS
|
|
assert has_hard_errors(report)
|
|
|
|
|
|
def test_lint_is_silent_about_pages_directly_in_an_area(kb_dir):
|
|
assert run_lint(kb_dir)["nested_pages"] == []
|
|
|
|
|
|
# --- collections that outgrew the shard threshold without areas (Gitea #59) ---
|
|
|
|
|
|
def _fill_collection(kb_dir, collection: str, type_path: str, field: str, distribution: dict):
|
|
"""Write pages flat into `kb/<collection>/`, `distribution` many per subtype
|
|
value - the shape a collection is in when nobody ever created an area.
|
|
|
|
The precondition is established rather than assumed: the fixture corpus
|
|
places its concept page in an area (as the real one does now), and a single
|
|
area is enough to make this finding stand down.
|
|
"""
|
|
for existing in sorted((kb_dir / collection).rglob("*.md")):
|
|
if existing.parent != kb_dir / collection:
|
|
existing.rename(kb_dir / collection / existing.name)
|
|
for area in sorted(p for p in (kb_dir / collection).iterdir() if p.is_dir()):
|
|
area.rmdir()
|
|
|
|
n = 0
|
|
for value, count in distribution.items():
|
|
for _ in range(count):
|
|
n += 1
|
|
write_page(
|
|
kb_dir / collection / f"page-{n:03d}.md",
|
|
{
|
|
"type": type_path, field: value,
|
|
"created": "2026-08-01", "modified": "2026-08-01",
|
|
"provenance": "general", "summary": f"Page {n}",
|
|
},
|
|
f"\n# page-{n:03d}\n",
|
|
)
|
|
|
|
|
|
def test_lint_recommends_areas_for_a_collection_past_the_threshold(kb_dir):
|
|
"""The concepts shape: 80 pages in one table, no areas, and a subtype axis
|
|
whose largest value (28) lands well under the threshold. Sharding is
|
|
per-area and automatic, so a collection with no areas never splits however
|
|
large it grows - the threshold is a dead value until someone makes areas."""
|
|
_fill_collection(
|
|
kb_dir, "concepts", "types/concept.md", "concept_type",
|
|
{"workflow": 28, "architecture": 20, "pattern": 17,
|
|
"decision": 7, "problem": 5, "protocol": 3},
|
|
)
|
|
finding = next(
|
|
i for i in run_lint(kb_dir)["unsharded_collections"] if i["collection"] == "concepts"
|
|
)
|
|
assert finding["field"] == "concept_type"
|
|
# Largest first, so the reader sees the area that decides whether it helps.
|
|
assert finding["distribution"][0] == {"value": "workflow", "count": 28}
|
|
assert {d["value"] for d in finding["distribution"]} == {
|
|
"workflow", "architecture", "pattern", "decision", "problem", "protocol"
|
|
}
|
|
assert finding["count"] == sum(d["count"] for d in finding["distribution"])
|
|
# `types/concept.md` carries the layout, so only the move is still owed -
|
|
# the report line says which half of the fix that is.
|
|
assert finding["layout_declared"] is True
|
|
|
|
|
|
def test_the_area_recommendation_says_the_layout_is_missing_when_it_is(kb_dir, tmp_path, monkeypatch):
|
|
"""A collection whose type has a `subtype_field:` but no `layout:` has
|
|
nothing for `move` to compute a destination from - the fix starts one step
|
|
earlier, and the report says so.
|
|
|
|
`types/source.md` used to be the worked example, but Gitea #66 gave it a
|
|
`layout:` covering every enum value, and `comparison` (the other type with
|
|
no `layout:`) has no `subtype_field:` at all to exercise this branch with.
|
|
So this is now the one place in the suite that still needs the
|
|
"has a subtype axis, hasn't been given a layout" shape, which is a fixture
|
|
type-spec, copied in beside the real ones so every other fixture page
|
|
(entities, concepts, sources, comparisons) keeps resolving normally.
|
|
|
|
Filled into `comparisons/` rather than `sources/`: the fixture wiki's own
|
|
source page now lives correctly under `sources/notes/`, and reusing that
|
|
directory here would mix its real `types/source.md` pages into this
|
|
fixture type's counts - `comparisons/` stays empty in the base fixture,
|
|
so filling it is self-contained."""
|
|
types_dir = tmp_path / "types"
|
|
shutil.copytree(config._PACKAGE_ROOT / "types", types_dir)
|
|
(types_dir / "badtype.md").write_text(
|
|
"---\n"
|
|
"type: types/type-spec.md\n"
|
|
"name: badtype\n"
|
|
"description: fixture type with a subtype field and no layout\n"
|
|
"schema: types/badtype.schema.yaml\n"
|
|
"subtype_field: bad_type\n"
|
|
"base_dir: badthings\n"
|
|
"---\n\n# badtype\n",
|
|
encoding="utf-8",
|
|
)
|
|
(types_dir / "badtype.schema.yaml").write_text(
|
|
"type: object\n"
|
|
"properties:\n"
|
|
" type: {type: string, const: 'types/badtype.md'}\n"
|
|
" bad_type: {type: string}\n"
|
|
" created: {type: string, format: date}\n"
|
|
" modified: {type: string, format: date}\n"
|
|
" provenance: {type: string, enum: [sourced, general, mixed]}\n"
|
|
" summary: {type: string}\n"
|
|
"required: [type, bad_type, created, modified, provenance, summary]\n"
|
|
"additionalProperties: false\n",
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setattr(config, "TYPES_DIR", types_dir)
|
|
monkeypatch.setattr(resolver, "_repo_root", tmp_path)
|
|
|
|
_fill_collection(
|
|
kb_dir, "comparisons", "types/badtype.md", "bad_type",
|
|
{"notes": 30, "article": 21},
|
|
)
|
|
report = run_lint(kb_dir)
|
|
finding = next(i for i in report["unsharded_collections"] if i["collection"] == "comparisons")
|
|
assert finding["layout_declared"] is False
|
|
assert "Declare a `layout:`" in render_markdown(report)
|
|
|
|
|
|
def test_the_area_recommendation_is_not_a_failure(kb_dir):
|
|
"""It reports a collection that has outgrown a layout, not a broken one.
|
|
A corpus whose only finding is this must stay green, or every instance
|
|
goes red on the release that shipped the check."""
|
|
_fill_collection(
|
|
kb_dir, "concepts", "types/concept.md", "concept_type",
|
|
{"workflow": 28, "architecture": 24},
|
|
)
|
|
report = run_lint(kb_dir)
|
|
assert report["unsharded_collections"]
|
|
assert "unsharded_collections" not in HARD_ERROR_KEYS
|
|
assert not has_hard_errors({**{key: [] for key in HARD_ERROR_KEYS},
|
|
"unsharded_collections": report["unsharded_collections"]})
|
|
|
|
|
|
def test_lint_is_silent_about_a_collection_under_the_threshold(kb_dir):
|
|
"""The pre-#66 sources shape: 29 pages, lopsided across `source_type`
|
|
because the schema handed out `notes` as a default whenever nobody chose
|
|
a value - and under the threshold anyway, so it never fires regardless.
|
|
That is what kept the bad split (one area of 25 plus four splinters) from
|
|
ever being recommended while the default still existed, without the check
|
|
needing to know anything about sources."""
|
|
_fill_collection(
|
|
kb_dir, "sources", "types/source.md", "source_type",
|
|
{"notes": 25, "article": 3, "document": 1},
|
|
)
|
|
assert run_lint(kb_dir)["unsharded_collections"] == []
|
|
|
|
|
|
def test_lint_is_silent_when_the_split_would_not_help(kb_dir):
|
|
"""Past the threshold, but 70 of 80 share one subtype value: splitting
|
|
produces one area still over the threshold plus splinters, which is not an
|
|
improvement. The second half of the criterion, and the one a
|
|
threshold-only check would have got wrong."""
|
|
_fill_collection(
|
|
kb_dir, "concepts", "types/concept.md", "concept_type",
|
|
{"workflow": 70, "architecture": 6, "pattern": 5},
|
|
)
|
|
assert run_lint(kb_dir)["unsharded_collections"] == []
|
|
|
|
|
|
def test_lint_is_silent_once_the_collection_has_areas(kb_dir):
|
|
"""After the fix - the pages sit in their areas - the finding goes away,
|
|
and `index rebuild` shards whatever outgrows the threshold from here."""
|
|
_fill_collection(
|
|
kb_dir, "concepts", "types/concept.md", "concept_type",
|
|
{"workflow": 28, "architecture": 24},
|
|
)
|
|
for page in sorted((kb_dir / "concepts").glob("page-*.md")):
|
|
area = "workflows" if "workflow" in page.read_text(encoding="utf-8") else "architectures"
|
|
(kb_dir / "concepts" / area).mkdir(exist_ok=True)
|
|
page.rename(kb_dir / "concepts" / area / page.name)
|
|
assert run_lint(kb_dir)["unsharded_collections"] == []
|
|
|
|
|
|
def test_lint_flags_legacy_citation_marker_as_hard_error(kb_dir):
|
|
write_page(
|
|
kb_dir / "concepts/protocols/Modbus.md",
|
|
{"type": "types/concept.md", "concept_type": "protocol", "tags": [], "created": "2026-07-25",
|
|
"modified": "2026-07-25", "related": [], "sources": ["Source - Aurora"], "confidence": 0.7},
|
|
"\n# Modbus\n\n## Definition\n\nUses port 502 ^[[Source - Aurora]].\n",
|
|
)
|
|
report = run_lint(kb_dir)
|
|
assert {"page": "Modbus", "marker": "^[[Source - Aurora]]"} in report["legacy_citation_markers"]
|
|
assert has_hard_errors(report) is True
|
|
|
|
|
|
def test_lint_flags_undefined_footnote_ref_as_hard_error(kb_dir):
|
|
write_page(
|
|
kb_dir / "concepts/protocols/Modbus.md",
|
|
{"type": "types/concept.md", "concept_type": "protocol", "tags": [], "created": "2026-07-25",
|
|
"modified": "2026-07-25", "related": [], "sources": [], "confidence": 0.7},
|
|
"\n# Modbus\n\n## Definition\n\nUses port 502 [^s-ghost].\n",
|
|
)
|
|
report = run_lint(kb_dir)
|
|
assert {"page": "Modbus", "ref": "s-ghost"} in report["undefined_footnote_refs"]
|
|
assert has_hard_errors(report) is True
|
|
|
|
|
|
def test_lint_flags_orphan_footnote_def_as_hard_error(kb_dir):
|
|
cid = cite_id("Source - Aurora")
|
|
block = render_cite_block({cid: ("Source - Aurora", None)})
|
|
write_page(
|
|
kb_dir / "concepts/protocols/Modbus.md",
|
|
{"type": "types/concept.md", "concept_type": "protocol", "tags": [], "created": "2026-07-25",
|
|
"modified": "2026-07-25", "related": [], "sources": ["Source - Aurora"], "confidence": 0.7},
|
|
f"\n# Modbus\n\n## Definition\n\nIndustrial protocol, no citation here.\n\n{block}",
|
|
)
|
|
report = run_lint(kb_dir)
|
|
assert {"page": "Modbus", "id": cid, "source": "Source - Aurora"} in report["orphan_footnote_defs"]
|
|
assert has_hard_errors(report) is True
|
|
|
|
|
|
def test_lint_clean_footnote_citation_has_no_hard_errors(kb_dir):
|
|
cid = cite_id("Source - Aurora")
|
|
block = render_cite_block({cid: ("Source - Aurora", None)})
|
|
write_page(
|
|
kb_dir / "concepts/protocols/Modbus.md",
|
|
{"type": "types/concept.md", "concept_type": "protocol", "tags": [], "created": "2026-07-25",
|
|
"modified": "2026-07-25", "related": [], "sources": ["Source - Aurora"], "confidence": 0.7},
|
|
f"\n# Modbus\n\n## Definition\n\nUses port 502 [^{cid}].\n\n{block}",
|
|
)
|
|
report = run_lint(kb_dir)
|
|
assert report["legacy_citation_markers"] == []
|
|
assert report["undefined_footnote_refs"] == []
|
|
assert report["orphan_footnote_defs"] == []
|
|
|
|
|
|
def test_lint_detects_quote_limit_violation(kb_dir):
|
|
write_page(
|
|
kb_dir / "entities/tools/quotey.md",
|
|
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-07-25",
|
|
"modified": "2026-07-25", "related": [], "sources": [], "confidence": 0.8},
|
|
"\n# quotey\n\n> First quote\n\n> Second quote\n\n> Third quote\n",
|
|
)
|
|
report = run_lint(kb_dir)
|
|
entry = next(i for i in report["quote_limit_violations"] if i["page"] == "quotey")
|
|
assert entry["quote_count"] == 3
|
|
# Quote-limit overages are advisory, not a hard error.
|
|
assert "quotey" not in [i["page"] for i in report.get("broken_links", [])]
|
|
|
|
|
|
def test_collection_contracts_are_not_scanned_as_pages(kb_dir):
|
|
"""COLLECTION.md sits one level below the kb root, where the meta-file guard
|
|
does not reach. It carries no page frontmatter, so scanning it would report a
|
|
frontmatter error per collection and a duplicate-title collision across them."""
|
|
report = run_lint(kb_dir)
|
|
assert report["frontmatter_errors"] == []
|
|
assert not any(d["stem"] == "COLLECTION" for d in report["duplicate_titles"])
|
|
|
|
|
|
def test_lint_flags_invalid_type_path(kb_dir):
|
|
write_page(
|
|
kb_dir / "entities/tools/bad-type.md",
|
|
{"type": "not-a-path", "entity_type": "tool", "tags": [], "created": "2026-07-25",
|
|
"modified": "2026-07-25", "related": [], "sources": [], "confidence": 0.8},
|
|
"\n# bad-type\n",
|
|
)
|
|
report = run_lint(kb_dir)
|
|
entry = next(i for i in report["invalid_type_paths"] if i["page"] == "bad-type")
|
|
assert entry["type"] == "not-a-path"
|
|
|
|
|
|
def test_lint_flags_unresolvable_type_path(kb_dir):
|
|
write_page(
|
|
kb_dir / "entities/tools/unresolvable-type.md",
|
|
{"type": "types/does-not-exist.md", "entity_type": "tool", "tags": [], "created": "2026-07-25",
|
|
"modified": "2026-07-25", "related": [], "sources": [], "confidence": 0.8},
|
|
"\n# unresolvable-type\n",
|
|
)
|
|
report = run_lint(kb_dir)
|
|
entry = next(i for i in report["type_resolution_errors"] if i["page"] == "unresolvable-type")
|
|
assert entry["type"] == "types/does-not-exist.md"
|
|
|
|
|
|
def test_lint_flags_schema_validation_error(kb_dir):
|
|
write_page(
|
|
kb_dir / "entities/tools/bad-schema.md",
|
|
{
|
|
"type": "types/entity.md", "entity_type": "not-a-real-entity-type", "tags": [],
|
|
"created": "2026-07-25", "modified": "2026-07-25", "related": [], "sources": [],
|
|
"confidence": 0.8, "provenance": "general", "summary": "x",
|
|
},
|
|
"\n# bad-schema\n",
|
|
)
|
|
report = run_lint(kb_dir)
|
|
entry = next(i for i in report["schema_validation_errors"] if i["page"] == "bad-schema")
|
|
assert "entity_type" in entry["error"]
|
|
|
|
|
|
def test_render_summary_drops_empty_and_informational_sections(kb_dir):
|
|
"""The full report is over 90% "None found." on a healthy corpus, which is
|
|
what pushed an agent to page through it with head/tail and then re-run
|
|
lint to see another part."""
|
|
report = run_lint(kb_dir)
|
|
summary = render_summary(report)
|
|
assert "None found." not in summary
|
|
assert "Most-Linked Pages" not in summary
|
|
assert "## Semantic Review (LLM to complete)" in summary
|
|
assert len(summary) < len(render_markdown(report))
|
|
|
|
|
|
def test_render_summary_keeps_sections_that_found_something(kb_dir):
|
|
write_page(
|
|
kb_dir / "entities/tools/dangling.md",
|
|
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-07-25",
|
|
"modified": "2026-07-25", "related": [], "sources": [], "confidence": 0.8},
|
|
"\n# dangling\n\nPoints at [[No Such Page]].\n",
|
|
)
|
|
summary = render_summary(run_lint(kb_dir))
|
|
assert "Broken Wikilinks" in summary
|
|
assert "No Such Page" in summary
|
|
|
|
|
|
def test_render_summary_says_so_when_there_is_nothing(kb_dir):
|
|
report = run_lint(kb_dir)
|
|
for key in report:
|
|
if isinstance(report[key], list):
|
|
report[key] = []
|
|
assert "No structural findings." in render_summary(report)
|
|
|
|
|
|
def test_lint_writes_a_report_and_names_its_path(kb_dir, raw_dir, tmp_path, monkeypatch, capsys):
|
|
"""Without a printed path the only way back to the full report is a second
|
|
lint call - the double-charge this default exists to remove."""
|
|
monkeypatch.setattr(config, "ROOT", tmp_path)
|
|
monkeypatch.setattr(config, "KB_DIR", kb_dir)
|
|
monkeypatch.setattr(config, "RAW_DIR", raw_dir)
|
|
lint_command(json_out=False, markdown_out=None, full=False, fail_on_error=False)
|
|
|
|
written = tmp_path / "reports" / f"Lint Report {date.today().isoformat()}.md"
|
|
assert written.exists()
|
|
assert written.read_text(encoding="utf-8").startswith("---\ntype: types/lint-report.md\n")
|
|
out = capsys.readouterr().out
|
|
assert "reports/Lint Report" in out
|
|
assert "None found." not in out
|
|
|
|
|
|
def test_lint_full_prints_the_whole_report(kb_dir, raw_dir, tmp_path, monkeypatch, capsys):
|
|
monkeypatch.setattr(config, "ROOT", tmp_path)
|
|
monkeypatch.setattr(config, "KB_DIR", kb_dir)
|
|
monkeypatch.setattr(config, "RAW_DIR", raw_dir)
|
|
lint_command(json_out=False, markdown_out=None, full=True, fail_on_error=False)
|
|
assert "None found." in capsys.readouterr().out
|
|
|
|
|
|
def test_lint_json_writes_no_report(kb_dir, raw_dir, tmp_path, monkeypatch, capsys):
|
|
"""`--json` is a machine-readable dump of the same findings; writing a
|
|
second copy to reports/ would be noise the caller never asked for."""
|
|
monkeypatch.setattr(config, "ROOT", tmp_path)
|
|
monkeypatch.setattr(config, "KB_DIR", kb_dir)
|
|
monkeypatch.setattr(config, "RAW_DIR", raw_dir)
|
|
lint_command(json_out=True, markdown_out=None, full=False, fail_on_error=False)
|
|
assert not (tmp_path / "reports").exists()
|
|
assert json.loads(capsys.readouterr().out)["page_count"] > 0
|
|
|
|
|
|
def test_lint_markdown_option_overrides_the_default_path(kb_dir, raw_dir, tmp_path, monkeypatch):
|
|
monkeypatch.setattr(config, "ROOT", tmp_path)
|
|
monkeypatch.setattr(config, "KB_DIR", kb_dir)
|
|
monkeypatch.setattr(config, "RAW_DIR", raw_dir)
|
|
target = tmp_path / "elsewhere" / "report.md"
|
|
lint_command(json_out=False, markdown_out=target, full=False, fail_on_error=False)
|
|
assert target.exists()
|
|
assert not (tmp_path / "reports").exists()
|
|
|
|
|
|
def test_lint_ignores_citation_syntax_shown_as_code(kb_dir):
|
|
"""A page that documents the citation mechanism writes the notation
|
|
instead of using it. Before code was masked out, both the backticked
|
|
marker and the fenced definition line counted as real references, and
|
|
`undefined_footnote_refs` is a hard error - so the wiki could not hold a
|
|
page about its own syntax. The only way out was to describe the notation
|
|
without writing it, which is invisible to whoever reads the page later."""
|
|
write_page(
|
|
kb_dir / "concepts/Citation Mechanism.md",
|
|
{"type": "types/concept.md", "concept_type": "protocol", "tags": [], "created": "2026-08-31",
|
|
"modified": "2026-08-31", "related": [], "sources": [], "confidence": 0.8,
|
|
"provenance": "general", "summary": "Notation shown as code, not used."},
|
|
"\n# Citation Mechanism\n\n## Definition\n\n"
|
|
"`cite add` prints a `[^cite-id]` marker to paste at the fact, and upserts\n"
|
|
"its definition into the trailing block:\n\n"
|
|
"```markdown\n[^s-beispiel]: [[Source - Beispiel]]\n```\n",
|
|
)
|
|
report = run_lint(kb_dir)
|
|
assert [i for i in report["undefined_footnote_refs"] if i["page"] == "Citation Mechanism"] == []
|
|
assert [i for i in report["orphan_footnote_defs"] if i["page"] == "Citation Mechanism"] == []
|
|
|
|
|
|
def test_lint_still_sees_a_real_citation_beside_a_mentioned_one(kb_dir):
|
|
"""The masking must not overshoot: a page may cite a source in the same
|
|
sentence in which it names the notation."""
|
|
cid = cite_id("Source - Aurora")
|
|
block = render_cite_block({cid: ("Source - Aurora", None)})
|
|
write_page(
|
|
kb_dir / "concepts/Mixed Citation.md",
|
|
{"type": "types/concept.md", "concept_type": "protocol", "tags": [], "created": "2026-08-31",
|
|
"modified": "2026-08-31", "related": [], "sources": ["Source - Aurora"], "confidence": 0.8,
|
|
"provenance": "sourced", "summary": "A real citation beside a mentioned one."},
|
|
f"\n# Mixed Citation\n\n## Definition\n\nThe marker `[^s-mentioned]` is written like "
|
|
f"this one [^{cid}].\n\n{block}",
|
|
)
|
|
report = run_lint(kb_dir)
|
|
assert [i for i in report["undefined_footnote_refs"] if i["page"] == "Mixed Citation"] == []
|
|
assert [i for i in report["orphan_footnote_defs"] if i["page"] == "Mixed Citation"] == []
|
|
assert [i for i in report["citation_frontmatter_drift"] if i["page"] == "Mixed Citation"] == []
|
|
|
|
|
|
def test_lint_ignores_wikilink_examples_in_code(kb_dir):
|
|
"""The same blindness, at the second place it mattered: an example
|
|
`[[wikilink]]` in a fenced block is notation, not a link, and a link is a
|
|
hard error when its target does not exist."""
|
|
write_page(
|
|
kb_dir / "concepts/Wikilink Syntax.md",
|
|
{"type": "types/concept.md", "concept_type": "protocol", "tags": [], "created": "2026-08-31",
|
|
"modified": "2026-08-31", "related": [], "sources": [], "confidence": 0.8,
|
|
"provenance": "general", "summary": "Notation shown as code, not used."},
|
|
"\n# Wikilink Syntax\n\n## Definition\n\nA link is written `[[Page Title]]`:\n\n"
|
|
"```markdown\nSee [[Some Page That Does Not Exist]] for details.\n```\n",
|
|
)
|
|
report = run_lint(kb_dir)
|
|
assert [i for i in report["broken_links"] if i["page"] == "Wikilink Syntax"] == []
|
|
|
|
|
|
def test_lint_counts_a_wrapped_quote_once(kb_dir):
|
|
"""The limit is about how much borrowed wording a page carries, which the
|
|
line count measured wrong: the same quotation counted 1 written long and 4
|
|
wrapped at the width the rest of the repo keeps."""
|
|
write_page(
|
|
kb_dir / "entities/tools/wrapped.md",
|
|
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-08-31",
|
|
"modified": "2026-08-31", "related": [], "sources": [], "confidence": 0.8,
|
|
"provenance": "general", "summary": "One wrapped quotation."},
|
|
"\n# wrapped\n\n> One quotation, wrapped across four lines,\n> which is how the rest of\n"
|
|
"> this repository wraps its prose, and\n> therefore not four quotations.\n",
|
|
)
|
|
report = run_lint(kb_dir)
|
|
assert [i for i in report["quote_limit_violations"] if i["page"] == "wrapped"] == []
|
|
|
|
|
|
def test_lint_counts_separated_quotes_separately(kb_dir):
|
|
"""Three blocks, two of them wrapped: the boundary case the line count and
|
|
the block count disagree on most."""
|
|
write_page(
|
|
kb_dir / "entities/tools/blocky.md",
|
|
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-08-31",
|
|
"modified": "2026-08-31", "related": [], "sources": [], "confidence": 0.8,
|
|
"provenance": "general", "summary": "Three quotations, two wrapped."},
|
|
"\n# blocky\n\n> First quote, wrapped\n> over two lines.\n\n> Second quote.\n\n"
|
|
"> Third quote, also\n> wrapped.\n",
|
|
)
|
|
report = run_lint(kb_dir)
|
|
entry = next(i for i in report["quote_limit_violations"] if i["page"] == "blocky")
|
|
assert entry["quote_count"] == 3
|
|
|
|
|
|
def test_lint_does_not_count_a_shell_prompt_as_a_quote(kb_dir):
|
|
"""`>` inside a fenced block is a continuation prompt or redirection, not
|
|
a quotation."""
|
|
write_page(
|
|
kb_dir / "entities/tools/shelly.md",
|
|
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-08-31",
|
|
"modified": "2026-08-31", "related": [], "sources": [], "confidence": 0.8,
|
|
"provenance": "general", "summary": "A shell transcript, not a quotation."},
|
|
"\n# shelly\n\n```bash\n> line one\n\n> line two\n\n> line three\n```\n",
|
|
)
|
|
report = run_lint(kb_dir)
|
|
assert [i for i in report["quote_limit_violations"] if i["page"] == "shelly"] == []
|
|
|
|
|
|
# --- the migration gate on `unlabelled_edges` / `unauthorised_labels` -------
|
|
#
|
|
# These read and write `.wikitool-kb.json`, which `kb_state` resolves relative
|
|
# to `config.ROOT`. The `kb_dir` fixture repoints `ROOT` at its own tmp_path
|
|
# (Gitea #44), so the gate is read off the fixture tree; before it did, these
|
|
# four ran against the real repository's state file and one of them overwrote
|
|
# it.
|
|
|
|
|
|
def _page_with_an_unlabelled_edge(kb_dir):
|
|
"""A `related:` entry that is a bare title rather than a `label: title`
|
|
mapping - the shape every page was in before the 4.0.0 migration."""
|
|
write_page(
|
|
kb_dir / "entities/tools/bare-edge.md",
|
|
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-09-03",
|
|
"modified": "2026-09-03", "related": ["Modbus"], "sources": [], "confidence": 0.8,
|
|
"provenance": "general", "summary": "One edge whose label was never declared."},
|
|
"\n# bare-edge\n\nAn edge without a label.\n",
|
|
)
|
|
|
|
|
|
def test_unlabelled_edge_is_advisory_below_kb_version_4(kb_dir):
|
|
"""The window the migration document describes: the machinery has landed,
|
|
the corpus has not been converted yet, and `lint --fail-on-error` must not
|
|
refuse the very tree the migration tells the instance to publish unit by
|
|
unit."""
|
|
_page_with_an_unlabelled_edge(kb_dir)
|
|
kb_state.write_kb_state(Version(3, 0, 0), [])
|
|
report = run_lint(kb_dir)
|
|
assert report["unlabelled_edges"] != []
|
|
assert "unlabelled_edges" not in hard_error_keys()
|
|
# Narrowed to the finding under test: the fixture corpus carries unrelated
|
|
# hard errors of its own, so asserting on the whole report would prove
|
|
# nothing about the gate.
|
|
assert has_hard_errors({"unlabelled_edges": report["unlabelled_edges"]}) is False
|
|
|
|
|
|
def test_unlabelled_edge_is_hard_at_kb_version_4(kb_dir):
|
|
"""Once the migration is recorded, a bare title is no longer a page waiting
|
|
its turn - it is an edge whose author did not say what it asserts."""
|
|
_page_with_an_unlabelled_edge(kb_dir)
|
|
kb_state.write_kb_state(Version(4, 0, 0), [])
|
|
report = run_lint(kb_dir)
|
|
assert report["unlabelled_edges"] != []
|
|
assert "unlabelled_edges" in hard_error_keys()
|
|
assert has_hard_errors({"unlabelled_edges": report["unlabelled_edges"]}) is True
|
|
|
|
|
|
def test_unauthorised_label_is_hard_at_kb_version_4(kb_dir):
|
|
"""The fixture contracts authorise `depends-on` but not `contradicts`."""
|
|
write_page(
|
|
kb_dir / "entities/tools/off-menu.md",
|
|
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-09-03",
|
|
"modified": "2026-09-03", "related": [{"contradicts": "Modbus"}], "sources": [],
|
|
"confidence": 0.8, "provenance": "general", "summary": "A label off this menu."},
|
|
"\n# off-menu\n\nA label the source collection never authorised.\n",
|
|
)
|
|
kb_state.write_kb_state(Version(4, 0, 0), [])
|
|
report = run_lint(kb_dir)
|
|
assert report["unauthorised_labels"] != []
|
|
assert "unauthorised_labels" in hard_error_keys()
|
|
assert has_hard_errors({"unauthorised_labels": report["unauthorised_labels"]}) is True
|
|
|
|
|
|
def test_unauthorised_label_is_judged_in_a_tree_that_is_not_the_configured_kb(
|
|
kb_dir, tmp_path, monkeypatch
|
|
):
|
|
"""`run_lint()` judges the tree it was handed, not the configured corpus.
|
|
|
|
The collection lookup used to resolve a page against `config.KB_DIR`; a
|
|
page anywhere else raised `ValueError`, read back as "no collection", and
|
|
the label check skipped the edge without a word. That is why
|
|
`unauthorised_labels` was untested in practice before Gitea #44 - every
|
|
fixture tree was somewhere else. Here `ROOT` deliberately points away from
|
|
the tree under lint, which is the case the old code got wrong.
|
|
"""
|
|
write_page(
|
|
kb_dir / "entities/tools/off-menu.md",
|
|
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-09-03",
|
|
"modified": "2026-09-03", "related": [{"contradicts": "Modbus"}], "sources": [],
|
|
"confidence": 0.8, "provenance": "general", "summary": "A label off this menu."},
|
|
"\n# off-menu\n\nA label the source collection never authorised.\n",
|
|
)
|
|
elsewhere = tmp_path / "elsewhere"
|
|
elsewhere.mkdir()
|
|
monkeypatch.setattr(config, "ROOT", elsewhere)
|
|
assert config.KB_DIR != kb_dir
|
|
|
|
report = run_lint(kb_dir)
|
|
assert {
|
|
"page": "off-menu", "target": "Modbus", "label": "contradicts", "destination": "concepts"
|
|
} in report["unauthorised_labels"]
|
|
|
|
|
|
def test_a_tree_that_never_declared_a_kb_version_keeps_every_key(kb_dir):
|
|
"""No `.wikitool-kb.json` means a fresh instance, which starts at the
|
|
current shape rather than migrating into it - so there is no outstanding
|
|
migration for a gated finding to be the noise of."""
|
|
assert kb_state.read_kb_version() is None
|
|
assert hard_error_keys() == HARD_ERROR_KEYS
|
|
|
|
|
|
# --- `redundant_see_also` --------------------------------------------------
|
|
#
|
|
# `see-also` is the catalogue's declared last resort. The finding is about the
|
|
# case where the *other* page already said something specific about the same
|
|
# pair, so the weak edge carries nothing the inbound view did not already
|
|
# render. Measured once on this repo's corpus, that was 57 of 180 see-also
|
|
# edges - which is why it is worth a check rather than a habit.
|
|
|
|
|
|
def _pair(kb_dir, forward, backward):
|
|
"""Two tool pages asserting `forward` and `backward` about each other."""
|
|
for name, edge in (("nearside", forward), ("farside", backward)):
|
|
other = "farside" if name == "nearside" else "nearside"
|
|
write_page(
|
|
kb_dir / f"entities/tools/{name}.md",
|
|
{"type": "types/entity.md", "entity_type": "tool", "tags": [],
|
|
"created": "2026-09-04", "modified": "2026-09-04",
|
|
"related": [] if edge is None else [{edge: other}],
|
|
"sources": [], "confidence": 0.8, "provenance": "general",
|
|
"summary": f"One half of a pair, asserting {edge} about the other."},
|
|
f"\n# {name}\n\nHalf a pair.\n",
|
|
)
|
|
|
|
|
|
def test_see_also_is_redundant_when_the_other_page_asserts_something_specific(kb_dir):
|
|
"""`nearside see-also farside` beside `farside depends-on nearside`: the
|
|
labelled edge already shows on both pages, so the weak one says nothing."""
|
|
_pair(kb_dir, "see-also", "depends-on")
|
|
report = run_lint(kb_dir)
|
|
assert {
|
|
"page": "nearside", "target": "farside", "reverse_label": "depends-on"
|
|
} in report["redundant_see_also"]
|
|
|
|
|
|
def test_a_see_also_with_no_reverse_edge_at_all_is_not_redundant(kb_dir):
|
|
"""The ordinary case the label exists for - nothing more specific fits, and
|
|
the other page says nothing back."""
|
|
_pair(kb_dir, "see-also", None)
|
|
assert [
|
|
i for i in run_lint(kb_dir)["redundant_see_also"] if i["page"] == "nearside"
|
|
] == []
|
|
|
|
|
|
def test_a_mutual_see_also_pair_is_not_reported_here(kb_dir):
|
|
"""Two weak edges about one pair is a different finding - a mirror, which
|
|
the catalogue's 'direction is authored, never mirrored' rule covers and a
|
|
corpus sweep resolves. This check must not claim it: it is about a weak
|
|
edge standing beside a *specific* one, and reporting the mutual case here
|
|
would tell an author to drop an edge without saying which."""
|
|
_pair(kb_dir, "see-also", "see-also")
|
|
assert [
|
|
i for i in run_lint(kb_dir)["redundant_see_also"]
|
|
if i["page"] in ("nearside", "farside")
|
|
] == []
|
|
|
|
|
|
def test_the_specific_edge_is_never_the_one_reported(kb_dir):
|
|
"""Only the `see-also` side is a finding. Reporting the labelled edge too
|
|
would make the pair unfixable - dropping both loses the assertion."""
|
|
_pair(kb_dir, "see-also", "depends-on")
|
|
assert [
|
|
i for i in run_lint(kb_dir)["redundant_see_also"] if i["page"] == "farside"
|
|
] == []
|
|
|
|
|
|
def test_redundant_see_also_is_advisory_at_every_kb_version(kb_dir):
|
|
"""Redundant, not wrong - and the check arrived long after the corpora it
|
|
judges, so promoting it would turn every existing instance red on the
|
|
upgrade that shipped it. Unlike `unlabelled_edges` there is no version at
|
|
which it becomes an error, so it is not migration-gated either."""
|
|
_pair(kb_dir, "see-also", "depends-on")
|
|
report = run_lint(kb_dir)
|
|
assert report["redundant_see_also"] != []
|
|
for version in (Version(3, 0, 0), Version(4, 0, 0), Version(5, 0, 0)):
|
|
kb_state.write_kb_state(version, [])
|
|
assert "redundant_see_also" not in hard_error_keys()
|
|
assert has_hard_errors({"redundant_see_also": report["redundant_see_also"]}) is False
|
|
|
|
|
|
def test_redundant_see_also_reaches_the_rendered_report_and_the_summary(kb_dir):
|
|
"""A finding nobody prints is a finding nobody acts on. `render_summary`
|
|
drops every empty section, so this also proves the section is not empty."""
|
|
_pair(kb_dir, "see-also", "depends-on")
|
|
report = run_lint(kb_dir)
|
|
assert "Redundant see-also" in render_markdown(report)
|
|
summary = render_summary(report)
|
|
assert "Redundant see-also" in summary
|
|
assert "[[nearside]]" in summary and "depends-on" in summary
|