Skip to content
Merged
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
32 changes: 26 additions & 6 deletions src/liminate/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -1183,6 +1183,7 @@ def parse_when_block(

# Parse the header — condition + optional unless guard + optional `:`.
stream = TokenStream(header_tokens[1:]) # skip the `when` connective
inherited_from: str | None = None
try:
if stream.at_end():
raise _ParseError(
Expand All @@ -1205,6 +1206,27 @@ def parse_when_block(
)
unless_guard = _parse_or_condition(stream)

# Meta-Structural Era — `inherited when ... from <agent>` agent
# attribution (Invariant Checkpoint v2 §43). Legal only with the
# `inherited` prefix. By this point the condition and optional
# guard are fully parsed, and no production in the when-condition
# grammar consumes `from`, so a `from` here is unambiguously
# attribution.
if is_inherited:
inherited_from = _try_consume_inherited_from(stream)
else:
peek = stream.peek()
if (
peek
and peek.type is TokenType.CONNECTIVE
and peek.value == "from"
):
raise _ParseError(
"'from' attribution on a 'when' header needs the "
"'inherited' prefix — try: inherited when <condition> "
"from <agent-name>."
)

# v3a §110: the colon after the `when` line is optional.
peek = stream.peek()
if peek and peek.type is TokenType.DELIMITER and peek.value == ":":
Expand Down Expand Up @@ -1243,12 +1265,10 @@ def parse_when_block(
when_node = WhenNode(condition=condition, unless=unless_guard, action=action)
if is_inherited:
when_node.inherited = True
# Agent attribution (`from <agent>`) on `inherited when` blocks is
# deferred: a trailing `from` on a `when` header belongs to
# condition slot resolution (e.g. `cite "x" from source`), not
# attribution. The `inherited` flag alone is sufficient for
# Invariant; `inherited_from` on `when` awaits a designed grammar.
when_node.inherited_from = None
# Invariant Checkpoint v2 §43 — statement-final agent attribution
# extended to `inherited when` headers. Flows into HANDLER_FIRE
# trigger metadata via listener._wrap_with_trigger.
when_node.inherited_from = inherited_from

# v3a §123: condition or guard mixed and/or — amber. Action sub-
# statements were already amber-checked individually by `parse()`,
Expand Down
8 changes: 7 additions & 1 deletion src/liminate/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,11 @@ def render(node: ASTNode) -> str:
if rationale is not None:
rendered = f'{rendered} because "{rationale}"'

# WhenNode attribution renders on the header line inside _render_when
# (multi-line output would otherwise land this suffix after the
# action block, where it's syntactically dead).
inherited_from = getattr(node, "inherited_from", None)
if inherited_from is not None:
if inherited_from is not None and not isinstance(node, WhenNode):
rendered = f"{rendered} from {inherited_from}"

return rendered
Expand Down Expand Up @@ -510,6 +513,9 @@ def _render_when(node: WhenNode, render_fn) -> str:
header = f"when {render_fn(node.condition)}"
if node.unless is not None:
header += f" unless {render_fn(node.unless)}"
inherited_from = getattr(node, "inherited_from", None)
if inherited_from is not None:
header += f" from {inherited_from}"
if isinstance(node.action, SequenceNode):
action_lines = [render_fn(op) for op in node.action.operations]
else:
Expand Down
168 changes: 167 additions & 1 deletion tests/test_inherited.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,12 @@

import io

from liminate.adapter import LiveValueRegistry, TestAdapter
from liminate.analyzer import SymbolEntry
from liminate.cli import run_file
from liminate.interpreter import HandlerTable, execute as _execute
from liminate.lexer import tokenize
from liminate.listener import listen
from liminate.parser import (
AddNode,
AssignNode,
Expand All @@ -33,6 +37,7 @@
parse,
parse_when_block,
)
from liminate.reorderer import reorder
from liminate.renderer import render
from liminate.result import ResultStatus
from liminate.vocabulary import ALL_RESERVED, TokenType, reserved_category
Expand Down Expand Up @@ -332,7 +337,8 @@ def test_inherited_when_header_parses():
node = parse_when_block(header, actions)
assert not hasattr(node, "status"), getattr(node, "message", node)
assert node.inherited is True
# Agent attribution on `when` headers is deferred (build §4.2).
# No `from` clause given, so attribution is absent (not deferred —
# see the `inherited when ... from <agent>` tests below).
assert node.inherited_from is None


Expand Down Expand Up @@ -380,6 +386,132 @@ def test_inherited_when_missing_when_is_parse_error():
assert result.status is ResultStatus.ERROR_PARSE


# ---------------------------------------------------------------------------
# `inherited when ... from <agent>` attribution (Invariant Checkpoint v2
# §43 / IRQ-3). Statement-final agent attribution extended to `inherited
# when` headers — same `_try_consume_inherited_from` helper, same
# agent-name rules, just a new grammatical position.
# ---------------------------------------------------------------------------


def test_inherited_when_from_attribution_parses():
header = tokenize("inherited when level is above 50 from compliance-agent")
actions = [tokenize("show level")]
node = parse_when_block(header, actions)
assert not hasattr(node, "status"), getattr(node, "message", node)
assert node.inherited is True
assert node.inherited_from == "compliance-agent"


def test_inherited_when_from_with_unless_guard_parses():
header = tokenize(
"inherited when level is above 50 unless level is above 90 "
"from compliance-agent"
)
actions = [tokenize("show level")]
node = parse_when_block(header, actions)
assert not hasattr(node, "status"), getattr(node, "message", node)
assert node.inherited is True
assert node.unless is not None
assert node.inherited_from == "compliance-agent"


def test_inherited_when_from_with_trailing_colon_parses():
header = tokenize("inherited when level is above 50 from compliance-agent:")
actions = [tokenize("show level")]
node = parse_when_block(header, actions)
assert not hasattr(node, "status"), getattr(node, "message", node)
assert node.inherited_from == "compliance-agent"


def test_inherited_when_of_condition_from_attribution_parses():
# The exact case the old deferral feared: `of` belongs to the
# condition (`status of report`), `source-agent` is the attribution.
header = tokenize(
"inherited when status of report is above 5 from source-agent"
)
actions = [tokenize("show report")]
node = parse_when_block(header, actions)
assert not hasattr(node, "status"), getattr(node, "message", node)
assert node.inherited_from == "source-agent"


def test_when_header_trailing_from_without_inherited_is_parse_error():
header = tokenize("when level is above 50 from compliance-agent")
actions = [tokenize("show level")]
result = parse_when_block(header, actions)
assert result.status is ResultStatus.ERROR_PARSE
assert "'inherited' prefix" in result.message


def test_inherited_when_from_quoted_agent_is_parse_error():
header = tokenize('inherited when level is above 50 from "compliance agent"')
actions = [tokenize("show level")]
result = parse_when_block(header, actions)
assert result.status is ResultStatus.ERROR_PARSE
assert "can't have spaces" in result.message


def test_inherited_when_from_reserved_word_is_parse_error():
header = tokenize("inherited when level is above 50 from require")
actions = [tokenize("show level")]
result = parse_when_block(header, actions)
assert result.status is ResultStatus.ERROR_PARSE
assert "reserved" in result.message


def test_inherited_when_from_missing_agent_is_parse_error():
header = tokenize("inherited when level is above 50 from")
actions = [tokenize("show level")]
result = parse_when_block(header, actions)
assert result.status is ResultStatus.ERROR_PARSE


def test_inherited_when_from_renders_on_header_line():
header = tokenize(
"inherited when level is above 50 unless level is above 90 "
"from compliance-agent"
)
actions = [tokenize("show level")]
node = parse_when_block(header, actions)
rendered = render(node)
lines = rendered.split("\n")
# Attribution lands on the header line, never after the action lines.
assert lines[0] == (
"inherited when level is above 50 unless level is above 90 "
"from compliance-agent"
)
assert "from" not in lines[1]


def test_inherited_when_from_round_trip():
header = tokenize("inherited when level is above 50 from compliance-agent")
actions = [tokenize("show level")]
node = parse_when_block(header, actions)
rendered = render(node)
lines = rendered.split("\n")
reparsed = parse_when_block(
tokenize(lines[0]),
[tokenize(line.strip()) for line in lines[1:] if line.strip()],
)
assert not hasattr(reparsed, "status"), getattr(reparsed, "message", reparsed)
assert reparsed.condition == node.condition
assert reparsed.unless == node.unless
assert reparsed.action == node.action
# WhenNode metadata fields are compare=False — assert explicitly
# rather than relying on `==` to catch a dropped inherited_from.
assert reparsed.inherited is True
assert reparsed.inherited_from == "compliance-agent"


def test_inherited_when_without_attribution_render_unchanged():
header = tokenize("inherited when level is above 50")
actions = [tokenize("show level")]
node = parse_when_block(header, actions)
rendered = render(node)
assert rendered == "inherited when level is above 50\n show level"


# ---------------------------------------------------------------------------
# Integration — run_file
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -458,3 +590,37 @@ def test_about_because_inherited_together(tmp_path):
output = _run(tmp_path, source, quiet=True)
assert "75000" in output
assert "Error:" not in output


# ---------------------------------------------------------------------------
# Integration — HANDLER_FIRE trigger metadata carries `inherited_from`
# through the listener (§43 / IRQ-3). This is the plumbing verification:
# listener._wrap_with_trigger already reads handler.when_node.inherited_from
# (Meta-Structural Era batch 3) but had never had a non-None value to
# carry until the `inherited when ... from <agent>` grammar landed.
# ---------------------------------------------------------------------------


def test_inherited_when_from_propagates_to_handler_fire_metadata():
symtab: dict[str, SymbolEntry] = {}
symtab["level"] = SymbolEntry(name="level", value=10, type="number")
ht = HandlerTable()
reg = LiveValueRegistry()

header = reorder(tokenize("inherited when level is above 50 from compliance-agent"))
actions = [reorder(tokenize("show level"))]
when_node = parse_when_block(header, actions)
assert not hasattr(when_node, "status"), getattr(when_node, "message", when_node)

register_result = _execute(
when_node, symtab, handler_table=ht, live_value_registry=reg,
)
assert register_result.status is ResultStatus.SUCCESS, register_result

adapter = TestAdapter([("level", 75)], name="test")
results = list(listen(symtab, ht, reg, adapters=[adapter]))
fires = [r for r in results if r.status is ResultStatus.HANDLER_FIRE]
assert len(fires) == 1
trigger = fires[0].metadata["trigger"]
assert trigger["inherited"] is True
assert trigger["inherited_from"] == "compliance-agent"