54d9540c08
Files changed: - .wikitool-kb.json - AGENTS.md - CHANGES.md - INSTALL-MCP.md - INSTALL.md - README.md - VERSION - instructions/capture-session.md - instructions/dev/issue-tracking.md - instructions/german-terminology.md - instructions/kb-profiles.md - instructions/migrate-corpus.md - instructions/migrations/5.0.0-confidence-removal.md - instructions/private-instance.md - instructions/setup-instance.md - instructions/wiki-lint/SKILL.md - instructions/wiki-manage/SKILL.md - instructions/wiki-query/SKILL.md - kb/CONTRACT.md - kb/CONVENTIONS.md - kb/CONVENTIONS.md.template - kb/concepts/architectures/Consolidation Tiers.md - kb/concepts/architectures/Context Isolation.md - kb/concepts/architectures/Cross-platform Agent Skills.md - kb/concepts/architectures/Episodic Memory.md - kb/concepts/architectures/Hybrid Search.md - kb/concepts/architectures/Implementation Spectrum.md - kb/concepts/architectures/Knowledge Graph.md - kb/concepts/architectures/LLM Wiki Pattern.md - kb/concepts/architectures/MCP-Leseserver.md - kb/concepts/architectures/Memory Lifecycle.md - kb/concepts/architectures/OKF Compatibility.md - kb/concepts/architectures/Optional Instance Context File.md - kb/concepts/architectures/Personalization Plane.md - kb/concepts/architectures/Procedural Memory.md - kb/concepts/architectures/RAG.md - kb/concepts/architectures/Scale Ceiling.md - kb/concepts/architectures/Semantic Memory.md - kb/concepts/architectures/Three-Layer Architecture.md - kb/concepts/architectures/Token Economics.md - kb/concepts/architectures/Working Memory.md - kb/concepts/decisions/Delete Rather Than Anonymize.md - kb/concepts/decisions/Denylist over Allowlist.md - kb/concepts/decisions/Diff-Reviewable Agent Edits.md - kb/concepts/decisions/Dual Licensing by File Plan.md - kb/concepts/decisions/Issue Label Scheme.md - kb/concepts/decisions/KB Stack Versioning.md - kb/concepts/decisions/Structural Enforcement over Documented Rule.md - kb/concepts/patterns/Audit Trail.md - kb/concepts/patterns/BM25.md - kb/concepts/patterns/Command Round-Trip Integrity.md - kb/concepts/patterns/Confidence Scoring.md - kb/concepts/patterns/Contradiction Resolution.md - kb/concepts/patterns/Entity Extraction.md - kb/concepts/patterns/Filter on Ingest.md - kb/concepts/patterns/Forgetting.md - kb/concepts/patterns/Graph Traversal.md - kb/concepts/patterns/Mesh Sync.md - kb/concepts/patterns/Quality Scoring.md - kb/concepts/patterns/Reciprocal Rank Fusion.md - kb/concepts/patterns/Self-Healing.md - kb/concepts/patterns/Shared vs Private.md - kb/concepts/patterns/Typed Relationships.md - kb/concepts/patterns/Vector Search.md - kb/concepts/patterns/Work Coordination.md - kb/concepts/problems/Ambient Environment Dependency.md - kb/concepts/problems/Detect-Repair Asymmetry.md - kb/concepts/problems/Green Suite Blind Spot.md - kb/concepts/problems/Naming Convention Conflict.md - kb/concepts/problems/Write-Once Frontmatter Fields.md - kb/concepts/protocols/CPPC.md - kb/concepts/protocols/Modbus.md - kb/concepts/protocols/SSD TRIM.md - kb/concepts/workflows/Anti-Cramming Heuristic.md - kb/concepts/workflows/Bulk Operations.md - kb/concepts/workflows/CI Integration.md - kb/concepts/workflows/Checkpoint Audit.md - kb/concepts/workflows/Claude Code Auto Mode.md - kb/concepts/workflows/Content Quality Control.md - kb/concepts/workflows/Crystallization.md - kb/concepts/workflows/Event-Driven Automation.md - kb/concepts/workflows/Hooks.md - kb/concepts/workflows/Index Scaling.md - kb/concepts/workflows/Iteration and Cost Limits.md - kb/concepts/workflows/KB Migration.md - kb/concepts/workflows/Knowledge Compounding.md - kb/concepts/workflows/Lint Workflow.md - kb/concepts/workflows/Mass-Update Gate.md - kb/concepts/workflows/Multi-Agent Collaboration.md - kb/concepts/workflows/Privacy and Governance.md - kb/concepts/workflows/Publish-Remote Gate.md - kb/concepts/workflows/Quality and Self-Correction.md - kb/concepts/workflows/Semantic Lint Automation.md - kb/concepts/workflows/Session Orientation.md - kb/concepts/workflows/Split Merge Reclassify.md - kb/concepts/workflows/Split Threshold.md - kb/concepts/workflows/Stub Threshold.md - kb/concepts/workflows/Supersession.md - kb/concepts/workflows/User Management.md - kb/concepts/workflows/Workflow Extraction.md - kb/concepts/workflows/Workflow Orchestration.md - kb/entities/people/Andrej Karpathy.md - kb/entities/people/E3DC GmbH.md - kb/entities/people/Rohit Gupta.md - kb/entities/people/Vannevar Bush.md - kb/entities/projects/BCDModule.md - kb/entities/projects/Chemenu.md - kb/entities/projects/andybalholm-edl.md - kb/entities/projects/goresponsiveness.md - kb/entities/projects/ha-core.md - kb/entities/projects/hacs-e3dc.md - kb/entities/projects/hacs-integration-blueprint.md - kb/entities/projects/llm-wiki-skills.md - kb/entities/projects/plugnburn-edl.md - kb/entities/projects/wiki-skills-vanillaflava.md - kb/entities/projects/wiki-skills.md - kb/entities/systems/AGENTS.md.md - kb/entities/systems/CLAUDE.md.md - kb/entities/systems/E3DC.md - kb/entities/systems/ENVIRONMENT.md.md - kb/entities/systems/Memex.md - kb/entities/systems/Tolkien Gateway.md - kb/entities/technologies/Arch Linux.md - kb/entities/technologies/Disk Encryption.md - kb/entities/technologies/Docker.md - kb/entities/technologies/GRUB.md - kb/entities/technologies/Gitea Actions.md - kb/entities/technologies/Gitea.md - kb/entities/technologies/Go.md - kb/entities/technologies/Home Assistant.md - kb/entities/technologies/Kernel PM Governors.md - kb/entities/technologies/LVM.md - kb/entities/technologies/Linux Kernel.md - kb/entities/technologies/MQTT.md - kb/entities/technologies/OPC UA.md - kb/entities/technologies/Python.md - kb/entities/technologies/Rust.md - kb/entities/technologies/Wine GE.md - kb/entities/technologies/Wine-Staging.md - kb/entities/technologies/acpi-cpufreq.md - kb/entities/technologies/amd-pstate.md - kb/entities/technologies/iii Engine.md - kb/entities/tools/AUR.md - kb/entities/tools/Act Runner.md - kb/entities/tools/Agent Memory.md - kb/entities/tools/Aura.md - kb/entities/tools/Bottles.md - kb/entities/tools/ChatGPT.md - kb/entities/tools/Claude Code.md - kb/entities/tools/Codex CLI.md - kb/entities/tools/Dataview.md - kb/entities/tools/GPG.md - kb/entities/tools/GitHub Copilot.md - kb/entities/tools/Gitea MCP Server.md - kb/entities/tools/Lutris.md - kb/entities/tools/Marp.md - kb/entities/tools/Mistral Vibe.md - kb/entities/tools/NotebookLM.md - kb/entities/tools/Obsidian Web Clipper.md - kb/entities/tools/Obsidian.md - kb/entities/tools/OpenAI Codex.md - kb/entities/tools/OpenCode.md - kb/entities/tools/Pi.md - kb/entities/tools/Proton.md - kb/entities/tools/Steam.md - kb/entities/tools/Wine.md - kb/entities/tools/awesome-llm-wiki.md - kb/entities/tools/farzaa gist.md - kb/entities/tools/gdeploy.md - kb/entities/tools/makepkg.md - kb/entities/tools/pascalandy schema.md - kb/entities/tools/qmd.md - kb/entities/tools/wikitool.md - kb/index.md - kb/log.md - raw/CONTRACT.md - tools/CONTRACT.md - tools/README.md - tools/chemenu/api.py - tools/chemenu/cli.py - tools/chemenu/commands/confidence_decay.py - tools/chemenu/commands/docs_verify.py - tools/chemenu/commands/doctor.py - tools/chemenu/commands/index_build.py - tools/chemenu/commands/new_page.py - tools/chemenu/commands/search.py - tools/chemenu/commands/touch.py - tools/chemenu/commands/version_cmd.py - tools/chemenu/conventions.py - tools/chemenu/corpus_diff.py - tools/chemenu/frontmatter_io.py - tools/chemenu/lint_core.py - tools/chemenu/mcp/server.py - tools/chemenu/page.py - tools/chemenu/search/base.py - tools/chemenu/search/filters.py - tools/chemenu/search/ripgrep.py - tools/chemenu/search/service.py - tools/chemenu/search/types.py - tools/chemenu/tests/conftest.py - tools/chemenu/tests/test_api.py - tools/chemenu/tests/test_confidence_decay.py - tools/chemenu/tests/test_corpus_diff.py - tools/chemenu/tests/test_docs_verify.py - tools/chemenu/tests/test_frontmatter_io.py - tools/chemenu/tests/test_index_build.py - tools/chemenu/tests/test_kb_scan.py - tools/chemenu/tests/test_lint.py - tools/chemenu/tests/test_mcp_server.py - tools/chemenu/tests/test_new_page.py - tools/chemenu/tests/test_page_ops.py - tools/chemenu/tests/test_provenance.py - tools/chemenu/tests/test_raw_cmd.py - tools/chemenu/tests/test_search.py - tools/chemenu/tests/test_touch.py - tools/chemenu/tests/test_type_resolver.py - tools/chemenu/tests/test_version_cmd.py - tools/chemenu/tests/test_xref.py - tools/chemenu/version.py - types/concept.md - types/concept.schema.yaml - types/entity.md - types/entity.schema.yaml - types/instruction.md - types/type-spec.md
841 lines
38 KiB
Python
841 lines
38 KiB
Python
"""The lint core, with no CLI attached.
|
|
|
|
Split out of `commands/lint.py` for the reason given in
|
|
`chemenu/search/service.py`: `run_lint()` is a pure function over a corpus
|
|
directory, and it was sitting in a module that imports `typer` and `rich`, so
|
|
no in-process caller could reach it without the CLI head.
|
|
|
|
Everything that decides *findings* lives here. Everything that decides *how a
|
|
terminal sees them* - the report file, the exit code, the flags - stays in
|
|
`commands/lint.py`. `render_markdown()` and `render_summary()` are on this side
|
|
of the line because the markdown report is a data product (it is what
|
|
`reports/` holds and what `kb/log.md` refers to), not terminal formatting.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import date
|
|
from pathlib import Path
|
|
|
|
from collections import Counter
|
|
|
|
from chemenu import blocks, config, kb_collections, links
|
|
from chemenu.catalog import SHARD_THRESHOLD, group_pages
|
|
from chemenu.frontmatter_io import frontmatter_error
|
|
from chemenu.markdown_code import strip_code_spans
|
|
from chemenu.provenance import broken_raw_refs as find_broken_raw_refs
|
|
from chemenu.provenance import duplicate_raw_file_owners as find_duplicate_raw_file_owners
|
|
from chemenu.provenance import extract_inline_cites
|
|
from chemenu.provenance import legacy_citation_markers as find_legacy_citation_markers
|
|
from chemenu.provenance import legacy_source_pages as find_legacy_source_pages
|
|
from chemenu.provenance import orphan_footnote_defs as find_orphan_footnote_defs
|
|
from chemenu.provenance import uncovered_raw_files as find_uncovered_raw_files
|
|
from chemenu.provenance import undefined_footnote_refs as find_undefined_footnote_refs
|
|
from chemenu.page import Page
|
|
from chemenu.version import Version
|
|
from chemenu.kb_scan import (
|
|
GENERATED_INDEX,
|
|
WIKILINK_RE,
|
|
build_link_graph,
|
|
find_duplicate_title_paths,
|
|
find_nested_pages,
|
|
inbound_links,
|
|
load_kb_pages,
|
|
)
|
|
from chemenu.type_resolver import resolver
|
|
|
|
# Style guide's one mechanically-checkable rule (hard oracle: a plain count).
|
|
# The rest of the style guide (tone, AI-phrase avoidance) is a soft/proxy judgment
|
|
# and stays with the LLM - see wiki-manage/wiki-ingest skill guidance, not lint.
|
|
#
|
|
# The unit is a quote, not a `>` line. It used to be the line, which measured
|
|
# the wrap width the rule has no opinion about: one quotation written long
|
|
# counted 1 and the same quotation wrapped at 100 columns counted 4. An author
|
|
# who took the finding seriously made the page harder to read to quiet it.
|
|
|
|
|
|
QUOTE_LIMIT = 2
|
|
|
|
# How many hub pages `most_linked` reports. Purely informational (wiki-status
|
|
# surfaces it); not a finding, so the cutoff only bounds report size.
|
|
MOST_LINKED_COUNT = 10
|
|
|
|
|
|
def count_quote_blocks(body: str) -> int:
|
|
"""How many distinct blockquotes `body` carries.
|
|
|
|
A run of consecutive `>` lines is one quote; a blank line or any
|
|
non-quoted line ends it. Code is masked out first, so a `>` inside a
|
|
fenced shell transcript is a prompt, not a quotation.
|
|
|
|
Lazy continuation - a quote whose wrapped lines drop the `>` - reads here
|
|
as two quotes rather than one. That over-counts in the direction the limit
|
|
already errs on, and the corpus prefixes every line, so the alternative
|
|
(tracking paragraph state) buys nothing.
|
|
"""
|
|
count, in_quote = 0, False
|
|
for line in strip_code_spans(body).splitlines():
|
|
is_quote = line.lstrip().startswith(">")
|
|
if is_quote and not in_quote:
|
|
count += 1
|
|
in_quote = is_quote
|
|
return count
|
|
|
|
|
|
def _display(path: Path) -> str:
|
|
"""A path for a report line: repo-root-relative if possible, the raw path
|
|
otherwise (a fixture tree in a test, or any tree `config.ROOT` does not
|
|
contain)."""
|
|
try:
|
|
return str(path.relative_to(config.ROOT))
|
|
except ValueError:
|
|
return str(path)
|
|
|
|
|
|
def find_misplaced(pages: dict[str, Page]) -> list[tuple[str, Page, Path]]:
|
|
"""(title, page, target_dir) for every page whose type resolves and whose
|
|
current directory differs from the one `TypeResolver.compute_target_dir`
|
|
would place it under - the same rule `new` places a page by. Shared by
|
|
`misplaced_pages()` below and `wikitool move --reconcile`, which is the
|
|
fix for what this finds.
|
|
|
|
A page with no `type:` or an unresolvable one is skipped - each is
|
|
reported separately, as `frontmatter_errors`/`type_resolution_errors`."""
|
|
found = []
|
|
for title, page in sorted(pages.items()):
|
|
type_path = page.frontmatter.get("type")
|
|
if not type_path:
|
|
continue
|
|
try:
|
|
target_dir = resolver.compute_target_dir(type_path, page.frontmatter, page.path)
|
|
except ValueError:
|
|
continue
|
|
if page.path.parent.resolve() != target_dir.resolve():
|
|
found.append((title, page, target_dir))
|
|
return found
|
|
|
|
|
|
def misplaced_pages(pages: dict[str, Page]) -> list[dict]:
|
|
"""Report form of `find_misplaced`: `{"page", "at", "should_be"}` per
|
|
finding, for the lint report.
|
|
|
|
Advisory rather than a hard error (see `HARD_ERROR_KEYS` below): plenty of
|
|
existing instances predate `move`, and a hand-placed page is not a
|
|
broken one - only one `wikitool move --page "<Title>"` would relocate."""
|
|
return [
|
|
{"page": title, "at": _display(page.path.parent), "should_be": _display(target_dir)}
|
|
for title, page, target_dir in find_misplaced(pages)
|
|
]
|
|
|
|
|
|
def unclassified_source_pages(pages: dict[str, Page]) -> list[dict]:
|
|
"""Source pages sitting in the `unclassified` catalog slot.
|
|
|
|
Advisory, like `misplaced_pages` above: `unclassified` is the visible
|
|
fallback for a genuinely unclear source (Gitea #66, replacing the old
|
|
silent `default: notes`), not a broken state - a page there is exactly as
|
|
valid as one anywhere else, only waiting for someone to look at it and
|
|
pick a real category with `wikitool touch --set source_type=<value>`."""
|
|
return [
|
|
{"page": title}
|
|
for title, page in sorted(pages.items())
|
|
if page.frontmatter.get("type") == "types/source.md"
|
|
and page.frontmatter.get("source_type") == "unclassified"
|
|
]
|
|
|
|
|
|
def nested_pages(kb_dir: Path, pages: dict[str, Page]) -> list[dict]:
|
|
"""Report form of `find_nested_pages`: `{"page", "at", "depth"}` per
|
|
finding.
|
|
|
|
Hard rather than advisory, unlike `misplaced_pages` above: a hand-placed
|
|
page in the wrong area is still a real page the catalog lists correctly.
|
|
A page nested past an area is not - `group_pages` folds it into the area
|
|
silently, so the *generated* catalog itself becomes wrong, which is the
|
|
thing invariant 1 does not allow. There is also no version this becomes
|
|
wrong at (unlike the migration-gated findings below): a nested page was
|
|
always going to be misread by the catalog that reads it today."""
|
|
return [
|
|
{"page": title, "at": _display(page.path.parent), "depth": depth}
|
|
for title, page, depth in find_nested_pages(kb_dir, pages)
|
|
]
|
|
|
|
|
|
def unsharded_collections(kb_dir: Path, pages: dict[str, Page]) -> list[dict]:
|
|
"""Collections past the catalog's shard threshold that have no areas to
|
|
shard, together with the subtype split that would give them some.
|
|
|
|
Sharding is already automatic, and it is per *area*: `index rebuild` hands
|
|
an area over `SHARD_THRESHOLD` rows its own `INDEX.md`. Creating an area is
|
|
not automatic and nothing ever asked for one - so a collection that never
|
|
grew any keeps its whole catalog in a single table, past the threshold,
|
|
forever. The threshold is then not a threshold but a dead value (Gitea
|
|
#59), and this is the only check that can notice: an ingest sees one
|
|
source and cannot see a collection's size, while `lint` sees the corpus and
|
|
runs every 10 sources anyway.
|
|
|
|
**A recommendation, not a failure** (it is deliberately absent from
|
|
`HARD_ERROR_KEYS`), and narrow enough to stay one: it fires only where the
|
|
split actually helps - every area it would create, the ungrouped remainder
|
|
included, lands at or under the threshold. That self-limits in both
|
|
directions. A collection under the threshold never fires, so a small
|
|
`kb/comparisons/` is not permanently in the report; and a collection whose
|
|
subtype values are lopsided (25 of 29 `source_type: notes`) does not fire
|
|
either, because splitting it would produce one area over the threshold and
|
|
a handful of splinters. What is left is a finding that appears when a
|
|
collection grows into it and is silent when it does not.
|
|
"""
|
|
findings: list[dict] = []
|
|
for collection in group_pages(kb_dir, pages):
|
|
if collection.count <= SHARD_THRESHOLD:
|
|
continue
|
|
# An area already exists, so the collection has been split once and
|
|
# `index rebuild` shards whatever outgrows the threshold from here.
|
|
# A page still sitting in the root is `misplaced_pages`' finding, not
|
|
# this one.
|
|
if any(area.name for area in collection.areas):
|
|
continue
|
|
|
|
counts: Counter[str] = Counter()
|
|
fields: set[str] = set()
|
|
# Whether every type writing here already declares the `layout:` that
|
|
# turns the subtype into a directory. It decides which half of the fix
|
|
# is still owed: without it there is nothing for `move` to compute a
|
|
# destination from, with it the move is all that is left.
|
|
layouts: set[bool] = set()
|
|
for area in collection.areas:
|
|
for page in area.pages:
|
|
type_path = page.frontmatter.get("type")
|
|
if not type_path:
|
|
continue
|
|
try:
|
|
field = resolver.get_subtype_field(type_path, page.path)
|
|
layout = resolver.get_layout(type_path, page.path)
|
|
except ValueError:
|
|
continue
|
|
value = page.frontmatter.get(field) if field else None
|
|
if not value:
|
|
continue
|
|
counts[str(value)] += 1
|
|
fields.add(str(field))
|
|
layouts.add(bool(layout))
|
|
|
|
if not counts:
|
|
continue
|
|
# The pages the subtype cannot place stay in the collection root, so
|
|
# they are an area of their own for the purpose of this test.
|
|
unplaced = collection.count - sum(counts.values())
|
|
if max([*counts.values(), unplaced]) > SHARD_THRESHOLD:
|
|
continue
|
|
|
|
findings.append(
|
|
{
|
|
"collection": collection.name,
|
|
"count": collection.count,
|
|
"field": ", ".join(sorted(fields)),
|
|
"layout_declared": layouts == {True},
|
|
"distribution": [
|
|
{"value": value, "count": count}
|
|
for value, count in sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
|
|
],
|
|
}
|
|
)
|
|
return findings
|
|
|
|
|
|
def run_lint(kb_dir: Path) -> dict:
|
|
pages = load_kb_pages(kb_dir)
|
|
duplicate_titles = find_duplicate_title_paths(kb_dir, config.ROOT)
|
|
|
|
# Pages whose frontmatter can't be parsed read back as `{}` everywhere
|
|
# else, which would let them slip past every frontmatter-driven check
|
|
# below with no finding at all - so they are detected explicitly.
|
|
frontmatter_errors = []
|
|
for title, page in sorted(pages.items()):
|
|
reason = frontmatter_error(page.path)
|
|
if reason is None and not page.frontmatter.get("type"):
|
|
reason = "missing `type:` field"
|
|
if reason is not None:
|
|
frontmatter_errors.append({"page": title, "error": reason})
|
|
|
|
graph = build_link_graph(pages)
|
|
broken_links = [
|
|
{"page": title, "target": target}
|
|
for title, targets in graph.items()
|
|
for target in sorted(targets)
|
|
if target not in pages
|
|
]
|
|
|
|
inbound = inbound_links({t: v for t, v in graph.items() if t != "index"})
|
|
orphan_pages = sorted(
|
|
title
|
|
for title, sources in inbound.items()
|
|
if not sources
|
|
and title not in ("index", "log")
|
|
# comparison pages are not linked to by design; index.md is sufficient coverage
|
|
and pages[title].kind != "comparison"
|
|
)
|
|
|
|
# Same link graph, opposite end: the most-linked-to pages are the wiki's
|
|
# hubs. Reported (not judged) so `wiki-status` can show them without
|
|
# re-deriving the graph.
|
|
inbound_counts = {title: len(sources) for title, sources in inbound.items()}
|
|
most_linked = [
|
|
{"page": title, "inbound": count}
|
|
for title, count in sorted(inbound_counts.items(), key=lambda kv: (-kv[1], kv[0]))
|
|
if count > 0
|
|
][:MOST_LINKED_COUNT]
|
|
|
|
# The catalog is sharded: `kb/index.md` is a map carrying counts and links,
|
|
# and the page rows live in a generated INDEX.md per collection/area. Both
|
|
# halves have to be read, or every page reads as missing from the index.
|
|
index_text = "".join(
|
|
path.read_text(encoding="utf-8")
|
|
for path in [kb_dir / "index.md", *sorted(kb_dir.rglob(GENERATED_INDEX))]
|
|
if path.exists()
|
|
)
|
|
index_links = {m.group(1).strip() for m in WIKILINK_RE.finditer(index_text)}
|
|
missing_from_index = sorted(set(pages) - index_links - {"index", "log"})
|
|
dangling_index_entries = sorted(index_links - set(pages))
|
|
|
|
title_mismatches = []
|
|
for title, page in sorted(pages.items()):
|
|
if page.kind not in ("entity", "concept"):
|
|
continue
|
|
h1 = page.h1_title
|
|
if h1 is not None and h1 != title:
|
|
title_mismatches.append({"page": title, "h1": h1})
|
|
|
|
misplaced = misplaced_pages(pages)
|
|
nested = nested_pages(kb_dir, pages)
|
|
|
|
unmarked_provenance = []
|
|
for title, page in sorted(pages.items()):
|
|
if page.kind not in ("entity", "concept"):
|
|
continue
|
|
sources_list = page.frontmatter.get("sources") or []
|
|
if not sources_list and page.frontmatter.get("provenance") != "general":
|
|
unmarked_provenance.append(title)
|
|
|
|
citation_frontmatter_drift = []
|
|
for title, page in sorted(pages.items()):
|
|
sources_list = set(page.frontmatter.get("sources") or [])
|
|
cited = {cited_title for cited_title, _file in extract_inline_cites(page.body)}
|
|
cited.discard(title) # a source page citing itself for a specific file within it is not drift
|
|
for missing_source in sorted(cited - sources_list):
|
|
citation_frontmatter_drift.append({"page": title, "cited_but_not_in_sources": missing_source})
|
|
|
|
legacy_citation_markers = find_legacy_citation_markers(pages)
|
|
undefined_footnote_refs = find_undefined_footnote_refs(pages)
|
|
orphan_footnote_defs = find_orphan_footnote_defs(pages)
|
|
|
|
# The frontmatter half of the link graph. `broken_links` above only walks
|
|
# `[[wikilinks]]` in page *bodies*, so a `related:`/`sources:`/`entities:`
|
|
# entry naming a page that does not exist - a rename that was not
|
|
# propagated, a deleted page, or a URL pasted where a title belongs - used
|
|
# to pass every check. Which fields hold page titles is declared by each
|
|
# type-spec's `page_ref_fields:`, not hardcoded here.
|
|
# Resolved against the directory `run_lint()` was handed, not against
|
|
# `config.KB_DIR`. A page under a tree that is not the configured corpus -
|
|
# every fixture tree, and any `lint <path>` aimed elsewhere - raised
|
|
# `ValueError` here and read as "no collection", which made the label
|
|
# authorisation below skip the edge in silence rather than judge it
|
|
# (Gitea #44).
|
|
def _collection_of(page):
|
|
try:
|
|
return page.path.relative_to(kb_dir).parts[0]
|
|
except (ValueError, IndexError):
|
|
return None
|
|
|
|
dangling_frontmatter_refs = []
|
|
malformed_edges: list[dict] = []
|
|
unlabelled_edges: list[dict] = []
|
|
unauthorised_labels: list[dict] = []
|
|
redundant_see_also: list[dict] = []
|
|
|
|
# Every specific thing any page asserts about any pair, collected before the
|
|
# loop below because the reverse direction of an edge is not known while
|
|
# standing on the page that carries it.
|
|
#
|
|
# What it is for: `see-also` is the catalogue's declared last resort - it
|
|
# asserts that nothing better fit. When the *other* page already says
|
|
# something specific about the same pair (`Wine GE depends-on Wine` opposite
|
|
# `Wine see-also Wine GE`), the weak edge adds nothing a reader did not
|
|
# have: direction is authored but the inbound view is rendered, so the
|
|
# labelled edge already shows on both pages. Measured once on this corpus,
|
|
# that was 57 of 180 `see-also` edges - the largest single class, and none
|
|
# of it a vocabulary gap.
|
|
typed_edges: dict[tuple[str, str], str] = {}
|
|
for source_title, source_page in pages.items():
|
|
for edge in links.edges(source_page.frontmatter, "related"):
|
|
if edge.is_labelled and edge.label != links.SEE_ALSO:
|
|
typed_edges[(source_title, edge.target)] = edge.label
|
|
|
|
for title, page in sorted(pages.items()):
|
|
type_path = page.frontmatter.get("type")
|
|
if not type_path:
|
|
continue
|
|
try:
|
|
ref_fields = resolver.get_page_ref_fields(type_path, page.path)
|
|
except ValueError:
|
|
continue # unresolvable type is already reported as type_resolution_errors
|
|
for field in ref_fields:
|
|
# Through `links` so a labelled edge (`- depends-on: Hermes`) is read
|
|
# as its target rather than as a mapping - the entry carries the
|
|
# label alongside the title now, and comparing the whole entry would
|
|
# report every declared edge as dangling.
|
|
for target in links.targets(page.frontmatter, field):
|
|
if target not in pages:
|
|
dangling_frontmatter_refs.append(
|
|
{"page": title, "field": field, "target": target}
|
|
)
|
|
for entry in links.malformed(page.frontmatter, field):
|
|
malformed_edges.append(
|
|
{"page": title, "field": field, "entry": str(entry)}
|
|
)
|
|
# Labels are checked on `related:` only. `sources:`/`entities:`/
|
|
# `concepts:` are the provenance path, unlabelled by construction.
|
|
if "related" in ref_fields:
|
|
source_collection = _collection_of(page)
|
|
for edge in links.edges(page.frontmatter, "related"):
|
|
if not edge.is_labelled:
|
|
unlabelled_edges.append({"page": title, "target": edge.target})
|
|
continue
|
|
if edge.label == links.SEE_ALSO:
|
|
reverse_label = typed_edges.get((edge.target, title))
|
|
if reverse_label is not None:
|
|
redundant_see_also.append(
|
|
{
|
|
"page": title,
|
|
"target": edge.target,
|
|
"reverse_label": reverse_label,
|
|
}
|
|
)
|
|
target_page = pages.get(edge.target)
|
|
if source_collection is None or target_page is None:
|
|
continue
|
|
destination = _collection_of(target_page)
|
|
if destination is None:
|
|
continue
|
|
allowed = kb_collections.authorised_labels(
|
|
source_collection, destination, kb_dir
|
|
)
|
|
if edge.label not in allowed:
|
|
unauthorised_labels.append(
|
|
{
|
|
"page": title,
|
|
"target": edge.target,
|
|
"label": edge.label,
|
|
"destination": destination,
|
|
}
|
|
)
|
|
|
|
# A generated region whose markers do not pair up is not a tidiness problem:
|
|
# the next write appends a second region beside it instead of replacing it,
|
|
# and the page then carries two. An agent rewriting prose at the boundary is
|
|
# how a marker goes missing, which is why this is a hard error.
|
|
unbalanced_marker_findings = [
|
|
{"page": title, "region": name}
|
|
for title, page in sorted(pages.items())
|
|
for name in blocks.unbalanced_markers(page.body)
|
|
]
|
|
|
|
quote_limit_violations = []
|
|
for title, page in sorted(pages.items()):
|
|
quote_count = count_quote_blocks(page.body)
|
|
if quote_count > QUOTE_LIMIT:
|
|
quote_limit_violations.append({"page": title, "quote_count": quote_count})
|
|
|
|
# Type system validation. Lint reports are not validated here: they are
|
|
# written to `reports/` outside kb/ and are never pages, so nothing this
|
|
# loop scans can be one.
|
|
invalid_type_paths = []
|
|
type_resolution_errors = []
|
|
schema_validation_errors = []
|
|
|
|
for title, page in sorted(pages.items()):
|
|
type_path = page.frontmatter.get("type")
|
|
if not type_path:
|
|
continue
|
|
|
|
# Check if type path is valid
|
|
if not type_path.endswith('.md'):
|
|
invalid_type_paths.append({"page": title, "type": type_path, "error": "Type path must end with .md"})
|
|
continue
|
|
|
|
# Try to resolve and validate the type
|
|
try:
|
|
resolver.load_type_spec(type_path, page.path)
|
|
|
|
# Try schema validation
|
|
try:
|
|
resolver.validate_frontmatter(page.frontmatter, type_path, page.path)
|
|
except ValueError as schema_error:
|
|
schema_validation_errors.append({"page": title, "type": type_path, "error": str(schema_error)})
|
|
|
|
except ValueError as resolution_error:
|
|
type_resolution_errors.append({"page": title, "type": type_path, "error": str(resolution_error)})
|
|
|
|
return {
|
|
"generated": date.today().isoformat(),
|
|
"page_count": len(pages),
|
|
"frontmatter_errors": frontmatter_errors,
|
|
"broken_links": broken_links,
|
|
"orphan_pages": orphan_pages,
|
|
"most_linked": most_linked,
|
|
"inbound_counts": inbound_counts,
|
|
"missing_from_index": missing_from_index,
|
|
"dangling_index_entries": dangling_index_entries,
|
|
"title_mismatches": title_mismatches,
|
|
"duplicate_titles": duplicate_titles,
|
|
"misplaced_pages": misplaced,
|
|
"nested_pages": nested,
|
|
"unsharded_collections": unsharded_collections(kb_dir, pages),
|
|
"unclassified_source_pages": unclassified_source_pages(pages),
|
|
"uncovered_raw_files": find_uncovered_raw_files(config.RAW_DIR, pages),
|
|
"broken_raw_refs": find_broken_raw_refs(pages),
|
|
"duplicate_raw_file_owners": find_duplicate_raw_file_owners(pages),
|
|
"legacy_source_pages": find_legacy_source_pages(pages),
|
|
"unmarked_provenance": unmarked_provenance,
|
|
"citation_frontmatter_drift": citation_frontmatter_drift,
|
|
"legacy_citation_markers": legacy_citation_markers,
|
|
"undefined_footnote_refs": undefined_footnote_refs,
|
|
"orphan_footnote_defs": orphan_footnote_defs,
|
|
"dangling_frontmatter_refs": dangling_frontmatter_refs,
|
|
"malformed_edges": malformed_edges,
|
|
"unlabelled_edges": unlabelled_edges,
|
|
"unauthorised_labels": unauthorised_labels,
|
|
"redundant_see_also": redundant_see_also,
|
|
"unbalanced_markers": unbalanced_marker_findings,
|
|
"quote_limit_violations": quote_limit_violations,
|
|
"invalid_type_paths": invalid_type_paths,
|
|
"type_resolution_errors": type_resolution_errors,
|
|
"schema_validation_errors": schema_validation_errors,
|
|
}
|
|
|
|
|
|
def _section(lines: list[str], title: str, items: list, formatter) -> None:
|
|
lines.append(f"## {title}")
|
|
lines.append("")
|
|
if not items:
|
|
lines.append("None found.")
|
|
else:
|
|
for item in items:
|
|
lines.append(f"- {formatter(item)}")
|
|
lines.append("")
|
|
|
|
|
|
def render_markdown(report: dict) -> str:
|
|
lines = [f"# Structural Lint Report ({report['generated']})", ""]
|
|
lines.append(f"Scanned {report['page_count']} pages under `wiki/`. This report covers only")
|
|
lines.append("mechanically-verifiable structural issues; see the Semantic Review section")
|
|
lines.append("below for judgment calls the LLM should complete.")
|
|
lines.append("")
|
|
|
|
_section(
|
|
lines, "Unreadable Frontmatter", report["frontmatter_errors"],
|
|
lambda i: f"[[{i['page']}]] - {i['error']}",
|
|
)
|
|
_section(
|
|
lines, "Broken Wikilinks", report["broken_links"],
|
|
lambda i: f"[[{i['page']}]] links to missing [[{i['target']}]]",
|
|
)
|
|
_section(lines, "Orphan Pages (no inbound links)", report["orphan_pages"], lambda i: f"[[{i}]]")
|
|
_section(
|
|
lines, f"Most-Linked Pages (top {MOST_LINKED_COUNT} hubs)", report["most_linked"],
|
|
lambda i: f"[[{i['page']}]] - {i['inbound']} inbound link(s)",
|
|
)
|
|
_section(lines, "Pages Missing from index.md", report["missing_from_index"], lambda i: f"[[{i}]]")
|
|
_section(lines, "Dangling index.md Entries", report["dangling_index_entries"], lambda i: f"[[{i}]]")
|
|
_section(
|
|
lines, "Duplicate Titles (naming collisions)", report["duplicate_titles"],
|
|
lambda i: f"`{i['stem']}` -> {', '.join(f'`{p}`' for p in i['paths'])}",
|
|
)
|
|
_section(
|
|
lines, "Filename / H1 Title Mismatches", report["title_mismatches"],
|
|
lambda i: f"[[{i['page']}]] H1 is '{i['h1']}'",
|
|
)
|
|
_section(
|
|
lines, "Misplaced Pages (not under their type-spec's computed directory)",
|
|
report.get("misplaced_pages", []),
|
|
lambda i: f"[[{i['page']}]] is at `{i['at']}`, should be under `{i['should_be']}` "
|
|
f"- `wikitool move --page \"{i['page']}\"`",
|
|
)
|
|
_section(
|
|
lines, "Nested Pages (more than one directory below their collection)",
|
|
report.get("nested_pages", []),
|
|
lambda i: f"[[{i['page']}]] is {i['depth']} directories below `kb/` at `{i['at']}` - "
|
|
"the catalog folds this into its area silently; `wikitool move --reconcile` fixes it "
|
|
"when the page's type resolves to a shallower directory, otherwise move it up by hand",
|
|
)
|
|
_section(
|
|
lines, f"Collections Past the Shard Threshold (>{SHARD_THRESHOLD}) With No Areas "
|
|
"- recommendation, not an error",
|
|
report.get("unsharded_collections", []),
|
|
lambda i: f"`kb/{i['collection']}/` holds {i['count']} pages in a single table and has no "
|
|
f"areas, so the per-area shard threshold never fires. Splitting on `{i['field']}` would "
|
|
"give: "
|
|
+ ", ".join(f"{d['value']} {d['count']}" for d in i["distribution"])
|
|
+ f" - all at or under {SHARD_THRESHOLD}. "
|
|
+ (
|
|
"The type-spec already declares the `layout:` for those values, so "
|
|
"`wikitool move --reconcile` and `wikitool index rebuild` are the whole fix"
|
|
if i.get("layout_declared")
|
|
else "Declare a `layout:` for those values in the type-spec, then "
|
|
"`wikitool move --reconcile` and `wikitool index rebuild`"
|
|
),
|
|
)
|
|
_section(
|
|
lines, "Unclassified Source Pages (source_type: unclassified) - recommendation, not an error",
|
|
report.get("unclassified_source_pages", []),
|
|
lambda i: f"[[{i['page']}]] - `wikitool touch --set source_type=<value>` once its "
|
|
"category is known",
|
|
)
|
|
_section(
|
|
lines, "Uncovered Raw Files (no source page)", report["uncovered_raw_files"],
|
|
lambda i: f"`{i}`",
|
|
)
|
|
_section(
|
|
lines, "Broken raw_files References", report["broken_raw_refs"],
|
|
lambda i: f"[[{i['page']}]] -> `{i['raw_path']}` (does not exist)",
|
|
)
|
|
_section(
|
|
lines, "Raw Files With More Than One Owner", report["duplicate_raw_file_owners"],
|
|
lambda i: f"`{i['raw_file']}` is claimed by " + ", ".join(f"[[{t}]]" for t in i["owners"]),
|
|
)
|
|
_section(
|
|
lines, "Legacy source: Field (not yet migrated to raw_files:)", report["legacy_source_pages"],
|
|
lambda i: f"[[{i['page']}]] source: `{i['source']}` ({i['reason']})",
|
|
)
|
|
_section(
|
|
lines, "Pages Missing provenance: general Marker", report["unmarked_provenance"],
|
|
lambda i: f"[[{i}]] has no sources and is not marked `provenance: general`",
|
|
)
|
|
_section(
|
|
lines, "Citation / Frontmatter Drift", report["citation_frontmatter_drift"],
|
|
lambda i: f"[[{i['page']}]] cites [[{i['cited_but_not_in_sources']}]] inline but it is missing from frontmatter `sources:`",
|
|
)
|
|
_section(
|
|
lines, "Legacy Citation Markers (pre-migration `^[[...]]`)", report["legacy_citation_markers"],
|
|
lambda i: f"[[{i['page']}]] still has `{i['marker']}` - run `wikitool cite add` and replace it with the `[^cite-id]` it prints",
|
|
)
|
|
_section(
|
|
lines, "Undefined Footnote References", report["undefined_footnote_refs"],
|
|
lambda i: f"[[{i['page']}]] references `[^{i['ref']}]`, which has no `[^{i['ref']}]: [[...]]` definition",
|
|
)
|
|
_section(
|
|
lines, "Orphan Footnote Definitions", report["orphan_footnote_defs"],
|
|
lambda i: f"[[{i['page']}]] defines `[^{i['id']}]` (-> [[{i['source']}]]) but nothing references it - run `wikitool cite sync`",
|
|
)
|
|
_section(
|
|
lines, "Malformed Edges", report.get("malformed_edges", []),
|
|
lambda i: f"[[{i['page']}]] `{i['field']}`: {i['entry']}",
|
|
)
|
|
_section(
|
|
lines, "Unbalanced Generated-Region Markers", report.get("unbalanced_markers", []),
|
|
lambda i: f"[[{i['page']}]]: `{i['region']}`",
|
|
)
|
|
_section(
|
|
lines, "Unlabelled Edges", report.get("unlabelled_edges", []),
|
|
lambda i: f"[[{i['page']}]] -> [[{i['target']}]]",
|
|
)
|
|
_section(
|
|
lines, "Labels Not Authorised by the Source Collection",
|
|
report.get("unauthorised_labels", []),
|
|
lambda i: f"[[{i['page']}]] `{i['label']}` -> kb/{i['destination']}/ ([[{i['target']}]])",
|
|
)
|
|
_section(
|
|
lines, "Redundant see-also (the other page already says something specific)",
|
|
report.get("redundant_see_also", []),
|
|
lambda i: f"[[{i['page']}]] `see-also` -> [[{i['target']}]], but [[{i['target']}]] already asserts `{i['reverse_label']}` back - drop the weaker edge, the inbound view renders the other one here",
|
|
)
|
|
_section(
|
|
lines, "Dangling Frontmatter References", report["dangling_frontmatter_refs"],
|
|
lambda i: f"[[{i['page']}]] `{i['field']}:` names `{i['target']}`, which is not a page",
|
|
)
|
|
_section(
|
|
lines, "Invalid Type Paths", report["invalid_type_paths"],
|
|
lambda i: f"[[{i['page']}]] has type: `{i['type']}` - {i['error']}",
|
|
)
|
|
_section(
|
|
lines, "Type Resolution Errors", report["type_resolution_errors"],
|
|
lambda i: f"[[{i['page']}]] type: `{i['type']}` - {i['error']}",
|
|
)
|
|
_section(
|
|
lines, "Schema Validation Errors", report["schema_validation_errors"],
|
|
lambda i: f"[[{i['page']}]] type: `{i['type']}` - {i['error']}",
|
|
)
|
|
_section(
|
|
lines, f"Pages Exceeding Quote Limit (>{QUOTE_LIMIT}/page)", report["quote_limit_violations"],
|
|
lambda i: f"[[{i['page']}]] has {i['quote_count']} quotes - trim or confirm they're load-bearing",
|
|
)
|
|
|
|
lines.append("## Semantic Review (LLM to complete)")
|
|
lines.append("")
|
|
lines.append("- Contradictions across pages: TODO")
|
|
lines.append("- Stale claims (unconfirmed >6 months): TODO")
|
|
lines.append("- Suggested new pages / missing cross-references: TODO")
|
|
lines.append("")
|
|
return "\n".join(lines)
|
|
|
|
|
|
# Sections that always carry content but are not findings, so the summary
|
|
# handles them separately: a hub list is a statistic, and the semantic review
|
|
# is the checklist that follows the report rather than part of it.
|
|
INFORMATIONAL_SECTIONS = ("Most-Linked Pages",)
|
|
SEMANTIC_REVIEW_SECTION = "Semantic Review"
|
|
|
|
|
|
def _split_sections(markdown: str) -> tuple[str, list[tuple[str, str]]]:
|
|
"""Cut a rendered report into its preamble and (title, body) sections."""
|
|
preamble, *rest = markdown.split("\n## ")
|
|
sections = []
|
|
for part in rest:
|
|
title, _, body = part.partition("\n")
|
|
sections.append((title.strip(), body.strip()))
|
|
return preamble.rstrip(), sections
|
|
|
|
|
|
def render_summary(report: dict) -> str:
|
|
"""The same report with the empty sections removed.
|
|
|
|
On a healthy corpus the full report is better than 90% "None found.", so
|
|
reading it in the terminal means paging past the answer. The file on disk
|
|
stays complete - this is what gets printed, and the written path underneath
|
|
it is how the rest is reached without running lint a second time.
|
|
"""
|
|
preamble, sections = _split_sections(render_markdown(report))
|
|
findings, trailing = [], []
|
|
for title, body in sections:
|
|
if title.startswith(SEMANTIC_REVIEW_SECTION):
|
|
trailing.append((title, body))
|
|
elif body != "None found." and not title.startswith(INFORMATIONAL_SECTIONS):
|
|
findings.append((title, body))
|
|
lines = [preamble, ""]
|
|
if not findings:
|
|
lines += ["No structural findings.", ""]
|
|
for title, body in findings + trailing:
|
|
lines += [f"## {title}", "", body, ""]
|
|
return "\n".join(lines)
|
|
|
|
|
|
def default_report_path(report: dict) -> Path:
|
|
"""Where a report goes when the caller names no path.
|
|
|
|
`reports/` is derived and gitignored ([reports/CONTRACT.md]), so writing
|
|
here by default costs the tree nothing.
|
|
"""
|
|
return config.ROOT / "reports" / f"Lint Report {report['generated']}.md"
|
|
|
|
|
|
# Findings that make a tree structurally wrong rather than merely untidy.
|
|
# `orphan_pages` is deliberately absent: many pages are validly reachable
|
|
# through the index or navigation only. `quote_limit_violations` is advisory
|
|
# too - it flags a habit, not a broken tree. `redundant_see_also` joins them for
|
|
# both of those reasons at once: a weak edge beside a specific one is redundant
|
|
# rather than 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` it is not migration-gated either - there
|
|
# is no version at which the redundancy becomes an error, only a sweep someone
|
|
# does or does not get to.
|
|
#
|
|
# `misplaced_pages` is the same shape again: it arrived long after most
|
|
# instances' corpora were hand-placed, a hand-placed page is not a broken one,
|
|
# and there is no version at which "not under the computed directory" becomes
|
|
# wrong - only `wikitool move` someone does or does not get to run.
|
|
#
|
|
# `unsharded_collections` is advisory by construction rather than by tolerance:
|
|
# it does not describe anything that is wrong, only a collection that has grown
|
|
# past the size at which areas start paying for themselves. Whether to split it
|
|
# is an authoring decision about how the corpus is organised - the tool can see
|
|
# that the split would work and say so, and that is the whole of its authority.
|
|
# Failing on it would also make `lint` red on a corpus that is entirely
|
|
# self-consistent, which is the state the recommendation is asking to improve.
|
|
#
|
|
# `unclassified_source_pages` is advisory for the same reason `misplaced_pages`
|
|
# is: `unclassified` (Gitea #66) is a deliberately visible catalog slot for a
|
|
# source whose category is genuinely unclear, not a defect - failing on it
|
|
# would penalise the honest "I don't know yet" that the slot exists to allow,
|
|
# where the old silent `default: notes` hid the same uncertainty for free.
|
|
#
|
|
# `malformed_edges` and `unbalanced_markers` are hard from the start: neither
|
|
# describes an unconverted page, only a broken one.
|
|
#
|
|
# `nested_pages` is hard from the start too, and for the same reason as
|
|
# `malformed_edges`/`unbalanced_markers` rather than `misplaced_pages`'s: it is
|
|
# not a hand-placement habit some instances predate, it is a page the
|
|
# generated catalog (`index rebuild`) silently mis-describes today, on every
|
|
# instance, at every version - see `nested_pages()` above.
|
|
#
|
|
# One definition, used by `lint --fail-on-error` and by the eval scorecard: if
|
|
# the two disagreed, a run could pass its score while lint refused it.
|
|
HARD_ERROR_KEYS = (
|
|
"frontmatter_errors",
|
|
"broken_links",
|
|
"dangling_index_entries",
|
|
"duplicate_titles",
|
|
"nested_pages",
|
|
"broken_raw_refs",
|
|
"duplicate_raw_file_owners",
|
|
"legacy_source_pages",
|
|
"citation_frontmatter_drift",
|
|
"legacy_citation_markers",
|
|
"undefined_footnote_refs",
|
|
"orphan_footnote_defs",
|
|
"dangling_frontmatter_refs",
|
|
"malformed_edges",
|
|
"unbalanced_markers",
|
|
"unlabelled_edges",
|
|
"unauthorised_labels",
|
|
"invalid_type_paths",
|
|
"type_resolution_errors",
|
|
"schema_validation_errors",
|
|
)
|
|
|
|
# Findings that only become hard once the corpus has reached a given shape.
|
|
#
|
|
# `unlabelled_edges` and `unauthorised_labels` describe exactly the state a
|
|
# corpus is in between the 4.0.0 machinery landing and the migration reaching
|
|
# each page - the window `.wikitool-kb.json` exists to represent. Failing on
|
|
# them during that window would refuse the very corpus that
|
|
# `instructions/migrations/4.0.0-link-taxonomy.md` tells an instance to publish
|
|
# unit by unit. So the promotion is tied to `kb_version` rather than to a
|
|
# release date: below 4.0.0 they are advisory, at or above it an unlabelled
|
|
# edge is no longer a page awaiting conversion but an edge whose author did not
|
|
# say what it asserts.
|
|
#
|
|
# Gated rather than simply promoted, which is where this departs from
|
|
# `legacy_citation_markers`: that one was flipped in a later version and any
|
|
# instance still owing the citation migration had to live with a red lint. The
|
|
# ledger can answer the question now, so it does.
|
|
MIGRATION_GATED_KEYS: dict[str, Version] = {
|
|
"unlabelled_edges": Version(4, 0, 0),
|
|
"unauthorised_labels": Version(4, 0, 0),
|
|
}
|
|
|
|
_ALWAYS_HARD = Version(0, 0, 0)
|
|
|
|
|
|
def hard_error_keys(kb_version: Version | None = None) -> tuple[str, ...]:
|
|
"""`HARD_ERROR_KEYS` minus the findings this corpus has not grown into yet.
|
|
|
|
`kb_version` defaults to what `.wikitool-kb.json` records. A tree without
|
|
one - a fresh instance, which starts at the current shape rather than
|
|
migrating into it - keeps every key: there is no outstanding migration for
|
|
a gated finding to be the noise of.
|
|
"""
|
|
from chemenu import kb_state
|
|
|
|
if kb_version is None:
|
|
kb_version = kb_state.read_kb_version()
|
|
if kb_version is None:
|
|
return HARD_ERROR_KEYS
|
|
return tuple(
|
|
key
|
|
for key in HARD_ERROR_KEYS
|
|
if kb_version >= MIGRATION_GATED_KEYS.get(key, _ALWAYS_HARD)
|
|
)
|
|
|
|
|
|
def has_hard_errors(report: dict) -> bool:
|
|
return any(report.get(key) for key in hard_error_keys())
|