# Copyright Kevin Deldycke <kevin@deldycke.com> 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 or a `claude.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
`claude.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.
One check reaches past the assets: {func}`test_repository_citations_resolve`
holds every text of the repository to the citation rule 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`. Matching is deliberately under-inclusive: every rule
keys off an unambiguous form (a quoted 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
`repomatic-audit` 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.config import Config
from repomatic.frontmatter import split_frontmatter
from repomatic.registry import COMPONENTS_BY_NAME, SKILL_FILENAME
from repomatic.release.prepare_release import SELF_PIN_COOLDOWN_EXEMPTION
from repomatic.tooling.bundle import get_data_content
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_WINDOW_RE = re.compile(r"--exclude-newer[= ]'(?P<window>[^']+)'")
"""A `--exclude-newer` flag carrying a quoted duration.
The bare and double-quoted forms are absent from every bundled asset, so
this stays keyed on the one shape in use rather than accepting three.
"""
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]
@bundled_asset
def test_cooldown_window_matches_config(asset_id: str, body: str) -> None:
"""A quoted cooldown window equals `[tool.repomatic] minimum-release-age`.
Two files may carry the duration as a literal, each pinned by a test
named in the module docstring. Skills that hand a maintainer a `uvx`
command are a third carrier, and the one nothing regenerates: raising the
window in config would leave four skills quoting the old span at anyone
who reads them.
"""
for window in COOLDOWN_WINDOW_RE.findall(body):
assert window == Config.minimum_release_age, (
f"{asset_id} gates an install on a {window!r} cooldown, but "
f"[tool.repomatic] minimum-release-age is "
f"{Config.minimum_release_age!r}."
)
[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}"
)
[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")
assert SELF_PIN_COOLDOWN_EXEMPTION in body, (
f"{asset_id} carries a per-package cooldown exemption that is not "
f"{SELF_PIN_COOLDOWN_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}"
)
CLAUDE_ANCHOR_RE = re.compile(r"claude\.md#(?P<anchor>[\w-]+)", re.IGNORECASE)
"""A link to one section of `claude.md`, as the URL of a forge or a site spells it."""
CLAUDE_MD = PROJECT_ROOT / "claude.md"
"""The instructions file every citation points into.
Spelled lowercase because that is the tracked name. A case-insensitive
filesystem resolves `CLAUDE.md` just the same, so the wrong spelling here
passes locally and raises `FileNotFoundError` on a Linux runner.
"""
CLAUDE_SECTION_RE = re.compile(
r"`?claude\.md`?\s+ยง\s+(?=(?P<tail>[^\n]{1,160}))", re.IGNORECASE
)
"""A `claude.md ยง <section>` citation, plus the text that follows it.
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}`CLAUDE_ANCHOR_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 claude_headings() -> list[str]:
"""Every section heading `claude.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 CLAUDE_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 claude_anchors() -> set[str]:
"""Every anchor the headings of `claude.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 claude_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 a `claude.md` section which that file does not declare.
Reads both forms a citation takes: the `claude.md ยง <section>` of prose
and the `claude.md#<anchor>` of a link. The text is unwrapped first, per
{data}`PROSE_WRAP_RE` and {data}`LITERAL_SEAM_RE`.
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 = claude_headings()
anchors = claude_anchors()
flat = PROSE_WRAP_RE.sub(" ", LITERAL_SEAM_RE.sub("", text))
unresolved = []
for match in CLAUDE_SECTION_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
if not any(
re.match(rf"{re.escape(heading)}(?![\w-])", tail, re.IGNORECASE)
for heading in headings
):
unresolved.append(f"ยง {tail[:60]}")
unresolved.extend(
f"#{match.group('anchor')}"
for match in CLAUDE_ANCHOR_RE.finditer(flat)
if match.group("anchor").lower() not in anchors
)
return unresolved
[docs]
@bundled_asset
def test_cited_claude_sections_resolve(asset_id: str, body: str) -> None:
"""Every citation of a `claude.md` section names one that file declares.
Retiring the `agent` component in `7.14.0` moved the generic conventions
out of this repository's `claude.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 claude.md {unresolved[0]!r}, which claude.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 a `claude.md` section, in any text here, resolves.
{func}`test_cited_claude_sections_resolve` reads the bundled assets alone,
and the same drift reaches every docstring, comment, workflow and page: a
section that leaves `claude.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
`claude.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 claude.md does not "
"declare. 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)
)