"""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 ""` 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())