stack: TOC-Scope auf types/ und docs/, Sprachregeln nach AGENTS.md zentralisiert, alle Templates auf Control-Plane-Sprache, --breaking akkumuliert (schliesst #99)
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:
+3
-3
@@ -166,12 +166,12 @@ tools/wikitool <command> --help
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `types list [--json]` | List every type-spec under `types/` (name, schema path, subtype field, description) - discover what page types exist without reading `types/*.md` directly |
|
||||
| `types describe <name> [--json]` | Print one type's full contract: required/optional frontmatter fields with enums, its subtype field (if any), and its authoring body |
|
||||
| `types describe <name> [--json]` | Print one type's full contract: required/optional frontmatter fields with enums, its subtype field (if any), and its authoring body. A type-spec over the `docs toc` threshold carries a generated table-of-contents region; it is stripped from this output rather than echoed, since the whole body is being handed over and a navigation aid into it would be noise |
|
||||
| `instructions sync [--force]` | Publish every `instructions/<name>/SKILL.md` into `.agents/skills/` and `.claude/skills/` as **copies**, and delete published skills whose source is gone. Both targets are gitignored, so a fresh clone runs this once - see `instructions/bootstrap.md`. Re-running is also how a drifted copy is repaired: the source always wins. `--force` is required only to replace a target directory that is not a published skill at all (no `SKILL.md` in it) |
|
||||
| `instructions verify` | Check the instruction layer: flat instructions validate against `types/instruction.schema.yaml`, each `SKILL.md` carries the frontmatter its harness reads, no `SKILL.md` carries a relative markdown link (`sync` copies it to a different depth than the source, so a `SKILL.md` references a target as a repo-root-relative plain path instead - see [instructions/CONTRACT.md](../instructions/CONTRACT.md) § "A skill's outbound reference is a plain path, not a link"), every published copy is byte-identical to its source, no instruction is left that nothing references, and nothing under `instructions/dev/` is referenced from outside it (a `<!-- dist:strip-start/end -->` block is exempt - see [instructions/CONTRACT.md](../instructions/CONTRACT.md)). Missing *every* copy is reported as "run sync", not as drift - that is a clean checkout |
|
||||
| `instructions list [--json]` | List the flat instructions with their descriptions. This is how the layer is discovered; `search` deliberately covers `kb/` only |
|
||||
| `docs verify` | Check the docs that mirror the code: every CLI command documented in this file's own § Commands table and, separately, in its § Error contracts table (both directions, checked per table, so a row dropped from one is not hidden by the same name surviving in the other, and only a name's presence in a row is checked, never the rest of that row's text), every directory under `kb/` has a `COLLECTION.md` and no directory outside it does, every collection declaring `profile:` and a `required_by_stack:` that agrees with the stack's own list, `kb/CONVENTIONS.md` naming all three tool-owned section headings if it exists at all, every stage contract present, no pre-migration `type: entity` blocks left in the contracts, the `.gitignore` canaries clear in both directions (nothing ignored under `raw/`/`kb/`, `incoming/` ignored, everything ignored under `reports/` and the published skill directories), and no `.md`/`.template` file `dist export` would ship citing an issue number - the tracker exists only in the origin repo, so such a number in a distributed instance is a reference its reader can neither resolve nor recognise as unresolvable (a `<!-- dist:strip-start/end -->` region is exempt: it is already gone from the text the check reads, which is the export plan's, not the working tree's), every reference file `docs toc` covers carrying the current table-of-contents region for its own headings - missing and stale are one check, because the generator is idempotent - and every relative markdown link in one of those same reference files resolving to a file that actually exists (a target's `#anchor` suffix is stripped first; code fences and inline code spans are masked before scanning, so a passage showing link syntax as an example is not mistaken for a real reference). The name is about documentation parity, not about the `docs/` directory - it neither reads nor requires one, the same way `kb/` predates the collection it now checks |
|
||||
| `docs toc [--apply]` | Create, refresh or remove the generated table-of-contents region (`<!-- wikitool:toc -->` ... `<!-- /wikitool:toc -->`, placed after the title and before the first `##`) on every reference file over 100 lines that `AGENTS.md`, a stage/collection contract, or the flat `instructions/**.md` form covers - the scope Anthropic's skill-authoring guidance names for a file previewed rather than read in full. Dry-run by default (prints which files would change); `--apply` writes. `docs verify` checks the result stays current the same way it checks every other generated-from-code copy |
|
||||
| `docs toc [--apply]` | Create, refresh or remove the generated table-of-contents region (`<!-- wikitool:toc -->` ... `<!-- /wikitool:toc -->`, placed after the title and before the first `##`) on every reference file over 100 lines, in the scope Anthropic's skill-authoring guidance names for a file previewed rather than read in full: `AGENTS.md`, every stage contract, `kb/CONVENTIONS.md`, every `kb/*/COLLECTION.md`, every flat `instructions/**.md` file, every `types/*.md` type-spec, and every `docs/` page. Computed from those categories rather than listed, so a file added later is in scope without a code change. `SKILL.md` is the one exception, and the same guidance is why: it places a skill body on the loading level that is read whole when the skill triggers, and aims its own TOC advice at the bundled reference files a skill points *at*. Human docs (`README.md`, `CHANGES.md`, `EVALS.md`, `INSTALL.md`, `tools/README.md`) are out of scope because AGENTS.md § File naming says no agent loads them as instruction. Dry-run by default (prints which files would change); `--apply` writes. `docs verify` checks the result stays current the same way it checks every other generated-from-code copy |
|
||||
|
||||
### Telemetry
|
||||
|
||||
@@ -189,7 +189,7 @@ tools/wikitool <command> --help
|
||||
| `version show [--json]` | Print this instance's stack version and where it came from (development tree, or a distribution with its export date and origin). Bare `wikitool version` is an alias for this. Read-only, offline, and **exempt from the Iteration Budget Gate** |
|
||||
| `version check [--url U] [--timeout S] [--json]` | Ask the origin's release feed whether a newer stack exists, and whether the step crosses a compatibility boundary (`state: current\|update\|migration\|ahead`). **The only command in `wikitool` that makes a network call** - never reached implicitly from another command, needs no key, times out, and reports an unreachable feed as an error rather than as "up to date". The feed is `$WIKITOOL_UPDATE_URL`, else the release stamp's, else the built-in origin; `$WIKITOOL_UPDATE_TOKEN` is only needed if that feed is not readable anonymously. Read-only and exempt from the budget gate |
|
||||
| `version notes [--version X.Y.Z]` | Print one version's `CHANGES.md` entry, for use as release notes (default: this tree's `VERSION`). Read-only and exempt from the budget gate |
|
||||
| `version bump --major\|--minor\|--patch --title "<...>" [--impact high\|medium\|low] [--breaking "<what breaks>"] [--no-migration "<reason>"] [--migration-required] [--dry-run]` | Raise or continue the **one running candidate** between two releases - `VERSION` gets a `-beta.N` suffix, never a second fresh number per bump. `--major/--minor/--patch` is **max-wins escalation** against the last release (patch < minor < major): a `--patch` on a MINOR candidate only advances `N`, and escalation never steps back down. Opens the matching `CHANGES.md` entry on the first bump of a candidate (heading, date, author, and a machine-managed `<!-- wikitool:bumps -->` list of every `--title` collected so far, graded by `--impact`, default `medium`) and updates that same entry in place on every later bump of the same candidate - one entry per candidate, not one per bump. The list renders grouped under `**High/Medium/Low impact**` headings (empty groups omitted), except when every bump so far is `medium`, where it stays the flat, ungrouped list the region always had - `version regrade` corrects a grade after the fact. Refuses more or fewer than one part, an empty title, an unknown `--impact`, and a `VERSION`/newest-changelog-entry mismatch. Compatibility follows the **leftmost non-zero component** of the candidate's base, which for this stack (at `1.0.0` and up) means MAJOR: PATCH is a fix, MINOR a compatible capability, MAJOR a version that is **not a drop-in replacement** - any hand-work on update, or a downgrade that no longer works. Whether content must be migrated is a second, independent question. The bump that first escalates a candidate past the boundary requires `--breaking "<what stops working>"` and, on top of it, a migration document targeting the candidate's base or `--no-migration "<reason>"`; both lines are written once, anchored just above the bump list, and persist over later bumps of the same candidate without being repeated, and both are refused on a bump that crosses nothing at all. A later bump of the same candidate that finds out `--no-migration` was wrong after all retracts that line with `--migration-required` instead of restating `--no-migration` - refused without a migration document already targeting the new base, and without an existing `--no-migration` line to retract. Which part a change earns stays a judgment call: the command enforces that a crossing documents itself, never that the part was chosen correctly |
|
||||
| `version bump --major\|--minor\|--patch --title "<...>" [--impact high\|medium\|low] [--breaking "<what breaks>"] [--no-migration "<reason>"] [--migration-required] [--dry-run]` | Raise or continue the **one running candidate** between two releases - `VERSION` gets a `-beta.N` suffix, never a second fresh number per bump. `--major/--minor/--patch` is **max-wins escalation** against the last release (patch < minor < major): a `--patch` on a MINOR candidate only advances `N`, and escalation never steps back down. Opens the matching `CHANGES.md` entry on the first bump of a candidate (heading, date, author, and a machine-managed `<!-- wikitool:bumps -->` list of every `--title` collected so far, graded by `--impact`, default `medium`) and updates that same entry in place on every later bump of the same candidate - one entry per candidate, not one per bump. The list renders grouped under `**High/Medium/Low impact**` headings (empty groups omitted), except when every bump so far is `medium`, where it stays the flat, ungrouped list the region always had - `version regrade` corrects a grade after the fact. Refuses more or fewer than one part, an empty title, an unknown `--impact`, and a `VERSION`/newest-changelog-entry mismatch. Compatibility follows the **leftmost non-zero component** of the candidate's base, which for this stack (at `1.0.0` and up) means MAJOR: PATCH is a fix, MINOR a compatible capability, MAJOR a version that is **not a drop-in replacement** - any hand-work on update, or a downgrade that no longer works. Whether content must be migrated is a second, independent question. The bump that first escalates a candidate past the boundary requires `--breaking "<what stops working>"` and, on top of it, a migration document targeting the candidate's base or `--no-migration "<reason>"`; both are anchored just above the bump list, persist over later bumps of the same candidate without being repeated, and are refused on a bump that crosses nothing at all. The two then behave differently on a *second* crossing, because they answer different questions: a further `--breaking` **joins** the ones already recorded (one reason per crossing - rendered flat on the marker line while there is only one, as bullets under a bare marker from the second onward, and repeating a reason verbatim is a no-op), while a further `--no-migration` **replaces** the single line that says whether content has to change. A candidate crossing the boundary twice is the normal shape of a long-running one, and each crossing is a separate thing an operator has to act on; whether content migrates stays one yes/no about the candidate as a whole. There is deliberately no retraction path for a single accumulated `--breaking` reason - `--migration-required` retracts the migration line, and nothing retracts a breaking one. A later bump of the same candidate that finds out `--no-migration` was wrong after all retracts that line with `--migration-required` instead of restating `--no-migration` - refused without a migration document already targeting the new base, and without an existing `--no-migration` line to retract. Which part a change earns stays a judgment call: the command enforces that a crossing documents itself, never that the part was chosen correctly |
|
||||
| `version regrade [INDICES...] [--impact high\|medium\|low]` | List the running candidate's bump titles with their impact grade and 1-based rendered position (no arguments - the correction path for a `--impact` judgement made at bump time), or change one or more of them in a single call: `version regrade 3 7 --impact high` grades both against a single read of today's list, not position 3 first and then position 7 against whatever that produced. Touches only the topmost entry's bump list - never `VERSION`, never any other part of `CHANGES.md`. The bare listing is read-only and exempt from the Iteration Budget Gate, like `version notes`; a call with indices writes `CHANGES.md` and is counted like `version bump`. Refuses an index outside the rendered list's range, an unknown `--impact`, indices given without `--impact`, a missing `VERSION`/`CHANGES.md`, a `VERSION`/newest-changelog-entry mismatch, or a topmost entry with no bump list at all |
|
||||
| `version release [--title "<...>"] [--dry-run]` | Fix the running candidate: strip `VERSION`'s `-beta.N` suffix and close its `CHANGES.md` entry, ending the pre-release phase `version bump` started. Without `--title` the heading keeps whichever bump last set it; with it, the heading's title is replaced - the normal case for a candidate that collected several bump titles, since the entry wants a summarising heading rather than the most recent one. Leaves the entry's machine-managed bump-title list untouched, as the record of what happened. Refuses when the candidate collected two or more bumps and the entry still carries no summary paragraph (at least 200 non-whitespace characters) between the bump list and the first `### <bump title>` changeset heading; a candidate with exactly one bump is exempt, since there its own changeset already is the summary. `--dry-run` runs this check too and reports the same refusal. Commits nothing and pushes nothing (invariant 5) - the following `publish` moves `VERSION` onto `main`, which `release.yml` reacts to. Refuses when `VERSION` is already a release (no running candidate to fix), or when the changelog's newest entry does not match `VERSION` |
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import json
|
||||
|
||||
import typer
|
||||
|
||||
from chemenu import toc
|
||||
from chemenu.commands._util import fail
|
||||
from chemenu.types_core import UnknownType, describe_type, list_types
|
||||
|
||||
@@ -88,4 +89,9 @@ def describe_type_command(
|
||||
typer.echo("")
|
||||
|
||||
typer.echo("## Authoring guidance")
|
||||
typer.echo(described["body"])
|
||||
# A type-spec over 100 lines carries a generated table-of-contents region
|
||||
# (`chemenu/toc.py`), which serves whoever opens the file. Here it would be
|
||||
# noise: this command already hands over the whole body, so there is
|
||||
# nothing left for a navigation aid to navigate - only markers and a list
|
||||
# of headings the reader is about to see anyway.
|
||||
typer.echo(toc.strip_region(described["body"]))
|
||||
|
||||
@@ -204,7 +204,7 @@ def bump_command(
|
||||
breaking: Optional[str] = typer.Option(
|
||||
None,
|
||||
"--breaking",
|
||||
help="What stops working, for the bump that first escalates to a boundary crossing (recorded in CHANGES.md). Required there, refused on a bump that crosses nothing",
|
||||
help="What stops working (recorded in CHANGES.md). Required on the bump that first escalates to a boundary crossing, optional on a later bump of the same crossing candidate - where it joins the reasons already recorded rather than replacing them. Refused on a bump that crosses nothing",
|
||||
),
|
||||
no_migration: Optional[str] = typer.Option(
|
||||
None,
|
||||
@@ -241,9 +241,16 @@ def bump_command(
|
||||
version is not a drop-in replacement, whether or not any content moves -
|
||||
requires `--breaking "<what stops working>"`, and on top of that either a
|
||||
migration document for the new base or `--no-migration "<reason>"`. Both
|
||||
lines are written into the entry once and then persist across every later
|
||||
bump at the same stage: a follow-up bump need not repeat them, and passing
|
||||
either on a bump that crosses nothing at all is refused.
|
||||
are written into the entry and persist across every later bump at the same
|
||||
stage, so a follow-up bump need not repeat them, and passing either on a
|
||||
bump that crosses nothing at all is refused.
|
||||
|
||||
A candidate can cross the boundary more than once, and the two flags part
|
||||
ways there. A further `--breaking` **joins** the reasons already recorded -
|
||||
each crossing is its own thing an operator has to act on, and replacing
|
||||
meant the second one silently deleted the first. A further
|
||||
`--no-migration` **replaces**: whether content has to change is one
|
||||
question about the candidate as a whole, not one per crossing.
|
||||
|
||||
A later bump of the same candidate that finds out `--no-migration` was
|
||||
wrong after all retracts it with `--migration-required` - write the
|
||||
|
||||
@@ -150,9 +150,10 @@ def test_stale_regions_is_false_right_after_upsert():
|
||||
|
||||
|
||||
def test_target_files_matches_the_documented_scope():
|
||||
"""Integration check against the real repo: the scope is AGENTS.md, every
|
||||
stage contract, kb/CONVENTIONS.md, every COLLECTION.md, and the flat
|
||||
`instructions/**.md` form - never a `SKILL.md`."""
|
||||
"""Integration check against the real repo: every agent-loaded category
|
||||
AGENTS.md § File naming names - AGENTS.md itself, every stage contract,
|
||||
kb/CONVENTIONS.md, every COLLECTION.md, the flat `instructions/**.md` form,
|
||||
every type-spec, every `docs/` page - with `SKILL.md` the one exception."""
|
||||
from chemenu import config
|
||||
|
||||
files = toc.target_files()
|
||||
@@ -164,5 +165,35 @@ def test_target_files_matches_the_documented_scope():
|
||||
assert "types/type-spec.md" in relatives
|
||||
assert "instructions/CONTRACT.md" in relatives
|
||||
assert "instructions/dev/version-parts.md" in relatives # flat, still instructions/**.md
|
||||
assert "types/source.md" in relatives # a page type-spec, not only the stage contract
|
||||
assert "docs/version-model.md" in relatives
|
||||
assert not any(rel.endswith("SKILL.md") for rel in relatives)
|
||||
assert not any(rel.startswith("types/") and rel != "types/type-spec.md" for rel in relatives)
|
||||
|
||||
|
||||
def test_target_files_lists_type_spec_once_despite_two_sources():
|
||||
"""`types/type-spec.md` is both a stage contract and a `types/*.md` file;
|
||||
the set in `target_files` is what keeps that from being a special case."""
|
||||
from chemenu import config
|
||||
|
||||
relatives = [f.relative_to(config.ROOT).as_posix() for f in toc.target_files()]
|
||||
assert relatives.count("types/type-spec.md") == 1
|
||||
|
||||
|
||||
def test_strip_region_removes_what_types_describe_would_otherwise_echo():
|
||||
body = (
|
||||
"# A type\n\n"
|
||||
f"{blocks.open_marker(toc.REGION_NAME)}\n## Contents\n\n- [X](#x)\n"
|
||||
f"{blocks.close_marker(toc.REGION_NAME)}\n\n## X\n\nProse.\n"
|
||||
)
|
||||
stripped = toc.strip_region(body)
|
||||
assert toc.REGION_NAME not in stripped
|
||||
assert "## Contents" not in stripped
|
||||
assert "## X" in stripped and "Prose." in stripped
|
||||
# The heading keeps the blank line the region used to sit in, rather than
|
||||
# being welded to the line above it.
|
||||
assert "# A type\n\n## X" in stripped
|
||||
|
||||
|
||||
def test_strip_region_leaves_a_body_that_never_had_one_alone():
|
||||
body = "# A type\n\n## X\n\nProse.\n"
|
||||
assert toc.strip_region(body) == body
|
||||
|
||||
@@ -444,6 +444,107 @@ def test_breaking_line_sits_above_the_bumps_region(tree):
|
||||
assert changes.index(version_mod.BREAKING_CHANGE_MARKER) < changes.index(version_mod._BUMPS_OPEN)
|
||||
|
||||
|
||||
# --- version bump: accumulating --breaking ----------------------------------
|
||||
|
||||
|
||||
def test_a_single_breaking_reason_stays_on_the_marker_line(tree):
|
||||
"""The shape every entry written before accumulation existed carries."""
|
||||
version_cmd.bump_command(
|
||||
major=True, minor=False, patch=False, title="Breaking",
|
||||
breaking="the feed moved", no_migration="kb/ untouched", migration_required=False,
|
||||
impact=None, dry_run=False,
|
||||
)
|
||||
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||
assert f"{version_mod.BREAKING_CHANGE_MARKER} the feed moved" in changes
|
||||
|
||||
|
||||
def test_a_second_breaking_reason_joins_the_first_instead_of_replacing_it(tree):
|
||||
"""The defect this exists for: the second --breaking used to delete the
|
||||
first, leaving the entry promising one break while shipping two."""
|
||||
version_cmd.bump_command(
|
||||
major=True, minor=False, patch=False, title="First crossing",
|
||||
breaking="the feed moved", no_migration="kb/ untouched", migration_required=False,
|
||||
impact=None, dry_run=False,
|
||||
)
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=True, patch=False, title="Second crossing",
|
||||
breaking="the import name changed", no_migration=None, migration_required=False,
|
||||
impact=None, dry_run=False,
|
||||
)
|
||||
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||
section = version_mod.changes_section(changes, Version.parse("2.0.0-beta.2"))
|
||||
assert version_mod.breaking_reasons(section) == ["the feed moved", "the import name changed"]
|
||||
assert "- the feed moved" in changes and "- the import name changed" in changes
|
||||
# Still the line an operator acts on first, above a list that can run long.
|
||||
assert changes.index(version_mod.BREAKING_CHANGE_MARKER) < changes.index(version_mod._BUMPS_OPEN)
|
||||
|
||||
|
||||
def test_repeating_a_breaking_reason_verbatim_is_a_no_op(tree):
|
||||
"""So a re-run after an interrupted bump converges instead of writing the
|
||||
same sentence twice."""
|
||||
for title in ("First", "Second"):
|
||||
version_cmd.bump_command(
|
||||
major=True, minor=False, patch=False, title=title,
|
||||
breaking="the feed moved", no_migration="kb/ untouched", migration_required=False,
|
||||
impact=None, dry_run=False,
|
||||
)
|
||||
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||
section = version_mod.changes_section(changes, Version.parse("2.0.0-beta.2"))
|
||||
assert version_mod.breaking_reasons(section) == ["the feed moved"]
|
||||
|
||||
|
||||
def test_breaking_reasons_reads_a_pre_accumulation_single_line_back(tree):
|
||||
"""An entry written by a build that predates accumulation round-trips: it
|
||||
parses as one reason, and a later bump grows it to a list rather than
|
||||
rewriting what was already there."""
|
||||
section = "## 2.0.0-beta.1 - 2026-09-01 - T\n\n**Breaking Change:** the feed moved\n\n"
|
||||
assert version_mod.breaking_reasons(section) == ["the feed moved"]
|
||||
grown = version_mod._add_breaking_reason(section, "the import name changed")
|
||||
assert version_mod.breaking_reasons(grown) == ["the feed moved", "the import name changed"]
|
||||
assert f"{version_mod.BREAKING_CHANGE_MARKER}\n- the feed moved\n" in grown
|
||||
|
||||
|
||||
def test_no_migration_still_replaces_rather_than_accumulating(tree):
|
||||
"""It answers one yes/no question about the candidate as a whole, so a
|
||||
second answer supersedes the first - unlike --breaking beside it."""
|
||||
version_cmd.bump_command(
|
||||
major=True, minor=False, patch=False, title="First",
|
||||
breaking="the feed moved", no_migration="kb/ untouched", migration_required=False,
|
||||
impact=None, dry_run=False,
|
||||
)
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=True, patch=False, title="Second",
|
||||
breaking=None, no_migration="still nothing to change", migration_required=False,
|
||||
impact=None, dry_run=False,
|
||||
)
|
||||
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||
assert changes.count(version_mod.MIGRATION_NONE_MARKER) == 1
|
||||
assert "kb/ untouched" not in changes
|
||||
assert "still nothing to change" in changes
|
||||
|
||||
|
||||
def test_retracting_no_migration_leaves_an_accumulated_breaking_list_intact(tree):
|
||||
version_cmd.bump_command(
|
||||
major=True, minor=False, patch=False, title="First",
|
||||
breaking="the feed moved", no_migration="kb/ untouched", migration_required=False,
|
||||
impact=None, dry_run=False,
|
||||
)
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=True, patch=False, title="Second",
|
||||
breaking="the import name changed", no_migration=None, migration_required=False,
|
||||
impact=None, dry_run=False,
|
||||
)
|
||||
_migration_document(tree, "2.0.0")
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=False, patch=True, title="Turns out it migrates",
|
||||
breaking=None, no_migration=None, migration_required=True, impact=None, dry_run=False,
|
||||
)
|
||||
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||
section = version_mod.changes_section(changes, Version.parse("2.0.0-beta.3"))
|
||||
assert version_mod.MIGRATION_NONE_MARKER not in changes
|
||||
assert version_mod.breaking_reasons(section) == ["the feed moved", "the import name changed"]
|
||||
|
||||
|
||||
# --- version regrade --------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
+84
-11
@@ -20,16 +20,49 @@ the stage/collection contracts, the flat `instructions/**.md` files) is a
|
||||
precedent first.
|
||||
|
||||
**Scope is computed, never a hand-picked list** - the same principle that
|
||||
governs `wikitool` itself. `target_files()` walks the file-naming categories
|
||||
AGENTS.md's own table calls agent-loaded reference material: `AGENTS.md`,
|
||||
every stage contract, `kb/CONVENTIONS.md`, every `kb/*/COLLECTION.md`, and
|
||||
every flat `instructions/**.md` file (a `SKILL.md` is excluded - it is loaded
|
||||
whole by the harness, not previewed at a second hop; `instructions/CONTRACT.md`
|
||||
§ "How much reasoning a step may carry" already treats a checklist read once
|
||||
as the table of contents it replaced). Human docs (`README.md`, `CHANGES.md`,
|
||||
`EVALS.md`, `INSTALL.md`, `tools/README.md`) are excluded too: the file-naming
|
||||
table says they are "Never loaded by an agent as instruction," so the preview
|
||||
mechanic this exists to mitigate does not apply to them.
|
||||
governs `wikitool` itself. `target_files()` walks every file-naming category
|
||||
AGENTS.md's own table calls agent-loaded: `AGENTS.md`, every stage contract,
|
||||
`kb/CONVENTIONS.md`, every `kb/*/COLLECTION.md`, every flat `instructions/**.md`
|
||||
file, every `types/*.md` type-spec, and every `docs/` page. One rule, and
|
||||
exactly one exception below it - which is the whole point, because a scope
|
||||
carrying several unexplained absences reads as an accident rather than a
|
||||
decision, and did: `docs/` and the page type-specs sat outside it for no
|
||||
recorded reason at all.
|
||||
|
||||
`docs/` belongs in for the reason the threshold exists. AGENTS.md § File naming
|
||||
calls it "Agents and humans | By link, or on explicit request" - agent-loaded,
|
||||
at a second hop, which is precisely the preview mechanic
|
||||
`instructions/CONTRACT.md` § "Reference depth" treats as real. The exclusion of
|
||||
the human docs (`README.md`, `CHANGES.md`, `EVALS.md`, `INSTALL.md`,
|
||||
`tools/README.md`) rests on a sentence that does not stretch to cover it: those
|
||||
are "Never loaded by an agent as instruction," while a `docs/` page is not
|
||||
loaded *as instruction* but is very much loaded.
|
||||
|
||||
**The one exception is `SKILL.md`**, and the vendored skill-authoring guidance
|
||||
is what puts it there rather than a judgment of ours. `commonplace/kb/work/
|
||||
skill-creator-distillation/sources/claude-code-skill-creator/SKILL.md:88-99`
|
||||
sets out three loading levels and places the SKILL.md body on the second - "In
|
||||
context whenever skill triggers (<500 lines ideal)" - while aiming its own TOC
|
||||
advice at the third, "large reference files (>300 lines)", i.e. bundled
|
||||
resources. The Codex source agrees by placement: its TOC bullet sits directly
|
||||
under "Keep references one level deep from SKILL.md. All reference files should
|
||||
link directly from SKILL.md" (`.../codex-skill-creator/SKILL.md:221-222`).
|
||||
Neither asks a skill body to carry a table of contents, because neither expects
|
||||
one to be previewed. `instructions/CONTRACT.md` § "How much reasoning a step
|
||||
may carry" arrives at the same place from the other side, treating a checklist
|
||||
read once as the table of contents it replaced. (Our longest skill is 254
|
||||
lines, so the <500 guidance costs us nothing either.)
|
||||
|
||||
A type-spec is loaded whole too - `tools/wikitool types describe` prints its
|
||||
entire authoring body - but it is *also* read as a file, by whoever edits it,
|
||||
and that is the reading the threshold is about. So it carries a region like any
|
||||
other reference file, and `types_cmd` strips the region back out of what
|
||||
`describe` prints: the command already hands over the whole body, so a
|
||||
navigation aid into it would be noise in the output and nothing else.
|
||||
|
||||
The threshold's own provenance is worth recording, because the two sources
|
||||
disagree: Codex says 100 lines, Claude Code says 300. This stack took the
|
||||
stricter number.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -80,7 +113,13 @@ def target_files() -> list[Path]:
|
||||
`instructions/<name>.md` form (`instructions/CONTRACT.md` says the `dev/`
|
||||
split is orthogonal to Linked/Manual, not a different file shape), so
|
||||
there is no separate rule for them to fall out of - and no hand-picking
|
||||
for a future file under either to be missed.
|
||||
for a future file under either to be missed. `types/` and `docs/` are
|
||||
walked for the same reason: a type or a rationale page added later is in
|
||||
scope by construction, not by someone remembering this function.
|
||||
|
||||
`types/type-spec.md` arrives twice - once as a stage contract, once from
|
||||
the `types/` walk - and the set at the bottom is what makes that a
|
||||
non-issue rather than something to special-case.
|
||||
"""
|
||||
files: list[Path] = [config.ROOT / "AGENTS.md"]
|
||||
files += [config.ROOT / rel for rel in _STAGE_CONTRACTS]
|
||||
@@ -96,6 +135,10 @@ def target_files() -> list[Path]:
|
||||
for path in instructions_dir.rglob("*.md")
|
||||
if path.name != "SKILL.md"
|
||||
)
|
||||
for subdir in ("types", "docs"):
|
||||
directory = config.ROOT / subdir
|
||||
if directory.is_dir():
|
||||
files += sorted(directory.rglob("*.md"))
|
||||
return sorted({f for f in files if f.is_file()})
|
||||
|
||||
|
||||
@@ -116,6 +159,36 @@ def body_without_region(text: str) -> str:
|
||||
return stripped.rstrip("\n") + "\n" if stripped else stripped
|
||||
|
||||
|
||||
_REGION_WITH_PADDING_RE = re.compile(
|
||||
r"\n*"
|
||||
+ re.escape(blocks.open_marker(REGION_NAME))
|
||||
+ r".*?"
|
||||
+ re.escape(blocks.close_marker(REGION_NAME))
|
||||
+ r"\n*",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
def strip_region(text: str) -> str:
|
||||
"""`text` with the generated TOC region removed, for a caller that is
|
||||
handing the whole body over anyway.
|
||||
|
||||
`types describe` is the one such caller: it prints a type-spec's entire
|
||||
authoring body, so the region's markers and heading list would be noise in
|
||||
its output rather than a way into anything. Distinct from
|
||||
`body_without_region`, which exists to *measure* a body against the line
|
||||
threshold and therefore also canonicalizes the trailing newline.
|
||||
|
||||
Takes the surrounding blank lines with it and puts one back, rather than
|
||||
calling `blocks.strip`: that collapses the padding to a single newline,
|
||||
which is the right answer when the result is about to be rebuilt from
|
||||
scratch (`upsert` does exactly that) and the wrong one here, where the
|
||||
output is printed as-is - it would leave the following `##` heading welded
|
||||
to the paragraph above it.
|
||||
"""
|
||||
return _REGION_WITH_PADDING_RE.sub("\n\n", text)
|
||||
|
||||
|
||||
def needs_toc(text: str) -> bool:
|
||||
return len(body_without_region(text).splitlines()) > THRESHOLD
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user