From 69e7bdc7c623329e0a4b3bb4df2fbd8b27aefc51 Mon Sep 17 00:00:00 2001 From: Tom Kaltofen Date: Thu, 30 Jul 2026 16:49:51 +0000 Subject: [PATCH 1/3] test: pin CLAUDE.md and AGENTS.md to one shared instruction set AGENTS.md was missing the whole mypy iteration notes subsection and nothing caught the drift. Define the shared part as every CLAUDE.md line not ending in a claude-only marker, copy the missing subsection over, and guard the invariant the way the other project-structure guards do. Both files leave the CI docs-only fast path: a test now asserts on their content, so a change to either has to run the gate. --- .github/workflows/ci.yaml | 4 +-- AGENTS.md | 8 ++++- CLAUDE.md | 4 +-- tests/test_agent_docs_sync.py | 66 +++++++++++++++++++++++++++++++++++ 4 files changed, 76 insertions(+), 6 deletions(-) create mode 100644 tests/test_agent_docs_sync.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 599141f8..29f7c26c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,13 +1,11 @@ name: CI -# Explicit, not "*.md": CONTRIBUTING.md and docs/ are asserted/executed by tests (guarded by tests/test_documentation/test_ci_paths_ignore.py). +# Explicit, not "*.md": CONTRIBUTING.md, docs/, CLAUDE.md and AGENTS.md are asserted/executed by tests (guarded by tests/test_ci_paths_ignore.py). # Safe only while CI is not a required status check on main: a skipped required check never reports, blocking docs-only PRs. on: push: branches: [ "main" ] paths-ignore: &docs_only - - AGENTS.md - - CLAUDE.md - CODE_OF_CONDUCT.md - NOTICE.md - README.md diff --git a/AGENTS.md b/AGENTS.md index 1b4468d3..13b0200a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,7 +51,7 @@ Each phase should be a clean, validated checkpoint with all tests passing and ch **CRITICAL**: If agent behavior is unexpected or incorrect: 1. **Update Agent Configuration**: Modify `.claude/agents/red-agent.md` or `.claude/agents/green-agent.md` to refine instructions, constraints, or workflow -2. **Update This File**: Modify `CLAUDE.md` and `AGENTS.md` (keep them in sync) to clarify orchestration rules or add missing guidance +2. **Update This File**: Modify `CLAUDE.md` and `AGENTS.md` (`tests/test_agent_docs_sync.py` pins them identical, except for `CLAUDE.md` lines ending in a `claude-only` HTML comment) to clarify orchestration rules or add missing guidance 3. **Document Changes**: Briefly explain what was learned and why the change improves behavior This enables continuous learning and improvement of the TDD workflow based on actual usage patterns. @@ -85,6 +85,12 @@ source .venv/bin/activate - **`attribution/ATTRIBUTION.md`**: `tox` regenerates this file from the installed dependency versions on every run, so a dependency change shows up as a diff here. This is intended: commit the update as part of the same change so the tracked file stays current. The release workflow does not regenerate it; it ships the committed copy, so keeping it up to date in PRs is what keeps releases accurate. - **Commits**: use [Conventional Commits](https://www.conventionalcommits.org/) (`feat:`, `fix:`, `chore:`, `docs:`, `test:`, `refactor:`, `minor:`). semantic-release computes the next version. This project deviates from the standard: the minor version (middle number) bumps only on `minor:` commits; `feat:` is treated as a patch bump. +### mypy iteration notes + +- `mypy --strict --ignore-missing-imports .` (what tox runs) checks `tests/` as well as `mloda/` and `mloda_plugins/`. Running `mypy mloda/` locally will miss test-tree type errors that block the tox gate. `tox` (or `tox -e python310`) is the only trustworthy mypy invocation. +- A stray local build (`python -m build` or `pip wheel .`) creates a `build/` directory. mypy picks it up and reports spurious errors. Clean it with `rm -rf build/` before running tox. The `[tool.mypy]` exclude list in `pyproject.toml` includes `build/` to prevent this at the gate. +- Running `mypy --strict .` in the dev venv (outside tox) may surface pyspark-related errors that tox's isolated env does not see; trust the tox run as the source of truth. + ## Issue Creation When filing a GitHub issue (via `gh issue create` or otherwise), follow the structure in `.github/ISSUE_TEMPLATE/issue.yml`: diff --git a/CLAUDE.md b/CLAUDE.md index 11c2f0e6..8757b1df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,7 @@ - **Orchestration Only**: Coordinate Test-Driven Development cycles between specialized agents - **No Code Implementation**: NEVER write implementation code or tests directly - **Agent Delegation**: Use Red Agent for test writing, Green Agent for implementation -- **Session root**: `.claude/agents/red-agent.md` and `.claude/agents/green-agent.md` are only auto-loaded when Claude Code's session is rooted at this repository. If working from a parent or workspace directory, start a session inside the clone (`cd code/mloda && claude`) so the agents are available natively. +- **Session root**: `.claude/agents/red-agent.md` and `.claude/agents/green-agent.md` are only auto-loaded when Claude Code's session is rooted at this repository. If working from a parent or workspace directory, start a session inside the clone (`cd code/mloda && claude`) so the agents are available natively. ## TDD Workflow @@ -52,7 +52,7 @@ Each phase should be a clean, validated checkpoint with all tests passing and ch **CRITICAL**: If agent behavior is unexpected or incorrect: 1. **Update Agent Configuration**: Modify `.claude/agents/red-agent.md` or `.claude/agents/green-agent.md` to refine instructions, constraints, or workflow -2. **Update This File**: Modify `CLAUDE.md` and `AGENTS.md` (keep them in sync) to clarify orchestration rules or add missing guidance +2. **Update This File**: Modify `CLAUDE.md` and `AGENTS.md` (`tests/test_agent_docs_sync.py` pins them identical, except for `CLAUDE.md` lines ending in a `claude-only` HTML comment) to clarify orchestration rules or add missing guidance 3. **Document Changes**: Briefly explain what was learned and why the change improves behavior This enables continuous learning and improvement of the TDD workflow based on actual usage patterns. diff --git a/tests/test_agent_docs_sync.py b/tests/test_agent_docs_sync.py new file mode 100644 index 00000000..3734de89 --- /dev/null +++ b/tests/test_agent_docs_sync.py @@ -0,0 +1,66 @@ +"""CLAUDE.md and AGENTS.md are one instruction set: every line not tagged claude-only must be identical.""" + +import difflib +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parent.parent + +# Naming both files here makes tests/test_ci_paths_ignore.py treat them as protected: a change to +# either must run the gate, so neither may stay in the ci.yaml docs-only paths-ignore list. +CLAUDE_MD = PROJECT_ROOT / "CLAUDE.md" +AGENTS_MD = PROJECT_ROOT / "AGENTS.md" + +CLAUDE_ONLY_MARKER = "" +SHARED_HEADING = "### mypy iteration notes" + + +def _lines(path: Path) -> list[str]: + return path.read_text(encoding="utf-8").splitlines() + + +def _is_claude_only(line: str) -> bool: + return line.rstrip().endswith(CLAUDE_ONLY_MARKER) + + +def _shared_lines() -> list[str]: + """The part of CLAUDE.md that is not Claude-specific, so it belongs to both agents.""" + return [line for line in _lines(CLAUDE_MD) if not _is_claude_only(line)] + + +def _contains_heading(lines: list[str], heading: str) -> bool: + return any(line.rstrip() == heading for line in lines) + + +def test_shared_part_of_claude_md_matches_agents_md() -> None: + shared = _shared_lines() + agents = _lines(AGENTS_MD) + diff = "\n".join( + difflib.unified_diff(shared, agents, fromfile="CLAUDE.md (shared part)", tofile="AGENTS.md", lineterm="") + ) + assert shared == agents, ( + f"CLAUDE.md and AGENTS.md have drifted:\n{diff}\n\n" + "Keep the two files identical. If a line is genuinely only true for Claude Code, end it with the " + f"{CLAUDE_ONLY_MARKER} marker; every other line must appear in AGENTS.md verbatim." + ) + + +def test_agents_md_has_no_claude_only_marker() -> None: + tagged = [ + f"{number}: {line}" for number, line in enumerate(_lines(AGENTS_MD), start=1) if CLAUDE_ONLY_MARKER in line + ] + found = "\n".join(tagged) + assert not tagged, ( + f"AGENTS.md must never contain the {CLAUDE_ONLY_MARKER} marker: the marker flags content that stays " + f"out of AGENTS.md, so a tagged line there contradicts itself. Drop the marker or keep the line in " + f"CLAUDE.md only:\n{found}" + ) + + +def test_mypy_iteration_notes_stay_shared() -> None: + sources = (("CLAUDE.md (shared part)", _shared_lines()), ("AGENTS.md", _lines(AGENTS_MD))) + missing = [name for name, lines in sources if not _contains_heading(lines, SHARED_HEADING)] + assert not missing, ( + f"The '{SHARED_HEADING}' subsection must be in both files, missing from: {missing}. It describes the " + "tox gate rather than Claude Code, so it must not be tagged claude-only and must not be dropped." + ) From 3318f19791a0abc73ccb7f7f9da336dbcbdc162e Mon Sep 17 00:00:00 2001 From: Tom Kaltofen Date: Thu, 30 Jul 2026 17:14:14 +0000 Subject: [PATCH 2/3] test: close the escape hatches in the agent doc sync guard Review found the guard could stay green while the drift it exists to catch recurred. The mypy notes anchor pinned only the heading, so emptying the subsection passed, and any line could leave the shared set just by carrying the marker, with no second signal anywhere. Pin the tagged lines to an explicit prefix allowlist (widening it is now a visible diff), require the subsection to carry a body, and pin both files in the paths-ignore protected set so a refactor of the string literals cannot silently reopen the docs-only fast path. Read bytes rather than text so a lost trailing newline or a CRLF rewrite is not invisible, and say in the docs that only content meaningless outside Claude Code may stay out of AGENTS.md. --- AGENTS.md | 2 +- CLAUDE.md | 2 +- tests/test_agent_docs_sync.py | 98 ++++++++++++++++++++++++++++------- tests/test_ci_paths_ignore.py | 2 + 4 files changed, 83 insertions(+), 21 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 13b0200a..3e19ac9d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,7 +51,7 @@ Each phase should be a clean, validated checkpoint with all tests passing and ch **CRITICAL**: If agent behavior is unexpected or incorrect: 1. **Update Agent Configuration**: Modify `.claude/agents/red-agent.md` or `.claude/agents/green-agent.md` to refine instructions, constraints, or workflow -2. **Update This File**: Modify `CLAUDE.md` and `AGENTS.md` (`tests/test_agent_docs_sync.py` pins them identical, except for `CLAUDE.md` lines ending in a `claude-only` HTML comment) to clarify orchestration rules or add missing guidance +2. **Update This File**: Modify `CLAUDE.md` and `AGENTS.md` (`tests/test_agent_docs_sync.py` pins them identical; only a line meaningless outside Claude Code may stay out of `AGENTS.md`, by ending in a `claude-only` HTML comment and being listed in `CLAUDE_ONLY_PREFIXES`) to clarify orchestration rules or add missing guidance 3. **Document Changes**: Briefly explain what was learned and why the change improves behavior This enables continuous learning and improvement of the TDD workflow based on actual usage patterns. diff --git a/CLAUDE.md b/CLAUDE.md index 8757b1df..96ac0e98 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,7 @@ Each phase should be a clean, validated checkpoint with all tests passing and ch **CRITICAL**: If agent behavior is unexpected or incorrect: 1. **Update Agent Configuration**: Modify `.claude/agents/red-agent.md` or `.claude/agents/green-agent.md` to refine instructions, constraints, or workflow -2. **Update This File**: Modify `CLAUDE.md` and `AGENTS.md` (`tests/test_agent_docs_sync.py` pins them identical, except for `CLAUDE.md` lines ending in a `claude-only` HTML comment) to clarify orchestration rules or add missing guidance +2. **Update This File**: Modify `CLAUDE.md` and `AGENTS.md` (`tests/test_agent_docs_sync.py` pins them identical; only a line meaningless outside Claude Code may stay out of `AGENTS.md`, by ending in a `claude-only` HTML comment and being listed in `CLAUDE_ONLY_PREFIXES`) to clarify orchestration rules or add missing guidance 3. **Document Changes**: Briefly explain what was learned and why the change improves behavior This enables continuous learning and improvement of the TDD workflow based on actual usage patterns. diff --git a/tests/test_agent_docs_sync.py b/tests/test_agent_docs_sync.py index 3734de89..559c41e3 100644 --- a/tests/test_agent_docs_sync.py +++ b/tests/test_agent_docs_sync.py @@ -1,6 +1,7 @@ """CLAUDE.md and AGENTS.md are one instruction set: every line not tagged claude-only must be identical.""" import difflib +from collections.abc import Callable from pathlib import Path @@ -14,9 +15,20 @@ CLAUDE_ONLY_MARKER = "" SHARED_HEADING = "### mypy iteration notes" +# Every CLAUDE.md line allowed out of the shared instruction set, by prefix: rewording a bullet body +# stays free, tagging a new line does not. Widening this is the visible signal that content stopped +# being shared with AGENTS.md. +CLAUDE_ONLY_PREFIXES: tuple[str, ...] = ("- **Session root**:",) + def _lines(path: Path) -> list[str]: - return path.read_text(encoding="utf-8").splitlines() + assert path.is_file(), ( + f"{path.name} must exist at the project root: CLAUDE.md and AGENTS.md are one instruction set and " + "each agent reads only its own file, so dropping one drops every shared rule for that agent." + ) + # Bytes plus split("\n"), not read_text().splitlines(): universal-newline translation and splitlines() + # each hide drift that `diff CLAUDE.md AGENTS.md` reports (CRLF, U+2028, a lost trailing newline). + return path.read_bytes().decode("utf-8").split("\n") def _is_claude_only(line: str) -> bool: @@ -28,39 +40,87 @@ def _shared_lines() -> list[str]: return [line for line in _lines(CLAUDE_MD) if not _is_claude_only(line)] -def _contains_heading(lines: list[str], heading: str) -> bool: - return any(line.rstrip() == heading for line in lines) +def _numbered(path: Path, keep: Callable[[str], bool]) -> list[str]: + return [f"{number}: {line}" for number, line in enumerate(_lines(path), start=1) if keep(line)] + + +def _section_body(lines: list[str], heading: str) -> list[str]: + """The lines below `heading`, up to the next markdown heading. Empty when `heading` is absent.""" + body: list[str] = [] + seen = False + for line in lines: + if not seen: + seen = line.rstrip() == heading + elif line.startswith("#"): + break + else: + body.append(line) + return body + + +def _diff(shared: list[str], agents: list[str]) -> str: + """Diff of repr-ed lines, so whitespace-only drift is not reported as two identical-looking lines.""" + return "\n".join( + difflib.unified_diff( + [repr(line) for line in shared], + [repr(line) for line in agents], + fromfile="CLAUDE.md (shared part)", + tofile="AGENTS.md", + lineterm="", + ) + ) def test_shared_part_of_claude_md_matches_agents_md() -> None: shared = _shared_lines() agents = _lines(AGENTS_MD) - diff = "\n".join( - difflib.unified_diff(shared, agents, fromfile="CLAUDE.md (shared part)", tofile="AGENTS.md", lineterm="") - ) assert shared == agents, ( - f"CLAUDE.md and AGENTS.md have drifted:\n{diff}\n\n" - "Keep the two files identical. If a line is genuinely only true for Claude Code, end it with the " - f"{CLAUDE_ONLY_MARKER} marker; every other line must appear in AGENTS.md verbatim." + f"CLAUDE.md and AGENTS.md have drifted:\n{_diff(shared, agents)}\n\n" + "AGENTS.md carries no content of its own: whatever belongs there belongs in CLAUDE.md too. Keep the " + "two files identical; only a line that is genuinely true for Claude Code alone may stay out, by " + f"ending it with the {CLAUDE_ONLY_MARKER} marker and listing its prefix in CLAUDE_ONLY_PREFIXES." ) def test_agents_md_has_no_claude_only_marker() -> None: - tagged = [ - f"{number}: {line}" for number, line in enumerate(_lines(AGENTS_MD), start=1) if CLAUDE_ONLY_MARKER in line - ] - found = "\n".join(tagged) + tagged = _numbered(AGENTS_MD, lambda line: CLAUDE_ONLY_MARKER in line) assert not tagged, ( f"AGENTS.md must never contain the {CLAUDE_ONLY_MARKER} marker: the marker flags content that stays " - f"out of AGENTS.md, so a tagged line there contradicts itself. Drop the marker or keep the line in " - f"CLAUDE.md only:\n{found}" + "out of AGENTS.md, so a tagged line there contradicts itself. Drop the marker or keep the line in " + "CLAUDE.md only:\n" + "\n".join(tagged) + ) + + +def test_claude_only_marker_ends_the_line() -> None: + misplaced = _numbered(CLAUDE_MD, lambda line: CLAUDE_ONLY_MARKER in line and not _is_claude_only(line)) + assert not misplaced, ( + f"The {CLAUDE_ONLY_MARKER} marker must be the last thing on the line. On these CLAUDE.md lines it sits " + "somewhere else, where it excludes nothing: move it to the end of the line, or keep the line in " + "AGENTS.md verbatim:\n" + "\n".join(misplaced) + ) + + +def test_claude_only_lines_match_the_allowlist() -> None: + tagged = [line for line in _lines(CLAUDE_MD) if _is_claude_only(line)] + unlisted = [line for line in tagged if not line.startswith(CLAUDE_ONLY_PREFIXES)] + assert not unlisted, ( + "Tagging a line claude-only takes it out of the instruction set every non-Claude agent reads, so the " + "marker alone is not enough. Add its prefix to CLAUDE_ONLY_PREFIXES in this file, so the diff shows " + "content leaving the shared contract:\n" + "\n".join(unlisted) + ) + unused = [prefix for prefix in CLAUDE_ONLY_PREFIXES if not any(line.startswith(prefix) for line in tagged)] + assert not unused, ( + f"CLAUDE_ONLY_PREFIXES entries that tag no CLAUDE.md line: {unused}. Drop the stale entry so the " + "allowlist keeps naming exactly the lines that are not shared with AGENTS.md." ) def test_mypy_iteration_notes_stay_shared() -> None: sources = (("CLAUDE.md (shared part)", _shared_lines()), ("AGENTS.md", _lines(AGENTS_MD))) - missing = [name for name, lines in sources if not _contains_heading(lines, SHARED_HEADING)] - assert not missing, ( - f"The '{SHARED_HEADING}' subsection must be in both files, missing from: {missing}. It describes the " - "tox gate rather than Claude Code, so it must not be tagged claude-only and must not be dropped." + bodies = {name: _section_body(lines, SHARED_HEADING) for name, lines in sources} + empty = [name for name, body in bodies.items() if not any(line.strip() for line in body)] + assert not empty, ( + f"The '{SHARED_HEADING}' subsection must carry its body in both files, missing or empty in: {empty}. " + "It describes the tox gate rather than Claude Code, so it must not be tagged claude-only, dropped, or " + "reduced to a bare heading." ) diff --git a/tests/test_ci_paths_ignore.py b/tests/test_ci_paths_ignore.py index ef9bce09..3b8b8cb8 100644 --- a/tests/test_ci_paths_ignore.py +++ b/tests/test_ci_paths_ignore.py @@ -138,6 +138,8 @@ def test_protected_set_is_not_stale() -> None: protected = _protected_paths() assert "docs/docs/index.md" in protected, "docs/ markdown is executed by tests/test_documentation" assert "CONTRIBUTING.md" in protected, "CONTRIBUTING.md content is asserted by tests/test_project_structure.py" + assert "CLAUDE.md" in protected, "CLAUDE.md content is asserted by tests/test_agent_docs_sync.py" + assert "AGENTS.md" in protected, "AGENTS.md content is asserted by tests/test_agent_docs_sync.py" def test_paths_ignore_declared_for_push_and_pull_request() -> None: From 679b25c8ea0f8f541c90b68e490309c980eceae5 Mon Sep 17 00:00:00 2001 From: Tom Kaltofen Date: Fri, 31 Jul 2026 07:04:14 +0000 Subject: [PATCH 3/3] test: check the two agent files for identity, drop the carve-out The marker, the prefix allowlist and the subsection anchor all existed to keep one bullet out of AGENTS.md, and they cost about a hundred lines of guard to do it. The bullet is not worth that: AGENTS.md already points at .claude/agents/red-agent.md elsewhere, so a line saying when those files auto-load is consistent with what it already carries. Both files are now byte-identical and the guard is one assert. Reading bytes stays, so a CRLF rewrite or a lost trailing newline cannot compare equal, and so does the existence assert. --- AGENTS.md | 3 +- CLAUDE.md | 4 +- tests/test_agent_docs_sync.py | 111 ++++------------------------------ 3 files changed, 16 insertions(+), 102 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3e19ac9d..3bbc499a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,6 +5,7 @@ - **Orchestration Only**: Coordinate Test-Driven Development cycles between specialized agents - **No Code Implementation**: NEVER write implementation code or tests directly - **Agent Delegation**: Use Red Agent for test writing, Green Agent for implementation +- **Session root**: `.claude/agents/red-agent.md` and `.claude/agents/green-agent.md` are only auto-loaded when Claude Code's session is rooted at this repository. If working from a parent or workspace directory, start a session inside the clone (`cd code/mloda && claude`) so the agents are available natively. ## TDD Workflow @@ -51,7 +52,7 @@ Each phase should be a clean, validated checkpoint with all tests passing and ch **CRITICAL**: If agent behavior is unexpected or incorrect: 1. **Update Agent Configuration**: Modify `.claude/agents/red-agent.md` or `.claude/agents/green-agent.md` to refine instructions, constraints, or workflow -2. **Update This File**: Modify `CLAUDE.md` and `AGENTS.md` (`tests/test_agent_docs_sync.py` pins them identical; only a line meaningless outside Claude Code may stay out of `AGENTS.md`, by ending in a `claude-only` HTML comment and being listed in `CLAUDE_ONLY_PREFIXES`) to clarify orchestration rules or add missing guidance +2. **Update This File**: Modify `CLAUDE.md` and `AGENTS.md` (`tests/test_agent_docs_sync.py` pins them identical) to clarify orchestration rules or add missing guidance 3. **Document Changes**: Briefly explain what was learned and why the change improves behavior This enables continuous learning and improvement of the TDD workflow based on actual usage patterns. diff --git a/CLAUDE.md b/CLAUDE.md index 96ac0e98..3bbc499a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,7 @@ - **Orchestration Only**: Coordinate Test-Driven Development cycles between specialized agents - **No Code Implementation**: NEVER write implementation code or tests directly - **Agent Delegation**: Use Red Agent for test writing, Green Agent for implementation -- **Session root**: `.claude/agents/red-agent.md` and `.claude/agents/green-agent.md` are only auto-loaded when Claude Code's session is rooted at this repository. If working from a parent or workspace directory, start a session inside the clone (`cd code/mloda && claude`) so the agents are available natively. +- **Session root**: `.claude/agents/red-agent.md` and `.claude/agents/green-agent.md` are only auto-loaded when Claude Code's session is rooted at this repository. If working from a parent or workspace directory, start a session inside the clone (`cd code/mloda && claude`) so the agents are available natively. ## TDD Workflow @@ -52,7 +52,7 @@ Each phase should be a clean, validated checkpoint with all tests passing and ch **CRITICAL**: If agent behavior is unexpected or incorrect: 1. **Update Agent Configuration**: Modify `.claude/agents/red-agent.md` or `.claude/agents/green-agent.md` to refine instructions, constraints, or workflow -2. **Update This File**: Modify `CLAUDE.md` and `AGENTS.md` (`tests/test_agent_docs_sync.py` pins them identical; only a line meaningless outside Claude Code may stay out of `AGENTS.md`, by ending in a `claude-only` HTML comment and being listed in `CLAUDE_ONLY_PREFIXES`) to clarify orchestration rules or add missing guidance +2. **Update This File**: Modify `CLAUDE.md` and `AGENTS.md` (`tests/test_agent_docs_sync.py` pins them identical) to clarify orchestration rules or add missing guidance 3. **Document Changes**: Briefly explain what was learned and why the change improves behavior This enables continuous learning and improvement of the TDD workflow based on actual usage patterns. diff --git a/tests/test_agent_docs_sync.py b/tests/test_agent_docs_sync.py index 559c41e3..8cd479b3 100644 --- a/tests/test_agent_docs_sync.py +++ b/tests/test_agent_docs_sync.py @@ -1,7 +1,6 @@ -"""CLAUDE.md and AGENTS.md are one instruction set: every line not tagged claude-only must be identical.""" +"""CLAUDE.md and AGENTS.md are one instruction set for different agents, so the two files must be identical.""" import difflib -from collections.abc import Callable from pathlib import Path @@ -12,115 +11,29 @@ CLAUDE_MD = PROJECT_ROOT / "CLAUDE.md" AGENTS_MD = PROJECT_ROOT / "AGENTS.md" -CLAUDE_ONLY_MARKER = "" -SHARED_HEADING = "### mypy iteration notes" - -# Every CLAUDE.md line allowed out of the shared instruction set, by prefix: rewording a bullet body -# stays free, tagging a new line does not. Widening this is the visible signal that content stopped -# being shared with AGENTS.md. -CLAUDE_ONLY_PREFIXES: tuple[str, ...] = ("- **Session root**:",) - def _lines(path: Path) -> list[str]: - assert path.is_file(), ( - f"{path.name} must exist at the project root: CLAUDE.md and AGENTS.md are one instruction set and " - "each agent reads only its own file, so dropping one drops every shared rule for that agent." - ) - # Bytes plus split("\n"), not read_text().splitlines(): universal-newline translation and splitlines() - # each hide drift that `diff CLAUDE.md AGENTS.md` reports (CRLF, U+2028, a lost trailing newline). + assert path.is_file(), f"{path.name} must exist at the project root: each agent reads only its own file." + # Bytes, not read_text(): universal-newline translation would let a CRLF rewrite compare equal. return path.read_bytes().decode("utf-8").split("\n") -def _is_claude_only(line: str) -> bool: - return line.rstrip().endswith(CLAUDE_ONLY_MARKER) - - -def _shared_lines() -> list[str]: - """The part of CLAUDE.md that is not Claude-specific, so it belongs to both agents.""" - return [line for line in _lines(CLAUDE_MD) if not _is_claude_only(line)] - - -def _numbered(path: Path, keep: Callable[[str], bool]) -> list[str]: - return [f"{number}: {line}" for number, line in enumerate(_lines(path), start=1) if keep(line)] - - -def _section_body(lines: list[str], heading: str) -> list[str]: - """The lines below `heading`, up to the next markdown heading. Empty when `heading` is absent.""" - body: list[str] = [] - seen = False - for line in lines: - if not seen: - seen = line.rstrip() == heading - elif line.startswith("#"): - break - else: - body.append(line) - return body - - -def _diff(shared: list[str], agents: list[str]) -> str: - """Diff of repr-ed lines, so whitespace-only drift is not reported as two identical-looking lines.""" +def _diff(claude: list[str], agents: list[str]) -> str: + """Diff of repr-ed lines, so whitespace-only drift is not printed as two identical-looking lines.""" return "\n".join( difflib.unified_diff( - [repr(line) for line in shared], + [repr(line) for line in claude], [repr(line) for line in agents], - fromfile="CLAUDE.md (shared part)", + fromfile="CLAUDE.md", tofile="AGENTS.md", lineterm="", ) ) -def test_shared_part_of_claude_md_matches_agents_md() -> None: - shared = _shared_lines() - agents = _lines(AGENTS_MD) - assert shared == agents, ( - f"CLAUDE.md and AGENTS.md have drifted:\n{_diff(shared, agents)}\n\n" - "AGENTS.md carries no content of its own: whatever belongs there belongs in CLAUDE.md too. Keep the " - "two files identical; only a line that is genuinely true for Claude Code alone may stay out, by " - f"ending it with the {CLAUDE_ONLY_MARKER} marker and listing its prefix in CLAUDE_ONLY_PREFIXES." - ) - - -def test_agents_md_has_no_claude_only_marker() -> None: - tagged = _numbered(AGENTS_MD, lambda line: CLAUDE_ONLY_MARKER in line) - assert not tagged, ( - f"AGENTS.md must never contain the {CLAUDE_ONLY_MARKER} marker: the marker flags content that stays " - "out of AGENTS.md, so a tagged line there contradicts itself. Drop the marker or keep the line in " - "CLAUDE.md only:\n" + "\n".join(tagged) - ) - - -def test_claude_only_marker_ends_the_line() -> None: - misplaced = _numbered(CLAUDE_MD, lambda line: CLAUDE_ONLY_MARKER in line and not _is_claude_only(line)) - assert not misplaced, ( - f"The {CLAUDE_ONLY_MARKER} marker must be the last thing on the line. On these CLAUDE.md lines it sits " - "somewhere else, where it excludes nothing: move it to the end of the line, or keep the line in " - "AGENTS.md verbatim:\n" + "\n".join(misplaced) - ) - - -def test_claude_only_lines_match_the_allowlist() -> None: - tagged = [line for line in _lines(CLAUDE_MD) if _is_claude_only(line)] - unlisted = [line for line in tagged if not line.startswith(CLAUDE_ONLY_PREFIXES)] - assert not unlisted, ( - "Tagging a line claude-only takes it out of the instruction set every non-Claude agent reads, so the " - "marker alone is not enough. Add its prefix to CLAUDE_ONLY_PREFIXES in this file, so the diff shows " - "content leaving the shared contract:\n" + "\n".join(unlisted) - ) - unused = [prefix for prefix in CLAUDE_ONLY_PREFIXES if not any(line.startswith(prefix) for line in tagged)] - assert not unused, ( - f"CLAUDE_ONLY_PREFIXES entries that tag no CLAUDE.md line: {unused}. Drop the stale entry so the " - "allowlist keeps naming exactly the lines that are not shared with AGENTS.md." - ) - - -def test_mypy_iteration_notes_stay_shared() -> None: - sources = (("CLAUDE.md (shared part)", _shared_lines()), ("AGENTS.md", _lines(AGENTS_MD))) - bodies = {name: _section_body(lines, SHARED_HEADING) for name, lines in sources} - empty = [name for name, body in bodies.items() if not any(line.strip() for line in body)] - assert not empty, ( - f"The '{SHARED_HEADING}' subsection must carry its body in both files, missing or empty in: {empty}. " - "It describes the tox gate rather than Claude Code, so it must not be tagged claude-only, dropped, or " - "reduced to a bare heading." +def test_claude_md_and_agents_md_are_identical() -> None: + claude, agents = _lines(CLAUDE_MD), _lines(AGENTS_MD) + assert claude == agents, ( + f"CLAUDE.md and AGENTS.md have drifted:\n{_diff(claude, agents)}\n\n" + "Neither file carries content of its own: whatever belongs in one belongs in the other, verbatim." )