feat(syntax): foundation types, error classes, ResolveContext Protocol
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
"""Engine syntax: tokenizer + resolver shared between runtime and Test Runner.
|
||||
|
||||
See `docs/superpowers/specs/2026-04-28-engine-syntax-design.md` for the
|
||||
language contract and parity rules.
|
||||
"""
|
||||
from engine.syntax.types import (
|
||||
CycleDetectedError,
|
||||
MalformedSyntaxError,
|
||||
RecursionLimitExceeded,
|
||||
RefOutOfSurfaceError,
|
||||
ResolveContext,
|
||||
SurfaceKind,
|
||||
SyntaxError,
|
||||
Token,
|
||||
TokenKind,
|
||||
UnknownRefError,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CycleDetectedError",
|
||||
"MalformedSyntaxError",
|
||||
"RecursionLimitExceeded",
|
||||
"RefOutOfSurfaceError",
|
||||
"ResolveContext",
|
||||
"SurfaceKind",
|
||||
"SyntaxError",
|
||||
"Token",
|
||||
"TokenKind",
|
||||
"UnknownRefError",
|
||||
]
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Foundational types for the engine syntax module.
|
||||
|
||||
Defines:
|
||||
- Token + TokenKind (the tokenizer's output shape)
|
||||
- TokenMeta (per-token metadata payload)
|
||||
- ResolveContext Protocol (the interface resolvers depend on)
|
||||
- SyntaxError hierarchy (UnknownRefError, RefOutOfSurfaceError,
|
||||
RecursionLimitExceeded, CycleDetectedError, MalformedSyntaxError)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
import random
|
||||
from dataclasses import dataclass, field
|
||||
from typing import (
|
||||
Any,
|
||||
Literal,
|
||||
Protocol,
|
||||
runtime_checkable,
|
||||
)
|
||||
|
||||
SurfaceKind = Literal["wildcard", "combine", "derivation", "assembler"]
|
||||
|
||||
|
||||
class TokenKind(enum.Enum):
|
||||
"""All token kinds emitted by tokenize_text. Single grammar, no surface variants."""
|
||||
|
||||
TEXT = "text"
|
||||
VAR = "var"
|
||||
REF = "ref"
|
||||
DP_BRACE = "dp_brace"
|
||||
DP_PIPE = "dp_pipe"
|
||||
DP_MULTI = "dp_multi"
|
||||
ESCAPE = "escape"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Token:
|
||||
"""A single tokenizer output. `meta` is kind-specific:
|
||||
|
||||
- VAR: {"name": str}
|
||||
- REF: {"uuid": str}
|
||||
- DP_MULTI: {"count": int, "sep": str, "branches": list[str]}
|
||||
- DP_BRACE: {"branches": list[str]}
|
||||
- ESCAPE: {"literal": str} (one of "$" or "@")
|
||||
- TEXT, DP_PIPE: {} (empty)
|
||||
"""
|
||||
|
||||
kind: TokenKind
|
||||
raw: str
|
||||
start: int
|
||||
end: int
|
||||
meta: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ResolveContext(Protocol):
|
||||
"""Interface the resolver depends on. Construct via build_resolve_ctx in
|
||||
engine/modules/__init__.py at pipeline start; in tests use a duck-typed
|
||||
fake (any class with these attrs and methods).
|
||||
"""
|
||||
|
||||
rng: random.Random
|
||||
max_ref_depth: int
|
||||
strict: bool
|
||||
surface: SurfaceKind
|
||||
developer_mode: bool
|
||||
warnings: list[dict[str, Any]]
|
||||
|
||||
def get_var(self, name: str) -> str | None:
|
||||
"""Return the bound value of `name`, or None if unbound."""
|
||||
...
|
||||
|
||||
def get_module(self, uuid: str) -> dict[str, Any] | None:
|
||||
"""Return the module catalog row for `uuid`, or None if missing.
|
||||
|
||||
Row shape (only fields the resolver uses): `{"type": str,
|
||||
"var_binding": str, "options": list[{"value": str, "weight": float}]}`.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
# ---------- Error hierarchy ---------------------------------------------------
|
||||
|
||||
|
||||
class SyntaxError(Exception):
|
||||
"""Base for all engine syntax errors. Distinct from Python's built-in
|
||||
SyntaxError (which is for source-code parse errors); this one is for
|
||||
wildcard syntax."""
|
||||
|
||||
|
||||
class UnknownRefError(SyntaxError):
|
||||
"""@{uuid} points to a module not in the catalog."""
|
||||
|
||||
def __init__(self, uuid: str) -> None:
|
||||
self.uuid = uuid
|
||||
super().__init__(f"unknown wildcard ref: @{{{uuid}}}")
|
||||
|
||||
|
||||
class RefOutOfSurfaceError(SyntaxError):
|
||||
"""@{uuid} appears in a non-wildcard surface (combine/derivation/assembler)."""
|
||||
|
||||
def __init__(self, uuid: str, surface: str) -> None:
|
||||
self.uuid = uuid
|
||||
self.surface = surface
|
||||
super().__init__(
|
||||
f"ref @{{{uuid}}} not allowed in {surface!r} surface; "
|
||||
f"only wildcard option values"
|
||||
)
|
||||
|
||||
|
||||
class RecursionLimitExceeded(SyntaxError):
|
||||
"""@{uuid} chain reached configured max_ref_depth."""
|
||||
|
||||
|
||||
class CycleDetectedError(SyntaxError):
|
||||
"""@{uuid} chain returned to an already-visiting UUID."""
|
||||
|
||||
def __init__(self, chain: list[str]) -> None:
|
||||
self.chain = chain
|
||||
super().__init__(
|
||||
"cycle: " + " → ".join(f"@{{{u}}}" for u in chain)
|
||||
)
|
||||
|
||||
|
||||
class MalformedSyntaxError(SyntaxError):
|
||||
"""Tokenizer / resolver received structurally invalid token meta."""
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Tests for engine.syntax — tokenizer + resolver.
|
||||
|
||||
Phase 1 coverage targets (initial impl): 80% lines / 75% branches.
|
||||
Phase 2 ratchet: 90% lines / 85% branches by 2026-07-28 (3 months post-merge).
|
||||
Update gates in `pyproject.toml` and CI workflow when ratcheting.
|
||||
"""
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Test the foundational types and error class hierarchy."""
|
||||
from __future__ import annotations
|
||||
|
||||
from engine.syntax import types as st
|
||||
|
||||
|
||||
def test_token_kind_values():
|
||||
"""All expected token kinds present, no extras."""
|
||||
expected = {"text", "var", "ref", "dp_brace", "dp_pipe", "dp_multi", "escape"}
|
||||
actual = {k.value for k in st.TokenKind}
|
||||
assert actual == expected
|
||||
|
||||
|
||||
def test_token_dataclass_shape():
|
||||
tok = st.Token(kind=st.TokenKind.TEXT, raw="hello", start=0, end=5, meta={})
|
||||
assert tok.kind == st.TokenKind.TEXT
|
||||
assert tok.raw == "hello"
|
||||
assert tok.start == 0
|
||||
assert tok.end == 5
|
||||
assert tok.meta == {}
|
||||
|
||||
|
||||
def test_unknown_ref_error_carries_uuid():
|
||||
err = st.UnknownRefError("a4f7b2e1")
|
||||
assert err.uuid == "a4f7b2e1"
|
||||
assert "a4f7b2e1" in str(err)
|
||||
|
||||
|
||||
def test_ref_out_of_surface_error_carries_uuid_and_surface():
|
||||
err = st.RefOutOfSurfaceError("a4f7b2e1", "combine")
|
||||
assert err.uuid == "a4f7b2e1"
|
||||
assert err.surface == "combine"
|
||||
assert "combine" in str(err)
|
||||
|
||||
|
||||
def test_cycle_detected_error_carries_chain():
|
||||
err = st.CycleDetectedError(["a", "b", "a"])
|
||||
assert err.chain == ["a", "b", "a"]
|
||||
assert "@{a}" in str(err)
|
||||
assert "@{b}" in str(err)
|
||||
|
||||
|
||||
def test_recursion_limit_exceeded_is_distinct_class():
|
||||
"""Distinct from CycleDetectedError so handlers can distinguish."""
|
||||
assert issubclass(st.RecursionLimitExceeded, st.SyntaxError)
|
||||
assert not issubclass(st.RecursionLimitExceeded, st.CycleDetectedError)
|
||||
|
||||
|
||||
def test_malformed_syntax_error_is_distinct_class():
|
||||
assert issubclass(st.MalformedSyntaxError, st.SyntaxError)
|
||||
|
||||
|
||||
def test_resolve_context_protocol_runtime_check():
|
||||
"""ResolveContext is a Protocol — duck-typeable."""
|
||||
import random
|
||||
|
||||
class FakeCtx:
|
||||
rng = random.Random(42)
|
||||
max_ref_depth = 8
|
||||
strict = False
|
||||
surface = "wildcard"
|
||||
developer_mode = False
|
||||
warnings: list = []
|
||||
|
||||
def get_var(self, name: str) -> str | None:
|
||||
return None
|
||||
|
||||
def get_module(self, uuid: str) -> dict | None:
|
||||
return None
|
||||
|
||||
# Should accept any object with the right attribute / method shape.
|
||||
ctx: st.ResolveContext = FakeCtx()
|
||||
assert ctx.surface == "wildcard"
|
||||
assert ctx.get_var("x") is None
|
||||
Reference in New Issue
Block a user