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