Skip to content
Closed
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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Akashic Plugin Contracts

这个仓库拥有 Akashic Plugin API v2 的跨仓库静态门控。它不加载插件,也不读取正式
这个仓库拥有 Akashic Plugin API v2/v3 的跨仓库静态门控。它不加载插件,也不读取正式
workspace;只解析候选仓库的 `plugin.py`。

```bash
Expand All @@ -9,11 +9,13 @@ python -m akashic_plugin_contracts check /path/to/plugin.py

当前硬规则:

- 模块声明 `api_version = 3` 时,必须提供非空 `name`、`version` 与精确的 `apply(ctx, config)`;
- API v3 不要求继承 `Plugin`,直接后台任务必须用 `ctx.spawn()` 绑定 Fiber scope;
- `Plugin` 子类必须显式声明 `api_version = 2`;
- 禁止旧 `initialize()` 生命周期;
- `prepare()` 与 `terminate()` 必须是 async,`activate()` 与 `retire()` 必须同步;
- `prepare()` 不能取得正式 `context.data_dir`;
- `prepare()` 不能启动后台任务;
- 生命周期不得绕过 `context.create_task()` 直接调用 `asyncio.create_task()`。

命令成功时返回 0,并输出绑定文件 SHA-256 的 JSON;违反契约时返回 1。
迁移期允许同一文件同时提供 v3 模块入口与 v2 `Plugin` 类;两套声明都会校验。命令成功时返回 0,并输出 API version、entrypoint 与文件 SHA-256 的 JSON;违反契约时返回 1。
8 changes: 7 additions & 1 deletion akashic_plugin_contracts/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,16 @@ def main(argv: list[str] | None = None) -> int:
args = parser.parse_args(argv)

reports = [check_plugin(path) for path in args.paths]
versions = {report.api_version for report in reports}
contract = (
f"akashic-plugin-api-v{next(iter(versions))}"
if len(versions) == 1
else "akashic-plugin-api"
)
print(
json.dumps(
{
"contract": "akashic-plugin-api-v2",
"contract": contract,
"passed": all(report.passed for report in reports),
"reports": [report.to_dict() for report in reports],
},
Expand Down
150 changes: 126 additions & 24 deletions akashic_plugin_contracts/contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ class ContractViolation:
class ContractReport:
path: str
sha256: str
api_version: int
entrypoint: str
plugin_classes: tuple[str, ...]
violations: tuple[ContractViolation, ...]

Expand All @@ -28,42 +30,132 @@ def to_dict(self) -> dict[str, object]:
return {
"path": self.path,
"sha256": self.sha256,
"api_version": self.api_version,
"entrypoint": self.entrypoint,
"plugin_classes": list(self.plugin_classes),
"passed": self.passed,
"violations": [asdict(item) for item in self.violations],
}


def check_plugin(path: Path) -> ContractReport:
"""Parse one plugin entrypoint and report every API v2 contract violation."""
"""Parse one plugin entrypoint and report every supported API violation."""

# 1. 固定被验收源码身份
source = path.read_bytes()
tree = ast.parse(source, filename=str(path))

# 2. 逐个检查 Plugin 子类的版本与生命周期
# 2. Select the module v3 contract or the legacy class v2 contract.
classes = [
node
for node in tree.body
if isinstance(node, ast.ClassDef) and _inherits_plugin(node)
]
violations: list[ContractViolation] = []
if not classes:
violations.append(
ContractViolation("PLG200", 1, "plugin.py 缺少 Plugin 子类")
)
for plugin_class in classes:
violations.extend(_check_class(plugin_class))
module_api_version = _module_constant(tree, "api_version")
if module_api_version == 3:
api_version = 3
entrypoint = "module"
violations.extend(_check_v3_module(tree))
for plugin_class in classes:
violations.extend(_check_class(plugin_class))
else:
api_version = 2
entrypoint = "class"
if module_api_version is not None:
violations.append(
ContractViolation(
"PLG300",
1,
"模块 api_version 只支持 3;API v2 版本声明属于 Plugin 子类",
)
)
if not classes:
violations.append(
ContractViolation("PLG200", 1, "plugin.py 缺少 Plugin 子类")
)
for plugin_class in classes:
violations.extend(_check_class(plugin_class))

# 3. 输出可供跨仓库 CI 绑定的稳定报告
return ContractReport(
path=str(path.resolve()),
sha256=hashlib.sha256(source).hexdigest(),
api_version=api_version,
entrypoint=entrypoint,
plugin_classes=tuple(item.name for item in classes),
violations=tuple(violations),
)


def _check_v3_module(tree: ast.Module) -> list[ContractViolation]:
"""Validate the named exports that Core invokes for API v3."""

# 1. Identity exports are literal and reviewable without importing plugin code.
violations: list[ContractViolation] = []
for name in ("name", "version"):
value = _module_constant(tree, name)
if not isinstance(value, str) or not value or value != value.strip():
violations.append(
ContractViolation(
"PLG301",
1,
f"API v3 模块必须声明非空字符串 {name}",
)
)

# 2. Core calls exactly apply(ctx, config); sync and async bodies are both valid.
apply = next(
(
node
for node in tree.body
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
and node.name == "apply"
),
None,
)
if apply is None:
violations.append(
ContractViolation("PLG302", 1, "API v3 模块缺少 apply(ctx, config)")
)
return violations
positional = (*apply.args.posonlyargs, *apply.args.args)
if (
tuple(arg.arg for arg in positional) != ("ctx", "config")
or apply.args.vararg is not None
or apply.args.kwarg is not None
or apply.args.kwonlyargs
):
violations.append(
ContractViolation(
"PLG303",
apply.lineno,
"API v3 apply 必须精确声明 apply(ctx, config)",
)
)
violations.extend(_check_v3_task_ownership(apply))
return violations


def _check_v3_task_ownership(
apply: ast.FunctionDef | ast.AsyncFunctionDef,
) -> list[ContractViolation]:
violations: list[ContractViolation] = []
for node in ast.walk(apply):
if isinstance(node, ast.Call) and _attribute_path(node.func) == (
"asyncio",
"create_task",
):
violations.append(
ContractViolation(
"PLG304",
node.lineno,
"API v3 后台任务必须用 ctx.spawn() 绑定 Fiber scope",
)
)
return violations


def _check_class(plugin_class: ast.ClassDef) -> list[ContractViolation]:
violations: list[ContractViolation] = []
api_version = _class_constant(plugin_class, "api_version")
Expand All @@ -89,18 +181,10 @@ def _check_class(plugin_class: ast.ClassDef) -> list[ContractViolation]:
"API v2 禁止 initialize(),按副作用归入 prepare() 或 activate()",
)
)
violations.extend(
_require_method_kind(methods, "prepare", asynchronous=True)
)
violations.extend(
_require_method_kind(methods, "activate", asynchronous=False)
)
violations.extend(
_require_method_kind(methods, "retire", asynchronous=False)
)
violations.extend(
_require_method_kind(methods, "terminate", asynchronous=True)
)
violations.extend(_require_method_kind(methods, "prepare", asynchronous=True))
violations.extend(_require_method_kind(methods, "activate", asynchronous=False))
violations.extend(_require_method_kind(methods, "retire", asynchronous=False))
violations.extend(_require_method_kind(methods, "terminate", asynchronous=True))
prepare = methods.get("prepare")
if prepare is not None:
violations.extend(_check_prepare_boundary(prepare))
Expand Down Expand Up @@ -138,17 +222,22 @@ def _check_prepare_boundary(
) -> list[ContractViolation]:
violations: list[ContractViolation] = []
for node in ast.walk(method):
if _attribute_path(node) == ("self", "context", "data_dir"):
if isinstance(node, ast.Attribute) and _attribute_path(node) == (
"self",
"context",
"data_dir",
):
violations.append(
ContractViolation(
"PLG204",
node.lineno,
"prepare() 不得取得正式 plugin-data 路径",
)
)
if (
isinstance(node, ast.Call)
and _attribute_path(node.func) == ("self", "context", "create_task")
if isinstance(node, ast.Call) and _attribute_path(node.func) == (
"self",
"context",
"create_task",
):
violations.append(
ContractViolation(
Expand Down Expand Up @@ -192,6 +281,19 @@ def _class_constant(node: ast.ClassDef, name: str) -> object:
return None


def _module_constant(node: ast.Module, name: str) -> object:
for item in node.body:
if not isinstance(item, ast.Assign) or len(item.targets) != 1:
continue
target = item.targets[0]
if isinstance(target, ast.Name) and target.id == name:
try:
return ast.literal_eval(item.value)
except (ValueError, TypeError):
return None
return None


def _attribute_path(node: ast.AST) -> tuple[str, ...]:
parts: list[str] = []
current = node
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"

[project]
name = "akashic-plugin-contracts"
version = "2.0.0"
description = "Akashic Plugin API v2 static contract gate"
version = "3.0.0"
description = "Akashic Plugin API v2 and v3 static contract gate"
requires-python = ">=3.11"

[project.scripts]
Expand Down
77 changes: 77 additions & 0 deletions tests/test_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ def test_accepts_explicit_v2_lifecycle(tmp_path: Path) -> None:
report = check_plugin(path)

assert report.passed
assert report.api_version == 2
assert report.entrypoint == "class"
assert report.plugin_classes == ("WeatherPlugin",)


Expand Down Expand Up @@ -67,3 +69,78 @@ def test_rejects_prepare_data_dir_and_unscoped_task(tmp_path: Path) -> None:
"PLG205",
"PLG206",
}


def test_accepts_v3_named_exports_without_plugin_class(tmp_path: Path) -> None:
path = tmp_path / "plugin.py"
path.write_text(
"from agent.plugin_composition import ServiceKey\n"
"api_version = 3\n"
"name = 'weather'\n"
"version = '1.0.0'\n"
"inject = (ServiceKey('clock'),)\n"
"async def apply(ctx, config):\n"
" await ctx.spawn(worker(), name='weather')\n",
encoding="utf-8",
)

report = check_plugin(path)

assert report.passed
assert report.api_version == 3
assert report.entrypoint == "module"
assert report.plugin_classes == ()


def test_accepts_v3_with_transition_v2_class(tmp_path: Path) -> None:
path = tmp_path / "plugin.py"
path.write_text(
"from agent.plugins import Plugin\n"
"api_version = 3\n"
"name = 'weather'\n"
"version = '1.0.0'\n"
"def apply(ctx, config):\n"
" return None\n"
"class WeatherPlugin(Plugin):\n"
" api_version = 2\n",
encoding="utf-8",
)

report = check_plugin(path)

assert report.passed
assert report.api_version == 3
assert report.plugin_classes == ("WeatherPlugin",)


def test_rejects_invalid_v3_identity_signature_and_task(tmp_path: Path) -> None:
path = tmp_path / "plugin.py"
path.write_text(
"import asyncio\n"
"api_version = 3\n"
"name = ' '\n"
"version = ''\n"
"async def apply(context, config, extra):\n"
" asyncio.create_task(worker())\n",
encoding="utf-8",
)

report = check_plugin(path)

assert {item.code for item in report.violations} == {
"PLG301",
"PLG303",
"PLG304",
}


def test_rejects_v3_without_apply(tmp_path: Path) -> None:
path = tmp_path / "plugin.py"
path.write_text(
"api_version = 3\n" "name = 'weather'\n" "version = '1.0.0'\n",
encoding="utf-8",
)

report = check_plugin(path)

assert {item.code for item in report.violations} == {"PLG302"}
Loading