Source code for tests.test_claude_assets

# Copyright Kevin Deldycke <[email protected]> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.

"""Bundled Claude assets hold no drifting copy of a repository fact.

Skills and agents ship verbatim to every downstream repo through
`repomatic init`, and nothing else audits their bodies: `test_skills.py`
checks frontmatter against the Agent Skills spec, the release sweep reads
them by hand once a cycle. So a cooldown window, a config key, a module
path, a skill command or an `AGENTS.md` section quoted in one of them is a
second copy of something the project already defines, free to drift the
moment the original moves.

A second family of checks holds the same assets to the prose rule in
`AGENTS.md` ยง Directives are written in Simplified Technical English: the
directive a rule opens with stays short enough to read once, while the
rationale following it is left alone.

The approved-word dictionary of ASD-STE100 stays out of scope. It is a
controlled ASD specification rather than something to vendor as a data file,
and the words it would rule on here (`run`, `sync`, `pin`, `release`) are the
ones that must keep matching the code identifiers they name.

Two checks reach past the assets: {func}`test_repository_citations_resolve`
and {func}`test_repository_skill_commands_name_a_bundled_skill` hold every
text of the repository to the citation and skill command rules the assets
follow.

Each drift check below pins one copy to its source, the way
`test_workflows.py::test_workflow_declares_cooldown_env` pins the
workflow-level cooldown block and `test_uv.py` pins `[tool.uv]
exclude-newer`. The cooldown window itself is the exception: no asset may
copy it at all, since each repository sets its own. Matching is
deliberately under-inclusive: every rule keys off an unambiguous form (a
flag argument, a backtick span opening on `[tool.repomatic`), so prose that
merely mentions a key in passing is skipped rather than guessed at. A missed
reference costs nothing; a false failure costs a maintainer the afternoon.

```{note}
The first run of {func}`test_config_keys_resolve` caught the audit pass of
`repomatic-upgrade` recommending `[tool.repomatic.workflow.ignore_paths]`
and `[tool.repomatic.workflow.extra_paths]`: the dataclass attributes are
snake_case, but the TOML keys are `workflow.ignore-paths` and
`workflow.extra-paths`, so a downstream repo following that advice got a
config that parsed, warned about unknown keys, and silently did nothing.
```
"""

from __future__ import annotations

import ast
import os
import re
from importlib import resources
from pathlib import Path

import pytest
import yaml
from click_extra import schema_field_infos

from repomatic.bundle import get_data_content
from repomatic.components import COMPONENTS_BY_NAME, SKILL_FILENAME
from repomatic.config import Config
from repomatic.frontmatter import split_frontmatter
from repomatic.release.self_pin import cooldown_exemption
from repomatic.upstream import UPSTREAM_PACKAGE

from .conftest import PROJECT_ROOT, WORKFLOWS_DIR, docs_heading_slug

CODE_FENCE_RE = re.compile(r"^[ \t]*(?P<marker>`{3,}(?!.*`)|~{3,})(?P<info>.*)$")
"""Opening or closing line of a fenced block, with its info string.

{func}`fenced_code_lines` reads the info string to tell a code block from a
MyST directive, which {data}`FENCE_RE` has no need to do. A line of prose that
opens on a long code span is no fence: a backtick follows its marker.
"""

CODE_SPAN_RE = re.compile(r"`([^`\n]+)`")
"""Inline code span, the only context these checks read.

Scoping to code spans is what keeps the rules from firing on prose. A
shell one-liner that greps for the literal string `[tool.repomatic]` sits
in a span of its own, and is skipped because the span does not *open* on
that table (see {data}`CONFIG_REF_RE`).
"""

CONFIG_REF_RE = re.compile(
    r"^\[tool\.repomatic(?P<table>(?:\.[a-z][a-z0-9_-]*)*)\]"
    r"(?:\s+(?P<key>[a-z][a-z0-9._-]*))?"
)
"""A `[tool.repomatic]` reference, in either form assets write it.

Both `[tool.repomatic] nuitka.enabled` and
`[tool.repomatic.workflow.paths]` name the same kind of thing, so the
table suffix and the trailing key are joined into one dotted path before
lookup. Anchored at the start of the span so it never matches mid-sentence.
"""

COOLDOWN_FLAG_RE = re.compile(r"--exclude-newer(?![\w-])")
"""The `--exclude-newer` flag itself, which `--exclude-newer-package` is not."""

COOLDOWN_LIFT = "2100-01-01"
"""The one literal an asset may pass to `--exclude-newer`: a cutoff no release reaches.

It lifts the window rather than copying it. `repomatic-ship` re-runs a fresh
resolution with it, which tells a cooldown artifact from a real failure.
"""

