diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 00000000..500e5118 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,24 @@ +name: check + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + manifests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: '3.11' + - name: install deps + run: pip install pyyaml pytest + - name: run check.py + run: python3 scripts/check.py + - name: run check.py test suite + run: python3 -m pytest scripts/tests/ -v diff --git a/plugins/vertical-plugins/equity-research/.claude-plugin/plugin.json b/plugins/vertical-plugins/equity-research/.claude-plugin/plugin.json index 7d0649ec..9cf5a1a7 100644 --- a/plugins/vertical-plugins/equity-research/.claude-plugin/plugin.json +++ b/plugins/vertical-plugins/equity-research/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "equity-research", - "version": "0.1.0", + "version": "0.1.1", "description": "Equity research tools: earnings analysis, initiating coverage reports, and research workflows", "author": { "name": "Anthropic FSI" diff --git a/plugins/vertical-plugins/equity-research/hooks/hooks.json b/plugins/vertical-plugins/equity-research/hooks/hooks.json index fe51488c..deffac97 100644 --- a/plugins/vertical-plugins/equity-research/hooks/hooks.json +++ b/plugins/vertical-plugins/equity-research/hooks/hooks.json @@ -1 +1,3 @@ -[] +{ + "hooks": {} +} diff --git a/plugins/vertical-plugins/financial-analysis/.claude-plugin/plugin.json b/plugins/vertical-plugins/financial-analysis/.claude-plugin/plugin.json index c3c39a33..8bb83a79 100644 --- a/plugins/vertical-plugins/financial-analysis/.claude-plugin/plugin.json +++ b/plugins/vertical-plugins/financial-analysis/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "financial-analysis", - "version": "0.1.0", + "version": "0.1.1", "description": "Core financial modeling and analysis tools: DCF, comps, LBO, 3-statement models, competitive analysis, and deck QC", "author": { "name": "Anthropic FSI" diff --git a/plugins/vertical-plugins/financial-analysis/hooks/hooks.json b/plugins/vertical-plugins/financial-analysis/hooks/hooks.json index fe51488c..deffac97 100644 --- a/plugins/vertical-plugins/financial-analysis/hooks/hooks.json +++ b/plugins/vertical-plugins/financial-analysis/hooks/hooks.json @@ -1 +1,3 @@ -[] +{ + "hooks": {} +} diff --git a/plugins/vertical-plugins/private-equity/.claude-plugin/plugin.json b/plugins/vertical-plugins/private-equity/.claude-plugin/plugin.json index 8972bac1..276b49e3 100644 --- a/plugins/vertical-plugins/private-equity/.claude-plugin/plugin.json +++ b/plugins/vertical-plugins/private-equity/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "private-equity", - "version": "0.1.0", + "version": "0.1.1", "description": "Private equity deal sourcing and workflow tools: company discovery, CRM integration, and founder outreach", "author": { "name": "Anthropic FSI" diff --git a/plugins/vertical-plugins/private-equity/hooks/hooks.json b/plugins/vertical-plugins/private-equity/hooks/hooks.json index fe51488c..deffac97 100644 --- a/plugins/vertical-plugins/private-equity/hooks/hooks.json +++ b/plugins/vertical-plugins/private-equity/hooks/hooks.json @@ -1 +1,3 @@ -[] +{ + "hooks": {} +} diff --git a/plugins/vertical-plugins/wealth-management/.claude-plugin/plugin.json b/plugins/vertical-plugins/wealth-management/.claude-plugin/plugin.json index e9c23274..117e5db6 100644 --- a/plugins/vertical-plugins/wealth-management/.claude-plugin/plugin.json +++ b/plugins/vertical-plugins/wealth-management/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "wealth-management", - "version": "0.1.0", + "version": "0.1.1", "description": "Wealth management and financial advisory tools: client reviews, financial planning, portfolio analysis, and client reporting", "author": { "name": "Anthropic FSI" diff --git a/plugins/vertical-plugins/wealth-management/hooks/hooks.json b/plugins/vertical-plugins/wealth-management/hooks/hooks.json index fe51488c..deffac97 100644 --- a/plugins/vertical-plugins/wealth-management/hooks/hooks.json +++ b/plugins/vertical-plugins/wealth-management/hooks/hooks.json @@ -1 +1,3 @@ -[] +{ + "hooks": {} +} diff --git a/scripts/check.py b/scripts/check.py index e8e93c7b..33c9e52b 100755 --- a/scripts/check.py +++ b/scripts/check.py @@ -88,6 +88,20 @@ def rel(p: Path) -> str: except json.JSONDecodeError as e: err(f"JSON parse: {rel(jf)}: {e}") +# --- 2b. hooks.json schema --- must be {"hooks": {...}}, never [] ---------- +for hjf in sorted(PLUGINS.glob("**/hooks/hooks.json")): + checked += 1 + try: + data = json.loads(hjf.read_text()) + except json.JSONDecodeError as e: + err(f"hooks.json parse: {rel(hjf)}: {e}") + continue + if not isinstance(data, dict) or "hooks" not in data: + err( + f'hooks.json: {rel(hjf)}: must be {{"hooks": {{...}}}}, ' + f"got {type(data).__name__}" + ) + # --- 3. agent.md frontmatter ----------------------------------------------- for md in sorted(PLUGINS.glob("agent-plugins/*/agents/*.md")): checked += 1 diff --git a/scripts/tests/conftest.py b/scripts/tests/conftest.py new file mode 100644 index 00000000..afaea584 --- /dev/null +++ b/scripts/tests/conftest.py @@ -0,0 +1,122 @@ +"""Test fixtures for check.py. + +Builds a minimal-valid repo skeleton in a tmpdir and copies `check.py` into it +so the script's `Path(__file__).resolve().parents[1]` correctly identifies the +fixture as ROOT. Tests then mutate one file at a time and assert check.py's +exit code + stderr. +""" +from __future__ import annotations + +import json +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +REAL_CHECK_PY = Path(__file__).resolve().parents[1] / "check.py" + + +@pytest.fixture +def minimal_repo(tmp_path: Path) -> Path: + """Build a minimal-valid repo skeleton; return its root path.""" + root = tmp_path / "repo" + + # scripts/check.py — copy, not symlink, so Path(__file__).resolve() + # lands inside the fixture + (root / "scripts").mkdir(parents=True) + shutil.copy(REAL_CHECK_PY, root / "scripts" / "check.py") + + # .claude-plugin/marketplace.json + (root / ".claude-plugin").mkdir() + (root / ".claude-plugin" / "marketplace.json").write_text( + json.dumps( + { + "name": "test-marketplace", + "owner": {"name": "Test"}, + "plugins": [ + { + "name": "test-vertical", + "source": "./plugins/vertical-plugins/test-vertical", + "description": "Test vertical", + }, + { + "name": "test-agent", + "source": "./plugins/agent-plugins/test-agent", + "description": "Test agent", + }, + ], + } + ) + ) + + # vertical plugin + vp = root / "plugins" / "vertical-plugins" / "test-vertical" + (vp / ".claude-plugin").mkdir(parents=True) + (vp / ".claude-plugin" / "plugin.json").write_text( + json.dumps( + { + "name": "test-vertical", + "version": "0.1.0", + "description": "Test vertical", + "author": {"name": "Test"}, + } + ) + ) + (vp / "hooks").mkdir() + (vp / "hooks" / "hooks.json").write_text(json.dumps({"hooks": {}})) + (vp / "skills" / "shared-skill").mkdir(parents=True) + (vp / "skills" / "shared-skill" / "SKILL.md").write_text( + "---\nname: shared-skill\ndescription: Test\n---\n\nbody\n" + ) + + # agent plugin (bundles the shared-skill) + ap = root / "plugins" / "agent-plugins" / "test-agent" + (ap / ".claude-plugin").mkdir(parents=True) + (ap / ".claude-plugin" / "plugin.json").write_text( + json.dumps( + { + "name": "test-agent", + "version": "0.1.0", + "description": "Test agent", + "author": {"name": "Test"}, + } + ) + ) + (ap / "agents").mkdir() + (ap / "agents" / "test-agent.md").write_text( + "---\nname: test-agent\ndescription: Test agent\n---\n\nbody\n" + ) + # bundled copy of the skill — must match the source byte-for-byte + (ap / "skills" / "shared-skill").mkdir(parents=True) + (ap / "skills" / "shared-skill" / "SKILL.md").write_text( + "---\nname: shared-skill\ndescription: Test\n---\n\nbody\n" + ) + + # managed-agent cookbook + mac = root / "managed-agent-cookbooks" / "test-agent" + mac.mkdir(parents=True) + (mac / "README.md").write_text("# test-agent\n") + (mac / "steering-examples.json").write_text(json.dumps([{"event": "x"}])) + (mac / "agent.yaml").write_text( + "name: test-agent\n" + "system:\n" + " file: ../../plugins/agent-plugins/test-agent/agents/test-agent.md\n" + "skills:\n" + " - from_plugin: ../../plugins/agent-plugins/test-agent\n" + ) + + return root + + +@pytest.fixture +def run_check(): + """Return a callable that runs check.py inside the given repo root.""" + def _run(repo: Path) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, str(repo / "scripts" / "check.py")], + capture_output=True, + text=True, + ) + return _run diff --git a/scripts/tests/test_check.py b/scripts/tests/test_check.py new file mode 100644 index 00000000..01f2558c --- /dev/null +++ b/scripts/tests/test_check.py @@ -0,0 +1,108 @@ +"""End-to-end tests for scripts/check.py. + +Each test mutates one file in the minimal-valid fixture repo and asserts +check.py's exit code + the specific error message. Subprocess-based so +the script is exercised as it actually runs in CI. +""" +from __future__ import annotations + +import json +from pathlib import Path + +def test_clean_tree_passes(minimal_repo: Path, run_check) -> None: + result = run_check(minimal_repo) + assert result.returncode == 0, result.stderr + assert "OK" in result.stdout + + +# --- hooks.json validator (Phase 1 addition) ------------------------------- + +def test_hooks_json_as_array_fails(minimal_repo: Path, run_check) -> None: + hjf = minimal_repo / "plugins" / "vertical-plugins" / "test-vertical" / "hooks" / "hooks.json" + hjf.write_text("[]") + result = run_check(minimal_repo) + assert result.returncode == 1 + assert "must be" in result.stderr + assert "got list" in result.stderr + assert "hooks/hooks.json" in result.stderr + + +def test_hooks_json_missing_hooks_key_fails(minimal_repo: Path, run_check) -> None: + hjf = minimal_repo / "plugins" / "vertical-plugins" / "test-vertical" / "hooks" / "hooks.json" + hjf.write_text("{}") + result = run_check(minimal_repo) + assert result.returncode == 1 + assert "must be" in result.stderr + + +def test_hooks_json_malformed_fails(minimal_repo: Path, run_check) -> None: + hjf = minimal_repo / "plugins" / "vertical-plugins" / "test-vertical" / "hooks" / "hooks.json" + hjf.write_text("{not json") + result = run_check(minimal_repo) + assert result.returncode == 1 + assert "hooks.json parse" in result.stderr + + +# --- existing validators (regression coverage) ----------------------------- + +def test_missing_steering_examples_fails(minimal_repo: Path, run_check) -> None: + (minimal_repo / "managed-agent-cookbooks" / "test-agent" / "steering-examples.json").unlink() + result = run_check(minimal_repo) + assert result.returncode == 1 + assert "missing" in result.stderr + assert "steering-examples.json" in result.stderr + + +def test_agent_md_missing_frontmatter_fails(minimal_repo: Path, run_check) -> None: + md = minimal_repo / "plugins" / "agent-plugins" / "test-agent" / "agents" / "test-agent.md" + md.write_text("no frontmatter here\n") + result = run_check(minimal_repo) + assert result.returncode == 1 + assert "frontmatter" in result.stderr + + +def test_broken_system_file_ref_fails(minimal_repo: Path, run_check) -> None: + yml = minimal_repo / "managed-agent-cookbooks" / "test-agent" / "agent.yaml" + yml.write_text( + "name: test-agent\n" + "system:\n" + " file: ../../plugins/agent-plugins/test-agent/agents/does-not-exist.md\n" + ) + result = run_check(minimal_repo) + assert result.returncode == 1 + assert "system.file" in result.stderr + assert "not found" in result.stderr + + +def test_bundled_skill_drift_fails(minimal_repo: Path, run_check) -> None: + bundled = ( + minimal_repo + / "plugins" + / "agent-plugins" + / "test-agent" + / "skills" + / "shared-skill" + / "SKILL.md" + ) + bundled.write_text("drifted content\n") + result = run_check(minimal_repo) + assert result.returncode == 1 + assert "drifted" in result.stderr + assert "sync-agent-skills.py" in result.stderr + + +def test_marketplace_source_must_resolve(minimal_repo: Path, run_check) -> None: + mp = minimal_repo / ".claude-plugin" / "marketplace.json" + data = json.loads(mp.read_text()) + data["plugins"].append( + { + "name": "ghost", + "source": "./plugins/vertical-plugins/does-not-exist", + "description": "broken", + } + ) + mp.write_text(json.dumps(data)) + result = run_check(minimal_repo) + assert result.returncode == 1 + assert "marketplace" in result.stderr + assert "ghost" in result.stderr