Files changed: - CHANGES.md - EVALS.md - VERSION - instructions/wiki-lint/SKILL.md - instructions/wiki-manage/SKILL.md - tools/CONTRACT.md - tools/chemenu/blocks.py - tools/chemenu/commands/lint.py - tools/chemenu/evals/scorecard.py - tools/chemenu/lint_core.py - tools/chemenu/tests/test_blocks.py - tools/chemenu/tests/test_evals.py - tools/chemenu/tests/test_lint.py - tools/chemenu/tests/test_pipeline_l0.py - types/type-spec.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
1259 lines
55 KiB
Python
1259 lines
55 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.lint_core import unfilled_sections
|
|
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": []},
|
|
"\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 _gdeploy_saying(kb_dir, prose: str) -> None:
|
|
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": []},
|
|
f"\n# gdeploy\n\n## Description\n\n{prose}\n",
|
|
)
|
|
|
|
|
|
def test_a_wrapped_link_to_an_existing_page_is_wrapped_not_broken(kb_dir):
|
|
"""The reported case: the target exists under its one-line title, and the
|
|
finding used to read as a missing page with the line break in its name."""
|
|
_gdeploy_saying(kb_dir, "Notes in [[Source -\n Aurora]] for details.")
|
|
report = run_lint(kb_dir)
|
|
assert report["broken_links"] == []
|
|
assert report["wrapped_wikilinks"] == [{"page": "gdeploy", "target": "Source - Aurora"}]
|
|
assert has_hard_errors(report)
|
|
assert "wraps a wikilink across lines" in render_markdown(report)
|
|
|
|
|
|
def test_a_wrapped_link_to_a_missing_page_is_both_findings(kb_dir):
|
|
_gdeploy_saying(kb_dir, "See [[Nonexistent\n Page]] for details.")
|
|
report = run_lint(kb_dir)
|
|
assert {"page": "gdeploy", "target": "Nonexistent Page"} in report["broken_links"]
|
|
assert report["wrapped_wikilinks"] == [{"page": "gdeploy", "target": "Nonexistent Page"}]
|
|
|
|
|
|
def test_the_target_of_a_wrapped_link_is_not_an_orphan(kb_dir):
|
|
assert "Source - Aurora" in run_lint(kb_dir)["orphan_pages"] # the precondition
|
|
_gdeploy_saying(kb_dir, "Notes in [[Source -\n Aurora]] for details.")
|
|
assert "Source - Aurora" not in run_lint(kb_dir)["orphan_pages"]
|
|
|
|
|
|
def test_a_wrapped_link_inside_code_is_no_finding(kb_dir):
|
|
_gdeploy_saying(kb_dir, "Example:\n\n```\n[[Nonexistent\n Page]]\n```")
|
|
report = run_lint(kb_dir)
|
|
assert report["wrapped_wikilinks"] == []
|
|
assert report["broken_links"] == []
|
|
|
|
|
|
def _organization_with_people(kb_dir) -> None:
|
|
"""An organization page holding its people as sections - the shape
|
|
`broken_anchors` exists for - plus a heading that only appears inside a
|
|
fence, which must not count as one."""
|
|
write_page(
|
|
kb_dir / "entities/organizations/Kunde X.md",
|
|
{"type": "types/entity.md", "entity_type": "organization", "tags": [],
|
|
"created": "2026-10-04", "modified": "2026-10-04", "related": [], "sources": []},
|
|
"\n# Kunde X\n\n## Personen\n\n### Anna Müller\n\nEinkauf.\n\n"
|
|
"#### `Bob` Builder ##\n\nBetrieb.\n\n```\n## Fenced\n```\n",
|
|
)
|
|
|
|
|
|
def test_an_anchor_naming_an_existing_heading_is_no_finding(kb_dir):
|
|
"""Any heading level, any capitalisation, inline-code backticks and closing
|
|
hashes ignored, an alias after the anchor, a nested anchor whose every
|
|
segment is a heading."""
|
|
_organization_with_people(kb_dir)
|
|
_gdeploy_saying(
|
|
kb_dir,
|
|
"[[Kunde X#Anna Müller]], [[Kunde X#anna MÜLLER|Anna]], [[Kunde X#Bob Builder]], "
|
|
"[[Kunde X#Personen#Anna Müller]], [[Kunde X#Kunde X]].",
|
|
)
|
|
report = run_lint(kb_dir)
|
|
assert report["broken_anchors"] == []
|
|
assert report["broken_links"] == []
|
|
|
|
|
|
def test_an_anchor_naming_no_heading_is_a_finding(kb_dir):
|
|
"""The promoted-person case: the page is still there, the section is not.
|
|
The anchor is reported as written, not in its normalized form."""
|
|
_organization_with_people(kb_dir)
|
|
_gdeploy_saying(kb_dir, "Ask [[Kunde X#Carla Neu|Carla]] or [[Kunde X#Personen#Dora]].")
|
|
report = run_lint(kb_dir)
|
|
assert report["broken_anchors"] == [
|
|
{"page": "gdeploy", "target": "Kunde X", "anchor": "Carla Neu"},
|
|
{"page": "gdeploy", "target": "Kunde X", "anchor": "Personen#Dora"},
|
|
]
|
|
assert report["broken_links"] == []
|
|
assert "has no such heading" in render_markdown(report)
|
|
|
|
|
|
def test_a_heading_inside_a_fence_is_not_a_section(kb_dir):
|
|
_organization_with_people(kb_dir)
|
|
_gdeploy_saying(kb_dir, "See [[Kunde X#Fenced]].")
|
|
assert run_lint(kb_dir)["broken_anchors"] == [
|
|
{"page": "gdeploy", "target": "Kunde X", "anchor": "Fenced"},
|
|
]
|
|
|
|
|
|
def test_an_anchor_on_a_missing_page_is_only_a_broken_link(kb_dir):
|
|
_gdeploy_saying(kb_dir, "See [[Nonexistent Page#Somewhere]].")
|
|
report = run_lint(kb_dir)
|
|
assert {"page": "gdeploy", "target": "Nonexistent Page"} in report["broken_links"]
|
|
assert report["broken_anchors"] == []
|
|
|
|
|
|
def test_an_anchor_inside_code_and_a_bare_self_anchor_are_no_finding(kb_dir):
|
|
_organization_with_people(kb_dir)
|
|
_gdeploy_saying(
|
|
kb_dir,
|
|
"Write `[[Kunde X#Nowhere]]` like this, or:\n\n```\n[[Kunde X#Nowhere]]\n```\n\n"
|
|
"Jump to [[#Nowhere]].",
|
|
)
|
|
assert run_lint(kb_dir)["broken_anchors"] == []
|
|
|
|
|
|
def test_a_broken_anchor_alone_keeps_lint_green():
|
|
"""Advisory: the link still reaches its page, and a hard finding would turn
|
|
an existing instance's lint red on the upgrade that shipped the check."""
|
|
assert "broken_anchors" not in HARD_ERROR_KEYS
|
|
report = {key: [] for key in HARD_ERROR_KEYS}
|
|
report["broken_anchors"] = [{"page": "a", "target": "b", "anchor": "c"}]
|
|
assert not has_hard_errors(report)
|
|
|
|
|
|
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": []},
|
|
"\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/"]},
|
|
"\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": []},
|
|
"\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": []},
|
|
"\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."""
|
|
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": []},
|
|
"\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": []},
|
|
"\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": []},
|
|
"\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": []},
|
|
"\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"]},
|
|
"\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": []},
|
|
"\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"]},
|
|
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"]},
|
|
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": []},
|
|
"\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": []},
|
|
"\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": []},
|
|
"\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": [],
|
|
"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": []},
|
|
"\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": [], "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"], "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": [], "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": [], "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": [], "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": [], "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": [], "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": [],
|
|
"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": [],
|
|
"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": [], "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
|
|
|
|
|
|
def _plain_page(path):
|
|
write_page(
|
|
path,
|
|
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-07-25",
|
|
"modified": "2026-07-25", "related": [], "sources": []},
|
|
f"\n# {path.stem}\n",
|
|
)
|
|
|
|
|
|
def test_lint_reports_a_reserved_device_name_as_a_hard_error(kb_dir):
|
|
_plain_page(kb_dir / "entities/tools/CON.md")
|
|
report = run_lint(kb_dir)
|
|
entry = next(i for i in report["unportable_titles"] if i["title"] == "CON")
|
|
assert entry["path"].endswith("entities/tools/CON.md")
|
|
assert "reserved device name" in entry["problem"]
|
|
assert has_hard_errors(report)
|
|
|
|
|
|
def test_lint_reports_case_variants_with_both_paths(kb_dir):
|
|
_plain_page(kb_dir / "entities/tools/Foo.md")
|
|
_plain_page(kb_dir / "concepts/foo.md")
|
|
report = run_lint(kb_dir)
|
|
entries = [i for i in report["unportable_titles"] if i["title"].casefold() == "foo"]
|
|
assert {i["title"] for i in entries} == {"Foo", "foo"}
|
|
by_title = {i["title"]: i for i in entries}
|
|
assert "concepts/foo.md" in by_title["Foo"]["problem"]
|
|
assert "entities/tools/Foo.md" in by_title["foo"]["problem"]
|
|
assert report["duplicate_titles"] == []
|
|
|
|
|
|
def test_lint_reports_an_nfd_title_against_an_nfc_title(kb_dir):
|
|
import unicodedata
|
|
|
|
_plain_page(kb_dir / "entities/tools" / f"{unicodedata.normalize('NFC', 'Café')}.md")
|
|
_plain_page(kb_dir / "concepts" / f"{unicodedata.normalize('NFD', 'Café')}.md")
|
|
report = run_lint(kb_dir)
|
|
assert len(report["unportable_titles"]) == 2
|
|
|
|
|
|
def test_lint_reports_a_page_named_index_below_a_collection(kb_dir):
|
|
_plain_page(kb_dir / "entities/tools/Index.md")
|
|
report = run_lint(kb_dir)
|
|
assert any(i["title"] == "Index" for i in report["unportable_titles"])
|
|
|
|
|
|
def test_lint_does_not_mistake_the_stacks_own_files_for_titles(kb_dir):
|
|
"""`COLLECTION.md` files and the kb root's meta files are not pages."""
|
|
report = run_lint(kb_dir)
|
|
assert report["unportable_titles"] == []
|
|
|
|
|
|
def test_unportable_titles_is_hard_whatever_the_corpus_version(kb_dir):
|
|
"""No migration exists for the rule, so `kb_version` never advances for it;
|
|
a gate would keep the finding advisory forever."""
|
|
from chemenu.lint_core import MIGRATION_GATED_KEYS
|
|
|
|
assert "unportable_titles" in HARD_ERROR_KEYS
|
|
assert "unportable_titles" not in MIGRATION_GATED_KEYS
|
|
assert "unportable_titles" in hard_error_keys(Version(1, 0, 0))
|
|
|
|
|
|
def test_rendered_report_names_the_remedy(kb_dir):
|
|
_plain_page(kb_dir / "entities/tools/CON.md")
|
|
text = render_markdown(run_lint(kb_dir))
|
|
assert "## Unportable Titles" in text
|
|
assert 'wikitool rename --from "CON"' in text
|
|
|
|
|
|
def _long_page(kb_dir, units):
|
|
title = "t" * (units - len("kb/entities/tools/") - len(".md"))
|
|
_plain_page(kb_dir / "entities/tools" / f"{title}.md")
|
|
return f"kb/entities/tools/{title}.md"
|
|
|
|
|
|
def test_lint_reports_a_path_over_the_budget_as_advisory(kb_dir, raw_dir):
|
|
over = _long_page(kb_dir, 161)
|
|
_long_page(kb_dir, 160)
|
|
report = run_lint(kb_dir)
|
|
assert report["long_paths"] == [{"path": over, "length": 161}]
|
|
assert "long_paths" not in HARD_ERROR_KEYS
|
|
|
|
|
|
def test_lint_reports_a_raw_path_over_the_budget(kb_dir, raw_dir):
|
|
name = "r" * (161 - len("raw/notes/"))
|
|
(raw_dir / "notes").mkdir(exist_ok=True)
|
|
(raw_dir / "notes" / name).write_text("x", encoding="utf-8")
|
|
report = run_lint(kb_dir)
|
|
assert {"path": f"raw/notes/{name}", "length": 161} in report["long_paths"]
|
|
|
|
|
|
def test_lint_fail_on_error_ignores_long_paths(kb_dir, raw_dir, tmp_path, monkeypatch):
|
|
import typer
|
|
|
|
monkeypatch.setattr(config, "ROOT", tmp_path)
|
|
monkeypatch.setattr(config, "KB_DIR", kb_dir)
|
|
monkeypatch.setattr(config, "RAW_DIR", raw_dir)
|
|
baseline = has_hard_errors(run_lint(kb_dir))
|
|
_long_page(kb_dir, 170)
|
|
report = run_lint(kb_dir)
|
|
assert report["long_paths"]
|
|
assert has_hard_errors(report) == baseline
|
|
if not baseline:
|
|
lint_command(json_out=True, markdown_out=None, full=False, fail_on_error=True)
|
|
|
|
|
|
def test_rendered_report_names_rename_for_a_long_path(kb_dir, raw_dir):
|
|
_long_page(kb_dir, 161)
|
|
text = render_markdown(run_lint(kb_dir))
|
|
assert "## Long Paths" in text
|
|
assert "wikitool rename" in text
|
|
|
|
|
|
# --- unfilled template sections ---
|
|
|
|
def _scaffolded_concept(name: str) -> str:
|
|
"""The body `wikitool new concept` scaffolds, rendered from the shipped
|
|
template rather than copied here, so a template change cannot leave this
|
|
test judging a skeleton nobody produces any more."""
|
|
from chemenu.commands.new_page import _apply_template_variables
|
|
|
|
spec = resolver.load_type_spec("types/concept.md")
|
|
return _apply_template_variables(
|
|
resolver.page_template(spec), {"name": name, "concept_type": "pattern"}
|
|
)
|
|
|
|
|
|
def _concept_page(kb_dir, name: str, body: str) -> None:
|
|
write_page(
|
|
kb_dir / f"concepts/protocols/{name}.md",
|
|
{"type": "types/concept.md", "concept_type": "pattern", "tags": [],
|
|
"created": "2026-10-04", "modified": "2026-10-04", "related": [], "sources": [],
|
|
"provenance": "general"},
|
|
"\n" + body + "\n",
|
|
)
|
|
|
|
|
|
def test_a_freshly_scaffolded_concept_reports_its_unwritten_sections(kb_dir):
|
|
"""`Beispiele` and `Verwandte Concepts` hold placeholder wikilinks, not
|
|
`TODO` lines - written by nobody, but not what the token marks, so they
|
|
stay out of the finding."""
|
|
_concept_page(kb_dir, "Scaffold", _scaffolded_concept("Scaffold"))
|
|
report = run_lint(kb_dir)
|
|
assert {
|
|
"page": "Scaffold",
|
|
"sections": ["Definition", "Kernpunkte", "Wann zu verwenden", "Wann NICHT zu verwenden"],
|
|
} in report["unfilled_sections"]
|
|
assert "only template placeholders under: Definition, Kernpunkte" in render_markdown(report)
|
|
assert "Unfilled Template Sections" in render_summary(report)
|
|
|
|
|
|
def test_the_same_concept_written_is_no_finding(kb_dir):
|
|
body = _scaffolded_concept("Written")
|
|
for placeholder, prose in (
|
|
("TODO: Klare Definition dessen, was dieses Concept ist.", "Ein Muster für X."),
|
|
("- TODO: Kernpunkt 1", "- Erstens."),
|
|
("- TODO: Kernpunkt 2", "- Zweitens."),
|
|
("- TODO: Kernpunkt 3", "- Drittens."),
|
|
("TODO: Bedingungen und Kontexte, in denen dieses Concept greift", "Wenn Y gilt."),
|
|
("TODO: Anti-Muster, Warnungen oder Situationen, in denen es fehl am Platz ist", "Nie bei Z."),
|
|
):
|
|
assert placeholder in body
|
|
body = body.replace(placeholder, prose)
|
|
_concept_page(kb_dir, "Written", body)
|
|
assert not any(f["page"] == "Written" for f in run_lint(kb_dir)["unfilled_sections"])
|
|
|
|
|
|
def test_the_fixture_corpus_has_no_unfilled_sections(kb_dir):
|
|
assert run_lint(kb_dir)["unfilled_sections"] == []
|
|
|
|
|
|
def test_one_open_field_beside_filled_ones_is_no_finding():
|
|
"""The honest gap marker: a complete page whose source does not give a
|
|
version. A finding here would push a session to delete the marker or
|
|
invent the value."""
|
|
body = (
|
|
"# Tool\n\n## Übersicht\n\nEin Werkzeug.\n\n## Kerndaten\n\n"
|
|
"- **Zweck:** Bauen\n- **Version:** TODO (nicht erfasst)\n"
|
|
"- **Repository:** TODO (falls zutreffend)\n"
|
|
)
|
|
assert unfilled_sections(body) == []
|
|
|
|
|
|
def test_a_short_complete_page_is_no_finding():
|
|
body = "# Rohit\n\n## Übersicht\n\nAutor des Artikels. Weitere Angaben macht die Quelle nicht.\n"
|
|
assert unfilled_sections(body) == []
|
|
|
|
|
|
def test_an_empty_section_is_no_finding():
|
|
"""A heading with nothing under it is untidy, not an unwritten scaffold."""
|
|
assert unfilled_sections("# P\n\n## Leer\n\n## Voll\n\nText.\n\n## Auch leer\n") == []
|
|
|
|
|
|
@pytest.mark.parametrize("line", [
|
|
"Siehe `TODO` im Code.",
|
|
"`TODO: kein Platzhalter`",
|
|
"[[TODO Retirement]] beschreibt das.",
|
|
"Ein TODO mitten im Satz.",
|
|
"TODO.md war die alte Liste.",
|
|
"TODO-Liste und TODOs.",
|
|
"- **Datei:** TODO.md",
|
|
])
|
|
def test_todo_that_is_not_a_placeholder(line):
|
|
assert unfilled_sections(f"# P\n\n## A\n\n{line}\n") == []
|
|
|
|
|
|
def test_todo_in_a_fence_is_content():
|
|
assert unfilled_sections("# P\n\n## A\n\n```\nTODO: fix\n```\n") == []
|
|
|
|
|
|
def test_generated_regions_and_footnote_definitions_are_out_of_view():
|
|
"""A generated region is not part of the section above it, and its own
|
|
heading opens no section; a footnote definition is not content."""
|
|
body = (
|
|
"# P\n\n## A\n\nTODO: Text\n\n"
|
|
"<!-- wikitool:links -->\n## Beziehungen\n\n- **see-also:** [[TODO List]]\n"
|
|
"<!-- /wikitool:links -->\n\n"
|
|
"<!-- wikitool:footnotes -->\n## Fußnoten\n\nTODO: generated\n"
|
|
"<!-- /wikitool:footnotes -->\n"
|
|
)
|
|
assert unfilled_sections(body) == ["A"]
|
|
assert unfilled_sections("# P\n\n## B\n\nTODO: x\n[^s]: [[Source - S]]\n") == ["B"]
|
|
|
|
|
|
@pytest.mark.parametrize("line", [
|
|
"TODO",
|
|
"TODO: Satz",
|
|
"TODO (falls zutreffend)",
|
|
"- TODO: Punkt",
|
|
"* TODO",
|
|
"1. TODO: Schritt",
|
|
"- [ ] TODO: Aufgabe",
|
|
"- **Zweck:** TODO",
|
|
"**Zweck:** TODO (nicht erfasst)",
|
|
"| TODO | x |",
|
|
])
|
|
def test_placeholder_forms(line):
|
|
assert unfilled_sections(f"# P\n\n## A\n\n{line}\n") == ["A"]
|
|
|
|
|
|
def test_a_section_runs_to_the_next_level_two_heading():
|
|
"""A `###` heading is content of its `##` section, so a section whose
|
|
only written line is a subheading is written - and the preamble before
|
|
the first `##` belongs to no section."""
|
|
body = "# P\n\nTODO: preamble\n\n## A\n\nTODO: x\n\n### Sub\n\n## B\n\nTODO: y\n- TODO: z\n"
|
|
assert unfilled_sections(body) == ["B"]
|
|
|
|
|
|
def test_unfilled_sections_alone_keep_lint_green():
|
|
"""Advisory: corpora carrying such pages predate the check, and no
|
|
migration can fill a section."""
|
|
assert "unfilled_sections" not in HARD_ERROR_KEYS
|
|
report = {key: [] for key in HARD_ERROR_KEYS}
|
|
report["unfilled_sections"] = [{"page": "a", "sections": ["B"]}]
|
|
assert not has_hard_errors(report)
|