generated from coulomb/repo-seed
feat(engine): typed-extension runtime (WP-0014 T2)
engine/extension.py: Extension contract (id/provides/declares_types/depends_on/ conflicts_with + bound hooks), ExtensionRuntime (register-with-verification, activate = dependency closure + conflict + type-collision checks rejecting impossible profiles), and ActiveExtensions with deterministic (topological, id-tie) hook dispatch — transform hooks chain, collect hooks gather. 9 tests green, coverage floor held, pyflakes clean. Marks T2 done. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,22 @@ is *one shard*: it is consumed by the orchestrator only via its `EngineShardAdap
|
|||||||
imports the derived tier (`union`/`projection`).
|
imports the derived tier (`union`/`projection`).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from shard_wiki.engine.extension import (
|
||||||
|
ActiveExtensions,
|
||||||
|
Extension,
|
||||||
|
ExtensionError,
|
||||||
|
ExtensionRuntime,
|
||||||
|
Hook,
|
||||||
|
)
|
||||||
from shard_wiki.engine.kernel import EngineKernel
|
from shard_wiki.engine.kernel import EngineKernel
|
||||||
from shard_wiki.engine.links import extract_wikilinks
|
from shard_wiki.engine.links import extract_wikilinks
|
||||||
|
|
||||||
__all__ = ["EngineKernel", "extract_wikilinks"]
|
__all__ = [
|
||||||
|
"EngineKernel",
|
||||||
|
"extract_wikilinks",
|
||||||
|
"Hook",
|
||||||
|
"Extension",
|
||||||
|
"ExtensionError",
|
||||||
|
"ExtensionRuntime",
|
||||||
|
"ActiveExtensions",
|
||||||
|
]
|
||||||
|
|||||||
161
src/shard_wiki/engine/extension.py
Normal file
161
src/shard_wiki/engine/extension.py
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
"""Typed-extension runtime — the engine framework (WikiEngineCoreArchitecture §4, E-3/E-9).
|
||||||
|
|
||||||
|
Everything beyond the kernel's c2-minimum is an :class:`Extension`: it declares a typed
|
||||||
|
contract (id, provided capabilities, declared types, bound hooks, dependencies, conflicts) and
|
||||||
|
the runtime **composes** an activation set deterministically, **rejecting impossible profiles**
|
||||||
|
(unmet deps / conflicts / type collisions) — the §6.5 capability-as-data discipline applied to
|
||||||
|
extensions. Extension structure is **verified at registration** (mirrors §6.6 conformance):
|
||||||
|
bad ids or non-callable hook handlers are refused, so the framework acts on verified data.
|
||||||
|
|
||||||
|
Hooks are dispatched in a declared, deterministic order (dependency-topological, ties by id):
|
||||||
|
*transform* hooks chain a payload through handlers; *collect* hooks gather contributions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable, Iterable, Mapping
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, ClassVar
|
||||||
|
|
||||||
|
__all__ = ["Hook", "Extension", "ExtensionError", "ExtensionRuntime", "ActiveExtensions"]
|
||||||
|
|
||||||
|
|
||||||
|
class ExtensionError(ValueError):
|
||||||
|
"""Raised when an extension is malformed or an activation set is impossible (§6.5)."""
|
||||||
|
|
||||||
|
|
||||||
|
class Hook(Enum):
|
||||||
|
# transform hooks: each handler takes (payload, ctx) and returns the next payload
|
||||||
|
ON_WRITE = "on_write" # transform a draft before persist
|
||||||
|
ON_READ = "on_read" # transform a page on read
|
||||||
|
ON_RESOLVE = "on_resolve" # transform a name resolution
|
||||||
|
ON_RENDER = "on_render" # produce a derived representation
|
||||||
|
# collect hooks: each handler takes (payload, ctx) and returns a contribution
|
||||||
|
ON_LINK = "on_link" # contribute link/transclusion edges
|
||||||
|
ON_QUERY = "on_query" # answer a query
|
||||||
|
ON_PROFILE = "on_profile" # contribute capability-profile positions (E-5)
|
||||||
|
|
||||||
|
|
||||||
|
_TRANSFORM = frozenset({Hook.ON_WRITE, Hook.ON_READ, Hook.ON_RESOLVE, Hook.ON_RENDER})
|
||||||
|
_COLLECT = frozenset({Hook.ON_LINK, Hook.ON_QUERY, Hook.ON_PROFILE})
|
||||||
|
|
||||||
|
|
||||||
|
class Extension:
|
||||||
|
"""Base class for a typed extension. Subclasses set the class vars and override
|
||||||
|
:meth:`hooks` to bind handlers (signature ``handler(payload, ctx) -> result``)."""
|
||||||
|
|
||||||
|
id: ClassVar[str] = ""
|
||||||
|
provides: ClassVar[tuple[str, ...]] = ()
|
||||||
|
declares_types: ClassVar[tuple[str, ...]] = ()
|
||||||
|
depends_on: ClassVar[tuple[str, ...]] = ()
|
||||||
|
conflicts_with: ClassVar[tuple[str, ...]] = ()
|
||||||
|
|
||||||
|
def hooks(self) -> Mapping[Hook, Callable[[Any, Any], Any]]:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
class ActiveExtensions:
|
||||||
|
"""A composed, ordered activation set with deterministic hook dispatch."""
|
||||||
|
|
||||||
|
def __init__(self, ordered: list[Extension]) -> None:
|
||||||
|
self._ordered = ordered
|
||||||
|
self.ids: tuple[str, ...] = tuple(e.id for e in ordered)
|
||||||
|
self._tables: dict[Hook, list[tuple[str, Callable[[Any, Any], Any]]]] = {}
|
||||||
|
for ext in ordered:
|
||||||
|
for hook, fn in ext.hooks().items():
|
||||||
|
self._tables.setdefault(hook, []).append((ext.id, fn))
|
||||||
|
|
||||||
|
def handlers(self, hook: Hook) -> tuple[str, ...]:
|
||||||
|
"""The extension ids bound to ``hook``, in dispatch order (for introspection)."""
|
||||||
|
return tuple(eid for eid, _ in self._tables.get(hook, ()))
|
||||||
|
|
||||||
|
def dispatch_transform(self, hook: Hook, payload: Any, ctx: Any = None) -> Any:
|
||||||
|
if hook not in _TRANSFORM:
|
||||||
|
raise ExtensionError(f"{hook} is not a transform hook")
|
||||||
|
for _eid, fn in self._tables.get(hook, ()):
|
||||||
|
payload = fn(payload, ctx)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def dispatch_collect(self, hook: Hook, payload: Any = None, ctx: Any = None) -> list[Any]:
|
||||||
|
if hook not in _COLLECT:
|
||||||
|
raise ExtensionError(f"{hook} is not a collect hook")
|
||||||
|
return [fn(payload, ctx) for _eid, fn in self._tables.get(hook, ())]
|
||||||
|
|
||||||
|
|
||||||
|
class ExtensionRuntime:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._registered: dict[str, Extension] = {}
|
||||||
|
|
||||||
|
def register(self, ext: Extension) -> Extension:
|
||||||
|
"""Register an extension after structural verification (mirrors §6.6)."""
|
||||||
|
if not ext.id or not ext.id.startswith("ext."):
|
||||||
|
raise ExtensionError(f"extension id must be 'ext.<name>', got {ext.id!r}")
|
||||||
|
if ext.id in self._registered:
|
||||||
|
raise ExtensionError(f"duplicate extension id: {ext.id}")
|
||||||
|
bound = ext.hooks()
|
||||||
|
for hook, fn in bound.items():
|
||||||
|
if not isinstance(hook, Hook):
|
||||||
|
raise ExtensionError(f"{ext.id}: hook key {hook!r} is not a Hook")
|
||||||
|
if not callable(fn):
|
||||||
|
raise ExtensionError(f"{ext.id}: handler for {hook} is not callable")
|
||||||
|
self._registered[ext.id] = ext
|
||||||
|
return ext
|
||||||
|
|
||||||
|
def activate(self, ids: Iterable[str]) -> ActiveExtensions:
|
||||||
|
"""Compose an activation set: dependency closure → conflict/type checks → deterministic
|
||||||
|
order. Raises :class:`ExtensionError` on an impossible profile."""
|
||||||
|
requested = set(ids)
|
||||||
|
unknown = requested - self._registered.keys()
|
||||||
|
if unknown:
|
||||||
|
raise ExtensionError(f"unknown extensions: {sorted(unknown)}")
|
||||||
|
|
||||||
|
# dependency closure
|
||||||
|
active: set[str] = set()
|
||||||
|
frontier = list(requested)
|
||||||
|
while frontier:
|
||||||
|
eid = frontier.pop()
|
||||||
|
if eid in active:
|
||||||
|
continue
|
||||||
|
ext = self._registered.get(eid)
|
||||||
|
if ext is None:
|
||||||
|
raise ExtensionError(f"unmet dependency: {eid}")
|
||||||
|
active.add(eid)
|
||||||
|
frontier.extend(d for d in ext.depends_on if d not in active)
|
||||||
|
|
||||||
|
exts = [self._registered[e] for e in active]
|
||||||
|
|
||||||
|
# conflicts
|
||||||
|
for ext in exts:
|
||||||
|
clash = active & set(ext.conflicts_with)
|
||||||
|
if clash:
|
||||||
|
raise ExtensionError(f"{ext.id} conflicts with active {sorted(clash)}")
|
||||||
|
|
||||||
|
# type collisions (two active extensions claiming the same type id)
|
||||||
|
owner: dict[str, str] = {}
|
||||||
|
for ext in exts:
|
||||||
|
for t in ext.declares_types:
|
||||||
|
if t in owner:
|
||||||
|
raise ExtensionError(
|
||||||
|
f"type collision on {t!r}: {owner[t]} and {ext.id}"
|
||||||
|
)
|
||||||
|
owner[t] = ext.id
|
||||||
|
|
||||||
|
return ActiveExtensions(self._topo_order(exts))
|
||||||
|
|
||||||
|
def _topo_order(self, exts: list[Extension]) -> list[Extension]:
|
||||||
|
"""Dependencies before dependents; ties broken by id (deterministic)."""
|
||||||
|
by_id = {e.id: e for e in exts}
|
||||||
|
ordered: list[Extension] = []
|
||||||
|
placed: set[str] = set()
|
||||||
|
|
||||||
|
def visit(ext: Extension) -> None:
|
||||||
|
if ext.id in placed:
|
||||||
|
return
|
||||||
|
for dep in sorted(d for d in ext.depends_on if d in by_id):
|
||||||
|
visit(by_id[dep])
|
||||||
|
placed.add(ext.id)
|
||||||
|
ordered.append(ext)
|
||||||
|
|
||||||
|
for ext in sorted(exts, key=lambda e: e.id):
|
||||||
|
visit(ext)
|
||||||
|
return ordered
|
||||||
109
tests/test_engine_extension.py
Normal file
109
tests/test_engine_extension.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
"""Tests for the typed-extension runtime (SHARD-WP-0014 T2)."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from shard_wiki.engine import Extension, ExtensionError, ExtensionRuntime, Hook
|
||||||
|
|
||||||
|
|
||||||
|
class Upper(Extension):
|
||||||
|
id = "ext.upper"
|
||||||
|
declares_types = ("upper",)
|
||||||
|
|
||||||
|
def hooks(self):
|
||||||
|
return {Hook.ON_WRITE: lambda body, ctx: body.upper()}
|
||||||
|
|
||||||
|
|
||||||
|
class Bang(Extension):
|
||||||
|
id = "ext.bang"
|
||||||
|
depends_on = ("ext.upper",)
|
||||||
|
|
||||||
|
def hooks(self):
|
||||||
|
return {Hook.ON_WRITE: lambda body, ctx: body + "!"}
|
||||||
|
|
||||||
|
|
||||||
|
class Profiler(Extension):
|
||||||
|
id = "ext.profiler"
|
||||||
|
|
||||||
|
def hooks(self):
|
||||||
|
return {Hook.ON_PROFILE: lambda payload, ctx: {"structure": "typed"}}
|
||||||
|
|
||||||
|
|
||||||
|
def _runtime(*exts):
|
||||||
|
rt = ExtensionRuntime()
|
||||||
|
for e in exts:
|
||||||
|
rt.register(e)
|
||||||
|
return rt
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_rejects_bad_id_and_noncallable_hook():
|
||||||
|
rt = ExtensionRuntime()
|
||||||
|
class BadId(Extension):
|
||||||
|
id = "upper" # missing 'ext.' prefix
|
||||||
|
with pytest.raises(ExtensionError, match="ext."):
|
||||||
|
rt.register(BadId())
|
||||||
|
|
||||||
|
class Liar(Extension):
|
||||||
|
id = "ext.liar"
|
||||||
|
def hooks(self):
|
||||||
|
return {Hook.ON_WRITE: "not-callable"} # type: ignore[dict-item]
|
||||||
|
with pytest.raises(ExtensionError, match="not callable"):
|
||||||
|
rt.register(Liar())
|
||||||
|
|
||||||
|
|
||||||
|
def test_transform_hook_chains_in_dependency_order():
|
||||||
|
rt = _runtime(Upper(), Bang())
|
||||||
|
active = rt.activate({"ext.bang"}) # pulls ext.upper via depends_on
|
||||||
|
assert set(active.ids) == {"ext.upper", "ext.bang"}
|
||||||
|
# upper (dependency) runs before bang (dependent): "hi" -> "HI" -> "HI!"
|
||||||
|
assert active.handlers(Hook.ON_WRITE) == ("ext.upper", "ext.bang")
|
||||||
|
assert active.dispatch_transform(Hook.ON_WRITE, "hi") == "HI!"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dependency_closure_is_automatic():
|
||||||
|
rt = _runtime(Upper(), Bang())
|
||||||
|
assert set(rt.activate({"ext.bang"}).ids) == {"ext.upper", "ext.bang"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_extension_rejected():
|
||||||
|
with pytest.raises(ExtensionError, match="unknown"):
|
||||||
|
ExtensionRuntime().activate({"ext.ghost"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_conflict_rejected():
|
||||||
|
class A(Extension):
|
||||||
|
id = "ext.a"
|
||||||
|
conflicts_with = ("ext.b",)
|
||||||
|
class B(Extension):
|
||||||
|
id = "ext.b"
|
||||||
|
with pytest.raises(ExtensionError, match="conflicts"):
|
||||||
|
_runtime(A(), B()).activate({"ext.a", "ext.b"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_type_collision_rejected():
|
||||||
|
class S1(Extension):
|
||||||
|
id = "ext.s1"
|
||||||
|
declares_types = ("record",)
|
||||||
|
class S2(Extension):
|
||||||
|
id = "ext.s2"
|
||||||
|
declares_types = ("record",)
|
||||||
|
with pytest.raises(ExtensionError, match="type collision"):
|
||||||
|
_runtime(S1(), S2()).activate({"ext.s1", "ext.s2"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_hook_gathers_contributions():
|
||||||
|
active = _runtime(Profiler()).activate({"ext.profiler"})
|
||||||
|
assert active.dispatch_collect(Hook.ON_PROFILE) == [{"structure": "typed"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrong_dispatch_kind_errors():
|
||||||
|
active = _runtime(Profiler()).activate({"ext.profiler"})
|
||||||
|
with pytest.raises(ExtensionError, match="not a transform hook"):
|
||||||
|
active.dispatch_transform(Hook.ON_PROFILE, "x")
|
||||||
|
|
||||||
|
|
||||||
|
def test_unmet_dependency_rejected():
|
||||||
|
# Bang depends on ext.upper, but only Bang is registered.
|
||||||
|
rt = ExtensionRuntime()
|
||||||
|
rt.register(Bang())
|
||||||
|
with pytest.raises(ExtensionError, match="unmet dependency|unknown"):
|
||||||
|
rt.activate({"ext.bang"})
|
||||||
@@ -63,7 +63,7 @@ helper). No extensions yet. Tests: page CRUD-as-history; kernel-only shard works
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: SHARD-WP-0014-T2
|
id: SHARD-WP-0014-T2
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "8ae8e58a-f081-432b-b2c5-b6435fbf3843"
|
state_hub_task_id: "8ae8e58a-f081-432b-b2c5-b6435fbf3843"
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user