test(runtime_wedge): module-functions signature snapshot drift gate
BaseAdapter docstring tells adapter authors: > ``runtime_wedge.mark_wedged()`` / ``clear_wedge()`` — flip the > workspace to ``degraded`` + auto-recover when your SDK hits a > non-recoverable error class. Import directly from ``runtime_wedge``; > the heartbeat forwards the state to the platform automatically. That's a contract — adapter templates depend on the four module-level functions (``is_wedged``, ``wedge_reason``, ``mark_wedged``, ``clear_wedge``) being importable by those exact names with those exact signatures. Renaming any silently breaks every adapter that calls them: the import resolves the module fine, the ``AttributeError`` only surfaces when the adapter actually hits its first SDK error — long after the rename merges. Same drift class as #2378 / #2380 / #2381 (BaseAdapter, skill_loader) applied to the module-level function surface. Changes: - tests/_signature_snapshot.py gains build_module_functions_record. Walks a module's public top-level functions, optionally filtered to a specific name list (used here — runtime_wedge has internal helpers like reset_for_test that intentionally aren't part of the contract). Skips re-exports via __module__ check so a `from foo import bar` doesn't pollute the snapshot. - tests/test_runtime_wedge_signature.py snapshots the four contract functions. Plus a defense-in-depth required-functions test that catches removal even when source + snapshot are updated together. Verified: deliberately renaming `mark_wedged` → `mark_wedged_RENAMED` trips the gate with full snapshot diff in the failure message. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4d1156cb8b
commit
70176e6c8f
@ -93,6 +93,47 @@ def build_class_signature_record(cls: type) -> dict:
|
|||||||
return {"class": cls.__name__, "methods": methods}
|
return {"class": cls.__name__, "methods": methods}
|
||||||
|
|
||||||
|
|
||||||
|
def build_module_functions_record(module: object, function_names: list[str] | None = None) -> dict:
|
||||||
|
"""Snapshot a module's public top-level functions. By default, walks
|
||||||
|
every public callable defined IN the module (excludes re-exports
|
||||||
|
via __module__ check). Pass ``function_names`` explicitly to pin a
|
||||||
|
specific set when the module exports more than the contract surface
|
||||||
|
(e.g. internal helpers that intentionally aren't part of the gate).
|
||||||
|
|
||||||
|
Returns: ``{module: <name>, functions: [<sorted records>]}``
|
||||||
|
"""
|
||||||
|
import types
|
||||||
|
|
||||||
|
fns: list[dict] = []
|
||||||
|
target_module = module.__name__
|
||||||
|
|
||||||
|
if function_names is not None:
|
||||||
|
for fn_name in sorted(function_names):
|
||||||
|
fn = getattr(module, fn_name, None)
|
||||||
|
if fn is None or not isinstance(fn, types.FunctionType):
|
||||||
|
# Caller asked for a name that isn't a function in the
|
||||||
|
# module — surface it as part of the snapshot so the
|
||||||
|
# error path stays in the failure-message-with-diff
|
||||||
|
# path rather than blowing up here.
|
||||||
|
fns.append({"name": fn_name, "missing": True})
|
||||||
|
continue
|
||||||
|
fns.append(_signature_record(fn_name, fn))
|
||||||
|
else:
|
||||||
|
for attr_name in sorted(vars(module)):
|
||||||
|
if attr_name.startswith("_"):
|
||||||
|
continue
|
||||||
|
attr = getattr(module, attr_name)
|
||||||
|
if not isinstance(attr, types.FunctionType):
|
||||||
|
continue
|
||||||
|
# Skip re-exports — only record functions defined IN this
|
||||||
|
# module so a `from foo import bar` doesn't pollute the
|
||||||
|
# snapshot.
|
||||||
|
if getattr(attr, "__module__", None) != target_module:
|
||||||
|
continue
|
||||||
|
fns.append(_signature_record(attr_name, attr))
|
||||||
|
return {"module": target_module, "functions": fns}
|
||||||
|
|
||||||
|
|
||||||
def build_dataclass_record(cls: type) -> dict:
|
def build_dataclass_record(cls: type) -> dict:
|
||||||
"""Snapshot a dataclass's field shape. Captures field name + type
|
"""Snapshot a dataclass's field shape. Captures field name + type
|
||||||
annotation + has_default per field, plus the @dataclass(frozen=...)
|
annotation + has_default per field, plus the @dataclass(frozen=...)
|
||||||
|
|||||||
40
workspace/tests/snapshots/runtime_wedge_signature.json
Normal file
40
workspace/tests/snapshots/runtime_wedge_signature.json
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"functions": [
|
||||||
|
{
|
||||||
|
"is_abstract": false,
|
||||||
|
"is_async": false,
|
||||||
|
"name": "clear_wedge",
|
||||||
|
"parameters": [],
|
||||||
|
"return_annotation": "None"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"is_abstract": false,
|
||||||
|
"is_async": false,
|
||||||
|
"name": "is_wedged",
|
||||||
|
"parameters": [],
|
||||||
|
"return_annotation": "bool"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"is_abstract": false,
|
||||||
|
"is_async": false,
|
||||||
|
"name": "mark_wedged",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"annotation": "str",
|
||||||
|
"has_default": false,
|
||||||
|
"kind": "POSITIONAL_OR_KEYWORD",
|
||||||
|
"name": "reason"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"return_annotation": "None"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"is_abstract": false,
|
||||||
|
"is_async": false,
|
||||||
|
"name": "wedge_reason",
|
||||||
|
"parameters": [],
|
||||||
|
"return_annotation": "str"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"module": "runtime_wedge"
|
||||||
|
}
|
||||||
94
workspace/tests/test_runtime_wedge_signature.py
Normal file
94
workspace/tests/test_runtime_wedge_signature.py
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
"""runtime_wedge public-API signature snapshot — drift gate.
|
||||||
|
|
||||||
|
``BaseAdapter`` docstring explicitly tells adapter authors to call
|
||||||
|
``runtime_wedge.mark_wedged(reason)`` / ``clear_wedge()`` when their
|
||||||
|
SDK hits a non-recoverable error class — the heartbeat thread reads
|
||||||
|
``is_wedged()`` / ``wedge_reason()`` to flip the workspace to
|
||||||
|
``degraded`` and surface the cause to the canvas.
|
||||||
|
|
||||||
|
That's a public adapter-facing API. Renaming any of the four
|
||||||
|
functions silently breaks every adapter that calls them: the import
|
||||||
|
still resolves the module, the missing attribute raises
|
||||||
|
``AttributeError`` only when the adapter actually hits its first
|
||||||
|
SDK error — long after the rename merges.
|
||||||
|
|
||||||
|
Same drift class as the BaseAdapter signature snapshot (#2378, #2380)
|
||||||
|
and skill_loader gate (#2381), applied to the module-level
|
||||||
|
function surface.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
WORKSPACE_DIR = Path(__file__).parent.parent
|
||||||
|
if str(WORKSPACE_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(WORKSPACE_DIR))
|
||||||
|
|
||||||
|
from tests._signature_snapshot import ( # noqa: E402
|
||||||
|
build_module_functions_record,
|
||||||
|
compare_against_snapshot,
|
||||||
|
)
|
||||||
|
|
||||||
|
SNAPSHOT_PATH = Path(__file__).parent / "snapshots" / "runtime_wedge_signature.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_full_snapshot() -> dict:
|
||||||
|
"""Pin only the four contract functions adapters call. Other module-
|
||||||
|
level helpers (``reset_for_test``, internal state) intentionally
|
||||||
|
aren't part of the snapshot — adapters MUST NOT depend on them.
|
||||||
|
"""
|
||||||
|
import runtime_wedge
|
||||||
|
|
||||||
|
return build_module_functions_record(
|
||||||
|
runtime_wedge,
|
||||||
|
function_names=[
|
||||||
|
"is_wedged",
|
||||||
|
"wedge_reason",
|
||||||
|
"mark_wedged",
|
||||||
|
"clear_wedge",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_wedge_signature_matches_snapshot():
|
||||||
|
compare_against_snapshot(_build_full_snapshot(), SNAPSHOT_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_has_required_functions():
|
||||||
|
"""Defense-in-depth: even if both source and snapshot are updated
|
||||||
|
together, removing any of the four adapter-facing functions
|
||||||
|
requires explicit edit here. The required set is the documented
|
||||||
|
public contract — see ``BaseAdapter`` docstring.
|
||||||
|
"""
|
||||||
|
if not SNAPSHOT_PATH.exists():
|
||||||
|
pytest.skip(f"{SNAPSHOT_PATH.name} not generated yet")
|
||||||
|
|
||||||
|
import json
|
||||||
|
snapshot = json.loads(SNAPSHOT_PATH.read_text())
|
||||||
|
fn_names = {f["name"] for f in snapshot["functions"]}
|
||||||
|
|
||||||
|
required = {
|
||||||
|
"is_wedged", # platform-side heartbeat reads this
|
||||||
|
"wedge_reason", # surfaces the why on the canvas
|
||||||
|
"mark_wedged", # adapters call this on non-recoverable errors
|
||||||
|
"clear_wedge", # adapters call this on auto-recovery
|
||||||
|
}
|
||||||
|
missing = required - fn_names
|
||||||
|
if missing:
|
||||||
|
pytest.fail(
|
||||||
|
f"runtime_wedge snapshot is missing required functions: {sorted(missing)}.\n"
|
||||||
|
"Either restore them on runtime_wedge.py, OR coordinate adapter "
|
||||||
|
"updates AND remove the entry from `required` in this test "
|
||||||
|
"with a justification."
|
||||||
|
)
|
||||||
|
|
||||||
|
for fn in snapshot["functions"]:
|
||||||
|
if fn.get("missing"):
|
||||||
|
pytest.fail(
|
||||||
|
f"runtime_wedge.{fn['name']} resolved as a non-function — "
|
||||||
|
"either it was replaced by a different kind of attribute "
|
||||||
|
"(class? module-level alias?) which adapters' direct call "
|
||||||
|
"would break, OR it was removed entirely."
|
||||||
|
)
|
||||||
Loading…
Reference in New Issue
Block a user