Source code for repomatic.agent_md

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

"""Project the audience-tagged parts of `claude.md` into a downstream repo.

`claude.md` § Section audience tags puts an `<!-- audience: ... -->` comment
under every heading upstream. This module reads those tags and writes the
sections a given repository is entitled to into that repository's own
instructions file, leaving everything it authored for itself untouched.

Upstream's copy is `claude.md` because Claude Code is what reads it here, but
the destination is whatever `[tool.repomatic] agent.location` resolves to,
defaulting through `[tool.repomatic.flavor] agent` to that agent's own filename.
`AGENTS.md` is the same document under the cross-agent convention, and a
repository keeping it outside the root is the case the key exists for.

The merge is the *overlay* half of the pair `init_project` already runs against
`pyproject.toml`: there the bundled template is the base and local keys graft on
top; here the repository's document is the base and the tagged sections overlay
into it. A section is identified by its heading title, which is also its anchor,
so a cross-reference written upstream keeps resolving downstream.

Three rules decide what a repository ends up with:

- **A tagged section is upstream's.** It is re-emitted from the bundled document
  on every sync, so a downstream edit to one is reverted. That is the point: the
  six repositories consuming this today have drifted on roughly four in five of
  the sections they nominally share, silently and in both directions.
- **An untagged section is the repository's.** It is carried through verbatim and
  no sync ever rewrites it, which is where repo-specific knowledge belongs.
- **A title collision resolves upstream's way.** An untagged local section whose
  title matches a tagged one is a hand-copied ancestor of it, and adopting it is
  the whole reason this exists. A repository wanting a section of its own on a
  neighbouring subject gives it a different title.

```{caution}
Ordering is not preserved across the boundary: tagged sections are emitted first
in upstream order, then the repository's own in theirs. A stable order is what
keeps the sync from fighting `format-markdown` for the canonical layout, per
`claude.md` § Common maintenance pitfalls, and it makes the managed block one
contiguous region a reader can skip. The first sync of an existing document
therefore moves its untagged sections down, once.
```
"""

from __future__ import annotations

import re
from dataclasses import dataclass
from pathlib import Path

from .bundle import get_data_content
from .registry import RepoScope

BUNDLED_INSTRUCTIONS = "claude.md"
"""The reference document, bundled under `repomatic/data/` as a symlink.

Kept a symlink back to the repository root rather than a copy, the way the
subagent definitions are, so the file this module ships is the one the
conformance tests in `tests/test_agent_md.py` check.

The name is upstream's own, not the destination's: what a consumer ends up
writing is `[tool.repomatic] agent.location`, which every function here takes as
a parameter rather than reading from this constant.
"""

AUDIENCES = ("all", "upstream", "downstream")
"""Every audience a section may declare.

`all` is upstream plus every consumer, `upstream` never leaves
`kdeldycke/repomatic`, and `downstream` is what a repository needs *because* it
consumes repomatic, which by definition does not describe repomatic itself.
"""

DOWNSTREAM_AUDIENCES = frozenset({"all", "downstream"})
"""Audiences a repository consuming repomatic receives.

`upstream` is the complement and never leaves `kdeldycke/repomatic`, which is
why {func}`merge_agent_md` is skipped there outright rather than filtered: the
source repository would otherwise receive the `downstream` sections written for
its consumers.
"""

TAG_SCOPES = {
    "all": RepoScope.ALL,
    "package": RepoScope.PACKAGE_ONLY,
}
"""Scope qualifiers a tag may carry, mapped onto the registry's own vocabulary.

Deliberately a subset of {class}`~repomatic.registry.RepoScope`: a qualifier is
added when a section demonstrably does not apply somewhere, not in anticipation.
"""

FENCE_RE = re.compile(r"^\s*```")

HEADING_RE = re.compile(r"^(?P<level>#{1,6})\s+(?P<title>.*)$")

TAG_RE = re.compile(
    r"^<!--\s*audience:\s*(?P<audience>[a-z]+)"
    r"(?:\s*;\s*scope:\s*(?P<scope>[a-z]+))?\s*-->$"
)

SUPERSEDES_RE = re.compile(r"^<!--\s*supersedes:\s*(?P<title>.+?)\s*-->$")
"""A heading title this section replaces, one comment per title.

Renaming a managed section otherwise strands the old one downstream: the merge
keys on the title, so the repository keeps its now-stale copy sitting beside the
corrected replacement, each contradicting the other. This is the same migration
`sync-labels` runs for a renamed label, and `claude.md` § Retiring a label is a
migration, not a deletion is the argument for why a rename beats a drop-and-add.

On its own line rather than folded into {data}`TAG_RE`, because a heading title
may contain the `;` and `:` that would otherwise delimit it.
"""