COOLDOWN_VALUE_RE = re.compile(
    r"(?:--(?:exclude-newer|min-release-age)(?![\w-])(?:=|[ \t]+)"
    r"|(?<![\w-])(?:UV_EXCLUDE_NEWER|NPM_CONFIG_MIN_RELEASE_AGE)[ \t]*[=:][ \t]*"
    r"|(?<![\w-])exclude-newer[ \t]*=[ \t]*)"
    r"(?:'(?P<single>[^'\n]*)'"
    r'|"(?P<double>[^"\n]*)"'
    r"|(?P<bare>[^\s'\"`]*[\d{][^\s'\"`]*))"
)
"""A cooldown window an asset sets, in every spelling uv and npm read.

The `--exclude-newer` and `--min-release-age` flags, the `UV_EXCLUDE_NEWER` and
`NPM_CONFIG_MIN_RELEASE_AGE` variables, and the `exclude-newer` TOML key. Each
value may be single-quoted, double-quoted or bare. A bare value counts only
when it holds a digit or a brace, as every span, date and placeholder does, so
prose that runs on after a key is passed over.
"""

MODULE_PATH_RE = re.compile(r"^(repomatic/[a-z0-9_/]+\.py)$")
"""A package module quoted as a repository-relative path."""

MODULE_SYMBOL_RE = re.compile(
    r"`(?P<symbol>[A-Za-z_][\w.]*)`\s*(?:in\s+)?\(?`(?P<module>repomatic/[a-z0-9_/]+\.py)`\)?"
)
"""An asset attributing a Python name to the module that defines it.

The two shapes assets write, `` `Class.attribute` (`repomatic/pkg/mod.py`) ``
and ``see `Class.attribute` in `repomatic/pkg/mod.py` ``, differ only by the
parentheses. Like {data}`MODULE_PATH_RE`, only the repository-relative path
counts: a bare `mod.py` is too ordinary a span to key on, and this package
already holds two modules named `matrix.py`.
"""

UVX_COMMAND_RE = re.compile(r"^[ \t]*(?:\$[ \t]+)?uvx[ \t]")
"""A line whose command is `uvx`, with or without a shell prompt.

Keyed on the command word, like every rule here: a `uvx` in the middle of a
pipeline, of a YAML `run:` value or of a comment is left alone.
"""

WORKFLOW_FILE_RE = re.compile(r"^(_?[a-z][a-z0-9-]*\.yaml)$")
"""A workflow filename quoted on its own."""

WORKFLOW_JOB_RE = re.compile(
    r"`(?P<workflow>_?[a-z][a-z0-9-]*\.yaml)` workflow's `(?P<job>[a-z][a-z0-9-]*)` job"
)
"""An asset attributing a job to the workflow that declares it.

Matched across the prose rather than inside one code span, because the
claim spans two of them. The possessive phrasing is what makes it safe to
read as an assertion: a line merely listing several workflows and a job
name never takes this shape, so the rule stays as under-inclusive as its
siblings while still pinning the one form that states ownership.
"""


