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, which 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.

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 (see CONFIG_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.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.

tests.test_claude_assets.COOLDOWN_FLAG_RE = re.compile('--exclude-newer(?![\\w-])')

The --exclude-newer flag itself, which --exclude-newer-package is not.

tests.test_claude_assets.COOLDOWN_WINDOW_RE = re.compile("--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.

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) `` 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.

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 uvx in the middle of a pipeline, of a YAML run: 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’s references/ pages deploy with it, so each page is an asset of its own, named after its path inside the skill.

Return type:

list[tuple[str, str]]

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 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.

Return type:

None

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.

Return type:

list[tuple[int, str]]

tests.test_claude_assets.uvx_commands(body)[source]

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.

Return type:

list[tuple[int, str]]

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:

None

tests.test_claude_assets.test_uvx_commands_carry_a_cooldown(asset_id, body)[source]

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.

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.

Return type:

None

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:

None

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_config warns once, then the setting is simply absent, so the behaviour the asset promised never arrives.

Return type:

None

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:

None

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.

Return type:

set[str]

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.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 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.

Return type:

None

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 run will find.

Return type:

None

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-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.

Return type:

None

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 description is 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:

int

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_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.

Return type:

list[tuple[int, str]]

tests.test_claude_assets.test_description_is_short_sentences(asset_id, body)[source]

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.

Return type:

None

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:

None

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.md just the same, so the wrong spelling here passes locally and raises FileNotFoundError on 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 and 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.

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_RE until 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, by bundled_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.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 directives() tracks it.

Return type:

list[str]

tests.test_claude_assets.claude_anchors()[source]

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 docs_heading_slug().

Return type:

set[str]

tests.test_claude_assets.repository_texts()[source]

Every text of the repository a citation can sit in, as (path, text).

Walks 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.

Return type:

list[tuple[str, str]]

tests.test_claude_assets.unresolved_citations(text)[source]

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 PROSE_WRAP_RE and 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.

Parameters:

text (str) – Content of one file, or of one bundled asset.

Return type:

list[str]

Returns:

One entry per dead citation, in the form the text spells it.

tests.test_claude_assets.test_cited_claude_sections_resolve(asset_id, body)[source]

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.

Parameters:
  • asset_id (str) – Bundled asset the citation was read from.

  • body (str) – Full asset text, frontmatter included.

Return type:

None

tests.test_claude_assets.test_repository_citations_resolve()[source]

Every citation of a claude.md section, 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 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.

Return type:

None