diff --git a/src/liminate/parser.py b/src/liminate/parser.py index b1830fa..9776207 100644 --- a/src/liminate/parser.py +++ b/src/liminate/parser.py @@ -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( @@ -1205,6 +1206,27 @@ def parse_when_block( ) unless_guard = _parse_or_condition(stream) + # Meta-Structural Era — `inherited when ... from ` 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 " + "from ." + ) + # v3a §110: the colon after the `when` line is optional. peek = stream.peek() if peek and peek.type is TokenType.DELIMITER and peek.value == ":": @@ -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 `) 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()`, diff --git a/src/liminate/renderer.py b/src/liminate/renderer.py index d60cc07..93cc80c 100644 --- a/src/liminate/renderer.py +++ b/src/liminate/renderer.py @@ -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 @@ -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: diff --git a/tests/test_inherited.py b/tests/test_inherited.py index f8a34e9..cc20c86 100644 --- a/tests/test_inherited.py +++ b/tests/test_inherited.py @@ -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, @@ -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 @@ -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 ` tests below). assert node.inherited_from is None @@ -380,6 +386,132 @@ def test_inherited_when_missing_when_is_parse_error(): assert result.status is ResultStatus.ERROR_PARSE +# --------------------------------------------------------------------------- +# `inherited when ... from ` 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 # --------------------------------------------------------------------------- @@ -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 ` 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"