feat: lint reports sections that hold only template placeholders as Unfilled Template Sections (#94)
CI / verify (push) Successful in 5m16s
CI / pwsh (push) Successful in 2m3s
Release / release (push) Successful in 34s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
This commit is contained in:
torbenandClaude Opus 5.5 committed 2026-10-04 17:11:37 +02:00
1 parent af8bcb5b57
commit a2a6baa265
15 files changed
+320 -5

No files matched your search

+20 -1
View File
@@ -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
<!-- /wikitool:bumps -->
### 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
+2 -1
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
8.0.0-beta.36
8.0.0-beta.37
+8 -1
View File
@@ -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`
+2 -1
View File
@@ -47,7 +47,8 @@ requirements come from `tools/wikitool types describe <type>`.
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 "<Title>" --source
"Source - X"`, which also adds `X` to `sources:` - paste the `[^cite-id]` marker it prints.
+1
View File
@@ -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.
+16
View File
@@ -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)]
+6
View File
@@ -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.",
+1
View File
@@ -31,6 +31,7 @@ ADVISORY_KEYS = (
"missing_from_index",
"title_mismatches",
"redundant_see_also",
"unfilled_sections",
)
+87
View File
@@ -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 -
+11
View File
@@ -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
+9
View File
@@ -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)
+145
View File
@@ -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)
+2
View File
@@ -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):
+9
View File
@@ -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 |