Files
chemenu/tools/chemenu/commands/lint.py
T
torbenandClaude Opus 5.5 4ec22d376d
CI / verify (push) Successful in 5m26s
CI / pwsh (push) Successful in 2m8s
Release / release (push) Successful in 34s
feat: people live on their organization's page until promoted; organization subtype, member-of, broken_anchors lint (#172)
Files changed:
- CHANGES.md
- README.md
- VERSION
- instructions/kb-profiles.md
- instructions/link-taxonomy.md
- instructions/page-lifecycle.md
- instructions/wiki-ingest/SKILL.md
- instructions/wiki-lint/SKILL.md
- kb/entities/COLLECTION.md
- kb/entities/INDEX.md
- kb/entities/organizations/E3DC GmbH.md
- kb/entities/people/E3DC GmbH.md
- kb/index.md
- kb/log.md
- tools/CONTRACT.md
- tools/chemenu/commands/lint.py
- tools/chemenu/kb_scan.py
- tools/chemenu/lint_core.py
- tools/chemenu/tests/test_lint.py
- tools/chemenu/tests/test_new_page.py
- tools/chemenu/tests/test_type_resolver.py
- tools/chemenu/tests/test_types_cmd.py
- types/entity.md
- types/entity.organization.md
- types/entity.person.md
- types/entity.schema.yaml

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
2026-10-04 10:51:46 +02:00

160 lines
7.4 KiB
Python

