From a2a6baa265dd17774d3c0418e37866f88e896bf6 Mon Sep 17 00:00:00 2001 From: Torben Nehmer Date: Sun, 4 Oct 2026 17:11:37 +0200 Subject: [PATCH] feat: lint reports sections that hold only template placeholders as Unfilled Template Sections (#94) Files changed: - CHANGES.md - EVALS.md - VERSION - instructions/wiki-lint/SKILL.md - instructions/wiki-manage/SKILL.md - tools/CONTRACT.md - tools/chemenu/blocks.py - tools/chemenu/commands/lint.py - tools/chemenu/evals/scorecard.py - tools/chemenu/lint_core.py - tools/chemenu/tests/test_blocks.py - tools/chemenu/tests/test_evals.py - tools/chemenu/tests/test_lint.py - tools/chemenu/tests/test_pipeline_l0.py - types/type-spec.md Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2 --- CHANGES.md | 21 +++- EVALS.md | 3 +- VERSION | 2 +- instructions/wiki-lint/SKILL.md | 9 +- instructions/wiki-manage/SKILL.md | 3 +- tools/CONTRACT.md | 1 + tools/chemenu/blocks.py | 16 +++ tools/chemenu/commands/lint.py | 6 + tools/chemenu/evals/scorecard.py | 1 + tools/chemenu/lint_core.py | 87 ++++++++++++++ tools/chemenu/tests/test_blocks.py | 11 ++ tools/chemenu/tests/test_evals.py | 9 ++ tools/chemenu/tests/test_lint.py | 145 ++++++++++++++++++++++++ tools/chemenu/tests/test_pipeline_l0.py | 2 + types/type-spec.md | 9 ++ 15 files changed, 320 insertions(+), 5 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 3147a42..1a12ebe 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -59,7 +59,7 @@ concern - readable here, never shipped as something to parse. --- -## 8.0.0-beta.36 - 2026-10-04 - kb/CONTRACT.md names section anchors in wikilinks and the Broken Anchors finding +## 8.0.0-beta.37 - 2026-10-04 - lint: Unfilled Template Sections - a section still holding only its template's TODO placeholders (advisory) **Author:** Torben Nehmer @@ -106,6 +106,7 @@ concern - readable here, never shipped as something to parse. - The test suite no longer ships, and dist upgrade deletes what a release stops shipping - Link-Katalog: Beteiligungs- und RACI-Label von der Projektseite aus - Organisationsseiten: Personen als Abschnitt mit Aufstieg, entity_type organization, member-of, Lint-Befund broken_anchors +- lint: Unfilled Template Sections - a section still holding only its template's TODO placeholders (advisory) **Low impact** - version bump no longer points at version release in its output @@ -149,6 +150,24 @@ concern - readable here, never shipped as something to parse. - kb/CONTRACT.md names section anchors in wikilinks and the Broken Anchors finding +### lint: Unfilled Template Sections - a section still holding only its template's TODO placeholders (advisory) + +`lint` had no finding for a page without substance: every check measured structure, links, +provenance or schema, so a page carrying only its `wikitool new` scaffold and one line read the +same as a complete one - and a later session took its subject as covered and stopped looking at +the source. The new advisory finding `unfilled_sections` names each page with a `##` section +whose non-blank lines are all template placeholders, with those sections' headings. A +placeholder line is one whose content starts with the token `TODO` (after an optional list +marker, checkbox, table cell or bold field label); code, generated regions and footnote +definitions are out of view, and neither an empty section nor a single open field beside written +ones is reported. Word count and "page carries a placeholder" were measured against two corpora +and rejected - both misjudge short complete pages or pages with one honest gap marker; the +section rule separated stubs from complete pages in both. On the demo corpus it names 34 pages. +`types/type-spec.md` now makes `TODO` the contract for placeholder text in a template, an +instance-owned one included; `wiki-lint` keeps the finding out of its mechanical repair step; +`eval score` counts it under `advisories`. Not a hard error, so no instance's +`lint --fail-on-error` turns red on this upgrade (Gitea #94). + ### kb/CONTRACT.md names section anchors in wikilinks and the Broken Anchors finding The stage contract's rules for writing a wikilink (§ Titles are identifiers) covered wrapped links diff --git a/EVALS.md b/EVALS.md index 87386b4..0d2cf68 100644 --- a/EVALS.md +++ b/EVALS.md @@ -330,7 +330,8 @@ giving it its own runner would have duplicated the suite to no end. One behaviour it pins is easy to mistake for a defect: **a scaffolded page does not lint clean**. `new` writes placeholder wikilinks for the author to replace, so a page that was -created but not yet written reports broken links. That is the scaffold saying it is unfinished. +created but not yet written reports broken links, and its `TODO`-only sections as the advisory +*Unfilled Template Sections*. That is the scaffold saying it is unfinished. ### How much of the stack the suite reaches diff --git a/VERSION b/VERSION index a748426..f1c4d35 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -8.0.0-beta.36 +8.0.0-beta.37 diff --git a/instructions/wiki-lint/SKILL.md b/instructions/wiki-lint/SKILL.md index 902d57d..e1c7083 100644 --- a/instructions/wiki-lint/SKILL.md +++ b/instructions/wiki-lint/SKILL.md @@ -47,9 +47,16 @@ mechanical half looks exactly like a complete one. names on Windows and macOS, filename/title mismatches, broken `raw_files:` references, raw files claimed by more than one source page, invalid type paths, schema failures, citation/frontmatter drift, and edges whose label is missing, not authorised - by the source collection, or redundant beside a specific label on the reverse direction. + by the source collection, or redundant beside a specific label on the reverse direction, and + pages with a section that still holds nothing but its template's `TODO` placeholders. **Do not re-derive any of it by reading pages.** + *Unfilled Template Sections* is not mechanical either - do **not** clear it under step 7. + Filling a section is authoring from a source (`wiki-manage`, AGENTS.md invariant 3), and + deleting its placeholders to quiet the finding leaves the same unwritten page without the + marker that made it visible. Retiring the page instead is `instructions/page-lifecycle.md`. + Report the pages at step 9. + The *Redundant see-also* section is the one that looks mechanical and is not - do **not** clear it under step 7. It names a `see-also` edge standing beside a specific label on the reverse direction, and the obvious repair destroys the thing worth keeping: `xref remove` diff --git a/instructions/wiki-manage/SKILL.md b/instructions/wiki-manage/SKILL.md index f0e2d93..d6258da 100644 --- a/instructions/wiki-manage/SKILL.md +++ b/instructions/wiki-manage/SKILL.md @@ -47,7 +47,8 @@ requirements come from `tools/wikitool types describe `. 4. **Gather what the wiki already knows** - `tools/wikitool search` again, for the surrounding subjects - so the prose connects to existing pages instead of restating them. -5. **Draft.** Fill in the generated skeleton's TODO sections, following the tone rules in +5. **Draft.** Fill in the generated skeleton's TODO sections - `lint` reports a section still + made of nothing else as unfilled - following the tone rules in `kb/CONVENTIONS.md` § Tone. If `provenance:` is `sourced` or `mixed`, cite hard facts as you write them with `tools/wikitool cite add --page "" --source "Source - X"`, which also adds `X` to `sources:` - paste the `[^cite-id]` marker it prints. diff --git a/tools/CONTRACT.md b/tools/CONTRACT.md index 99c0958..945e53f 100644 --- a/tools/CONTRACT.md +++ b/tools/CONTRACT.md @@ -1132,6 +1132,7 @@ Run structural lint checks against kb/. - 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). +- Advisory only: Unfilled Template Sections - a page with at least one `##` section whose non-blank lines are all template placeholders (content starting with `TODO`, after an optional list marker, checkbox, table cell or bold field label), reported as `{page, sections}`. Code, generated regions and footnote definitions do not count; an empty section, or one placeholder beside written lines, is no finding. Writing the section from the page's sources or retiring the page is the fix - not a mechanical one. - 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. diff --git a/tools/chemenu/blocks.py b/tools/chemenu/blocks.py index a2892a3..3ba1cf6 100644 --- a/tools/chemenu/blocks.py +++ b/tools/chemenu/blocks.py @@ -125,6 +125,22 @@ def marker_pairs(body: str) -> dict[str, int]: return {name: min(opens.count(name), closes.count(name)) for name in sorted(names)} +_ANY_REGION_RE = re.compile( + rf"<!-- wikitool:({_NAME}) -->.*?<!-- /wikitool:\1 -->", re.DOTALL +) + + +def mask_regions(body: str) -> str: + """`body` with every complete generated region, markers included, replaced + by spaces - same length, same line structure, like + `markdown_code.strip_code_spans()`, so a scan of what the author wrote can + run over the result without reading what the tool wrote. An unpaired + marker masks nothing; `unbalanced_markers()` is that finding.""" + return _ANY_REGION_RE.sub( + lambda m: "".join(c if c == "\n" else " " for c in m.group(0)), body + ) + + def unbalanced_markers(body: str) -> list[str]: """Region names whose open and close markers do not pair up.""" opens = [m.group(1) for m in _ANY_OPEN_RE.finditer(body)] diff --git a/tools/chemenu/commands/lint.py b/tools/chemenu/commands/lint.py index 142cc1d..df16a81 100644 --- a/tools/chemenu/commands/lint.py +++ b/tools/chemenu/commands/lint.py @@ -94,6 +94,12 @@ __all__ = [ "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).", + "Advisory only: Unfilled Template Sections - a page with at least one `##` section whose " + "non-blank lines are all template placeholders (content starting with `TODO`, after an " + "optional list marker, checkbox, table cell or bold field label), reported as " + "`{page, sections}`. Code, generated regions and footnote definitions do not count; an " + "empty section, or one placeholder beside written lines, is no finding. Writing the " + "section from the page's sources or retiring the page is the fix - not a mechanical one.", "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.", diff --git a/tools/chemenu/evals/scorecard.py b/tools/chemenu/evals/scorecard.py index d85a5a5..9ce68f4 100644 --- a/tools/chemenu/evals/scorecard.py +++ b/tools/chemenu/evals/scorecard.py @@ -31,6 +31,7 @@ ADVISORY_KEYS = ( "missing_from_index", "title_mismatches", "redundant_see_also", + "unfilled_sections", ) diff --git a/tools/chemenu/lint_core.py b/tools/chemenu/lint_core.py index dcca909..7ed3895 100644 --- a/tools/chemenu/lint_core.py +++ b/tools/chemenu/lint_core.py @@ -14,6 +14,7 @@ of the line because the markdown report is a data product (it is what from __future__ import annotations import os +import re from datetime import date from pathlib import Path @@ -23,6 +24,7 @@ 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 CITE_DEF_RE 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 @@ -333,6 +335,77 @@ def broken_anchors(pages: dict[str, Page]) -> list[dict]: return findings +# The token every type-spec template writes its placeholder text with +# (`types/type-spec.md` § Template variables). A line is a placeholder when its +# content starts with it - after an optional list marker, task checkbox, table +# cell opener and bold field label - and the token ends there: `TODO: ...`, +# `TODO (falls zutreffend)`, `| TODO |`. Not `TODO.md`, `TODO-Liste` or +# `TODOs`, and not a `TODO` anywhere but the start of the line's content. +PLACEHOLDER_TOKEN = "TODO" +_PLACEHOLDER_LINE_RE = re.compile( + r"^[ \t]*" + r"(?:(?:[-*+]|\d+[.)])[ \t]+)?" + r"(?:\[[ xX]\][ \t]+)?" + r"(?:\|[ \t]*)?" + r"(?:\*\*[^*\n]+\*\*[ \t]*)?" + + re.escape(PLACEHOLDER_TOKEN) + + r"(?=$|[\s:()|])" +) +_SECTION_HEADING_RE = re.compile(r"^##[ \t]+(.+?)(?:[ \t]+#+)?[ \t]*$") + + +def unfilled_sections(body: str) -> list[str]: + """The `##` headings of this body whose sections hold nothing but + placeholder lines, in page order. + + A section runs to the next `##` heading; a `###` heading inside it is + content, not a boundary. A section with no non-blank line at all is not + reported - an empty legacy heading is untidy, not an unwritten scaffold - + and neither is one where a single placeholder sits beside written lines: a + field nobody could fill (`**Version:** TODO (nicht erfasst)`) is an honest + gap marker, and a finding on it would push a session to delete the marker + or invent a value. + + What the author did not write is out of view first: generated regions and + footnote definitions are dropped as if absent, and code is masked, so a + `TODO` quoted in a span or a fence is content rather than a placeholder. + """ + visible = blocks.mask_regions(body) + visible = CITE_DEF_RE.sub(lambda m: re.sub(r"[^\n]", " ", m.group(0)), visible) + masked = strip_code_spans(visible).split("\n") + sections: list[tuple[str, bool, bool]] = [] # heading, any line, all placeholders + for line, masked_line in zip(visible.split("\n"), masked): + heading = _SECTION_HEADING_RE.match(masked_line) + if heading: + sections.append((_SECTION_HEADING_RE.match(line).group(1), False, True)) + continue + if not sections or not line.strip(): + continue + title, _, all_placeholders = sections[-1] + sections[-1] = (title, True, all_placeholders and bool(_PLACEHOLDER_LINE_RE.match(masked_line))) + return [title for title, any_line, all_placeholders in sections if any_line and all_placeholders] + + +def unfilled_section_pages(pages: dict[str, Page]) -> list[dict]: + """`{"page", "sections"}` for every page with at least one section that is + still nothing but its template's placeholders. + + Advisory (see `HARD_ERROR_KEYS`). What it is for is not the thin page + itself but what the page does to the next session: it stands in the + catalog, `search` finds it, and a reader takes the subject as covered and + does not open the source again - costlier than a missing page, which is + visibly missing. Word count could not tell such a page from a short + complete one, and "carries a placeholder" also hits complete pages with + one field left open; a section made of placeholders alone separated both. + """ + findings = [] + for title, page in sorted(pages.items()): + sections = unfilled_sections(page.body) + if sections: + findings.append({"page": title, "sections": sections}) + 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) @@ -581,6 +654,7 @@ def run_lint(kb_dir: Path) -> dict: "broken_links": broken_links, "wrapped_wikilinks": wrapped_links, "broken_anchors": broken_anchors(pages), + "unfilled_sections": unfilled_section_pages(pages), "orphan_pages": orphan_pages, "most_linked": most_linked, "inbound_counts": inbound_counts, @@ -651,6 +725,12 @@ def render_markdown(report: dict) -> str: lambda i: f"[[{i['page']}]] links to [[{i['target']}#{i['anchor']}]], but [[{i['target']}]] " "has no such heading - point the link at the page that section became, or drop the anchor", ) + _section( + lines, "Unfilled Template Sections - advisory, not an error", + report.get("unfilled_sections", []), + lambda i: f"[[{i['page']}]] - only template placeholders under: {', '.join(i['sections'])} " + "- write them from the page's sources (wiki-manage) or retire the page (page-lifecycle)", + ) _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"], @@ -906,6 +986,13 @@ def default_report_path(report: dict) -> Path: # instance's lint red on the upgrade that shipped it, which no other part of # that upgrade asked for. # +# `unfilled_sections` is advisory for the reason `broken_anchors` is: it +# arrived after corpora that already carry such pages - the demo corpus had 34 - +# and failing on it would turn their lint red on the upgrade that shipped it. It +# is not migration-gated either: no migration can fill a section mechanically, +# so there is no version at which the corpus has grown into it, only pages +# someone does or does not get to write or retire. +# # `wrapped_wikilinks` is hard from the start without tightening anything: every # link it names was a hard `broken_links` finding before the link graph learned # to fold a wrapped target, so an instance's lint is exactly as red as it was - diff --git a/tools/chemenu/tests/test_blocks.py b/tools/chemenu/tests/test_blocks.py index 2111675..bab7ba7 100644 --- a/tools/chemenu/tests/test_blocks.py +++ b/tools/chemenu/tests/test_blocks.py @@ -88,3 +88,14 @@ def test_marker_pairs_counts_rather_than_sets(): body = blocks.replace(PROSE, blocks.LINKS, _links()) doubled = body + "\n" + _links() + "\n" assert blocks.marker_pairs(doubled) == {"links": 2} + + +def test_mask_regions_keeps_offsets_and_skips_an_unpaired_marker(): + body = blocks.replace(PROSE, blocks.LINKS, _links()) + masked = blocks.mask_regions(body) + assert len(masked) == len(body) + assert masked.count("\n") == body.count("\n") + assert masked.startswith(PROSE.rstrip("\n")) + assert "Beziehungen" not in masked and "wikitool" not in masked + unpaired = body.replace(blocks.close_marker(blocks.LINKS), "") + assert blocks.mask_regions(unpaired) == unpaired diff --git a/tools/chemenu/tests/test_evals.py b/tools/chemenu/tests/test_evals.py index 5544ecf..fa9768f 100644 --- a/tools/chemenu/tests/test_evals.py +++ b/tools/chemenu/tests/test_evals.py @@ -168,6 +168,15 @@ def test_an_advisory_alone_does_not_fail_a_run(clean_report): assert any(not r["passed"] for r in card["trajectory"]) +def test_unfilled_sections_are_counted_as_an_advisory(clean_report): + """A session that leaves a scaffold unwritten shows in the score without + failing it.""" + report = {**clean_report, "unfilled_sections": [{"page": "X", "sections": ["A"]}]} + card = scorecard.score("s", records=[], report=report) + assert card["structure"]["advisories"]["unfilled_sections"] == 1 + assert not card["structure"]["hard_errors"] + + def test_a_broken_tree_fails_the_run_whatever_the_trajectory(clean_report): card = scorecard.score("s", records=[], report={**clean_report, "broken_links": [{"page": "X"}]}) assert scorecard.failed(card) diff --git a/tools/chemenu/tests/test_lint.py b/tools/chemenu/tests/test_lint.py index 60e027a..bec5f38 100644 --- a/tools/chemenu/tests/test_lint.py +++ b/tools/chemenu/tests/test_lint.py @@ -16,6 +16,7 @@ from chemenu.commands.lint import ( run_lint, ) from chemenu.frontmatter_io import write_page +from chemenu.lint_core import unfilled_sections from chemenu.provenance import cite_id, render_cite_block from chemenu.version import Version @@ -1111,3 +1112,147 @@ def test_rendered_report_names_rename_for_a_long_path(kb_dir, raw_dir): text = render_markdown(run_lint(kb_dir)) assert "## Long Paths" in text assert "wikitool rename" in text + + +# --- unfilled template sections --- + +def _scaffolded_concept(name: str) -> str: + """The body `wikitool new concept` scaffolds, rendered from the shipped + template rather than copied here, so a template change cannot leave this + test judging a skeleton nobody produces any more.""" + from chemenu.commands.new_page import _apply_template_variables + + spec = resolver.load_type_spec("types/concept.md") + return _apply_template_variables( + resolver.page_template(spec), {"name": name, "concept_type": "pattern"} + ) + + +def _concept_page(kb_dir, name: str, body: str) -> None: + write_page( + kb_dir / f"concepts/protocols/{name}.md", + {"type": "types/concept.md", "concept_type": "pattern", "tags": [], + "created": "2026-10-04", "modified": "2026-10-04", "related": [], "sources": [], + "provenance": "general"}, + "\n" + body + "\n", + ) + + +def test_a_freshly_scaffolded_concept_reports_its_unwritten_sections(kb_dir): + """`Beispiele` and `Verwandte Concepts` hold placeholder wikilinks, not + `TODO` lines - written by nobody, but not what the token marks, so they + stay out of the finding.""" + _concept_page(kb_dir, "Scaffold", _scaffolded_concept("Scaffold")) + report = run_lint(kb_dir) + assert { + "page": "Scaffold", + "sections": ["Definition", "Kernpunkte", "Wann zu verwenden", "Wann NICHT zu verwenden"], + } in report["unfilled_sections"] + assert "only template placeholders under: Definition, Kernpunkte" in render_markdown(report) + assert "Unfilled Template Sections" in render_summary(report) + + +def test_the_same_concept_written_is_no_finding(kb_dir): + body = _scaffolded_concept("Written") + for placeholder, prose in ( + ("TODO: Klare Definition dessen, was dieses Concept ist.", "Ein Muster für X."), + ("- TODO: Kernpunkt 1", "- Erstens."), + ("- TODO: Kernpunkt 2", "- Zweitens."), + ("- TODO: Kernpunkt 3", "- Drittens."), + ("TODO: Bedingungen und Kontexte, in denen dieses Concept greift", "Wenn Y gilt."), + ("TODO: Anti-Muster, Warnungen oder Situationen, in denen es fehl am Platz ist", "Nie bei Z."), + ): + assert placeholder in body + body = body.replace(placeholder, prose) + _concept_page(kb_dir, "Written", body) + assert not any(f["page"] == "Written" for f in run_lint(kb_dir)["unfilled_sections"]) + + +def test_the_fixture_corpus_has_no_unfilled_sections(kb_dir): + assert run_lint(kb_dir)["unfilled_sections"] == [] + + +def test_one_open_field_beside_filled_ones_is_no_finding(): + """The honest gap marker: a complete page whose source does not give a + version. A finding here would push a session to delete the marker or + invent the value.""" + body = ( + "# Tool\n\n## Übersicht\n\nEin Werkzeug.\n\n## Kerndaten\n\n" + "- **Zweck:** Bauen\n- **Version:** TODO (nicht erfasst)\n" + "- **Repository:** TODO (falls zutreffend)\n" + ) + assert unfilled_sections(body) == [] + + +def test_a_short_complete_page_is_no_finding(): + body = "# Rohit\n\n## Übersicht\n\nAutor des Artikels. Weitere Angaben macht die Quelle nicht.\n" + assert unfilled_sections(body) == [] + + +def test_an_empty_section_is_no_finding(): + """A heading with nothing under it is untidy, not an unwritten scaffold.""" + assert unfilled_sections("# P\n\n## Leer\n\n## Voll\n\nText.\n\n## Auch leer\n") == [] + + +@pytest.mark.parametrize("line", [ + "Siehe `TODO` im Code.", + "`TODO: kein Platzhalter`", + "[[TODO Retirement]] beschreibt das.", + "Ein TODO mitten im Satz.", + "TODO.md war die alte Liste.", + "TODO-Liste und TODOs.", + "- **Datei:** TODO.md", +]) +def test_todo_that_is_not_a_placeholder(line): + assert unfilled_sections(f"# P\n\n## A\n\n{line}\n") == [] + + +def test_todo_in_a_fence_is_content(): + assert unfilled_sections("# P\n\n## A\n\n```\nTODO: fix\n```\n") == [] + + +def test_generated_regions_and_footnote_definitions_are_out_of_view(): + """A generated region is not part of the section above it, and its own + heading opens no section; a footnote definition is not content.""" + body = ( + "# P\n\n## A\n\nTODO: Text\n\n" + "<!-- wikitool:links -->\n## Beziehungen\n\n- **see-also:** [[TODO List]]\n" + "<!-- /wikitool:links -->\n\n" + "<!-- wikitool:footnotes -->\n## Fußnoten\n\nTODO: generated\n" + "<!-- /wikitool:footnotes -->\n" + ) + assert unfilled_sections(body) == ["A"] + assert unfilled_sections("# P\n\n## B\n\nTODO: x\n[^s]: [[Source - S]]\n") == ["B"] + + +@pytest.mark.parametrize("line", [ + "TODO", + "TODO: Satz", + "TODO (falls zutreffend)", + "- TODO: Punkt", + "* TODO", + "1. TODO: Schritt", + "- [ ] TODO: Aufgabe", + "- **Zweck:** TODO", + "**Zweck:** TODO (nicht erfasst)", + "| TODO | x |", +]) +def test_placeholder_forms(line): + assert unfilled_sections(f"# P\n\n## A\n\n{line}\n") == ["A"] + + +def test_a_section_runs_to_the_next_level_two_heading(): + """A `###` heading is content of its `##` section, so a section whose + only written line is a subheading is written - and the preamble before + the first `##` belongs to no section.""" + body = "# P\n\nTODO: preamble\n\n## A\n\nTODO: x\n\n### Sub\n\n## B\n\nTODO: y\n- TODO: z\n" + assert unfilled_sections(body) == ["B"] + + +def test_unfilled_sections_alone_keep_lint_green(): + """Advisory: corpora carrying such pages predate the check, and no + migration can fill a section.""" + assert "unfilled_sections" not in HARD_ERROR_KEYS + report = {key: [] for key in HARD_ERROR_KEYS} + report["unfilled_sections"] = [{"page": "a", "sections": ["B"]}] + assert not has_hard_errors(report) diff --git a/tools/chemenu/tests/test_pipeline_l0.py b/tools/chemenu/tests/test_pipeline_l0.py index 6836726..32a2853 100644 --- a/tools/chemenu/tests/test_pipeline_l0.py +++ b/tools/chemenu/tests/test_pipeline_l0.py @@ -96,6 +96,8 @@ def test_a_scaffolded_page_is_not_yet_a_finished_one(empty_kb): assert report["broken_links"], \ "the scaffold no longer carries placeholder links - update this test" assert {f["page"] for f in report["broken_links"]} == {"Scaffold Only"} + assert [f["page"] for f in report["unfilled_sections"]] == ["Scaffold Only"], \ + "the scaffold no longer marks its placeholders with TODO - see types/type-spec.md" def test_a_wiki_built_by_the_tools_lints_clean(empty_kb): diff --git a/types/type-spec.md b/types/type-spec.md index 320eea7..11fcb86 100644 --- a/types/type-spec.md +++ b/types/type-spec.md @@ -233,6 +233,15 @@ between markers by `xref` and `cite`, rendered from frontmatter, and re-rendered so scaffolding them would create a section an author is forbidden to edit and the tool would replace anyway. See `tools/chemenu/blocks.py`. +**Placeholder text in a template starts with the token `TODO`** - as the whole line or after a +list marker, checkbox, table cell or bold field label (`TODO: ...`, `- TODO: ...`, +`- **Version:** TODO (falls zutreffend)`, `| TODO |`), with the token followed by the line end, +whitespace, `:`, `(`, `)` or `|`. It is a contract, not a habit: `wikitool lint` reports a `##` +section whose non-blank lines on a page are still all such lines as *Unfilled Template Sections* +(advisory), and a template that marks its placeholders any other way - an instance-owned one +included - leaves its unwritten sections invisible to that finding. A single placeholder beside +written lines is not reported, so a field whose value is genuinely unknown may keep its marker. + ### Ownership boundary | Owned here | Owned by `kb/CONTRACT.md` | Owned by `kb/CONVENTIONS.md` and the collection contracts |