From 11245ed06573667584b09a333aa1a1eaf4cf63f2 Mon Sep 17 00:00:00 2001 From: "Derek Palmer (Creative)" Date: Sat, 30 May 2026 10:08:45 -0400 Subject: [PATCH] =?UTF-8?q?refactor(skill-parity):=20own=20canonical?= =?UTF-8?q?=E2=86=94copies=20body=20rule=20in=20one=20module?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promote a Skill Body Parity module that owns frontmatter stripping and the canonical-vs-distributed-copies body comparison, sourcing paths from the Distribution Inventory. installer (strip/extract_frontmatter), doctor (_check_skill_body_parity), and scripts/validate_skill_copies.py now call it instead of re-implementing the rule. Deletes doctor's importlib+uuid dynamic load of validate_skill_copies.py: since parity now only reads files (no target-repo code executed), the check runs without --run-scripts. Observable behavior is otherwise unchanged; --run-scripts still gates the marketplace script load. Closes #81 --- .coderabbit 2.yaml | 6 + CONTEXT 2.md | 71 ++++++++++ .../skills/forerunner-arch-review/SKILL 2.md | 37 ++++++ scripts/validate_skill_copies.py | 30 ++--- skills/forerunner-arch-review/SKILL 2.md | 37 ++++++ src/codeforerunner/doctor.py | 45 ++----- src/codeforerunner/installer.py | 19 +-- .../prompts/tasks/arch-review 2.md | 121 ++++++++++++++++++ src/codeforerunner/skill_parity.py | 95 ++++++++++++++ tests/test_skill_parity.py | 100 +++++++++++++++ 10 files changed, 492 insertions(+), 69 deletions(-) create mode 100644 .coderabbit 2.yaml create mode 100644 CONTEXT 2.md create mode 100644 plugins/codeforerunner/skills/forerunner-arch-review/SKILL 2.md create mode 100644 skills/forerunner-arch-review/SKILL 2.md create mode 100644 src/codeforerunner/prompts/tasks/arch-review 2.md create mode 100644 src/codeforerunner/skill_parity.py create mode 100644 tests/test_skill_parity.py diff --git a/.coderabbit 2.yaml b/.coderabbit 2.yaml new file mode 100644 index 0000000..323e688 --- /dev/null +++ b/.coderabbit 2.yaml @@ -0,0 +1,6 @@ +reviews: + auto_review: + enabled: false + +chat: + auto_reply: false diff --git a/CONTEXT 2.md b/CONTEXT 2.md new file mode 100644 index 0000000..a3fa2f9 --- /dev/null +++ b/CONTEXT 2.md @@ -0,0 +1,71 @@ +# codeforerunner Context + +codeforerunner is a prompt-first documentation tool for keeping repository knowledge aligned with code. Its language separates prompt-pack tasks from thin runtime wrappers. + +## Language + +**Architecture Review**: +A documentation task that surfaces repo-grounded opportunities to deepen modules and improve maintainability. The command/path name is `arch-review`; the human-facing title is Architecture Review. +_Avoid_: Architecture audit, refactor report + +**Deepening Opportunity**: +A candidate architecture improvement that hides more behavior behind a smaller interface, increasing leverage for callers and locality for maintainers. Architecture Reviews rank Deepening Opportunities but do not implement them. +_Avoid_: Refactor idea, cleanup item + +**Task Registry**: +A catalog of codeforerunner task identity and policy: task name, output role, refresh inclusion, scan exemption, and installable skill surface. It is the source agents and wrappers consult instead of rediscovering task facts from scattered files. +_Avoid_: Task list, command list + +**Prompt Session**: +A run-scoped interaction with codeforerunner prompt tasks that owns task lookup, scan-first enforcement, scan state, and bundle resolution. CLI and MCP code act as adapters to a Prompt Session rather than each reimplementing task ordering rules. +_Avoid_: Prompt runner, execution context + +**Distribution Inventory**: +A catalog of codeforerunner distribution artifacts and install policy: canonical skill, skill copies, per-task skills, marketplace manifest, managed markers, and default install destinations. Installer, doctor, and validators consult the Distribution Inventory instead of repeating packaging paths. +_Avoid_: Package list, artifact list + +**npm Publishing**: +The release path that makes the Node installer package available through npm-compatible registries. codeforerunner treats npmjs publishing, GitHub Packages publishing, and pinned installer shims as related but separately fixable release surfaces. +_Avoid_: JavaScript release, package upload + +**Release Surface Manifest**: +A catalog of release surfaces, versions, registry targets, authentication modes, and validation expectations for codeforerunner releases. npm Publishing uses it to keep npmjs, GitHub Packages, installer shims, and release PR checks aligned. +_Avoid_: Release checklist, publish config + +**Package Contents Inspector**: +A release validation module that checks the packed npm artifact before publish, including required files, executable entrypoints, skill payloads, lock metadata, and shim pins. It treats the package artifact as the test surface. +_Avoid_: npm pack script, file list check + +**Agent Onboarding**: +A task that creates or refreshes the instructions and domain vocabulary a coding agent needs before working in a repo. Agent Onboarding may create or update `CONTEXT.md` with conservative glossary terms inferred from stable repo evidence. +_Avoid_: Init docs, setup docs + +## Example Dialogue + +Dev: "Run arch-review on this repo." + +Domain expert: "That means produce an Architecture Review: ranked Deepening Opportunities, grounded in scan evidence, without changing code or proposing final interfaces yet." + +Dev: "Run init for this repo." + +Domain expert: "That means perform Agent Onboarding: update agent instructions and, when stable terms are evident, maintain the repo glossary in `CONTEXT.md`." + +Dev: "Add a new prompt task." + +Domain expert: "Register it in the Task Registry so wrappers, skills, docs, and refresh policy read the same task identity." + +Dev: "Why does scan-first logic exist in both CLI and MCP?" + +Domain expert: "That rule belongs to a Prompt Session: adapters should ask the session whether a task can run." + +Dev: "Where should canonical skill paths and marketplace paths live?" + +Domain expert: "In the Distribution Inventory, so installer, doctor, and validators share distribution facts." + +Dev: "Fix npm publishing." + +Domain expert: "Treat npm Publishing as three release surfaces: npmjs trusted publish, GitHub Packages publish, and installer shim pins." + +Dev: "How do we keep release workflows aligned?" + +Domain expert: "Use a Release Surface Manifest, then validate packed npm artifacts with a Package Contents Inspector before publish." diff --git a/plugins/codeforerunner/skills/forerunner-arch-review/SKILL 2.md b/plugins/codeforerunner/skills/forerunner-arch-review/SKILL 2.md new file mode 100644 index 0000000..46cd986 --- /dev/null +++ b/plugins/codeforerunner/skills/forerunner-arch-review/SKILL 2.md @@ -0,0 +1,37 @@ +--- +name: forerunner-arch-review +description: Rank architecture improvement candidates. Use when the user wants architecture friction reviewed before planning refactors. +--- + +# forerunner-arch-review + +Produces an Architecture Review: ranked Deepening Opportunities that identify shallow modules, leaky seams, testability friction, and high-leverage refactor candidates. + +Inspired by Matt Pocock's `/improve-codebase-architecture` skill: +https://github.com/mattpocock/skills/tree/main/skills/engineering/improve-codebase-architecture + +## Activate when + +User asks to: review architecture, find deepening opportunities, identify shallow modules, assess refactor candidates, or improve codebase architecture. + +## Collect this context + +- Scan result (run `/forerunner-scan` first) +- Key module/package files from the scan result +- Existing tests for the modules under review +- `CONTEXT.md` or `CONTEXT-MAP.md` if present +- Relevant `docs/adr/*.md` files if present +- Existing architecture docs only when they clarify current design + +## Execute + +Run `forerunner generate --prompt-only arch-review` — outputs the assembled prompt bundle to stdout. Read this output and execute the architecture review task it describes. + +Without CLI, get the prompt from: +- `src/codeforerunner/prompts/tasks/arch-review.md` +- `src/codeforerunner/prompts/system/base.md` +- `src/codeforerunner/prompts/partials/output-rules.md` + +## Output + +`.forerunner/arch-review.md` with a top recommendation and 3-7 ranked Deepening Opportunities. Each candidate includes files/modules, problem, evidence, proposed direction, locality/leverage benefits, testing impact, risk/blast radius, and recommendation strength. Do not implement changes or propose final interfaces. diff --git a/scripts/validate_skill_copies.py b/scripts/validate_skill_copies.py index e6d2f64..f54961a 100755 --- a/scripts/validate_skill_copies.py +++ b/scripts/validate_skill_copies.py @@ -16,25 +16,12 @@ CANONICAL_SKILL_REL, DISTRIBUTED_SKILL_COPIES_REL, ) +from codeforerunner.skill_parity import check_skill_body_parity # noqa: E402 CANONICAL = CANONICAL_SKILL_REL COPIES = list(DISTRIBUTED_SKILL_COPIES_REL) -def strip_frontmatter(text: str) -> str: - text = text.replace("\r\n", "\n").replace("\r", "\n") - lines = text.split("\n") - if lines and lines[0] == "---": - for index in range(1, len(lines)): - if lines[index] == "---": - return "\n".join(lines[index + 1 :]).strip() - return text.strip() - - -def read_body(path: Path) -> str: - return strip_frontmatter((ROOT / path).read_text(encoding="utf-8")) - - def print_checked_files(*, stream=sys.stdout) -> None: print(f"canonical: {CANONICAL}", file=stream) for path in COPIES: @@ -42,17 +29,16 @@ def print_checked_files(*, stream=sys.stdout) -> None: def main() -> int: - canonical_body = read_body(CANONICAL) - failures: list[Path] = [] - - for copy in COPIES: - if read_body(copy) != canonical_body: - failures.append(copy) + result = check_skill_body_parity(ROOT) - if failures: + if not result.ok: print("V10 violation: distributed skill body drift detected.", file=sys.stderr) print_checked_files(stream=sys.stderr) - for path in failures: + if result.missing_canonical: + print(f"missing: {CANONICAL}", file=sys.stderr) + for path in result.missing_copies: + print(f"missing: {path}", file=sys.stderr) + for path in result.drifted_copies: print(f"mismatch: {path}", file=sys.stderr) return 1 diff --git a/skills/forerunner-arch-review/SKILL 2.md b/skills/forerunner-arch-review/SKILL 2.md new file mode 100644 index 0000000..46cd986 --- /dev/null +++ b/skills/forerunner-arch-review/SKILL 2.md @@ -0,0 +1,37 @@ +--- +name: forerunner-arch-review +description: Rank architecture improvement candidates. Use when the user wants architecture friction reviewed before planning refactors. +--- + +# forerunner-arch-review + +Produces an Architecture Review: ranked Deepening Opportunities that identify shallow modules, leaky seams, testability friction, and high-leverage refactor candidates. + +Inspired by Matt Pocock's `/improve-codebase-architecture` skill: +https://github.com/mattpocock/skills/tree/main/skills/engineering/improve-codebase-architecture + +## Activate when + +User asks to: review architecture, find deepening opportunities, identify shallow modules, assess refactor candidates, or improve codebase architecture. + +## Collect this context + +- Scan result (run `/forerunner-scan` first) +- Key module/package files from the scan result +- Existing tests for the modules under review +- `CONTEXT.md` or `CONTEXT-MAP.md` if present +- Relevant `docs/adr/*.md` files if present +- Existing architecture docs only when they clarify current design + +## Execute + +Run `forerunner generate --prompt-only arch-review` — outputs the assembled prompt bundle to stdout. Read this output and execute the architecture review task it describes. + +Without CLI, get the prompt from: +- `src/codeforerunner/prompts/tasks/arch-review.md` +- `src/codeforerunner/prompts/system/base.md` +- `src/codeforerunner/prompts/partials/output-rules.md` + +## Output + +`.forerunner/arch-review.md` with a top recommendation and 3-7 ranked Deepening Opportunities. Each candidate includes files/modules, problem, evidence, proposed direction, locality/leverage benefits, testing impact, risk/blast radius, and recommendation strength. Do not implement changes or propose final interfaces. diff --git a/src/codeforerunner/doctor.py b/src/codeforerunner/doctor.py index 9aeafd1..728fc09 100644 --- a/src/codeforerunner/doctor.py +++ b/src/codeforerunner/doctor.py @@ -12,6 +12,7 @@ from typing import Callable from codeforerunner import distribution as _dist +from codeforerunner import skill_parity as _parity from codeforerunner.config import CONFIG_FILENAME, ConfigError, load_from_repo # Distribution artifact identity and markers come from the Distribution @@ -62,25 +63,14 @@ def _load_script_module(repo: Path, relpath: str, module_name: str): def _check_skill_body_parity(repo: Path, run_scripts: bool = False) -> list[Finding]: - """Verify that all distributed skill copies match the canonical body.""" - if not run_scripts: - return [ - Finding( - "warn", - "skill-body-parity", - "skipping script validation (pass --run-scripts to allow executing repo scripts)", - ) - ] - try: - skill_mod = _load_script_module( - repo, "scripts/validate_skill_copies.py", "_forerunner_doctor_skill_copies" - ) - strip_frontmatter: Callable[[str], str] = skill_mod.strip_frontmatter - except Exception as exc: # pragma: no cover - defensive - return [Finding("error", "skill-body-parity", f"loader failure: {exc}")] - - canonical_path = repo / CANONICAL_REL - if not canonical_path.is_file(): + """Verify that all distributed skill copies match the canonical body. + + Body parity is owned by the Skill Body Parity module, which only reads + files (no target-repo code is executed), so this runs regardless of + ``run_scripts`` — that flag still gates checks that load repo scripts. + """ + result = _parity.check_skill_body_parity(repo) + if result.missing_canonical: return [ Finding( "error", @@ -88,21 +78,12 @@ def _check_skill_body_parity(repo: Path, run_scripts: bool = False) -> list[Find f"canonical skill missing: {CANONICAL_REL}", ) ] - canonical_body = strip_frontmatter(canonical_path.read_text(encoding="utf-8")) findings: list[Finding] = [] - for rel in SKILL_COPIES_REL: - p = repo / rel - if not p.is_file(): - findings.append( - Finding("error", "skill-body-parity", f"copy missing: {rel}") - ) - continue - body = strip_frontmatter(p.read_text(encoding="utf-8")) - if body != canonical_body: - findings.append( - Finding("error", "skill-body-parity", f"body drift in {rel}") - ) + for rel in result.missing_copies: + findings.append(Finding("error", "skill-body-parity", f"copy missing: {rel}")) + for rel in result.drifted_copies: + findings.append(Finding("error", "skill-body-parity", f"body drift in {rel}")) if not findings: findings.append( Finding( diff --git a/src/codeforerunner/installer.py b/src/codeforerunner/installer.py index f7a764d..8dc1eec 100644 --- a/src/codeforerunner/installer.py +++ b/src/codeforerunner/installer.py @@ -11,6 +11,7 @@ from typing import Iterable, Literal from codeforerunner import distribution as _dist +from codeforerunner import skill_parity as _parity from codeforerunner.tasks import installable_slugs as _installable_slugs # Distribution artifact identity and markers come from the Distribution @@ -129,25 +130,13 @@ def resolve_marketplace_target(agent: str, override: Path | None) -> Target: def strip_frontmatter(text: str) -> str: - """Body extraction matching scripts/validate_skill_copies.py.""" - text = text.replace("\r\n", "\n").replace("\r", "\n") - lines = text.split("\n") - if lines and lines[0] == "---": - for i in range(1, len(lines)): - if lines[i] == "---": - return "\n".join(lines[i + 1 :]).strip() - return text.strip() + """Body extraction; owned by the Skill Body Parity module.""" + return _parity.body_of(text) def extract_frontmatter(text: str) -> str: """Return frontmatter block (incl. fences) or '' if none.""" - text = text.replace("\r\n", "\n").replace("\r", "\n") - lines = text.split("\n") - if lines and lines[0] == "---": - for i in range(1, len(lines)): - if lines[i] == "---": - return "\n".join(lines[: i + 1]) + "\n" - return "" + return _parity.split_frontmatter(text)[0] def _hash(s: str) -> str: diff --git a/src/codeforerunner/prompts/tasks/arch-review 2.md b/src/codeforerunner/prompts/tasks/arch-review 2.md new file mode 100644 index 0000000..c48595e --- /dev/null +++ b/src/codeforerunner/prompts/tasks/arch-review 2.md @@ -0,0 +1,121 @@ +# Task: Architecture Review + +Inspired by Matt Pocock's `/improve-codebase-architecture` skill: +https://github.com/mattpocock/skills/tree/main/skills/engineering/improve-codebase-architecture + +Ranks repo-grounded Deepening Opportunities: architecture improvements that hide more behavior behind smaller interfaces, increasing leverage for callers and locality for maintainers. +Requires scan result as input. + +## Input + +- Scan result from `prompts/tasks/scan.md` +- File tree +- Key module/package files relevant to the scan result +- Existing tests for the modules under review +- `CONTEXT.md` or `CONTEXT-MAP.md` if present +- Relevant `docs/adr/*.md` files if present +- Existing architecture docs only when they clarify current design + +## Review Focus + +Look for architecture friction, not documentation drift: + +1. Modules that are shallow: interface complexity nearly matches implementation complexity +2. Concepts that require bouncing across many files to understand +3. Seams that leak implementation details into callers +4. Pure helpers extracted for testability while real behavior remains hard to test +5. Adapter seams with more abstraction than real variation +6. Missing or weak tests caused by poor locality + +Apply the deletion test to suspected shallow modules: if deleting the module makes complexity disappear, it was probably a pass-through; if complexity reappears across many callers, it was earning its keep. + +## Vocabulary + +Use these architecture terms consistently: + +- Module +- Interface +- Implementation +- Deep +- Shallow +- Seam +- Adapter +- Leverage +- Locality +- Deepening Opportunity +- Deletion test + +Use repo vocabulary from `CONTEXT.md` when present. If the repo lacks `CONTEXT.md` or the vocabulary is incomplete, infer temporary terms from evidence and list them under `## Suggested Glossary Additions`; do not write or rewrite `CONTEXT.md`. + +## Candidate Format + +For each Deepening Opportunity, include: + +- **Files/modules**: concrete files or modules involved +- **Problem**: architecture friction observed +- **Evidence**: repo evidence supporting the finding +- **Proposed direction**: plain-English direction only +- **Benefits**: locality and leverage improvements +- **Testing impact**: what becomes easier to verify, without final test code +- **Risk / blast radius**: likely scope and migration risk +- **Recommendation strength**: `Strong`, `Worth exploring`, or `Speculative` + +## Rules + +- Claims must derive from provided files. If evidence is absent, omit or document in `## Gaps`. +- Do not report stale README/API/diagram/doc drift; use `check`, `review`, `diagrams`, or `flows` for that. +- Do not propose final function signatures, dataclass fields, schema shapes, or file-by-file implementation plans. +- Do not mutate `CONTEXT.md` or create ADRs. +- Do not imply Matt Pocock endorses codeforerunner. +- Keep the highest-signal 3-7 candidates. Fewer is acceptable when evidence is thin. + +## Output Format + + + +# Architecture Review + +> Inspired by Matt Pocock's `/improve-codebase-architecture` skill: +> https://github.com/mattpocock/skills/tree/main/skills/engineering/improve-codebase-architecture + +## Summary + +One paragraph describing the main architecture pressure observed. + +## Top Recommendation + +Name the highest-value Deepening Opportunity and why it should be first. + +## Deepening Opportunities + +### 1. [Candidate Name] + +**Recommendation strength:** Strong | Worth exploring | Speculative + +**Files/modules:** ... + +**Problem:** ... + +**Evidence:** ... + +**Proposed direction:** ... + +**Benefits:** ... + +**Testing impact:** ... + +**Risk / blast radius:** ... + +## Suggested Glossary Additions + +Only include if `CONTEXT.md` is missing or incomplete. Suggest terms; do not write them. + +## Not Yet Decided + +- Final interface shapes +- Migration sequence +- Exact tests + +## Gaps + +List missing evidence that could materially change the review. diff --git a/src/codeforerunner/skill_parity.py b/src/codeforerunner/skill_parity.py new file mode 100644 index 0000000..719b87d --- /dev/null +++ b/src/codeforerunner/skill_parity.py @@ -0,0 +1,95 @@ +"""Skill Body Parity — single owner of the canonical-vs-copies body rule. + +The codeforerunner skill body is authored once (the canonical skill) and +shipped as several distributed copies. V10 requires every copy's *body* +(frontmatter stripped) to equal the canonical body. That rule, and the +frontmatter parsing it depends on, previously lived in three places — the +installer, the doctor health check, and a standalone validation script. This +module is the single owner: the installer's install planning, the doctor +check, and ``scripts/validate_skill_copies.py`` all consult it. + +Artifact paths come from the Distribution Inventory; nothing here executes +target-repo code — it only reads files — so callers can check parity without +the doctor's ``--run-scripts`` opt-in. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from codeforerunner import distribution as _dist + + +def split_frontmatter(text: str) -> tuple[str, str]: + """Split *text* into (frontmatter_block, body). + + ``frontmatter_block`` includes the ``---`` fences and a trailing newline, + or ``""`` when there is no frontmatter. ``body`` is the remainder, trimmed. + """ + text = text.replace("\r\n", "\n").replace("\r", "\n") + lines = text.split("\n") + if lines and lines[0] == "---": + for i in range(1, len(lines)): + if lines[i] == "---": + block = "\n".join(lines[: i + 1]) + "\n" + body = "\n".join(lines[i + 1 :]).strip() + return block, body + return "", text.strip() + + +def body_of(text: str) -> str: + """Return the body of *text* with any frontmatter removed and trimmed.""" + return split_frontmatter(text)[1] + + +@dataclass(frozen=True) +class ParityResult: + """Outcome of a canonical-vs-copies body comparison under a repo root.""" + + ok: bool + missing_canonical: bool + missing_copies: tuple[Path, ...] + drifted_copies: tuple[Path, ...] + checked: int + + +def check_skill_body_parity(repo_root: Path) -> ParityResult: + """Compare every distributed skill copy's body to the canonical body. + + Reads the canonical skill and its distributed copies (paths from the + Distribution Inventory) under ``repo_root`` and reports which copies are + missing or have drifted bodies. + """ + canonical_path = repo_root / _dist.CANONICAL_SKILL_REL + if not canonical_path.is_file(): + return ParityResult( + ok=False, + missing_canonical=True, + missing_copies=(), + drifted_copies=(), + checked=0, + ) + + canonical_body = body_of(canonical_path.read_text(encoding="utf-8")) + + missing: list[Path] = [] + drifted: list[Path] = [] + checked = 0 + for rel in _dist.DISTRIBUTED_SKILL_COPIES_REL: + p = repo_root / rel + if not p.is_file(): + missing.append(rel) + continue + checked += 1 + if body_of(p.read_text(encoding="utf-8")) != canonical_body: + drifted.append(rel) + + ok = not missing and not drifted + return ParityResult( + ok=ok, + missing_canonical=False, + missing_copies=tuple(missing), + drifted_copies=tuple(drifted), + checked=checked, + ) diff --git a/tests/test_skill_parity.py b/tests/test_skill_parity.py new file mode 100644 index 0000000..7d9614a --- /dev/null +++ b/tests/test_skill_parity.py @@ -0,0 +1,100 @@ +"""Behavior tests for the Skill Body Parity module (skill_parity.py). + +Owns frontmatter stripping and the canonical-vs-distributed-copies body +comparison, sourcing artifact paths from the Distribution Inventory. +""" + +from __future__ import annotations + +from pathlib import Path + +from codeforerunner import distribution as dist +from codeforerunner import skill_parity as sp + + +# --- frontmatter ----------------------------------------------------------- + + +def test_body_of_strips_frontmatter_and_trims(): + text = "---\nname: x\nversion: 1\n---\n\nthe body\n" + assert sp.body_of(text) == "the body" + + +def test_body_of_without_frontmatter_returns_trimmed_text(): + assert sp.body_of(" plain text\n") == "plain text" + + +def test_body_of_normalizes_crlf(): + assert sp.body_of("---\r\na: 1\r\n---\r\nbody\r\n") == "body" + + +def test_split_frontmatter_returns_block_and_body(): + block, body = sp.split_frontmatter("---\nname: x\n---\nbody\n") + assert block == "---\nname: x\n---\n" + assert body == "body" + + +def test_split_frontmatter_no_frontmatter_has_empty_block(): + block, body = sp.split_frontmatter("just body") + assert block == "" + assert body == "just body" + + +# --- parity ---------------------------------------------------------------- + + +def _write_checkout(root: Path, *, canonical: str, copies: dict[Path, str]) -> None: + cpath = root / dist.CANONICAL_SKILL_REL + cpath.parent.mkdir(parents=True, exist_ok=True) + cpath.write_text(canonical, encoding="utf-8") + for rel, text in copies.items(): + p = root / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(text, encoding="utf-8") + + +def test_parity_ok_when_bodies_match(tmp_path): + body = "---\na: 1\n---\nshared body\n" + copies = {rel: body for rel in dist.DISTRIBUTED_SKILL_COPIES_REL} + _write_checkout(tmp_path, canonical=body, copies=copies) + + result = sp.check_skill_body_parity(tmp_path) + assert result.ok + assert result.checked == len(dist.DISTRIBUTED_SKILL_COPIES_REL) + assert not result.drifted_copies and not result.missing_copies + assert not result.missing_canonical + + +def test_parity_detects_body_drift(tmp_path): + canonical = "---\na: 1\n---\ncanonical body\n" + rels = list(dist.DISTRIBUTED_SKILL_COPIES_REL) + copies = {rels[0]: canonical, rels[1]: "---\nb: 2\n---\nDRIFTED body\n"} + _write_checkout(tmp_path, canonical=canonical, copies=copies) + + result = sp.check_skill_body_parity(tmp_path) + assert not result.ok + assert rels[1] in result.drifted_copies + assert rels[0] not in result.drifted_copies + + +def test_parity_reports_missing_copy(tmp_path): + canonical = "body\n" + rels = list(dist.DISTRIBUTED_SKILL_COPIES_REL) + _write_checkout(tmp_path, canonical=canonical, copies={rels[0]: canonical}) # rels[1] absent + + result = sp.check_skill_body_parity(tmp_path) + assert not result.ok + assert rels[1] in result.missing_copies + + +def test_parity_reports_missing_canonical(tmp_path): + copies = {rel: "body\n" for rel in dist.DISTRIBUTED_SKILL_COPIES_REL} + for rel, text in copies.items(): + p = tmp_path / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(text, encoding="utf-8") + # canonical not written + + result = sp.check_skill_body_parity(tmp_path) + assert not result.ok + assert result.missing_canonical