"""`wikitool lint` - the terminal adapter over `chemenu.lint_core`.
The checks, the report and the hard-error rule live in `chemenu/lint_core.py`,
which imports no CLI machinery. This module owns only what a terminal needs:
the flags, where the report file lands, and the exit code.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Optional
import typer
from chemenu import cli_contract, config
from chemenu.commands._util import rel_path, success
from chemenu.lint_core import (
HARD_ERROR_KEYS,
MIGRATION_GATED_KEYS,
MOST_LINKED_COUNT,
QUOTE_LIMIT,
count_quote_blocks,
default_report_path,
hard_error_keys,
has_hard_errors,
render_markdown,
render_summary,
run_lint,
)
# Re-exported: `from chemenu.commands.lint import run_lint` still resolves, and
# so does every other name the tests and sibling commands already import.
__all__ = [
"HARD_ERROR_KEYS",
"MIGRATION_GATED_KEYS",
"MOST_LINKED_COUNT",
"QUOTE_LIMIT",
"count_quote_blocks",
"default_report_path",
"hard_error_keys",
"has_hard_errors",
"render_markdown",
"render_summary",
"run_lint",
"lint_command",
]
@cli_contract.record(cli_contract.CommandRecord(
path="lint",
summary="Run structural lint checks against kb/.",
synopsis=(cli_contract.Variant(
usage="lint [--json] [--markdown out.md] [--full] [--fail-on-error]",
),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.YES,
atomic="Writes one report file (single atomic write) unless `--json`",
budget=cli_contract.Budget.COUNTED,
),
notes=(
"Structural and provenance checks over `kb/`: broken wikilinks, wikilinks wrapped "
"across a line break, dangling frontmatter "
"references, orphan pages, index drift, schema gaps, duplicate titles, title "
"mismatches, uncovered raw files, broken `raw_files:` refs, raw files claimed by more "
"than one source page, unmarked provenance, citation/frontmatter drift, and unbalanced "
"generated-region markers.",
"Unportable Titles is a hard finding, and hard at every `kb_version`: a page whose title "
"is not a valid file name on Windows and macOS (forbidden character, reserved name, "
"trailing dot or space), or that collides with another page by case or Unicode "
"normalization. `wikitool rename` is the fix.",
"Wrapped Wikilinks is a hard finding: a `[[...]]` with a line break inside it. The link "
"graph reads it as the title it folds to (the break and its indentation become one "
"space), so it is not also a broken link unless that title is missing; the fix is to put "
"it back on one line.",
"Broken Anchors is advisory: a `[[Page#Section]]` whose page exists but has no heading "
"the anchor names (any level, compared without case, inline-code backticks or extra "
"whitespace; every segment of a nested `[[Page#A#B]]`). The link still reaches the page, "
"so nothing else reports it - typically a section that was promoted to a page of its "
"own or renamed. A missing page is Broken Wikilinks instead.",
"Pages nested more than one directory below their collection are a hard finding - the "
"generated catalog folds these into their area silently rather than merely reading it.",
"Edges whose label is missing or not authorised by the source collection's `outbound:` "
"are both hard once `kb_version` has reached the release that introduced labelled "
"edges, and advisory below it.",
"Advisory only: `see-also` edges whose reverse direction already carries a specific "
"label - never migration-gated.",
"Advisory only: a collection past the catalog's per-area shard threshold that has no "
"areas to shard, reported with the split its subtype field would produce, and only "
"when that split puts every resulting area at or under the threshold.",
"Advisory only: source pages sitting in the `unclassified` catalog slot.",
"Advisory only: Long Paths - a file under `kb/` or `raw/` whose path below the "
"instance root is over 160 UTF-16 code units, the budget that keeps a Windows checkout "
"without long paths working. Reported as `{path, length}`; a corpus over the budget breaks "
"no lint run. `wikitool rename` is the fix for a page.",
"Advisory only: quote-limit overages (>2 blockquotes/page).",
"Prints only the sections that found something and always writes the full report to "
"`reports/Lint Report <date>.md` (or `--markdown`), naming the path. `--full` prints "
"everything; `--json` prints the findings and writes nothing.",
"Exits 0 whatever it finds unless `--fail-on-error` is passed.",
),
failures=(cli_contract.Failure(
cause="Only with `--fail-on-error`: hard findings exist",
reaction="Act on the findings - exit 1 here means \"act on the findings\", not \"the "
"tool is broken\". Re-running is safe, but only to re-*measure* after a fix",
),),
examples=(
"tools/wikitool lint",
"tools/wikitool lint --json",
"tools/wikitool lint --fail-on-error",
),
never=(
"Never re-run just to re-read the findings - the printed path holds the full report.",
),
see_also=(
"`wiki-lint` skill - the procedure that runs this",
"`wikitool move --reconcile` - fixes Misplaced and Nested Pages",
"`wikitool rename` - fixes Unportable Titles and, for a page, Long Paths",
"`wikitool log status` - whether a full lint is due",
),
))
def lint_command(
json_out: bool = typer.Option(False, "--json", help="Print the raw findings as JSON and write no report"),
markdown_out: Optional[Path] = typer.Option(None, "--markdown", help="Write the markdown report here instead of the default reports/Lint Report <date>.md"),
full: bool = typer.Option(False, "--full", help="Print the whole report instead of only the sections with findings"),
fail_on_error: bool = typer.Option(False, "--fail-on-error", help="Exit non-zero if hard errors were found"),
):
"""Run structural lint checks against kb/.
\f
Unless `--json` is given, the full report is always written to a file and
its path is printed. That path is the point: a lint report is long, and an
agent that only saw it on stdout had no way back to the part it scrolled
past except by running lint again - two budget slots for one look at the
corpus.
"""
report = run_lint(config.KB_DIR)
if json_out:
typer.echo(json.dumps(report, indent=2))
if fail_on_error and has_hard_errors(report):
raise typer.Exit(code=1)
return
typer.echo(render_markdown(report) if full else render_summary(report))
target = markdown_out or default_report_path(report)
frontmatter = (
"---\n"
"type: types/lint-report.md\n"
f"created: {report['generated']}\n"
f"summary: Structural lint report - {report['page_count']} pages scanned\n"
"---\n\n"
)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(frontmatter + render_markdown(report) + "\n", encoding="utf-8", newline="\n")
success(f"Full report written to {rel_path(target)}")
if fail_on_error and has_hard_errors(report):
raise typer.Exit(code=1)