[docs] def bundled_assets() -> list[tuple[str, str]]: """Every bundled skill and agent, as `(asset id, body)` pairs. Skills are folders entered through `SKILL.md`; agents are single files. Both deploy verbatim, so both are held to the same rules. A skill's `references/` pages deploy with it, so each page is an asset of its own, named after its path inside the skill. """ assets = [] for entry in COMPONENTS_BY_NAME["skills"].files: assets.append(( entry.file_id, get_data_content(f"{entry.source}/{SKILL_FILENAME}"), )) references = ( resources .files("repomatic.data") .joinpath(entry.source) .joinpath("references") ) if references.is_dir(): assets.extend( ( f"{entry.file_id}/references/{page.name}", page.read_text(encoding="UTF-8"), ) for page in sorted(references.iterdir(), key=lambda page: page.name) if page.name.endswith(".md") ) assets.extend( (entry.file_id, get_data_content(entry.source)) for entry in COMPONENTS_BY_NAME["subagents"].files ) return assets
bundled_asset = pytest.mark.parametrize( ("asset_id", "body"), bundled_assets(), ids=[asset_id for asset_id, _body in bundled_assets()], )
[docs] def literal_cooldown_windows(body: str) -> list[str]: """Every literal cooldown window an asset sets, in order. A `{placeholder}`, or a `$` reference that a shell or a workflow expands, is filled in at run time, and {data}`COOLDOWN_LIFT` lifts the window rather than copying it: none of them is reported. """ found = [] for match in COOLDOWN_VALUE_RE.finditer(body): value = match["single"] or match["double"] or match["bare"] or "" if value.startswith(("{", "$")) or value == COOLDOWN_LIFT: continue found.append(value) return found
[docs] def test_detector_flags_literal_cooldown_windows() -> None: """The detector reads a literal in each spelling, and nothing else.""" body = ( "uvx --exclude-newer '1 week' apple\n" 'uv lock --exclude-newer "P7D"\n' "uv pip install --exclude-newer=7days banana\n" "UV_EXCLUDE_NEWER: '2 weeks'\n" 'exclude-newer = "3 days"\n' "npx --min-release-age 7 cherry\n" "uvx --exclude-newer '{window}' date\n" "UV_EXCLUDE_NEWER: ${{ inputs.window }}\n" "uvx --exclude-newer 2100-01-01 --from . elderberry\n" "uvx --exclude-newer-package fig=P0D grape\n" "npx --min-release-age-exclude kiwi lemon\n" "Pass `--exclude-newer` to gate an install, or --exclude-newer and a span.\n" "The workflow-wide `UV_EXCLUDE_NEWER` sets the global `exclude-newer`.\n" ) assert literal_cooldown_windows(body) == [ "1 week", "P7D", "7days", "2 weeks", "3 days", "7", ]
[docs] @bundled_asset def test_cooldown_window_is_a_placeholder(asset_id: str, body: str) -> None: """An asset names the cooldown window with a placeholder, never a literal. Two files may carry the duration as a literal, each pinned by a test named in the module docstring. A skill or an agent cannot be a third: it ships verbatim to repositories that each set their own `[tool.repomatic] minimum-release-age`, so a literal there gates their installs on the default window, whatever they configured. A `{window}` placeholder, with a sentence naming where to read it, holds no copy that can go stale. """ windows = literal_cooldown_windows(body) assert not windows, ( f"{asset_id} sets the literal cooldown window {windows[0]!r}. Write a " "{window} placeholder instead, and say where to read it: the env: " "block of any .github/workflows/*.yaml carries the repository's own." )
[docs] def fenced_code_lines(body: str) -> list[tuple[int, str]]: """Every line of a fenced code block, as `(line number, text)` pairs. A fence whose info string opens on `{` is a MyST directive. It holds prose, so its lines are left out, and a code block nested in it still counts. Inside a code block every line is content, a shorter fence included, until a bare fence of the same character and at least the same length closes it. """ found = [] # One `(marker, holds code)` pair per open fence, the innermost last. open_fences: list[tuple[str, bool]] = [] for number, line in enumerate(body.splitlines(), 1): fence = CODE_FENCE_RE.match(line) in_code = bool(open_fences) and open_fences[-1][1] if fence: marker, info = fence.group("marker"), fence.group("info").strip() if open_fences and not info and marker.startswith(open_fences[-1][0]): open_fences.pop() continue if not in_code: open_fences.append((marker, not info.startswith("{"))) continue if in_code: found.append((number, line)) return found
[docs] def uvx_commands(body: str) -> list[tuple[int, str]]: """Every `uvx` command of a fenced code block, as `(line number, command)` pairs. A trailing backslash carries a command onto the next line. The lines it spans are joined, because a flag can sit on any of them. """ found = [] lines = fenced_code_lines(body) for index, (number, line) in enumerate(lines): if not UVX_COMMAND_RE.match(line): continue command = line.strip() for _number, continuation in lines[index + 1 :]: if not command.endswith("\\"): break command = command[:-1] + continuation.strip() found.append((number, command)) return found
[docs] def test_uvx_commands_come_from_code_blocks_only() -> None: """The reader keeps a command of a code block, and nothing else. One asset holds the only such command today, so a reader that finds nothing passes every asset. This holds each shape the reader must tell apart. """ body = ( "```` ```bash ```` opens a code block, and this line opens none.\n" "uvx plum is prose.\n" "\n" "```{note}\n" "uvx cherry is prose in a directive.\n" "```\n" "\n" "```shell-session\n" "$ uvx --exclude-newer '1 week' \\\n" " apricot\n" "apricot 1.2.3\n" "```\n" "\n" "````{tip}\n" "```bash\n" "uvx fig\n" "```\n" "````\n" "\n" "```yaml\n" "run: uvx quince\n" "```\n" ) assert uvx_commands(body) == [ (9, "$ uvx --exclude-newer '1 week' apricot"), (16, "uvx fig"), ]
[docs] @bundled_asset def test_uvx_commands_carry_a_cooldown(asset_id: str, body: str) -> None: """Every `uvx` command an asset hands over carries `--exclude-newer`. A `uvx` environment has no lockfile, so the flag is all that keeps a release published an hour ago out of the tree it resolves. A per-package exemption beside it is no substitute: it lifts the window for one package, and sets none. {func}`uvx_commands` reads what a maintainer copies into a shell, and nothing else. A `run:` value of a workflow is left alone, because it takes its window from the workflow-level `UV_EXCLUDE_NEWER`. """ for number, command in uvx_commands(body): assert COOLDOWN_FLAG_RE.search(command), ( f"{asset_id}:{number} runs `uvx` with no `--exclude-newer`, so the " f"install resolves outside the cooldown: {command!r}" )
CONTEXT_COMMAND_RE = re.compile(r"^!`") """A line a skill runs as a shell command when it loads."""
[docs] def adjacent_context_commands(body: str) -> list[int]: """Line numbers of the `!` commands sitting right under another line. Claude Code runs each line opening on `` !` `` as one command when the skill loads. The bundled `mdformat.toml` sets `wrap = "no"`, so the formatter joins the lines of a paragraph: a command written right under another line lands on that line at the next autofix pass, and the skill then loads one broken command. A blank line between two commands keeps each one a paragraph of its own. A fenced block is left alone. """ in_code = {number for number, _line in fenced_code_lines(body)} lines = body.splitlines() return [ number for number, line in enumerate(lines, 1) if number not in in_code and CONTEXT_COMMAND_RE.match(line) and number > 1 and lines[number - 2].strip() ]
[docs] def test_detector_flags_adjacent_context_commands() -> None: """The detector reads a command under a command, and nothing else.""" body = ( "## Context\n" "\n" "!`echo apple`\n" "!`echo banana`\n" "\n" "!`echo cherry`\n" "\n" "```shell-session\n" "!`echo fig`\n" "!`echo grape`\n" "```\n" ) assert adjacent_context_commands(body) == [4]
[docs] @bundled_asset def test_context_commands_stand_alone(asset_id: str, body: str) -> None: """Every `!` command of a skill's context section is its own paragraph.""" adjacent = adjacent_context_commands(body) assert not adjacent, ( f"{asset_id}:{adjacent[0]} runs a `!` command right under another line, " "which the formatter joins with it. Leave a blank line between them." )
[docs] @bundled_asset def test_self_pin_exemption_matches_constant(asset_id: str, body: str) -> None: """The repomatic self-pin bypass is quoted exactly as the freeze emits it. A hand-typed variant (a different span, the package spelled with an underscore) reads as the documented exemption while gating nothing, and uv reports no error for an exemption naming a package it never resolves. """ if "--exclude-newer-package" not in body: pytest.skip("asset declares no per-package cooldown exemption") exemption = cooldown_exemption(UPSTREAM_PACKAGE) assert exemption in body, ( f"{asset_id} carries a per-package cooldown exemption that is not " f"{exemption!r} verbatim." )
[docs] @bundled_asset def test_config_keys_resolve(asset_id: str, body: str) -> None: """Every `[tool.repomatic]` key an asset recommends exists in the schema. Downstream repos act on these verbatim, and an unknown key is close to silent: `load_repomatic_config` warns once, then the setting is simply absent, so the behaviour the asset promised never arrives. """ declared = {info.key for info in schema_field_infos(Config)} for span in CODE_SPAN_RE.findall(body): match = CONFIG_REF_RE.match(span.strip()) if not match: continue table = match.group("table").lstrip(".") dotted = ".".join(part for part in (table, match.group("key")) if part) if not dotted: continue assert dotted in declared, ( f"{asset_id} recommends [tool.repomatic] {dotted}, which is not a " "declared config key. TOML keys are kebab-case, unlike the " "dataclass attributes they map to." )
[docs] @bundled_asset def test_module_paths_exist(asset_id: str, body: str) -> None: """Every package module quoted as a path is still there under that name. Assets point at modules to say where a rule is implemented. A rename turns that into a dead end for whoever follows it, and the pointer is the whole value of the sentence carrying it. """ for span in CODE_SPAN_RE.findall(body): match = MODULE_PATH_RE.match(span.strip()) if not match: continue module = match.group(1) assert (PROJECT_ROOT / module).is_file(), ( f"{asset_id} points at {module}, which no longer exists." )
[docs] def module_bindings(source: str) -> set[str]: """Every name *source* binds, class and function members included. Walking the whole tree also collects names bound inside a function body, which no asset ever points at. That keeps the lookup permissive on purpose: the question asked of it is whether a name is defined in the module at all, and a wrong answer there costs a false failure. """ names = set() for node in ast.walk(ast.parse(source)): if isinstance(node, (ast.AsyncFunctionDef, ast.ClassDef, ast.FunctionDef)): names.add(node.name) elif isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store): names.add(node.id) return names
[docs] @bundled_asset def test_attributed_symbols_live_in_the_named_module(asset_id: str, body: str) -> None: """A Python name an asset credits to a module is defined by that module. The path check above only proves the file is still there, so a name that moved between two modules that both survive still reads as valid. The `7.14.0` regroup split `metadata.py` into a package and left `babysit-ci` crediting `metadata/core.py` for a `skip_binary_build` that had landed in `metadata/git.py`. That is what {func}`test_attributed_jobs_live_in_the_named_workflow` guards for a job, one dimension over. Only the last component of a dotted name is looked up: assets name the composed `Metadata` class, while each attribute is defined on whichever mixin module owns it. """ for match in MODULE_SYMBOL_RE.finditer(body): symbol, module = match.group("symbol"), match.group("module") path = PROJECT_ROOT / module assert path.is_file(), f"{asset_id} points at {module}, which no longer exists." attribute = symbol.rsplit(".", 1)[-1] assert attribute in module_bindings(path.read_text(encoding="UTF-8")), ( f"{asset_id} credits {module} with {symbol}, which it does not define." )
[docs] @bundled_asset def test_workflow_files_exist(asset_id: str, body: str) -> None: """Every workflow filename quoted in an asset is still a workflow. Assets name workflows constantly: which one to poll, which one runs a fix, which one to dispatch. A renamed or retired workflow leaves those sentences pointing at a file no `gh workflow run` will find. """ for span in CODE_SPAN_RE.findall(body): match = WORKFLOW_FILE_RE.match(span.strip()) if not match: continue workflow = match.group(1) assert (WORKFLOWS_DIR / workflow).is_file(), ( f"{asset_id} names {workflow}, which is not a workflow file." )
[docs] @bundled_asset def test_attributed_jobs_live_in_the_named_workflow(asset_id: str, body: str) -> None: """A job an asset attributes to a workflow is declared by that workflow. Both halves of the claim can outlive the claim itself: `update-docs` moved from `docs.yaml` to `autofix.yaml` in `5.7.1`, and the `sphinx-docs` agent kept crediting `docs.yaml` for eleven releases, because the workflow still existed and so did the job. Only the pairing was wrong, which is exactly what no existence check can see. """ for match in WORKFLOW_JOB_RE.finditer(body): workflow, job = match.group("workflow"), match.group("job") path = WORKFLOWS_DIR / workflow assert path.is_file(), f"{asset_id} names {workflow}, which is not a workflow." jobs = yaml.safe_load(path.read_text(encoding="UTF-8")).get("jobs", {}) assert job in jobs, ( f"{asset_id} credits {workflow} with a {job!r} job, which it does not " f"declare. Its jobs are: {', '.join(sorted(jobs))}." )
DIRECTIVE_WORD_LIMIT = 25 """Longest directive a bundled asset may carry, in words. ASD-STE100 caps an instruction at 25 words and a description at 20. One number covers both here, because a frontmatter `description` is an instruction to the router deciding whether to load the asset at all. """ BOLD_LEAD_RE = re.compile(r"^\*\*(?P<lead>.+?)\*\*") """The bolded opening of a list item, which is where its directive sits. The house style opens a rule bullet with the rule in bold, then follows it with the rationale in plain prose. That split is what makes this checkable at all: the bold span is held to the limit and the rationale after it is not, so a rule can still explain itself at length. """ FENCE_RE = re.compile(r"^\s*```") """A fenced block delimiter. What sits between two of them is not prose.""" IMPERATIVE_VERBS = frozenset({ "add", "always", "ask", "avoid", "call", "check", "commit", "confirm", "copy", "create", "delete", "do", "drop", "edit", "fix", "keep", "leave", "list", "make", "mark", "merge", "move", "name", "never", "open", "pass", "pick", "prefer", "push", "read", "record", "remove", "rename", "replace", "report", "rerun", "reset", "run", "set", "skip", "stop", "treat", "update", "use", "verify", "wait", "write", }) """Verbs that open a directive in a bullet carrying no bold lead. Enumerated rather than inferred, which keeps the check under-inclusive by construction: a bullet opening on a verb nobody listed is read as prose and skipped. That is the same trade the drift checks above make, for the same reason. A missed directive costs nothing; a false failure costs an afternoon. """ LIST_ITEM_RE = re.compile(r"^\s*(?:[-*+]|\d+\.)\s+(?P<rest>.*)$") """A bullet or a numbered step, with whatever follows its marker.""" SENTENCE_END_RE = re.compile(r"^(?P<first>.+?[.:!?])(?:\s|$)") """The first sentence of a directive that carries no bold lead.""" URL_RE = re.compile(r"https?://\S+") """A bare URL, counted as one word rather than as its own punctuation."""
[docs] def word_count(text: str) -> int: """Words in a directive, counting a code span or a URL as one each. A directive quoting a long command is not a long sentence, and reads as one item to whoever follows it. """ return len(URL_RE.sub("URL", CODE_SPAN_RE.sub("CODE", text)).split())
[docs] def directives(body: str) -> list[tuple[int, str]]: """Every directive in an asset body, as `(line number, text)` pairs. A directive is the bold lead of a list item, the first sentence of a list item opening on an {data}`IMPERATIVE_VERBS` entry, or the bold lead of a paragraph. That last shape is what the prose-heavy sections use instead of bullets, and it carries rules exactly as a bullet does. Plain prose and fenced code carry no directive and are skipped. """ found = [] in_fence = False for number, line in enumerate(body.splitlines(), 1): if FENCE_RE.match(line): in_fence = not in_fence continue if in_fence: continue item = LIST_ITEM_RE.match(line) if item: rest = item.group("rest") bold = BOLD_LEAD_RE.match(rest) if bold: found.append((number, bold.group("lead"))) continue first_word = re.match(r"[A-Za-z`][\w`'-]*", rest) if first_word and first_word.group().strip("`").lower() in IMPERATIVE_VERBS: sentence = SENTENCE_END_RE.match(rest) found.append((number, sentence.group("first") if sentence else rest)) continue # An unindented `**lead**` opens a paragraph-shaped rule. Anchoring at # column zero leaves an indented one as a bullet's continuation. paragraph = BOLD_LEAD_RE.match(line) if paragraph: found.append((number, paragraph.group("lead"))) return found
[docs] @bundled_asset def test_description_is_short_sentences(asset_id: str, body: str) -> None: """Every sentence of a frontmatter `description` clears the word limit. The description is the only part of an asset read on every turn, since it is what the router matches against to decide whether to load the rest. A 56-word run-on there costs matching accuracy on every session, which is why this is the one place the limit binds unconditionally. The limit binds each sentence and never the whole field. Four or five short sentences are the right shape, because the router matches against the field's whole vocabulary: trimming trigger nouns to reach a total costs matching accuracy and buys nothing. The only total is the Agent Skills spec's 1024-character ceiling, which `tests/test_skills.py` holds. So a description made of short sentences passes however long it grows, and review is what catches one that rambles. """ meta, _body = split_frontmatter(body) description = meta.get("description", "") for sentence in re.split(r"(?<=[.;])\s+", description): if not sentence.strip(): continue assert word_count(sentence) <= DIRECTIVE_WORD_LIMIT, ( f"{asset_id} opens with a {word_count(sentence)}-word sentence in " f"its description, over the {DIRECTIVE_WORD_LIMIT}-word limit. " f"Split it: {sentence!r}" )
[docs] @bundled_asset def test_directives_are_short(asset_id: str, body: str) -> None: """Every directive in an asset body clears the word limit. Only the directive is measured, never the rationale that follows it, so a rule keeps as much room to explain itself as it needs. A directive over the limit is nearly always two rules sharing a bullet, or a rule with its exception folded in: both read better split. """ _meta, content = split_frontmatter(body) for number, directive in directives(content): assert word_count(directive) <= DIRECTIVE_WORD_LIMIT, ( f"{asset_id}:{number} carries a {word_count(directive)}-word " f"directive, over the {DIRECTIVE_WORD_LIMIT}-word limit. Split " f"the rule from its exception, or move the detail into the " f"rationale after it: {directive!r}" )
ANCHOR_CITATION_RE = re.compile( r"(?P<file>agents|claude)\.md#(?P<anchor>[\w-]+)", re.IGNORECASE ) """A link to one section of the instructions file, as a forge or a site spells it. Matches the retired name `claude.md` too, which {func}`unresolved_citations` reports whatever anchor it names. """ AGENTS_MD = PROJECT_ROOT / "AGENTS.md" """The instructions file every citation points into. Spelled uppercase because that is the tracked name. A case-insensitive filesystem resolves `agents.md` just the same, so the wrong spelling here passes locally and raises `FileNotFoundError` on a Linux runner. """ SECTION_CITATION_RE = re.compile( r"`?(?P<file>agents|claude)\.md`?\s+ยง\s+(?=(?P<tail>[^\n]{1,160}))", re.IGNORECASE, ) """An `AGENTS.md ยง <section>` citation, plus the text that follows it. Matches the retired name `claude.md` too, which {func}`unresolved_citations` reports whatever section it names. Where the name ends is not something the citation reliably marks: the corpus holds `ยง Documentation sync (upstream maintainers).`, which closes on punctuation the name itself contains, beside `ยง Changelog and docs updates for style rules`, which runs into prose with no punctuation at all. So the tail runs well past the longest heading and {func}`unresolved_citations` matches the headings against it, rather than guessing a boundary and looking up whatever it captured. The tail sits in a lookahead, which reads it and consumes none of it: a second citation on the same line is then still there to find. """ LITERAL_SEAM_RE = re.compile(r"""["']\s*\n\s*[fFrR]{0,2}["']""") """The seam between two adjacent string literals of one Python expression. A URL too long for one line is split over two literals, which hides its anchor from {data}`ANCHOR_CITATION_RE` until the seam is closed. """ PROSE_WRAP_RE = re.compile(r"\n[ \t]*(?:#+[ \t]?)?") """A line break inside prose, with the indent and the comment marker behind it. A citation wraps like the rest of a docstring or a comment, which leaves the first words of a section name on one line and the others on the next. """ REPOSITORY_FILES = ("pyproject.toml", "readme.md") """Top-level files a citation can sit in, read beside {data}`REPOSITORY_ROOTS`.""" REPOSITORY_ROOTS = (".github", "docs", "repomatic", "tests") """Directories a citation can sit in, relative to the project root. `.claude/` is left out: the bundled assets it holds are read in the form they ship, by {func}`bundled_assets`. """ TEXT_SUFFIXES = frozenset({".md", ".py", ".toml", ".yaml", ".yml"}) """Suffixes of the files that hold prose: docstrings, comments and pages."""
[docs] def agents_headings() -> list[str]: """Every section heading `AGENTS.md` declares, fenced blocks excluded. A `#` opening a line inside a fence is a shell or TOML comment rather than a heading, so the fence state is tracked the way {func}`directives` tracks it. """ headings = [] in_fence = False for line in AGENTS_MD.read_text(encoding="UTF-8").splitlines(): if FENCE_RE.match(line): in_fence = not in_fence elif not in_fence and line.startswith("#"): headings.append(line.lstrip("#").strip()) return headings
[docs] def agents_anchors() -> set[str]: """Every anchor the headings of `AGENTS.md` answer to. A forge and the documentation site each derive an anchor from a heading in their own way, and a link can aim at the file on either one, so both slugs count: the forge's, and the site's {func}`~tests.conftest.docs_heading_slug`. """ anchors = set() for heading in agents_headings(): anchors.add(docs_heading_slug(heading)) anchors.add(re.sub(r"[^\w\- ]", "", heading.lower()).replace(" ", "-")) return anchors
[docs] def repository_texts() -> list[tuple[str, str]]: """Every text of the repository a citation can sit in, as `(path, text)`. Walks {data}`REPOSITORY_ROOTS` rather than asking git for the tracked files, so the list is the same in a checkout and in an unpacked source distribution. A build folder, a cache and a hidden directory are skipped. So is a symbolic link: the bundled workflows are links, and each one is read where its target lives. """ paths = [PROJECT_ROOT / name for name in REPOSITORY_FILES] for root in REPOSITORY_ROOTS: for folder, folders, files in os.walk(PROJECT_ROOT / root): folders[:] = sorted( name for name in folders if not name.startswith((".", "_build")) and name != "__pycache__" ) paths.extend(Path(folder) / name for name in files) return [ ( path.relative_to(PROJECT_ROOT).as_posix(), path.read_text(encoding="UTF-8"), ) for path in sorted(paths) if path.suffix in TEXT_SUFFIXES and not path.is_symlink() ]
[docs] def unresolved_citations(text: str) -> list[str]: """Each citation of an `AGENTS.md` section which that file does not declare. Reads both forms a citation takes: the `AGENTS.md ยง <section>` of prose and the `AGENTS.md#<anchor>` of a link. The text is unwrapped first, per {data}`PROSE_WRAP_RE` and {data}`LITERAL_SEAM_RE`. A citation of the retired name `claude.md` is dead whatever it names: that file is gone. A heading is matched as a prefix of the citation, which keeps the check as under-inclusive as its siblings: a name that merely opens on a real heading passes. The boundary that follows it must be a non-word character, so a heading cannot satisfy a citation that only starts with its letters. :param text: Content of one file, or of one bundled asset. :return: One entry per dead citation, in the form the text spells it. """ headings = agents_headings() anchors = agents_anchors() flat = PROSE_WRAP_RE.sub(" ", LITERAL_SEAM_RE.sub("", text)) unresolved = [] for match in SECTION_CITATION_RE.finditer(flat): tail = match.group("tail").lstrip('"') # A tail opening on a bracket is a placeholder, in a text that # describes the citation form itself. if not re.match(r"[\w`]", tail): continue is_retired = match.group("file").lower() == "claude" if is_retired or not any( re.match(rf"{re.escape(heading)}(?![\w-])", tail, re.IGNORECASE) for heading in headings ): unresolved.append(f"{match.group('file')}.md ยง {tail[:60]}") unresolved.extend( f"{match.group('file')}.md#{match.group('anchor')}" for match in ANCHOR_CITATION_RE.finditer(flat) if match.group("file").lower() == "claude" or match.group("anchor").lower() not in anchors ) return unresolved
[docs] @bundled_asset def test_cited_agents_sections_resolve(asset_id: str, body: str) -> None: """Every citation of an `AGENTS.md` section names one that file declares. Retiring the `agent` component in `7.14.0` moved the generic conventions out of this repository's `AGENTS.md`, and 32 citations reaching into them became dead ends inside assets that still ship verbatim. A downstream reader cannot tell a stale pointer from a section their own file is missing, so a citation either names something or gives way to the rule itself, stated inline. :param asset_id: Bundled asset the citation was read from. :param body: Full asset text, frontmatter included. """ unresolved = unresolved_citations(body) assert not unresolved, ( f"{asset_id} cites {unresolved[0]!r}, which AGENTS.md does not " "declare. Drop the pointer and state the rule inline, or aim it at " "a section that exists." )
[docs] def test_repository_citations_resolve() -> None: """Every citation of an `AGENTS.md` section, in any text here, resolves. {func}`test_cited_agents_sections_resolve` reads the bundled assets alone, and the same drift reaches every docstring, comment, workflow and page: a section that leaves `AGENTS.md` strands each citation of it, and nothing fails. A citation in a workflow or in a field docstring of `Config` also travels, to a repository or to a reader of the published site, where `AGENTS.md` is not this file. So a citation names a section this file declares, or gives way to one of three things: the rule stated inline, a section of `docs/`, or a cross-reference to the code that holds the rule. """ violations = [ f"{path}: {citation}" for path, text in repository_texts() for citation in unresolved_citations(text) ] assert not violations, ( f"{len(violations)} citations name a section that AGENTS.md does not " "declare, or the retired claude.md. State the rule inline, or aim each " "one at a section of docs/ or at the code that holds the rule:\n" + "\n".join(violations) )
RETIRED_CITATIONS = ( "see claude.md" + " ยง Naming conventions for automated operations", "see `claude.md`" + " ยง Naming conventions for automated operations", "https://example.com/blob/main/claude.md" + "#naming-conventions", ) """Citations of the retired name, split so that no text of this repository holds one."""
[docs] @pytest.mark.parametrize("citation", RETIRED_CITATIONS) def test_retired_instructions_file_never_resolves(citation: str) -> None: """A citation of `claude.md` is dead, whatever section or anchor it names. The file is gone, so a pointer into it strands the reader. Reading the old spelling as a miss is what stops a skill or a docstring from reaching for it again out of habit. """ assert unresolved_citations(citation)
[docs] def test_citation_check_tells_a_live_section_from_a_dead_one() -> None: """The check passes a heading of `AGENTS.md` and fails one it does not declare. The control for {func}`test_retired_instructions_file_never_resolves`: a check that reported every citation, or none, would satisfy that test alone. """ live = f"see AGENTS.md ยง {agents_headings()[0]}" dead = "see AGENTS.md" + " ยง A heading nothing declares" assert unresolved_citations(live) == [] assert unresolved_citations(dead)
SKILL_COMMAND_RE = re.compile(r"(?<![\w.:/-])/(?P<name>repomatic-[a-z0-9-]+)") """A slash command naming a skill of the `repomatic-` family, like `/repomatic-ship`. Read for that family alone. A bare `/name` token is as often a path (`/opt`) as a command, while the family holds the skills that get folded into a sibling: `repomatic-deps-review` into `repomatic-deps`, `repomatic-audit` into `repomatic-upgrade`. A retired skill of another name goes unnoticed. """
[docs] def stranded_skill_commands(text: str) -> list[str]: """Each {data}`SKILL_COMMAND_RE` command of *text* no bundled skill answers to. :param text: Content of one file, or of one bundled asset. :return: One entry per stranded command, in the order the text names them. """ skills = {entry.file_id for entry in COMPONENTS_BY_NAME["skills"].files} return [ f"/{match.group('name')}" for match in SKILL_COMMAND_RE.finditer(text) if match.group("name") not in skills ]
[docs] @bundled_asset def test_skill_commands_name_a_bundled_skill(asset_id: str, body: str) -> None: """Every `/repomatic-*` command an asset names is a skill that ships. A skill folded into a sibling leaves each command naming it behind, in assets that still ship verbatim, and the reader asks for a skill no repository receives. """ stranded = stranded_skill_commands(body) assert not stranded, ( f"{asset_id} names {stranded[0]}, which no bundled skill answers to." " Point it at the skill that took over, or drop the pointer." )
[docs] def test_repository_skill_commands_name_a_bundled_skill() -> None: """Every `/repomatic-*` command, in any text here, is a skill that ships. The readme, the documentation, the pull request templates and the docstrings send a reader to a skill the same way the bundled assets do. """ violations = [ f"{path}: {command}" for path, text in repository_texts() for command in stranded_skill_commands(text) ] assert not violations, ( f"{len(violations)} commands name no bundled skill. Point each one at the" " skill that took over, or drop it:\n" + "\n".join(violations) )
[docs] def test_skill_command_check_tells_a_bundled_skill_from_a_retired_one() -> None: """The check passes a bundled skill and fails a retired one. The control for the two tests above: a check that matched nothing would pass them both. The retired command is split, so that no text here holds one. """ assert stranded_skill_commands("run `/repomatic-upgrade v8.0.0 v8.1.0`") == [] retired = "/" + "repomatic-audit" assert stranded_skill_commands(f"run `{retired}` first") == [retired]