[docs] @dataclass(frozen=True) class Section: """One heading of an instructions file, with its tag and body as written.""" level: int """Heading depth, from 2 (the document title is not a section).""" title: str """Heading text, which is also the anchor a cross-reference targets.""" audience: str """Declared audience, or the empty string when the section carries no tag.""" scope: str """Declared scope qualifier, defaulting to `all` when the tag omits one.""" text: str """Verbatim source, from the heading line to the line before the next one. Kept whole rather than split into heading and body so a re-emitted section is byte-identical to its upstream form, down to the trailing blank lines `format-markdown` settled on. """ supersedes: tuple[str, ...] = () """Heading titles this section replaces downstream, from {data}`SUPERSEDES_RE`.""" @property def is_managed(self) -> bool: """Whether upstream owns this section, rather than the repository.""" return bool(self.audience)
[docs] def reaches(self, is_awesome: bool, is_python: bool, is_package: bool) -> bool: """Whether a repository with these traits receives this section. :param is_awesome: `True` for `awesome-*` repositories. :param is_python: `True` when a PEP 621 `[project].name` is present. :param is_package: `True` when the project also builds a distributable. :return: Whether the section is both downstream-bound and in scope. :raises KeyError: If the tag carries a scope this module does not know. Upstream tags are held to {data}`TAG_SCOPES` by `tests/test_agent_md.py`, so this signals a bundled document from a newer repomatic than the code reading it. """ if self.audience not in DOWNSTREAM_AUDIENCES: return False return TAG_SCOPES[self.scope].matches(is_awesome, is_python, is_package)
[docs] def parse_sections(content: str) -> tuple[str, list[Section]]: """Split an instructions file into its preamble and its sections. The preamble is everything above the first section heading: the document title, and whatever one-paragraph description a repository put under it. Both belong to the repository and survive every sync. :param content: Full text of an instructions file. :return: The preamble, and one {class}`Section` per heading below level 1. """ lines = content.splitlines() # Heading positions first, so a section's text is a plain slice afterwards. heads: list[tuple[int, int, str]] = [] in_fence = False for index, line in enumerate(lines): if FENCE_RE.match(line): in_fence = not in_fence elif not in_fence and (match := HEADING_RE.match(line)): level = len(match.group("level")) if level > 1: heads.append((index, level, match.group("title").strip())) if not heads: return content, [] sections = [] for position, (start, level, title) in enumerate(heads): end = heads[position + 1][0] if position + 1 < len(heads) else len(lines) block = lines[start:end] audience, scope = "", "all" supersedes: list[str] = [] # The tag block sits right under the heading, so a short lookahead # reaches it without ever running into the body. It is one comment per # line, so the window has to clear the audience tag plus however many # `supersedes:` lines follow it. for candidate in block[1:8]: stripped = candidate.strip() if tag := TAG_RE.match(stripped): audience = tag.group("audience") scope = tag.group("scope") or "all" elif renamed := SUPERSEDES_RE.match(stripped): supersedes.append(renamed.group("title")) elif stripped and not stripped.startswith("<!--"): # First line of prose: the tag block is over. break sections.append( Section( level, title, audience, scope, "\n".join(block).strip("\n"), tuple(supersedes), ) ) return "\n".join(lines[: heads[0][0]]).strip("\n"), sections
[docs] def render_agent_md( existing: str, *, is_awesome: bool = False, is_python: bool = True, is_package: bool = True, ) -> str: """Overlay the sections a repository is entitled to onto its own document. Idempotent: rendering an already-merged document returns it unchanged, which is what lets `repomatic init` report it as untouched and keeps the unattended `sync-repomatic` job from opening a pull request every run. :param existing: Current instructions file of the target repository, empty when it has none yet. :param is_awesome: `True` for `awesome-*` repositories. :param is_python: `True` when a PEP 621 `[project].name` is present. :param is_package: `True` when the project also builds a distributable. :return: The merged document, always newline-terminated. """ upstream_preamble, upstream_sections = parse_sections( get_data_content(BUNDLED_INSTRUCTIONS) ) managed = [ section for section in upstream_sections if section.reaches(is_awesome, is_python, is_package) ] local_preamble, local_sections = parse_sections(existing) claimed = {section.title for section in managed} claimed.update(title for section in managed for title in section.supersedes) # A tagged local section is dropped whatever its title: either it is being # re-emitted from upstream just below, or upstream stopped sending it here # and the copy on disk is an orphan no stale-file check would ever see. kept = [ section for section in local_sections if not section.is_managed and section.title not in claimed ] blocks = [local_preamble or upstream_preamble] blocks.extend(section.text for section in managed) blocks.extend(section.text for section in kept) return "\n\n".join(block for block in blocks if block) + "\n"
[docs] def merge_agent_md( target: Path, *, is_awesome: bool = False, is_python: bool = True, is_package: bool = True, ) -> bool: """Write the entitled sections into *target*, creating the file if absent. :param target: Path to the repository's instructions file, from `[tool.repomatic] agent.location`. :param is_awesome: `True` for `awesome-*` repositories. :param is_python: `True` when a PEP 621 `[project].name` is present. :param is_package: `True` when the project also builds a distributable. :return: Whether the file was created or modified. """ existing = target.read_text(encoding="UTF-8") if target.is_file() else "" merged = render_agent_md( existing, is_awesome=is_awesome, is_python=is_python, is_package=is_package, ) if merged == existing: return False target.parent.mkdir(parents=True, exist_ok=True) target.write_text(merged, encoding="UTF-8") return True