This is an automated email from the ASF dual-hosted git repository.

potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow-steward.git


The following commit(s) were added to refs/heads/main by this push:
     new c300fa9  feat(skill-validator): principle + trigger-preservation SOFT 
checks (#137)
c300fa9 is described below

commit c300fa9a6d681a129c84ddee65b7207f2e4769ae
Author: Yeonguk Choo <[email protected]>
AuthorDate: Wed May 13 01:31:50 2026 +0900

    feat(skill-validator): principle + trigger-preservation SOFT checks (#137)
---
 tools/skill-validator/README.md                    |  28 ++
 .../src/skill_validator/__init__.py                | 307 ++++++++++++++++++++-
 tools/skill-validator/tests/test_validator.py      | 199 ++++++++++++-
 3 files changed, 524 insertions(+), 10 deletions(-)

diff --git a/tools/skill-validator/README.md b/tools/skill-validator/README.md
index ff696dc..42c3677 100644
--- a/tools/skill-validator/README.md
+++ b/tools/skill-validator/README.md
@@ -4,6 +4,8 @@
 
 - [skill-validator](#skill-validator)
   - [What it checks](#what-it-checks)
+    - [Hard rules (failure)](#hard-rules-failure)
+    - [SOFT advisories (warning, do not 
fail)](#soft-advisories-warning-do-not-fail)
   - [Run](#run)
   - [Design notes](#design-notes)
 
@@ -19,6 +21,8 @@ link integrity, and placeholder conventions.
 
 ## What it checks
 
+### Hard rules (failure)
+
 1. **YAML frontmatter** — Every `SKILL.md` must have a valid
    frontmatter block with required keys (`name`, `description`,
    `license`).
@@ -27,6 +31,24 @@ link integrity, and placeholder conventions.
 3. **Placeholder convention** — Skill docs must use `<PROJECT>`,
    `<upstream>`, and `<tracker>` instead of hardcoded project names.
 
+### SOFT advisories (warning, do not fail)
+
+4. **Principle compliance** — Heuristic warnings when frontmatter
+   carries content the LLM router doesn't need:
+   - **Action-inventory** in `description` (≥ 5 commas in one sentence)
+   - **Distinct-from-sibling-skill** clauses (`Unlike`, `Distinct from`, 
`Counterpart to`, `rather than`)
+   - **Chain-handoff** narrative (`Hands off to`, `ready for X to take over`)
+   - **Parenthetical rationale** (parens containing `typically`, `implies`, 
`because`, `since`, `is required first`, `needs to`, `requires`)
+   - **Criteria-source path** (`process step N`, `Step Na`, ``docs/X.md``, 
`documented in …`)
+5. **Trigger-phrase preservation** — Compares quoted phrases in
+   `when_to_use` against a base ref (default `origin/main`) and
+   warns when any phrase has been dropped. Silently skipped when
+   git or the base ref is unavailable. Override via
+   `SKILL_VALIDATOR_BASE_REF`.
+
+SOFT advisories are surfaced as warnings on stderr without failing
+the run. The reviewer has the final say on borderline cases.
+
 ## Run
 
 From the repo root:
@@ -41,6 +63,12 @@ Or install and run as CLI:
 uv run --project tools/skill-validator --group dev skill-validate
 ```
 
+CLI flags:
+
+- `--strict` — promote SOFT categories to hard failures.
+- `--skip-categories principle_compliance,trigger_preservation` —
+  skip given violation categories entirely (silent).
+
 ## Design notes
 
 - **stdlib-only** — no external dependencies. The frontmatter parser
diff --git a/tools/skill-validator/src/skill_validator/__init__.py 
b/tools/skill-validator/src/skill_validator/__init__.py
index 40b50ba..c7e6e4f 100644
--- a/tools/skill-validator/src/skill_validator/__init__.py
+++ b/tools/skill-validator/src/skill_validator/__init__.py
@@ -17,7 +17,7 @@
 
 """Validate framework skill definitions.
 
-This module validates three aspects of every skill under
+This module validates five aspects of every skill under
 .claude/skills/:
 
 1. YAML frontmatter — every SKILL.md must have a valid frontmatter
@@ -26,6 +26,16 @@ This module validates three aspects of every skill under
    files and docs must point to existing files and anchors.
 3. Placeholder convention — skill docs must use <PROJECT>,
    <upstream>, and <tracker> instead of hardcoded project names.
+4. Principle compliance (SOFT) — frontmatter should not carry
+   rationale parens, sub-step inventories, distinct-from clauses,
+   chain-handoff narratives, or criteria-source paths that the LLM
+   router does not need.
+5. Trigger-phrase preservation (SOFT) — quoted phrases inside
+   when_to_use must not be dropped vs the base ref (default
+   origin/main), preventing routing-recall regressions.
+
+SOFT categories surface as advisory warnings (stderr) without
+failing the run unless ``--strict`` is passed.
 
 Run from repo root:
     uv run --project tools/skill-validator --group dev pytest
@@ -114,6 +124,33 @@ YAML_BLOCK_SCALAR_HEADERS = {"|", ">", "|-", "|+", ">-", 
">+"}
 # https://code.claude.com/docs/en/skills#frontmatter-reference
 MAX_METADATA_CHARS = 1536
 
+PRINCIPLE_CATEGORY = "principle_compliance"
+TRIGGER_PRESERVATION_CATEGORY = "trigger_preservation"
+SOFT_CATEGORIES: frozenset[str] = frozenset(
+    {PRINCIPLE_CATEGORY, TRIGGER_PRESERVATION_CATEGORY},
+)
+
+ACTION_INVENTORY_COMMA_THRESHOLD = 5
+
+DISTINCT_FROM_RE = re.compile(
+    r"\b(?:Unlike|Distinct from|Counterpart to|rather than)\b",
+    re.IGNORECASE,
+)
+CHAIN_HANDOFF_RE = re.compile(
+    r"(?:Finishes? by handing off|Hands? off to|ready for [`\w-]+ to take 
over)",
+    re.IGNORECASE,
+)
+PARENTHETICAL_RATIONALE_RE = re.compile(
+    r"\([^)]*?(?:typically|implies|because|since|is required first|needs 
to|requires)[^)]*\)",
+    re.IGNORECASE,
+)
+CRITERIA_SOURCE_RE = re.compile(
+    r"(?:process step \d+|\bStep \d+[a-z]?\b|`docs/[^`]+\.md`|documented in 
`[^`]+`)",
+    re.IGNORECASE,
+)
+
+QUOTED_PHRASE_RE = re.compile(r'"([^"]+)"')
+
 # Markdown link pattern: [text](url)
 LINK_PATTERN = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
 
@@ -134,10 +171,17 @@ ELLIPSIS_URLS = {"...", "…"}
 class Violation:
     """A single validation violation."""
 
-    def __init__(self, path: Path, line: int | None, message: str) -> None:
+    def __init__(
+        self,
+        path: Path,
+        line: int | None,
+        message: str,
+        category: str = "general",
+    ) -> None:
         self.path = path
         self.line = line
         self.message = message
+        self.category = category
 
     def __str__(self) -> str:
         if self.line is not None:
@@ -429,6 +473,169 @@ def validate_placeholders(path: Path, text: str) -> 
Iterable[Violation]:
                 )
 
 
+# ---------------------------------------------------------------------------
+# Principle-compliance SOFT warnings
+# ---------------------------------------------------------------------------
+
+
+def _collapse_ws(text: str) -> str:
+    """Collapse all internal whitespace runs (incl. newlines) to single 
spaces."""
+    return " ".join(text.split())
+
+
+def _split_sentences(text: str) -> list[str]:
+    """Split text into sentences on period + whitespace boundaries."""
+    return [s.strip() for s in re.split(r"\.\s+|\.\n+|\.$", text) if s.strip()]
+
+
+def _check_action_inventory(text: str) -> str | None:
+    """Return the first sentence in *text* with >= threshold commas, else 
None."""
+    for sentence in _split_sentences(text):
+        if sentence.count(",") >= ACTION_INVENTORY_COMMA_THRESHOLD:
+            return sentence
+    return None
+
+
+def validate_principle_compliance(path: Path, text: str) -> 
Iterable[Violation]:
+    """Surface advisory warnings for content that does not aid LLM-router
+    selection — rationale, sub-step enumerations, distinct-from clauses,
+    chain-handoff narratives, or criteria-source paths.
+
+    SOFT — informative, not blocking. Borderline cases are expected; the
+    reviewer has the final say.
+    """
+    fm = parse_frontmatter(text) or {}
+    description = fm.get("description", "")
+    when_to_use = fm.get("when_to_use", "")
+    combined = f"{description}\n{when_to_use}"
+
+    sentence = _check_action_inventory(description)
+    if sentence:
+        preview = _collapse_ws(sentence)
+        if len(preview) > 80:
+            preview = preview[:80] + "…"
+        yield Violation(
+            path,
+            1,
+            f"action-inventory in description ({sentence.count(',')} commas) — 
"
+            f"consider moving the enum to body: '{preview}'",
+            category=PRINCIPLE_CATEGORY,
+        )
+
+    for match in DISTINCT_FROM_RE.finditer(combined):
+        yield Violation(
+            path,
+            1,
+            f"distinct-from clause — router needs skip-when redirects, not 
comparisons: '{_collapse_ws(match.group())}'",
+            category=PRINCIPLE_CATEGORY,
+        )
+
+    for match in CHAIN_HANDOFF_RE.finditer(combined):
+        yield Violation(
+            path,
+            1,
+            f"chain-handoff narrative — belongs in body: 
'{_collapse_ws(match.group())}'",
+            category=PRINCIPLE_CATEGORY,
+        )
+
+    for match in PARENTHETICAL_RATIONALE_RE.finditer(combined):
+        snippet = _collapse_ws(match.group())
+        if len(snippet) > 60:
+            snippet = snippet[:60] + "…)"
+        yield Violation(
+            path,
+            1,
+            f"parenthetical rationale — router needs *whether*, not *why*: 
'{snippet}'",
+            category=PRINCIPLE_CATEGORY,
+        )
+
+    for match in CRITERIA_SOURCE_RE.finditer(combined):
+        yield Violation(
+            path,
+            1,
+            f"criteria-source path — router doesn't open docs: 
'{_collapse_ws(match.group())}'",
+            category=PRINCIPLE_CATEGORY,
+        )
+
+
+# ---------------------------------------------------------------------------
+# Trigger-phrase non-regression
+# ---------------------------------------------------------------------------
+
+
+def _extract_when_to_use(text: str) -> str:
+    """Return the raw when_to_use scalar (or empty string)."""
+    fm = parse_frontmatter(text) or {}
+    return fm.get("when_to_use", "")
+
+
+def _extract_quoted_phrases(text: str) -> set[str]:
+    """Return every quoted phrase in *text* (trimmed, non-empty)."""
+    return {m.group(1).strip() for m in QUOTED_PHRASE_RE.finditer(text) if 
m.group(1).strip()}
+
+
+def _git_show(base_ref: str, rel_path: str, repo_root: Path) -> str | None:
+    """Return the contents of *rel_path* at *base_ref*, or None if unavailable.
+
+    Silent fail-open on any git error — the trigger-preservation check
+    is advisory and must not block local development on fresh clones,
+    detached HEAD, or shallow checkouts.
+    """
+    import subprocess
+
+    try:
+        result = subprocess.run(
+            ["git", "show", f"{base_ref}:{rel_path}"],
+            cwd=str(repo_root),
+            capture_output=True,
+            text=True,
+            check=True,
+        )
+        return result.stdout
+    except (subprocess.CalledProcessError, FileNotFoundError, OSError):
+        return None
+
+
+def validate_trigger_preservation(
+    path: Path,
+    text: str,
+    base_ref: str | None = None,
+    repo_root: Path | None = None,
+) -> Iterable[Violation]:
+    """Diff quoted when_to_use phrases against a base ref.
+
+    Reports any phrase present in the base version but missing from the
+    current text as a SOFT routing-recall warning. Base ref defaults to
+    ``$SKILL_VALIDATOR_BASE_REF`` (then ``origin/main``). Silently
+    skipped when the base ref or the file at that ref isn't available.
+    """
+    import os
+
+    if base_ref is None:
+        base_ref = os.environ.get("SKILL_VALIDATOR_BASE_REF", "origin/main")
+
+    root = repo_root or find_repo_root()
+    try:
+        rel_path = str(path.resolve().relative_to(root))
+    except ValueError:
+        return
+
+    base_text = _git_show(base_ref, rel_path, root)
+    if base_text is None:
+        return
+
+    base_triggers = _extract_quoted_phrases(_extract_when_to_use(base_text))
+    new_triggers = _extract_quoted_phrases(_extract_when_to_use(text))
+    missing = base_triggers - new_triggers
+    for trigger in sorted(missing):
+        yield Violation(
+            path,
+            1,
+            f"trigger phrase dropped from when_to_use vs {base_ref}: 
{trigger!r}",
+            category=TRIGGER_PRESERVATION_CATEGORY,
+        )
+
+
 # ---------------------------------------------------------------------------
 # Orchestrator
 # ---------------------------------------------------------------------------
@@ -490,9 +697,11 @@ def run_validation(root: Path | None = None) -> 
list[Violation]:
             violations.append(Violation(path, None, f"cannot read file: 
{exc}"))
             continue
 
-        # Only SKILL.md files get frontmatter validation
+        # Only SKILL.md files get frontmatter + SOFT principle checks
         if path.name == "SKILL.md":
             violations.extend(validate_frontmatter(path, text))
+            violations.extend(validate_principle_compliance(path, text))
+            violations.extend(validate_trigger_preservation(path, text, 
repo_root=repo_root))
 
         # All skill files get link + placeholder validation
         violations.extend(validate_links(path, text, skill_dirs, doc_files))
@@ -506,18 +715,98 @@ def main(argv: list[str] | None = None) -> int:
     parser = argparse.ArgumentParser(
         description="Validate framework skill definitions.",
     )
-    parser.parse_args(argv)
+    parser.add_argument(
+        "--skip-categories",
+        default="",
+        help="Comma-separated list of violation categories to skip entirely.",
+    )
+    parser.add_argument(
+        "--strict",
+        action="store_true",
+        help="Promote SOFT categories (advisory) to hard failures.",
+    )
+    args = parser.parse_args(argv)
 
+    skip = {c.strip() for c in args.skip_categories.split(",") if c.strip()}
     violations = run_validation()
+    filtered = [v for v in violations if v.category not in skip]
 
-    if not violations:
+    if args.strict:
+        hard = filtered
+        soft: list[Violation] = []
+    else:
+        hard = [v for v in filtered if v.category not in SOFT_CATEGORIES]
+        soft = [v for v in filtered if v.category in SOFT_CATEGORIES]
+
+    if not filtered:
         print("skill-validator: OK (no violations)")
         return 0
 
-    print(f"skill-validator: {len(violations)} violation(s) found\n")
-    for v in violations:
-        print(v)
-    return 1
+    if soft:
+        _print_soft_warnings(soft)
+
+    if hard:
+        print(f"skill-validator: {len(hard)} violation(s) found\n")
+        for v in hard:
+            print(v)
+        return 1
+
+    return 0
+
+
+# ---------------------------------------------------------------------------
+# SOFT warning formatter
+# ---------------------------------------------------------------------------
+
+
+_SOFT_RULE_PREFIXES: tuple[str, ...] = (
+    "action-inventory",
+    "distinct-from",
+    "chain-handoff",
+    "parenthetical rationale",
+    "criteria-source",
+    "trigger phrase",
+)
+
+
+def _rule_name(message: str) -> str:
+    for prefix in _SOFT_RULE_PREFIXES:
+        if message.startswith(prefix):
+            return prefix
+    return "other"
+
+
+def _print_soft_warnings(soft: list[Violation]) -> None:
+    from collections import Counter, defaultdict
+
+    repo_root = find_repo_root()
+    by_file: dict[Path, list[Violation]] = defaultdict(list)
+    for v in soft:
+        by_file[v.path].append(v)
+
+    print(
+        f"skill-validator: {len(soft)} SOFT warning(s) across "
+        f"{len(by_file)} skill(s) — advisory, not blocking\n",
+        file=sys.stderr,
+    )
+
+    for path in sorted(by_file, key=str):
+        try:
+            rel = path.relative_to(repo_root)
+        except ValueError:
+            rel = path
+        warnings = by_file[path]
+        plural = "s" if len(warnings) > 1 else ""
+        print(f"  {rel}  ({len(warnings)} warning{plural})", file=sys.stderr)
+        for v in warnings:
+            print(f"    [{_rule_name(v.message)}] {v.message}", 
file=sys.stderr)
+        print(file=sys.stderr)
+
+    counter = Counter(_rule_name(v.message) for v in soft)
+    print("  summary by rule:", file=sys.stderr)
+    for rule, count in sorted(counter.items(), key=lambda x: (-x[1], x[0])):
+        print(f"    {rule:24s} {count}", file=sys.stderr)
+    print(file=sys.stderr)
 
 
 if __name__ == "__main__":
diff --git a/tools/skill-validator/tests/test_validator.py 
b/tools/skill-validator/tests/test_validator.py
index ee41da4..c62bcbf 100644
--- a/tools/skill-validator/tests/test_validator.py
+++ b/tools/skill-validator/tests/test_validator.py
@@ -26,6 +26,9 @@ import pytest
 from skill_validator import (
     FORBIDDEN_PATTERNS,
     MAX_METADATA_CHARS,
+    PRINCIPLE_CATEGORY,
+    SOFT_CATEGORIES,
+    TRIGGER_PRESERVATION_CATEGORY,
     extract_headings,
     find_repo_root,
     parse_frontmatter,
@@ -35,6 +38,8 @@ from skill_validator import (
     validate_frontmatter,
     validate_links,
     validate_placeholders,
+    validate_principle_compliance,
+    validate_trigger_preservation,
 )
 
 # ---------------------------------------------------------------------------
@@ -418,9 +423,201 @@ class TestRunValidation:
 
         This is the primary integration test: it exercises every
         SKILL.md, every supporting file, and every internal link.
+
+        SOFT categories (principle_compliance, trigger_preservation)
+        are excluded — they are advisory and surface as warnings, not
+        failures. The main runtime gate is `--strict`.
         """
-        violations = run_validation()
+        from skill_validator import SOFT_CATEGORIES
+
+        violations = [v for v in run_validation() if v.category not in 
SOFT_CATEGORIES]
         if violations:
             # Pretty-print the first few failures so pytest output is useful
             lines = [str(v) for v in violations[:10]]
             pytest.fail(f"{len(violations)} validation violation(s) found:\n" 
+ "\n".join(lines))
+
+
+# ---------------------------------------------------------------------------
+# Principle-compliance SOFT warnings
+# ---------------------------------------------------------------------------
+
+
+def _fm(description: str = "", when_to_use: str = "") -> str:
+    parts = ["---", "name: test-skill", "license: Apache-2.0"]
+    if description:
+        parts.append(f"description: |\n  {description}")
+    if when_to_use:
+        parts.append(f"when_to_use: |\n  {when_to_use}")
+    parts.append("---")
+    parts.append("# body")
+    return "\n".join(parts) + "\n"
+
+
+class TestPrincipleCompliance:
+    def test_action_inventory_in_description_warned(self) -> None:
+        text = _fm(description="Does a, b, c, d, e, f, and finally g.")
+        violations = list(validate_principle_compliance(Path("skill.md"), 
text))
+        msgs = [v.message for v in violations]
+        assert any("action-inventory" in m for m in msgs)
+        assert all(v.category == PRINCIPLE_CATEGORY for v in violations)
+
+    def test_action_inventory_below_threshold_silent(self) -> None:
+        text = _fm(description="Does a, b, and c.")  # 2 commas
+        violations = list(validate_principle_compliance(Path("skill.md"), 
text))
+        assert not any("action-inventory" in v.message for v in violations)
+
+    def test_distinct_from_clause_warned(self) -> None:
+        text = _fm(description="Walks a maintainer through review. Distinct 
from triage skill.")
+        violations = list(validate_principle_compliance(Path("skill.md"), 
text))
+        assert any("distinct-from" in v.message for v in violations)
+
+    def test_unlike_clause_warned(self) -> None:
+        text = _fm(description="Unlike security-issue-import, no Gmail 
involved.")
+        violations = list(validate_principle_compliance(Path("skill.md"), 
text))
+        assert any("distinct-from" in v.message for v in violations)
+
+    def test_chain_handoff_warned(self) -> None:
+        text = _fm(description="Does the thing. Hands off to 
security-issue-sync after.")
+        violations = list(validate_principle_compliance(Path("skill.md"), 
text))
+        assert any("chain-handoff" in v.message for v in violations)
+
+    def test_ready_for_x_to_take_over_warned(self) -> None:
+        text = _fm(description="Lands the tracker, ready for 
security-cve-allocate to take over.")
+        violations = list(validate_principle_compliance(Path("skill.md"), 
text))
+        assert any("chain-handoff" in v.message for v in violations)
+
+    def test_parenthetical_rationale_warned(self) -> None:
+        text = _fm(description="Closes the tracker (a separate REJECT flow is 
required first).")
+        violations = list(validate_principle_compliance(Path("skill.md"), 
text))
+        assert any("parenthetical rationale" in v.message for v in violations)
+
+    def test_parenthetical_typically_warned(self) -> None:
+        text = _fm(description="Merges two trackers (typically discovered 
independently).")
+        violations = list(validate_principle_compliance(Path("skill.md"), 
text))
+        assert any("parenthetical rationale" in v.message for v in violations)
+
+    def test_neutral_parenthetical_not_warned(self) -> None:
+        """A spec-style paren like (`<tracker>`, `<upstream>`) should not trip 
the rule."""
+        text = _fm(description="Use placeholders (`<tracker>`, `<upstream>`, 
`<security-list>`).")
+        violations = list(validate_principle_compliance(Path("skill.md"), 
text))
+        assert not any("parenthetical rationale" in v.message for v in 
violations)
+
+    def test_criteria_source_doc_path_warned(self) -> None:
+        text = _fm(description="Walks the checklist documented in 
`docs/setup/agents.md`.")
+        violations = list(validate_principle_compliance(Path("skill.md"), 
text))
+        assert any("criteria-source" in v.message for v in violations)
+
+    def test_criteria_source_process_step_warned(self) -> None:
+        text = _fm(when_to_use='Invoke after "consensus reached" — typically 
after process step 6.')
+        violations = list(validate_principle_compliance(Path("skill.md"), 
text))
+        assert any("criteria-source" in v.message for v in violations)
+
+    def test_criteria_source_step_with_letter_warned(self) -> None:
+        text = _fm(when_to_use='Invoke when "duplicate" surfaces at Step 2a.')
+        violations = list(validate_principle_compliance(Path("skill.md"), 
text))
+        assert any("criteria-source" in v.message for v in violations)
+
+    def test_clean_frontmatter_silent(self) -> None:
+        text = _fm(
+            description="Triage open PRs and propose a disposition.",
+            when_to_use='Invoke when a maintainer says "triage the PR queue".',
+        )
+        violations = list(validate_principle_compliance(Path("skill.md"), 
text))
+        assert violations == []
+
+
+# ---------------------------------------------------------------------------
+# Trigger-phrase non-regression
+# ---------------------------------------------------------------------------
+
+
+class TestTriggerPreservation:
+    def test_unavailable_base_ref_no_op(self, tmp_path: Path) -> None:
+        """When git or the base ref isn't reachable, the check returns no 
violations."""
+        skill = tmp_path / "SKILL.md"
+        skill.write_text(_fm(when_to_use='Invoke when "trim me" is said.'), 
encoding="utf-8")
+        violations = list(
+            validate_trigger_preservation(
+                skill,
+                skill.read_text(encoding="utf-8"),
+                base_ref="nonexistent/ref/__nope__",
+                repo_root=tmp_path,
+            )
+        )
+        # No git history at *all* for tmp_path — silently no-op.
+        assert violations == []
+
+    def test_quoted_phrase_diff_reports_missing(self, tmp_path: Path) -> None:
+        """Initialise a tiny git repo and detect a dropped trigger."""
+        import subprocess
+
+        # Skip cleanly if git isn't available in the test environment.
+        try:
+            subprocess.run(
+                ["git", "init", "-q"],
+                cwd=str(tmp_path),
+                check=True,
+                capture_output=True,
+            )
+            subprocess.run(
+                ["git", "-c", "user.email=t@t", "-c", "user.name=t", "config", 
"commit.gpgsign", "false"],
+                cwd=str(tmp_path),
+                check=True,
+                capture_output=True,
+            )
+        except (subprocess.CalledProcessError, FileNotFoundError):
+            pytest.skip("git not available")
+
+        skills_dir = tmp_path / ".claude" / "skills"
+        skills_dir.mkdir(parents=True)
+        skill = skills_dir / "demo" / "SKILL.md"
+        skill.parent.mkdir()
+
+        # Base version has both triggers
+        skill.write_text(
+            _fm(when_to_use='Invoke when "alpha" or "beta" is said.'),
+            encoding="utf-8",
+        )
+        subprocess.run(["git", "add", "-A"], cwd=str(tmp_path), check=True, 
capture_output=True)
+        subprocess.run(
+            [
+                "git",
+                "-c",
+                "user.email=t@t",
+                "-c",
+                "user.name=t",
+                "commit",
+                "-q",
+                "-m",
+                "init",
+            ],
+            cwd=str(tmp_path),
+            check=True,
+            capture_output=True,
+        )
+
+        # Current version drops "beta"
+        skill.write_text(_fm(when_to_use='Invoke when "alpha" is said.'), 
encoding="utf-8")
+
+        violations = list(
+            validate_trigger_preservation(
+                skill,
+                skill.read_text(encoding="utf-8"),
+                base_ref="HEAD",
+                repo_root=tmp_path,
+            )
+        )
+        assert len(violations) == 1
+        assert violations[0].category == TRIGGER_PRESERVATION_CATEGORY
+        assert "'beta'" in violations[0].message
+
+
+# ---------------------------------------------------------------------------
+# SOFT category exposure
+# ---------------------------------------------------------------------------
+
+
+class TestSoftCategories:
+    def test_soft_categories_set(self) -> None:
+        assert PRINCIPLE_CATEGORY in SOFT_CATEGORIES
+        assert TRIGGER_PRESERVATION_CATEGORY in SOFT_CATEGORIES

Reply via email to