23307c3c5f
Files changed: - CHANGES.md - VERSION - instructions/dev/testing-conventions.md - tools/chemenu/lint_core.py - tools/chemenu/tests/conftest.py - tools/chemenu/tests/test_hermetic_env.py - tools/chemenu/tests/test_lint.py
582 lines
26 KiB
Python
582 lines
26 KiB
Python
import json
|
|
from datetime import date
|
|
|
|
import pytest
|
|
|
|
from chemenu import config, kb_state
|
|
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/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_flags_legacy_citation_marker_as_hard_error(kb_dir):
|
|
write_page(
|
|
kb_dir / "concepts/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/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/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/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
|