tests.test_claude_assets module¶
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: 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 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.
- tests.test_claude_assets.CODE_FENCE_RE = re.compile('^[ \\t]*(?P<marker>`{3,}(?!.*`)|~{3,})(?P<info>.*)$')¶
Opening or closing line of a fenced block, with its info string.
fenced_code_lines()reads the info string to tell a code block from a MyST directive, whichFENCE_REhas no need to do. A line of prose that opens on a long code span is no fence: a backtick follows its marker.
- tests.test_claude_assets.CODE_SPAN_RE = re.compile('`([^`\\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 (seeCONFIG_REF_RE).
- tests.test_claude_assets.CONFIG_REF_RE = re.compile('^\\[tool\\.repomatic(?P<table>(?:\\.[a-z][a-z0-9_-]*)*)\\](?:\\s+(?P<key>[a-z][a-z0-9._-]*))?')¶
A
[tool.repomatic]reference, in either form assets write it.Both
[tool.repomatic] nuitka.enabledand[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.
- tests.test_claude_assets.COOLDOWN_FLAG_RE = re.compile('--exclude-newer(?![\\w-])')¶
The
--exclude-newerflag itself, which--exclude-newer-packageis not.
- tests.test_claude_assets.COOLDOWN_WINDOW_RE = re.compile("--exclude-newer[= ]'(?P<window>[^']+)'")¶
A
--exclude-newerflag 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.
- tests.test_claude_assets.MODULE_PATH_RE = re.compile('^(repomatic/[a-z0-9_/]+\\.py)$')¶
A package module quoted as a repository-relative path.
- tests.test_claude_assets.MODULE_SYMBOL_RE = re.compile('`(?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) `` andsee ``Class.attributeinrepomatic/pkg/mod.py, differ only by the parentheses. Like :data:`MODULE_PATH_RE`, only the repository-relative path counts: a bare ``mod.pyis too ordinary a span to key on, and this package already holds two modules namedmatrix.py.
- tests.test_claude_assets.UVX_COMMAND_RE = re.compile('^[ \\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
uvxin the middle of a pipeline, of a YAMLrun:value or of a comment is left alone.
- tests.test_claude_assets.WORKFLOW_FILE_RE = re.compile('^(_?[a-z][a-z0-9-]*\\.yaml)$')¶
A workflow filename quoted on its own.
- tests.test_claude_assets.WORKFLOW_JOB_RE = re.compile("`(?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.
- tests.test_claude_assets.bundled_assets()[source]¶
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’sreferences/pages deploy with it, so each page is an asset of its own, named after its path inside the skill.
- tests.test_claude_assets.test_cooldown_window_matches_config(asset_id, body)[source]¶
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
uvxcommand 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.- Return type:
- tests.test_claude_assets.fenced_code_lines(body)[source]¶
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.
- tests.test_claude_assets.uvx_commands(body)[source]¶
Every
uvxcommand 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.
- tests.test_claude_assets.test_uvx_commands_come_from_code_blocks_only()[source]¶
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.
- Return type:
- tests.test_claude_assets.test_uvx_commands_carry_a_cooldown(asset_id, body)[source]¶
Every
uvxcommand an asset hands over carries--exclude-newer.A
uvxenvironment 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.uvx_commands()reads what a maintainer copies into a shell, and nothing else. Arun:value of a workflow is left alone, because it takes its window from the workflow-levelUV_EXCLUDE_NEWER.- Return type:
- tests.test_claude_assets.test_self_pin_exemption_matches_constant(asset_id, body)[source]¶
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.
- Return type:
- tests.test_claude_assets.test_config_keys_resolve(asset_id, body)[source]¶
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_configwarns once, then the setting is simply absent, so the behaviour the asset promised never arrives.- Return type:
- tests.test_claude_assets.test_module_paths_exist(asset_id, body)[source]¶
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.
- Return type:
- tests.test_claude_assets.module_bindings(source)[source]¶
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.
- tests.test_claude_assets.test_attributed_symbols_live_in_the_named_module(asset_id, body)[source]¶
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.0regroup splitmetadata.pyinto a package and leftbabysit-cicreditingmetadata/core.pyfor askip_binary_buildthat had landed inmetadata/git.py. That is whattest_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
Metadataclass, while each attribute is defined on whichever mixin module owns it.- Return type:
- tests.test_claude_assets.test_workflow_files_exist(asset_id, body)[source]¶
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 runwill find.- Return type:
- tests.test_claude_assets.test_attributed_jobs_live_in_the_named_workflow(asset_id, body)[source]¶
A job an asset attributes to a workflow is declared by that workflow.
Both halves of the claim can outlive the claim itself:
update-docsmoved fromdocs.yamltoautofix.yamlin5.7.1, and thesphinx-docsagent kept creditingdocs.yamlfor 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.- Return type:
- tests.test_claude_assets.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
descriptionis an instruction to the router deciding whether to load the asset at all.
- tests.test_claude_assets.BOLD_LEAD_RE = re.compile('^\\*\\*(?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.
- tests.test_claude_assets.FENCE_RE = re.compile('^\\s*```')¶
A fenced block delimiter. What sits between two of them is not prose.
- tests.test_claude_assets.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.
- tests.test_claude_assets.LIST_ITEM_RE = re.compile('^\\s*(?:[-*+]|\\d+\\.)\\s+(?P<rest>.*)$')¶
A bullet or a numbered step, with whatever follows its marker.
- tests.test_claude_assets.SENTENCE_END_RE = re.compile('^(?P<first>.+?[.:!?])(?:\\s|$)')¶
The first sentence of a directive that carries no bold lead.
- tests.test_claude_assets.URL_RE = re.compile('https?://\\S+')¶
A bare URL, counted as one word rather than as its own punctuation.
- tests.test_claude_assets.word_count(text)[source]¶
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 type:
- tests.test_claude_assets.directives(body)[source]¶
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
IMPERATIVE_VERBSentry, 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.
- tests.test_claude_assets.test_description_is_short_sentences(asset_id, body)[source]¶
Every sentence of a frontmatter
descriptionclears 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.pyholds. So a description made of short sentences passes however long it grows, and review is what catches one that rambles.- Return type:
- tests.test_claude_assets.test_directives_are_short(asset_id, body)[source]¶
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.
- Return type:
- tests.test_claude_assets.CLAUDE_ANCHOR_RE = re.compile('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.
- tests.test_claude_assets.CLAUDE_MD = PosixPath('/home/runner/work/repomatic/repomatic/claude.md')¶
The instructions file every citation points into.
Spelled lowercase because that is the tracked name. A case-insensitive filesystem resolves
CLAUDE.mdjust the same, so the wrong spelling here passes locally and raisesFileNotFoundErroron a Linux runner.
- tests.test_claude_assets.CLAUDE_SECTION_RE = re.compile('`?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 andunresolved_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.
- tests.test_claude_assets.LITERAL_SEAM_RE = re.compile('["\']\\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
CLAUDE_ANCHOR_REuntil the seam is closed.
- tests.test_claude_assets.PROSE_WRAP_RE = re.compile('\\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.
- tests.test_claude_assets.REPOSITORY_FILES = ('pyproject.toml', 'readme.md')¶
Top-level files a citation can sit in, read beside
REPOSITORY_ROOTS.
- tests.test_claude_assets.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, bybundled_assets().
- tests.test_claude_assets.TEXT_SUFFIXES = frozenset({'.md', '.py', '.toml', '.yaml', '.yml'})¶
Suffixes of the files that hold prose: docstrings, comments and pages.
- tests.test_claude_assets.claude_headings()[source]¶
Every section heading
claude.mddeclares, 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 waydirectives()tracks it.
- tests.test_claude_assets.claude_anchors()[source]¶
Every anchor the headings of
claude.mdanswer 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
docs_heading_slug().
- tests.test_claude_assets.repository_texts()[source]¶
Every text of the repository a citation can sit in, as
(path, text).Walks
REPOSITORY_ROOTSrather 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.
- tests.test_claude_assets.unresolved_citations(text)[source]¶
Each citation of a
claude.mdsection which that file does not declare.Reads both forms a citation takes: the
claude.md § <section>of prose and theclaude.md#<anchor>of a link. The text is unwrapped first, perPROSE_WRAP_REandLITERAL_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.
- tests.test_claude_assets.test_cited_claude_sections_resolve(asset_id, body)[source]¶
Every citation of a
claude.mdsection names one that file declares.Retiring the
agentcomponent in7.14.0moved the generic conventions out of this repository’sclaude.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.
- tests.test_claude_assets.test_repository_citations_resolve()[source]¶
Every citation of a
claude.mdsection, in any text here, resolves.test_cited_claude_sections_resolve()reads the bundled assets alone, and the same drift reaches every docstring, comment, workflow and page: a section that leavesclaude.mdstrands each citation of it, and nothing fails. A citation in a workflow or in a field docstring ofConfigalso travels, to a repository or to a reader of the published site, whereclaude.mdis 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.- Return type: