Newer
Older
programming-template / .claude / tests / test_attribution_delegation.py
"""委譲先サブエージェントへのコミット・PR 帰属行の受け渡しのテスト(Issue #54).

実行(リポジトリルートで):
    python -B -m unittest discover -s .claude/tests -v

仕様の正本(Issue #54 の要件):
    1. ops-runner は,呼び出し元から Co-Authored-By 行・PR 帰属行が渡されたら,自身の帰属指定より
       優先して一字一句そのまま使い,渡されなければ自身の帰属指定に従う
    2. `/commit` の司令塔は,自分に与えられたコミット帰属行と PR 帰属行を ops-runner への
       委譲プロンプトに必ず含める
    3. `/auto-refactor`・`/auto-audit` の項目オーケストレータにもコミット帰属行を渡し,
       同じ優先ルールに従う
    4. 具体的なモデル名をハードコードしない

対象は実行コードではなく指示書(Markdown)であるため,各テストは「仕様が要求する性質が,
文書の構造・内容として満たされているか」を機械的に検証する.外部依存なし(標準ライブラリのみ).

`.claude/tests/` はテンプレート自身のメタテスト置き場であり,`/sync-template` の同期対象外
(`SKIP_FILES`)として派生プロジェクトには配布しない.
"""

from __future__ import annotations

import os
import re
import unittest

TESTS_DIR = os.path.dirname(os.path.abspath(__file__))
REPO_ROOT = os.path.dirname(os.path.dirname(TESTS_DIR))

OPS_RUNNER = os.path.join(REPO_ROOT, ".claude", "agents", "ops-runner.md")
COMMIT_SKILL = os.path.join(REPO_ROOT, ".claude", "skills", "commit", "SKILL.md")
COMMIT_REFERENCE = os.path.join(REPO_ROOT, ".claude", "skills", "commit", "reference.md")
AUTO_REFACTOR = os.path.join(REPO_ROOT, ".claude", "skills", "auto-refactor", "SKILL.md")
AUTO_AUDIT = os.path.join(REPO_ROOT, ".claude", "skills", "auto-audit", "SKILL.md")

ALL_FILES = (OPS_RUNNER, COMMIT_SKILL, COMMIT_REFERENCE, AUTO_REFACTOR, AUTO_AUDIT)
AUTO_LOOPS = (AUTO_REFACTOR, AUTO_AUDIT)

HEADING_RE = re.compile(r"^(#{1,6})\s+(.*\S)\s*$")
FRONTMATTER_RE = re.compile(r"\A---\n.*?\n---\n", re.DOTALL)

# 具体的なモデル名(ファミリー名・帰属行の実例).frontmatter の `model:` 指定は帰属とは無関係なので除外する
MODEL_NAME_RE = re.compile(r"\b(Opus|Sonnet|Haiku)\b|Claude\s+[A-Z][a-z]+\s+\d", re.IGNORECASE)
PRECEDENCE_RE = re.compile(r"自身の帰属指定.{0,10}(より)?優先")
VERBATIM = "一字一句そのまま"


def read(path: str) -> str:
    with open(path, "r", encoding="utf-8") as f:
        return f.read()


def body_without_frontmatter(text: str) -> str:
    return FRONTMATTER_RE.sub("", text, count=1)


def section(text: str, title_part: str) -> str:
    """見出しに `title_part` を含む節の本文(下位の見出しを含む)を返す."""
    lines = text.splitlines()
    start = None
    level = 0
    for i, line in enumerate(lines):
        m = HEADING_RE.match(line)
        if not m:
            continue
        if start is None:
            if title_part in m.group(2):
                start, level = i + 1, len(m.group(1))
            continue
        if len(m.group(1)) <= level:
            return "\n".join(lines[start:i])
    if start is None:
        raise AssertionError(f"節が見つからない: {title_part}")
    return "\n".join(lines[start:])


class TestOpsRunnerAttributionPrecedence(unittest.TestCase):
    """仕様 1: ops-runner は渡された帰属行を優先して一字一句使い,無ければ自身の指定に従う."""

    @classmethod
    def setUpClass(cls):
        cls.body = section(read(OPS_RUNNER), "帰属行")

    def test_covers_commit_and_pr(self):
        self.assertIn("Co-Authored-By:", self.body)
        self.assertIn("PR", self.body)

    def test_passed_lines_take_precedence_verbatim(self):
        self.assertRegex(self.body, PRECEDENCE_RE)
        self.assertIn(VERBATIM, self.body)

    def test_does_not_append_own_attribution(self):
        # 渡された行に自身の帰属行を併記しない(二重の Co-Authored-By を防ぐ)
        self.assertRegex(self.body, r"併記(を)?しない")

    def test_falls_back_to_own_attribution_when_not_passed(self):
        self.assertRegex(self.body, r"渡されなかった.*自身の帰属指定に従う")

    def test_partial_fallback_per_line(self):
        # 一方だけ渡された場合,渡されなかった側だけフォールバックすること
        self.assertRegex(self.body, r"一方のみ|片方")


class TestCommitSkillPassesAttribution(unittest.TestCase):
    """仕様 2: `/commit` 司令塔は委譲プロンプトにコミット・PR 帰属行を必ず含める."""

    @classmethod
    def setUpClass(cls):
        cls.step2 = section(read(COMMIT_SKILL), "ステップ 2")

    def test_delegation_prompt_requires_attribution(self):
        items = [l for l in self.step2.splitlines() if l.startswith("- ") and "帰属行" in l]
        self.assertEqual(len(items), 1, f"委譲プロンプト項目に帰属行が無い/重複: {items}")
        item = items[0]
        self.assertIn("必須", item)
        self.assertIn("Co-Authored-By:", item)
        self.assertIn("PR 帰属行", item)
        self.assertIn(VERBATIM, item)

    def test_points_to_ops_runner_rule(self):
        self.assertIn("コミット・PR の帰属行", self.step2)


class TestCommitReferenceHandlesAttribution(unittest.TestCase):
    """仕様 1(手順書側): ops-runner が読む reference.md でもコミット・PR 双方で同じ優先ルールが書かれている."""

    @classmethod
    def setUpClass(cls):
        cls.text = read(COMMIT_REFERENCE)

    def test_commit_message_step_uses_passed_line(self):
        body = section(self.text, "ステップ 3: コミットメッセージの生成")
        self.assertIn("Co-Authored-By:", body)
        self.assertRegex(body, PRECEDENCE_RE)
        self.assertIn(VERBATIM, body)
        self.assertRegex(body, r"渡されなかった.*自身の帰属指定に従う")

    def test_pr_creation_uses_passed_line(self):
        body = section(self.text, "プルリクエスト (PR) の作成")
        self.assertIn("PR 帰属行", body)
        self.assertRegex(body, PRECEDENCE_RE)
        self.assertIn(VERBATIM, body)
        self.assertRegex(body, r"渡されなければ自身の帰属指定に従う")


class TestAutoLoopsPassAttribution(unittest.TestCase):
    """仕様 3: `/auto-refactor`・`/auto-audit` は項目オーケストレータへコミット帰属行を渡し,同じ優先ルールに従わせる."""

    def test_m3_handoff_includes_commit_attribution(self):
        for path in AUTO_LOOPS:
            with self.subTest(file=os.path.relpath(path, REPO_ROOT)):
                m3 = section(read(path), "ステップ M3")
                items = [l for l in m3.splitlines() if l.startswith("- ") and "帰属行" in l]
                self.assertEqual(len(items), 1, f"M3 の受け渡しに帰属行が無い/重複: {items}")
                self.assertIn("Co-Authored-By:", items[0])
                self.assertIn(VERBATIM, items[0])

    def test_charter_has_precedence_rule(self):
        for path in AUTO_LOOPS:
            with self.subTest(file=os.path.relpath(path, REPO_ROOT)):
                charter = section(read(path), "項目オーケストレータの憲章")
                rule = section(charter, "コミットの帰属行")
                self.assertIn("Co-Authored-By:", rule)
                self.assertRegex(rule, PRECEDENCE_RE)
                self.assertIn(VERBATIM, rule)
                self.assertRegex(rule, r"渡されなかった.*自身の帰属指定に従う")

    def test_charter_rule_is_identical_in_both_loops(self):
        # 2 つのループで同じルールが食い違っていないこと
        rules = [section(section(read(p), "項目オーケストレータの憲章"), "コミットの帰属行").strip()
                 for p in AUTO_LOOPS]
        self.assertEqual(rules[0], rules[1])


class TestNoHardcodedModelNames(unittest.TestCase):
    """仕様 4: 帰属行の指示に具体的なモデル名をハードコードしない."""

    def test_no_model_names_in_instruction_bodies(self):
        for path in ALL_FILES:
            with self.subTest(file=os.path.relpath(path, REPO_ROOT)):
                body = body_without_frontmatter(read(path))
                hits = [l for l in body.splitlines() if MODEL_NAME_RE.search(l)]
                self.assertEqual(hits, [], f"具体的なモデル名が含まれる: {hits}")

    def test_no_literal_coauthor_line_with_identity(self):
        # `Co-Authored-By: <名前> <メール>` 形式の具体的な帰属行の実例を書かない
        for path in ALL_FILES:
            with self.subTest(file=os.path.relpath(path, REPO_ROOT)):
                self.assertNotRegex(read(path), r"Co-Authored-By:\s*[^`\s][^`\n]*<[^>]+@[^>]+>")


if __name__ == "__main__":
    unittest.main()