stack: TOC-Scope auf types/ und docs/, Sprachregeln nach AGENTS.md zentralisiert, alle Templates auf Control-Plane-Sprache, --breaking akkumuliert (schliesst #99)
CI / verify (push) Successful in 45s
Release / release (push) Successful in 36s

Files changed:
- AGENTS.md
- CHANGES.md
- ENVIRONMENT.md.template
- SOUL.md
- SOUL.md.template
- USER.md.template
- VERSION
- docs/ownership-and-templates.md
- docs/version-model.md
- instructions/CONTRACT.md
- instructions/dev/doc-pull-through.md
- instructions/dev/stack-close/SKILL.md
- instructions/dev/stack-dev/SKILL.md
- instructions/dev/version-parts.md
- instructions/setup-instance.md
- kb/CONVENTIONS.md
- kb/CONVENTIONS.md.template
- kb/concepts/COLLECTION.md
- kb/sources/COLLECTION.md
- tools/CONTRACT.md
- tools/chemenu/commands/types_cmd.py
- tools/chemenu/commands/version_cmd.py
- tools/chemenu/tests/test_toc.py
- tools/chemenu/tests/test_version_cmd.py
- tools/chemenu/toc.py
- tools/chemenu/version.py
- types/comparison.md
- types/concept.md
- types/entity.md
- types/lint-report.md
- types/source.md
This commit is contained in:
torben committed 2026-09-15 16:21:02 +02:00
1 parent c0dc2129bb
commit c64479fe02
31 files changed
+1093 -603

No files matched your search

+82 -6
View File
@@ -583,9 +583,11 @@ def regrade(text: str, version: "Version", updates: dict[int, str]) -> str:
def _set_marker_line(section: str, marker: str, line: str) -> str:
"""Add or replace the one-line `marker ...` paragraph in `section`.
Used for the breaking-change and no-migration lines, which - unlike the
bumps list - are not accumulated: a later bump that repeats `--breaking`
restates it rather than growing a list nobody would read as history.
Used for the no-migration line, which - unlike the bumps list and unlike
the breaking-change paragraph below - is **not** accumulated: it answers
one yes/no question about the candidate as a whole ("does content have to
change?"), so a second answer replaces the first rather than joining it,
and `_clear_marker_line` is its retraction path.
Anchored just above the bumps region (not below it, as before Gitea #95):
with a graded, potentially 30-line list, the line an operator most needs
to act on stayed the deepest thing in the entry otherwise.
@@ -599,6 +601,73 @@ def _set_marker_line(section: str, marker: str, line: str) -> str:
return section.rstrip() + f"\n\n{line}\n"
# The breaking-change paragraph, matched whole: the marker line plus any `- `
# bullets under it. `_set_marker_line`'s `^marker.*$` reaches the first line
# only, which is exactly wrong for a form that can carry bullets beneath it.
_BREAKING_PARAGRAPH_RE = re.compile(
rf"^{re.escape(BREAKING_CHANGE_MARKER)}.*(?:\n-[ \t].*)*$", re.MULTILINE
)
def breaking_reasons(section: str) -> list[str]:
"""The breaking-change paragraph parsed back into one reason per crossing,
in written order.
Two shapes read the same way, which is what lets an entry written before
accumulation existed round-trip untouched: `**Breaking Change:** <reason>`
is one reason, and a bare `**Breaking Change:**` followed by `- ` bullets
is one reason per bullet. Same "flat while there is only one of them"
trick `_bumps_block` plays with its impact groups, and for the same
reason - the common case keeps the shape it always had.
"""
match = _BREAKING_PARAGRAPH_RE.search(section)
if not match:
return []
lines = match.group(0).splitlines()
head = lines[0][len(BREAKING_CHANGE_MARKER):].strip()
reasons = [head] if head else []
reasons += [line.strip()[2:].strip() for line in lines[1:]]
return [reason for reason in reasons if reason]
def _breaking_paragraph(reasons: list[str]) -> str:
"""Render the breaking-change paragraph from one reason per crossing.
One reason stays on the marker line - byte-identical to what every entry
written before accumulation carries. Two or more move to bullets under a
bare marker, because a single line holding two unrelated breakages reads
as one run-on sentence and an operator has to act on each separately.
"""
if len(reasons) == 1:
return f"{BREAKING_CHANGE_MARKER} {reasons[0]}"
bullets = "\n".join(f"- {reason}" for reason in reasons)
return f"{BREAKING_CHANGE_MARKER}\n{bullets}"
def _add_breaking_reason(section: str, reason: str) -> str:
"""Append `reason` to the breaking-change paragraph, or start one.
Accumulates rather than replaces: a candidate can cross the compatibility
boundary more than once (this is the normal shape of a long-running one),
and each crossing is a separate thing the operator of an existing instance
has to act on. Replacing meant the second `--breaking` silently deleted
the first - the entry then promised a single break while shipping two.
Repeating a reason verbatim is a no-op, so a re-run after an interrupted
bump converges instead of writing the same sentence twice.
"""
existing = breaking_reasons(section)
if reason in existing:
return section
paragraph = _breaking_paragraph(existing + [reason])
if existing:
return _BREAKING_PARAGRAPH_RE.sub(lambda _m: paragraph, section, count=1)
anchor = section.find(_BUMPS_OPEN)
if anchor != -1:
return section[:anchor] + f"{paragraph}\n\n" + section[anchor:]
return section.rstrip() + f"\n\n{paragraph}\n"
def _clear_marker_line(section: str, marker: str) -> str:
"""Remove the one-line `marker ...` paragraph from `section`, if present.
@@ -633,10 +702,15 @@ def _update_open_candidate(
impact: str = DEFAULT_IMPACT,
) -> str:
"""Move the topmost entry's heading to `version`/`date`/`title`, append
`(impact, title)` to its machine-managed bump list, and set the
`(impact, title)` to its machine-managed bump list, and record the
breaking/no-migration lines only where this call supplies them - see
`insert_changes_entry`.
The two are recorded differently on purpose: a `breaking_reason` **joins**
whatever crossings the candidate already recorded (`_add_breaking_reason`),
a `no_migration_reason` **replaces** the single line that answers whether
content has to change (`_set_marker_line`).
`migration_required` retracts an earlier `--no-migration` line instead of
setting one - the two are mutually exclusive on a single bump, enforced by
the caller (`version_cmd.bump_command`), not here."""
@@ -653,7 +727,7 @@ def _update_open_candidate(
)
if breaking_reason:
section = _set_marker_line(section, BREAKING_CHANGE_MARKER, f"{BREAKING_CHANGE_MARKER} {breaking_reason}")
section = _add_breaking_reason(section, breaking_reason)
if no_migration_reason:
section = _set_marker_line(section, MIGRATION_NONE_MARKER, f"{MIGRATION_NONE_MARKER} - {no_migration_reason}")
elif migration_required:
@@ -684,7 +758,9 @@ def insert_changes_entry(
candidate still open" here.
A fresh entry gets the skeleton only: heading, date, author, - when a
compatibility boundary is crossed - the line saying what breaks, plus the
compatibility boundary is crossed - the line saying what breaks (one
crossing, so the flat one-line form; a candidate that crosses again
accumulates bullets there, see `_add_breaking_reason`), plus the
line saying no content has to change where that applies, and then the
machine-managed bump list (started with this one `(impact, title)` pair,
for a candidate). The break comes first, above the bump list rather than