exitzero 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
exitzero/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Policy-driven verification for AI-assisted coding teams."""
2
+
3
+ __version__ = "0.1.0"
exitzero/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
exitzero/api.py ADDED
@@ -0,0 +1,69 @@
1
+ """Small, versioned contracts shared by core and plugins."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ from typing import Callable, Any
6
+
7
+ API_VERSION = 1
8
+ HOOK_SLOTS = frozenset({"PreToolUse", "PostToolUse", "pre-commit", "CI"})
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class Finding:
13
+ rule: str
14
+ message: str
15
+ path: str | None = None
16
+ line: int | None = None
17
+ severity: str = "error"
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class CheckSpec:
22
+ id: str
23
+ kind: str
24
+ paths: tuple[str, ...] = ()
25
+ options: dict[str, Any] = field(default_factory=dict)
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Context:
30
+ root: Path
31
+ policy: dict[str, Any]
32
+ policy_path: Path
33
+
34
+
35
+ CheckHandler = Callable[[Context, CheckSpec], list[Finding]]
36
+ LintHandler = Callable[[Context], list[Finding]]
37
+ HookHandler = Callable[[Context, str], list[Finding]]
38
+
39
+
40
+ @dataclass
41
+ class Registry:
42
+ """Plugins call register(); duplicate names and unsupported slots fail closed."""
43
+
44
+ checks: dict[str, CheckHandler] = field(default_factory=dict)
45
+ linters: dict[str, LintHandler] = field(default_factory=dict)
46
+ hooks: dict[str, list[HookHandler]] = field(default_factory=dict)
47
+ commands: dict[str, Callable[[Context, list[str]], int]] = field(default_factory=dict)
48
+
49
+ def add_check(self, name: str, handler: CheckHandler) -> None:
50
+ self._add(self.checks, name, handler)
51
+
52
+ def add_linter(self, name: str, handler: LintHandler) -> None:
53
+ self._add(self.linters, name, handler)
54
+
55
+ def add_hook(self, slot: str, handler: HookHandler) -> None:
56
+ if slot not in HOOK_SLOTS:
57
+ raise ValueError("Unsupported hook slot")
58
+ self.hooks.setdefault(slot, []).append(handler)
59
+
60
+ def add_command(self, name: str, handler: Callable[[Context, list[str]], int]) -> None:
61
+ if name in {"init", "check", "lint-config", "hooks", "report", "plugin"}:
62
+ raise ValueError("Reserved CLI command")
63
+ self._add(self.commands, name, handler)
64
+
65
+ @staticmethod
66
+ def _add(target: dict, name: str, handler: Callable) -> None:
67
+ if not name or name in target or not callable(handler):
68
+ raise ValueError("Invalid or duplicate plugin registration")
69
+ target[name] = handler
exitzero/cli.py ADDED
@@ -0,0 +1,208 @@
1
+ """Public command-line interface; check outcomes always come from the runner."""
2
+ import argparse
3
+ import json
4
+ from pathlib import Path
5
+ import shlex
6
+ import sys
7
+
8
+ from . import __version__
9
+ from .api import Context, HOOK_SLOTS
10
+ from .files import safe_path, select_files, validate_relative
11
+ from .hooks import CURSOR_EVENTS, cursor_response, install
12
+ from .loader import discover
13
+ from .policy import DEFAULT_POLICY, load_policy, python_profile_policy, sync_agents
14
+ from .runner import run
15
+
16
+
17
+ def parser() -> argparse.ArgumentParser:
18
+ cli = argparse.ArgumentParser(prog="exitzero")
19
+ cli.add_argument("--version", action="version", version=__version__)
20
+ cli.add_argument("--root", default=".", help="Repository directory")
21
+ cli.add_argument("--policy", default="exitzero.toml", help="Policy path relative to root")
22
+ commands = cli.add_subparsers(dest="command", required=True)
23
+ init = commands.add_parser("init", help="Create policy and managed AGENTS section")
24
+ init.add_argument("--sync", action="store_true", help="Regenerate only the managed AGENTS section")
25
+ init.add_argument("--profile", choices=("default", "python"), default="default",
26
+ help="Policy starter profile (python adds static checks and command slots)")
27
+ init.add_argument("--source-root", action="append", default=[], metavar="PATH",
28
+ help="Python source root; repeat for multiple roots")
29
+ init.add_argument("--allow-module", action="append", default=[], metavar="MODULE",
30
+ help="External Python module trusted by the imports check")
31
+ init.add_argument("--test-command", metavar="COMMAND",
32
+ help="Shell-free test command to store as argv")
33
+ init.add_argument("--review-command", action="append", default=[], metavar="COMMAND",
34
+ help="Shell-free review command; repeatable")
35
+ for name in ("check", "lint-config"):
36
+ child = commands.add_parser(name)
37
+ child.add_argument("--format", choices=("human", "json"), default="human")
38
+ hooks = commands.add_parser("hooks").add_subparsers(dest="hook_command", required=True)
39
+ installer = hooks.add_parser("install")
40
+ installer.add_argument("--adapter", choices=("cursor", "pre-commit"), default="cursor")
41
+ hook_run = hooks.add_parser("run")
42
+ hook_run.add_argument("--adapter", choices=("generic", "cursor"), default="generic")
43
+ hook_run.add_argument("--slot", choices=sorted(HOOK_SLOTS), default="CI")
44
+ hook_run.add_argument("--event", choices=sorted(CURSOR_EVENTS), default="stop")
45
+ hook_run.add_argument("--format", choices=("human", "json"), default="human")
46
+ report = commands.add_parser("report")
47
+ report.add_argument("--format", choices=("human", "json"), default="human")
48
+ plugin = commands.add_parser("plugin")
49
+ plugin.add_argument("name")
50
+ plugin.add_argument("args", nargs=argparse.REMAINDER)
51
+ return cli
52
+
53
+
54
+ def emit(receipt: dict, output: str) -> None:
55
+ if output == "json":
56
+ print(json.dumps(receipt, ensure_ascii=False, sort_keys=True))
57
+ return
58
+ print(f"exitzero: {receipt['status']} (exit {receipt['exit_code']})")
59
+ for finding in receipt["findings"]:
60
+ location = f" {finding['path']}" if finding.get("path") else ""
61
+ if finding.get("line"):
62
+ location += f":{finding['line']}"
63
+ print(f" {finding['rule']}{location}: {finding['message']}")
64
+ print(f"Receipt: {receipt['receipt'] or 'NOT WRITTEN'}")
65
+
66
+
67
+ _COMMAND_OPERATORS = frozenset("|&;<>()")
68
+
69
+
70
+ def _parse_init_command(value: str | None) -> list[str] | None:
71
+ """Parse a user command without invoking a shell or accepting operators."""
72
+
73
+ if value is None:
74
+ return None
75
+ if not isinstance(value, str) or not value.strip() or "\x00" in value:
76
+ raise ValueError("command must be a non-empty string")
77
+ quote: str | None = None
78
+ escaped = False
79
+ for character in value:
80
+ if escaped:
81
+ escaped = False
82
+ continue
83
+ if quote is not None:
84
+ if character == quote:
85
+ quote = None
86
+ elif character == "\\" and quote == '"':
87
+ escaped = True
88
+ continue
89
+ if character in "'\"":
90
+ quote = character
91
+ elif character == "\\":
92
+ escaped = True
93
+ elif character in _COMMAND_OPERATORS or character in "\r\n":
94
+ raise ValueError("commands cannot contain shell operators or control characters")
95
+ if quote is not None or escaped:
96
+ raise ValueError("command has an unterminated quote or escape")
97
+ try:
98
+ argv = shlex.split(value, posix=True)
99
+ except ValueError as error:
100
+ raise ValueError("command has invalid shell-style quoting") from error
101
+ if not argv or any(not isinstance(item, str) or not item for item in argv):
102
+ raise ValueError("command must contain at least one argument")
103
+ return argv
104
+
105
+
106
+ def _init_generation(args: argparse.Namespace) -> str:
107
+ generation_requested = bool(
108
+ args.profile != "default" or args.source_root or args.allow_module
109
+ or args.test_command is not None or args.review_command
110
+ )
111
+ if args.sync and generation_requested:
112
+ raise ValueError("init --sync cannot be combined with policy generation options")
113
+ if args.profile == "default" and generation_requested:
114
+ raise ValueError("--profile python is required with generation options")
115
+ if args.profile != "python":
116
+ return DEFAULT_POLICY
117
+ roots = args.source_root or ["."]
118
+ for root in roots:
119
+ validate_relative(root)
120
+ if any(character in root for character in "*?[]"):
121
+ raise ValueError("source roots must be directories, not glob patterns")
122
+ for module in args.allow_module:
123
+ if not module or any(not part.isidentifier() for part in module.split(".")):
124
+ raise ValueError("allow modules must contain dotted module names")
125
+ test_argv = _parse_init_command(args.test_command)
126
+ review_argvs: list[list[str]] = []
127
+ for value in args.review_command:
128
+ argv = _parse_init_command(value)
129
+ if argv is None:
130
+ raise ValueError("review commands must be non-empty strings")
131
+ review_argvs.append(argv)
132
+ return python_profile_policy(roots, args.allow_module, test_argv, review_argvs)
133
+
134
+
135
+ def main(argv: list[str] | None = None) -> int:
136
+ args = parser().parse_args(argv)
137
+ root = Path(args.root).resolve()
138
+ if args.command in {"check", "lint-config"}:
139
+ receipt = run(root, args.policy, args.command)
140
+ emit(receipt, args.format)
141
+ return receipt["exit_code"]
142
+ if args.command == "hooks" and args.hook_command == "run":
143
+ if args.adapter == "cursor":
144
+ try:
145
+ payload = json.loads(sys.stdin.read(1024 * 1024))
146
+ if not isinstance(payload, dict) or type(payload.get("loop_count", 0)) is not int or payload.get("loop_count", 0) < 0:
147
+ raise ValueError("Invalid Cursor payload")
148
+ except (ValueError, OSError):
149
+ # Still execute and persist the actual gate outcome for every invocation.
150
+ receipt = run(root, args.policy, "check", CURSOR_EVENTS[args.event], input_error=True)
151
+ print(json.dumps({"error": "Invalid Cursor JSON input", "receipt": receipt["receipt"]}))
152
+ return 2
153
+ receipt = run(root, args.policy, "check", CURSOR_EVENTS[args.event])
154
+ print(json.dumps(cursor_response(receipt, args.event, payload)))
155
+ # Cursor reads the JSON protocol; generic adapters expose unchanged gate codes.
156
+ return 0 if receipt["exit_code"] != 2 else 2
157
+ receipt = run(root, args.policy, "check", args.slot)
158
+ emit(receipt, args.format)
159
+ return receipt["exit_code"]
160
+ try:
161
+ path = safe_path(root, args.policy)
162
+ if args.command == "init":
163
+ if not root.is_dir():
164
+ raise ValueError("Root directory must exist")
165
+ generated_policy = _init_generation(args)
166
+ if not args.sync:
167
+ if path.exists():
168
+ raise ValueError("Policy already exists; use init --sync")
169
+ path.parent.mkdir(parents=True, exist_ok=True)
170
+ path.write_text(generated_policy, encoding="utf-8")
171
+ ignore = safe_path(root, ".gitignore")
172
+ existing = ignore.read_text(encoding="utf-8") if ignore.exists() else ""
173
+ additions = [item for item in ("/.exitzero/", "__pycache__/") if item not in existing.splitlines()]
174
+ if additions:
175
+ ignore.write_text(existing.rstrip() + ("\n" if existing else "") + "\n".join(additions) + "\n", encoding="utf-8")
176
+ if not select_files(root, ["**/*.py"]):
177
+ example = safe_path(root, "exitzero_sample.py")
178
+ example.write_text('"""Replace this sample with your project checks."""\n\ndef add(left: int, right: int) -> int:\n return left + right\n', encoding="utf-8")
179
+ policy = load_policy(path)
180
+ sync_agents(root, policy)
181
+ print("Policy and AGENTS.md synchronized. Run exitzero check to verify.")
182
+ return 0
183
+ if args.command == "report":
184
+ directory = safe_path(root, ".exitzero/runs")
185
+ files = sorted(directory.glob("*.json"), key=lambda p: p.stat().st_mtime_ns)
186
+ if not files:
187
+ raise ValueError("No receipt exists; run check first")
188
+ latest = safe_path(root, files[-1].relative_to(root).as_posix())
189
+ emit(json.loads(latest.read_text(encoding="utf-8")), args.format)
190
+ return 0
191
+ policy = load_policy(path)
192
+ if args.command == "hooks":
193
+ print("Installed " + install(root, path, args.adapter) + ". Run lint-config to verify configuration.")
194
+ return 0
195
+ registry = discover(policy["plugins"])
196
+ if args.name not in registry.commands:
197
+ raise ValueError("No such plugin command")
198
+ result = registry.commands[args.name](Context(root, policy, path), args.args)
199
+ if type(result) is not int or result not in (0, 1, 2):
200
+ raise ValueError("Plugin command must return exit code 0, 1 or 2")
201
+ return result
202
+ except Exception as error:
203
+ print(f"exitzero: unable to complete command ({type(error).__name__}); check policy, paths and existing files.", file=sys.stderr)
204
+ return 2
205
+
206
+
207
+ if __name__ == "__main__":
208
+ raise SystemExit(main())
exitzero/files.py ADDED
@@ -0,0 +1,51 @@
1
+ """Repository-scoped reads that avoid credentials and generated directories."""
2
+ from pathlib import Path, PurePosixPath
3
+
4
+ EXCLUDED = frozenset({".git", ".venv", "venv", "node_modules", "__pycache__", ".exitzero", "build", "dist"})
5
+ SENSITIVE_DIRS = frozenset({".ssh", ".aws", ".gnupg"})
6
+ SENSITIVE_NAMES = frozenset({"auth.json", "credentials", "credentials.json", "tokens.json", "token.json", "id_rsa", "id_ed25519"})
7
+
8
+
9
+ def validate_relative(value: str) -> None:
10
+ if (not isinstance(value, str) or not value or "\\" in value or "\x00" in value
11
+ or PurePosixPath(value).is_absolute() or ".." in PurePosixPath(value).parts
12
+ or ":" in value):
13
+ raise ValueError("Expected a relative repository path without traversal")
14
+
15
+
16
+ def is_sensitive(path: Path) -> bool:
17
+ name = path.name.lower()
18
+ return (name == ".env" or name.startswith(".env.") or name in SENSITIVE_NAMES
19
+ or path.suffix.lower() in {".pem", ".key", ".p12", ".pfx", ".keystore"}
20
+ or any(part.lower() in SENSITIVE_DIRS for part in path.parts))
21
+
22
+
23
+ def safe_path(root: Path, relative: str) -> Path:
24
+ validate_relative(relative)
25
+ candidate = root / relative
26
+ if is_sensitive(Path(relative)):
27
+ raise ValueError("Credential-like paths are not valid inspection targets")
28
+ cursor = root
29
+ for part in Path(relative).parts:
30
+ cursor = cursor / part
31
+ if cursor.is_symlink():
32
+ raise ValueError("Symlinks are not valid inspection targets")
33
+ if not candidate.resolve().is_relative_to(root.resolve()):
34
+ raise ValueError("Path escapes repository")
35
+ return candidate
36
+
37
+
38
+ def select_files(root: Path, patterns: tuple[str, ...] | list[str]) -> list[Path]:
39
+ selected: set[Path] = set()
40
+ for pattern in patterns:
41
+ validate_relative(pattern)
42
+ if is_sensitive(Path(pattern)):
43
+ raise ValueError("Credential-like paths are not valid inspection targets")
44
+ for candidate in root.glob(pattern):
45
+ relative = candidate.relative_to(root)
46
+ if any(part in EXCLUDED for part in relative.parts) or is_sensitive(relative):
47
+ continue
48
+ safe_path(root, relative.as_posix())
49
+ if candidate.is_file():
50
+ selected.add(candidate)
51
+ return sorted(selected)
exitzero/hooks.py ADDED
@@ -0,0 +1,130 @@
1
+ """IDE adapters delegate to the shared runner and preserve existing hooks."""
2
+ import hashlib
3
+ import json
4
+ from pathlib import Path
5
+ import shlex
6
+ import subprocess
7
+ import sys
8
+
9
+ from .api import Context, Finding
10
+ from .files import safe_path
11
+
12
+ CURSOR_EVENTS = {
13
+ "preToolUse": "PreToolUse",
14
+ "postToolUse": "PostToolUse",
15
+ "beforeShellExecution": "PreToolUse",
16
+ "beforeMCPExecution": "PreToolUse",
17
+ "afterFileEdit": "PostToolUse",
18
+ "stop": "PostToolUse",
19
+ }
20
+
21
+
22
+ def _launcher() -> list[str]:
23
+ checkout = Path(__file__).resolve().parents[4] / "bin" / "exitzero"
24
+ return [sys.executable, str(checkout)] if checkout.is_file() else [sys.executable, "-m", "exitzero"]
25
+
26
+
27
+ def expected_cursor_command(root: Path, policy_path: Path, slot: str = "PostToolUse") -> str:
28
+ event = "stop" if slot == "PostToolUse" else "preToolUse"
29
+ return shlex.join([*_launcher(), "--root", str(root), "--policy", str(policy_path.relative_to(root)),
30
+ "hooks", "run", "--adapter", "cursor", "--event", event])
31
+
32
+
33
+ def _manifest(root: Path) -> dict:
34
+ path = safe_path(root, ".exitzero/hooks.json")
35
+ if path.exists():
36
+ value = json.loads(path.read_text(encoding="utf-8"))
37
+ if (not isinstance(value, dict) or type(value.get("version")) is not int
38
+ or value["version"] != 1 or not isinstance(value.get("files"), dict)
39
+ or any(not isinstance(digest, str) or len(digest) != 64 for digest in value["files"].values())):
40
+ raise ValueError("Invalid hook installation manifest")
41
+ return value
42
+ return {"version": 1, "files": {}}
43
+
44
+
45
+ def installed_inputs(root: Path) -> list[str]:
46
+ """Include exactly the hook files inspected by the configuration linter."""
47
+ return [".exitzero/hooks.json", *_manifest(root)["files"]]
48
+
49
+
50
+ def cursor_hook_error(entry: object) -> str | None:
51
+ """Validate execution fields shared by Cursor installation and config lint."""
52
+ if not isinstance(entry, dict):
53
+ return "Cursor hook entries must be objects"
54
+ kind = entry.get("type", "command")
55
+ if kind not in ("command", "prompt"):
56
+ return "Cursor hook type must be command or prompt"
57
+ field = "prompt" if kind == "prompt" else "command"
58
+ if not isinstance(entry.get(field), str) or not entry[field].strip():
59
+ return f"Cursor hook entries require a non-empty {field}"
60
+ return None
61
+
62
+
63
+ def install(root: Path, policy_path: Path, adapter: str) -> str:
64
+ manifest = _manifest(root)
65
+ if adapter == "cursor":
66
+ relative = ".cursor/hooks.json"
67
+ path = safe_path(root, relative)
68
+ data = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {"version": 1, "hooks": {}}
69
+ if (not isinstance(data, dict) or type(data.get("version")) is not int
70
+ or data["version"] != 1 or not isinstance(data.get("hooks"), dict)):
71
+ raise ValueError("Invalid existing Cursor hook configuration")
72
+ hooks = data["hooks"].setdefault("stop", [])
73
+ if not isinstance(hooks, list) or any(cursor_hook_error(h) for h in hooks):
74
+ raise ValueError("Invalid existing Cursor stop hooks")
75
+ command = expected_cursor_command(root, policy_path)
76
+ owned = next((h for h in hooks if h.get("type", "command") == "command" and h.get("command") == command), None)
77
+ if owned is None:
78
+ hooks.append({"command": command, "loop_limit": 1})
79
+ else:
80
+ owned.setdefault("loop_limit", 1)
81
+ content = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
82
+ else:
83
+ # Git executes hooks relative to the worktree root. Respect custom hook paths.
84
+ result = subprocess.run(["git", "-C", str(root), "rev-parse", "--git-path", "hooks/pre-commit"],
85
+ capture_output=True, text=True, check=False)
86
+ if result.returncode != 0:
87
+ raise ValueError("pre-commit adapter requires a Git repository")
88
+ candidate = Path(result.stdout.strip())
89
+ candidate = candidate if candidate.is_absolute() else root / candidate
90
+ try:
91
+ relative = candidate.relative_to(root).as_posix()
92
+ except ValueError:
93
+ raise ValueError("External Git hook directories require manual CLI installation") from None
94
+ path = safe_path(root, relative)
95
+ command = shlex.join([*_launcher(), "--root", str(root), "--policy", policy_path.relative_to(root).as_posix(),
96
+ "hooks", "run", "--slot", "pre-commit"])
97
+ content = "#!/bin/sh\n# exitzero managed hook\nexec " + command + "\n"
98
+ if path.exists() and path.read_text(encoding="utf-8") != content:
99
+ raise ValueError("Existing pre-commit hook preserved; chain the CLI manually")
100
+ path.parent.mkdir(parents=True, exist_ok=True)
101
+ path.write_text(content, encoding="utf-8")
102
+ if adapter == "pre-commit":
103
+ path.chmod(path.stat().st_mode | 0o111)
104
+ manifest["files"][relative] = hashlib.sha256(path.read_bytes()).hexdigest()
105
+ target = safe_path(root, ".exitzero/hooks.json")
106
+ target.parent.mkdir(parents=True, exist_ok=True)
107
+ target.write_text(json.dumps(manifest, sort_keys=True, indent=2) + "\n", encoding="utf-8")
108
+ return relative
109
+
110
+
111
+ def lint_installed(context: Context) -> list[Finding]:
112
+ manifest = _manifest(context.root)
113
+ findings = []
114
+ for relative, digest in manifest["files"].items():
115
+ path = safe_path(context.root, relative)
116
+ if not path.is_file() or hashlib.sha256(path.read_bytes()).hexdigest() != digest:
117
+ findings.append(Finding("harness.hooks-drift", "Installed hook changed or disappeared; review and reinstall it.", relative))
118
+ return findings
119
+
120
+
121
+ def cursor_response(receipt: dict, event: str, payload: dict) -> dict:
122
+ passed = receipt["exit_code"] == 0
123
+ if event in {"preToolUse", "beforeShellExecution", "beforeMCPExecution"}:
124
+ return {"permission": "allow" if passed else "deny", "user_message": "exitzero: " + receipt["status"],
125
+ "agent_message": "Review the exitzero run receipt." if not passed else ""}
126
+ if event == "postToolUse" and not passed:
127
+ return {"additional_context": "exitzero failed. Inspect the run receipt and fix the reported checks."}
128
+ if event == "stop" and not passed and payload.get("status") != "aborted" and payload.get("loop_count", 0) < 1:
129
+ return {"followup_message": "exitzero failed. Run exitzero check, inspect the receipt, and fix the reported checks."}
130
+ return {}
exitzero/ledger.py ADDED
@@ -0,0 +1,25 @@
1
+ """Append-only per-run receipts; no source, environment or command output."""
2
+ import json
3
+ from pathlib import Path
4
+ import tempfile
5
+
6
+ from .files import safe_path
7
+
8
+
9
+ def persist(root: Path, receipt: dict) -> str:
10
+ directory = safe_path(root, ".exitzero/runs")
11
+ directory.mkdir(parents=True, exist_ok=True)
12
+ relative = f".exitzero/runs/{receipt['run_id']}.json"
13
+ destination = safe_path(root, relative)
14
+ receipt["receipt"] = relative
15
+ temporary: Path | None = None
16
+ try:
17
+ with tempfile.NamedTemporaryFile(mode="w", dir=directory, prefix=".pending-", encoding="utf-8", delete=False) as handle:
18
+ temporary = Path(handle.name)
19
+ json.dump(receipt, handle, ensure_ascii=False, sort_keys=True, indent=2)
20
+ handle.write("\n")
21
+ temporary.replace(destination)
22
+ finally:
23
+ if temporary is not None:
24
+ temporary.unlink(missing_ok=True)
25
+ return relative
exitzero/loader.py ADDED
@@ -0,0 +1,19 @@
1
+ """Load only policy-selected plugins, by entry point or explicit module name."""
2
+ import importlib
3
+ from importlib.metadata import entry_points
4
+
5
+ from .api import API_VERSION, Registry
6
+
7
+
8
+ def discover(names: list[str]) -> Registry:
9
+ registry = Registry()
10
+ available = entry_points(group="exitzero.plugins")
11
+ for name in names:
12
+ matches = [entry for entry in available if entry.name == name]
13
+ if len(matches) > 1:
14
+ raise ValueError("Ambiguous plugin entry point")
15
+ plugin = matches[0].load() if matches else importlib.import_module(name)
16
+ if getattr(plugin, "API_VERSION", None) != API_VERSION:
17
+ raise ValueError("Unsupported plugin API version")
18
+ plugin.register(registry)
19
+ return registry