-
Notifications
You must be signed in to change notification settings - Fork 7
[DBA-295] Migrate validate-agent-guidance from Bash to Python
#79
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 1 commit
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
9af2d71
DBA-295 Migrate `validate-agent-guidance` from Bash to Python
catstrike a812709
Replace `validate-agent-guidance` script with `make lint-skills` in s…
catstrike 2969283
Update .pre-commit-config.yaml
catstrike 94ec4f5
Address copilot comments
catstrike 097ab29
Redundant copy of Makefile in documentation
catstrike a47035d
Update scripts/validate_agent_guidance.py
catstrike c21047a
Merge branch 'main' into ls/validate_agent_guidance_py
356ffc9
drop autosteer skill from evals
f1b37f6
Update scripts/validate_agent_guidance.py
gasparian 9789eb9
Update scripts/validate_agent_guidance.py
gasparian a621161
fix ruff-format on validate_agent_guidance.py
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file was deleted.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,229 @@ | ||
| #!/usr/bin/env python3 | ||
| """Tier 1: Static validation of agent skills and guidance docs. | ||
|
|
||
| Checks structural correctness, cross-references, and file existence. | ||
| """ | ||
|
|
||
| import json | ||
| import os | ||
| import re | ||
| import sys | ||
| from dataclasses import dataclass | ||
| from functools import cached_property | ||
| from pathlib import Path | ||
|
|
||
| _EVAL_EXEMPT_SKILLS: frozenset[str] = frozenset({"eval-skills"}) | ||
|
|
||
|
|
||
| @dataclass | ||
| class MdFile: | ||
| path: Path | ||
|
|
||
| @cached_property | ||
| def _content(self) -> str: | ||
| return self.path.read_text() | ||
|
catstrike marked this conversation as resolved.
Outdated
|
||
|
|
||
| @cached_property | ||
| def make_targets(self) -> frozenset[str]: | ||
| return frozenset(re.findall(r"`make ([a-z][a-z0-9_-]*)`", self._content)) | ||
|
|
||
| @cached_property | ||
| def shell_scripts(self) -> frozenset[str]: | ||
| # Skip lines with shell variable expansions (${...}) to avoid false positives | ||
| lines = "\n".join(line for line in self._content.splitlines() if "${" not in line) | ||
| return frozenset(re.findall(r"scripts/[a-z][a-z0-9_.-]*\.sh", lines)) | ||
|
|
||
| @cached_property | ||
| def doc_references(self) -> frozenset[str]: | ||
| return frozenset(re.findall(r"`(docs/[a-z][a-z0-9_/-]*\.(?:md|txt))`", self._content)) | ||
|
|
||
| @cached_property | ||
| def has_top_level_heading(self) -> bool: | ||
| return bool(re.search(r"^# ", self._content, re.MULTILINE)) | ||
|
|
||
| @cached_property | ||
| def has_steps_section(self) -> bool: | ||
| return bool(re.search(r"^## Steps|^### \d|^## .+ checklist", self._content, re.MULTILINE)) | ||
|
|
||
| @cached_property | ||
| def frontmatter(self) -> dict[str, str]: | ||
| """Parse YAML-style frontmatter between leading --- delimiters into a flat dict.""" | ||
| m = re.match(r"^---\n(.*?)\n---\n", self._content, re.DOTALL) | ||
|
catstrike marked this conversation as resolved.
Outdated
|
||
| if not m: | ||
| return {} | ||
| result: dict[str, str] = {} | ||
| for line in m.group(1).splitlines(): | ||
| if ": " in line: | ||
| key, _, value = line.partition(": ") | ||
| result[key.strip()] = value.strip() | ||
| return result | ||
|
catstrike marked this conversation as resolved.
Outdated
|
||
|
|
||
|
|
||
| @dataclass | ||
| class ClaudeDir: | ||
| root: Path | ||
|
|
||
| @property | ||
| def path(self) -> Path: | ||
| return self.root / ".claude" | ||
|
|
||
| @cached_property | ||
| def skill_dirs(self) -> list[Path]: | ||
| """Sorted list of every skill directory under .claude/skills/.""" | ||
| return [d for d in sorted((self.path / "skills").iterdir()) if d.is_dir()] | ||
|
catstrike marked this conversation as resolved.
Outdated
|
||
|
|
||
| @cached_property | ||
| def skill_files(self) -> list[MdFile]: | ||
| """MdFile for each SKILL.md that exists inside a skill directory.""" | ||
| return [MdFile(d / "SKILL.md") for d in self.skill_dirs if (d / "SKILL.md").is_file()] | ||
|
|
||
| @cached_property | ||
| def agent_files(self) -> list[MdFile]: | ||
| """MdFile for each agent definition (.md) under .claude/agents/.""" | ||
| agents_dir = self.path / "agents" | ||
| if not agents_dir.is_dir(): | ||
| return [] | ||
| return [MdFile(f) for f in sorted(agents_dir.glob("*.md"))] | ||
|
|
||
|
|
||
| # ---------- 1. SKILL.md structure ---------- | ||
|
|
||
|
|
||
| def validate_skill_structure(claude: ClaudeDir) -> list[str]: | ||
| errors = [] | ||
| for skill_dir in claude.skill_dirs: | ||
| skill_name = skill_dir.name | ||
| skill_file = skill_dir / "SKILL.md" | ||
|
|
||
| if not skill_file.is_file(): | ||
| errors.append(f"Skill '{skill_name}' has no SKILL.md") | ||
| continue | ||
|
|
||
| md = MdFile(skill_file) | ||
|
|
||
| fm = md.frontmatter | ||
|
catstrike marked this conversation as resolved.
|
||
| if not fm: | ||
| errors.append(f"{skill_name}/SKILL.md missing YAML frontmatter (--- block)") | ||
| else: | ||
| for field in ("name", "description"): | ||
| if field not in fm: | ||
| errors.append(f"{skill_name}/SKILL.md frontmatter missing required field '{field}'") | ||
| if fm.get("name") and fm["name"] != skill_name: | ||
| errors.append(f"{skill_name}/SKILL.md frontmatter 'name' ({fm['name']!r}) does not match directory name") | ||
|
gasparian marked this conversation as resolved.
Outdated
|
||
|
|
||
| if not md.has_top_level_heading: | ||
| errors.append(f"{skill_name}/SKILL.md missing top-level heading (# Title)") | ||
|
|
||
| if not md.has_steps_section: | ||
| errors.append(f"{skill_name}/SKILL.md missing '## Steps', numbered step sections, or checklist sections") | ||
|
|
||
| return errors | ||
|
|
||
|
|
||
| # ---------- 2. Make target existence ---------- | ||
|
|
||
|
|
||
| def _make_target_exists(target: str, makefile_content: str) -> bool: | ||
| return bool(re.search(rf"^{re.escape(target)}:", makefile_content, re.MULTILINE)) | ||
|
|
||
|
|
||
| def validate_make_targets(claude: ClaudeDir) -> list[str]: | ||
| errors = [] | ||
| makefile = claude.root / "Makefile" | ||
| if not makefile.is_file(): | ||
| return errors | ||
| makefile_content = makefile.read_text() | ||
|
|
||
| for md in claude.skill_files: | ||
| skill_name = md.path.parent.name | ||
| for target in sorted(md.make_targets): | ||
| if not _make_target_exists(target, makefile_content): | ||
| errors.append(f"Make target '{target}' referenced in {skill_name}/SKILL.md does not exist in Makefile") | ||
|
|
||
| claude_file = claude.root / "CLAUDE.md" | ||
| if claude_file.is_file(): | ||
| for target in sorted(MdFile(claude_file).make_targets): | ||
| if not _make_target_exists(target, makefile_content): | ||
| errors.append(f"Make target '{target}' referenced in CLAUDE.md does not exist in Makefile") | ||
|
|
||
| return errors | ||
|
|
||
|
|
||
| # ---------- 3. Script existence ---------- | ||
|
|
||
|
|
||
| def validate_scripts(claude: ClaudeDir) -> list[str]: | ||
| errors = [] | ||
| for md in claude.skill_files: | ||
| skill_name = md.path.parent.name | ||
| for script in sorted(md.shell_scripts): | ||
| script_path = claude.root / script | ||
| if not script_path.is_file(): | ||
| errors.append(f"Script '{script}' referenced in {skill_name}/SKILL.md does not exist") | ||
| elif not os.access(script_path, os.X_OK): | ||
| errors.append(f"Script '{script}' referenced in {skill_name}/SKILL.md is not executable") | ||
|
|
||
| return errors | ||
|
|
||
|
|
||
| # ---------- 4. Cross-doc consistency ---------- | ||
|
|
||
|
|
||
| def validate_doc_references(claude: ClaudeDir) -> list[str]: | ||
| errors = [] | ||
| claude_file = claude.root / "CLAUDE.md" | ||
| if not claude_file.is_file(): | ||
| return errors | ||
| for doc in sorted(MdFile(claude_file).doc_references): | ||
| if not (claude.root / doc).is_file(): | ||
| errors.append(f"Doc '{doc}' referenced in CLAUDE.md does not exist") | ||
| return errors | ||
|
|
||
|
|
||
| # ---------- 5. Eval files ---------- | ||
|
|
||
|
|
||
| def validate_eval_files( | ||
| claude: ClaudeDir, | ||
| exempt_skills: frozenset[str] = _EVAL_EXEMPT_SKILLS, | ||
| ) -> list[str]: | ||
| errors = [] | ||
| for skill_dir in claude.skill_dirs: | ||
| skill_name = skill_dir.name | ||
| eval_file = skill_dir / "evals" / "evals.json" | ||
|
|
||
| if eval_file.is_file(): | ||
| try: | ||
| json.loads(eval_file.read_text()) | ||
| except json.JSONDecodeError as e: | ||
| errors.append(f"{skill_name}/evals/evals.json is not valid JSON: {e}") | ||
| elif skill_name not in exempt_skills: | ||
| errors.append(f"Skill '{skill_name}' is missing evals/evals.json — add eval test cases") | ||
|
|
||
| return errors | ||
|
|
||
|
|
||
| # ---------- Main ---------- | ||
|
|
||
|
|
||
| def main() -> None: | ||
| root = Path(__file__).parent.parent | ||
| claude = ClaudeDir(root) | ||
| errors = [ | ||
| *validate_skill_structure(claude), | ||
| *validate_make_targets(claude), | ||
| *validate_scripts(claude), | ||
| *validate_doc_references(claude), | ||
| *validate_eval_files(claude), | ||
| ] | ||
| for error in errors: | ||
| print(f"ERROR: {error}", file=sys.stderr) | ||
| if errors: | ||
| print(f"\nAgent guidance validation failed with {len(errors)} error(s).", file=sys.stderr) | ||
| sys.exit(1) | ||
|
|
||
| print("Agent guidance validation passed.") | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| main() | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.