Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,17 @@ Inside the plugin, reference bundled files via `${CLAUDE_PLUGIN_ROOT}/...` — i

## Verify before done

`ruff` and `pytest` are dev tooling only — they never ship inside `plugin/`. If `python3 -m pip`
is unavailable, install them isolated with [`uv`](https://docs.astral.sh/uv/)
(`uv tool install ruff pytest`, then `export PATH="$HOME/.local/bin:$PATH"`).

```sh
ruff check . && ruff format --check .
ruff check . && ruff format --check . # apply with `ruff format .` if it wants changes
python3 -m pytest -q

# Stdlib-only guard (mirrored in tests/test_stdlib_only.py): the shipped payload must import
# only the standard library. This must find nothing.
grep -rnE '^\s*(import|from)\s+(msal|azure|requests|urllib3|httpx|aiohttp|msgraph|pydantic|yaml|dotenv)\b' plugin/src/
```

## Non-negotiable conventions
Expand Down
80 changes: 80 additions & 0 deletions tests/test_stdlib_only.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""Enforce — not just assert — the stdlib-only guarantee for the shipped payload.

The plugin under plugin/src/ must import only the Python standard library (plus its own package)
at runtime: that is the whole portability/auditability promise (no install friction, no third-party
attack surface). ruff/pytest are dev tooling and live OUTSIDE plugin/, so they never count here.

Two layers of defence:
- test_no_forbidden_imports — a fast denylist grep, mirrored verbatim in README's verify steps,
so a human and CI fail on the same obvious offenders (msal, azure, requests, ...).
- test_every_import_is_stdlib_or_first_party — the real guarantee: AST-walk every import and assert
each top-level module is in the stdlib or is the plugin's own package. Catches anything the
denylist forgot.
"""

from __future__ import annotations

import ast
import re
import sys
from pathlib import Path

PLUGIN_SRC = Path(__file__).resolve().parent.parent / "plugin" / "src"

# First-party package(s) shipped in the plugin. Rename alongside src/example when instantiating.
FIRST_PARTY = {"example"}

# Mirrored in README "Verify before done". Keep the two in sync.
FORBIDDEN = (
"msal",
"azure",
"requests",
"urllib3",
"httpx",
"aiohttp",
"msgraph",
"pydantic",
"yaml",
"dotenv",
)


def _py_files() -> list[Path]:
return sorted(PLUGIN_SRC.rglob("*.py"))


def test_plugin_src_has_python_files():
# Guard against the test silently passing because the path moved.
assert _py_files(), f"no .py files under {PLUGIN_SRC} — did the layout change?"


def test_no_forbidden_imports():
"""The denylist grep, applied as code. Same intent as the README one-liner."""
pattern = re.compile(r"^\s*(?:import|from)\s+(" + "|".join(FORBIDDEN) + r")\b", re.MULTILINE)
offenders = []
for path in _py_files():
for m in pattern.finditer(path.read_text(encoding="utf-8")):
offenders.append(f"{path.relative_to(PLUGIN_SRC.parent.parent)}: {m.group(1)}")
assert not offenders, "forbidden third-party imports in shipped plugin:\n " + "\n ".join(offenders)


def test_every_import_is_stdlib_or_first_party():
"""The real guarantee: every imported top-level module is stdlib or the plugin's own package."""
stdlib = sys.stdlib_module_names # Python 3.10+
bad: list[str] = []
for path in _py_files():
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
for node in ast.walk(tree):
if isinstance(node, ast.Import):
mods = [a.name.split(".")[0] for a in node.names]
elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
mods = [node.module.split(".")[0]]
else:
continue
for mod in mods:
if mod not in stdlib and mod not in FIRST_PARTY:
bad.append(f"{path.relative_to(PLUGIN_SRC.parent.parent)}: {mod}")
assert not bad, (
"non-stdlib / non-first-party imports in shipped plugin (stdlib-only is non-negotiable):\n "
+ "\n ".join(bad)
)
Loading