Files
chemenu/tools/chemenu/tests/test_lint.py
T
torben b138fd8e64
CI / verify (push) Successful in 53s
Release / release (push) Successful in 35s
move: source_type: Default streichen, unclassified als sichtbares Fach, layout: fuer source (schliesst #66)
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
2026-09-08 20:38:15 +02:00

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