diff --git a/src/liminate/analyzer.py b/src/liminate/analyzer.py index 6c08d6c..20f0609 100644 --- a/src/liminate/analyzer.py +++ b/src/liminate/analyzer.py @@ -1857,6 +1857,15 @@ def _check_define(node: DefineNode, symtab: dict[str, SymbolEntry]) -> None: NOT resolve the body's field/value types against the symbol table (there is no iterator context to resolve them against). """ + # Surface-form self-reference runs first and regardless of whether + # `node.name` is already in `symtab`. When `p` has never been defined, + # `is p` parses to a BareWord-equality ConditionNode, not a + # PredicateApplicationNode — so the reference-graph walk below never + # sees it (there is no predicate reference in the AST to walk). Left + # unchecked, `define p: is p` on a first-ever definition silently + # means "equals the text 'p'" instead of erroring, even though the + # line reads as self-referential to a human. + _check_self_referential_define(node.name, node.condition) _check_define_condition(node.condition, symtab) # Cycle check runs only after the forward-declaration check above has # passed clean — every predicate_name reachable in one hop is already @@ -1867,6 +1876,31 @@ def _check_define(node: DefineNode, symtab: dict[str, SymbolEntry]) -> None: _check_predicate_definition_cycle(node.name, node.condition, symtab) +def _check_self_referential_define(name: str, cond: ASTNode) -> None: + """Reject `define p: is p` — a body that compares against a bare word + identical to the name being defined. + + When `p` is not yet a known predicate, the parser resolves `is p` to + a string-equality ConditionNode with value=BareWord('p'), not to a + PredicateApplicationNode — so the reference-graph walk in + _find_predicate_cycle never sees it. The line reads as a self- + reference and silently means "equals the text 'p'", so it is rejected + here on the surface form, before that ambiguity can resolve either + way. + """ + if isinstance(cond, CompoundConditionNode): + _check_self_referential_define(name, cond.left) + _check_self_referential_define(name, cond.right) + return + if not isinstance(cond, ConditionNode): + return + for operand in (cond.field, cond.value, cond.value2): + if isinstance(operand, BareWord) and operand.word == name: + raise _SemanticError(f"Definition '{name}' can't refer to itself.") + if isinstance(operand, NameRef) and operand.name == name: + raise _SemanticError(f"Definition '{name}' can't refer to itself.") + + def _check_define_condition( cond: ASTNode, symtab: dict[str, SymbolEntry], diff --git a/tests/test_define.py b/tests/test_define.py index f057e58..e03aec2 100644 --- a/tests/test_define.py +++ b/tests/test_define.py @@ -472,3 +472,73 @@ def test_legal_ten_deep_predicate_chain_still_passes(): s.run_line("remember a number called x with 5") verdict = s.run_line("require x is p10") assert verdict.status is ResultStatus.SUCCESS + + +# --------------------------------------------------------------------------- +# Self-referential define on a first-ever definition (no prior `p`). +# +# `_find_predicate_cycle` above only ever sees a PredicateApplicationNode — +# it cannot catch this case, because when `p` has never been defined, `is p` +# parses to a BareWord-equality ConditionNode instead (see +# test_direct_self_reference_on_first_definition_hits_forward_declaration_check +# for the decoupled construction that demonstrates why). Real `Session` usage +# hits string equality, not a predicate reference — so it needs its own +# surface-form check, `_check_self_referential_define`, run unconditionally +# in `_check_define` regardless of whether `p` is already known. +# --------------------------------------------------------------------------- + + +def test_self_reference_on_first_definition_is_rejected(): + s = Session() + r = s.run_line("define p: is p") + assert r.status is ResultStatus.ERROR_SEMANTIC + assert r.message == "Definition 'p' can't refer to itself." + + +def test_self_reference_in_field_position_is_rejected(): + s = Session() + r = s.run_line("define p: p is above 1") + assert r.status is ResultStatus.ERROR_SEMANTIC + assert r.message == "Definition 'p' can't refer to itself." + + +def test_self_reference_in_compound_and_branch_is_rejected(): + s = Session() + r = s.run_line("define p: is above 1 and is p") + assert r.status is ResultStatus.ERROR_SEMANTIC + assert r.message == "Definition 'p' can't refer to itself." + + +def test_self_reference_in_compound_or_branch_is_rejected(): + s = Session() + r = s.run_line("define p: is p or is above 1") + assert r.status is ResultStatus.ERROR_SEMANTIC + assert r.message == "Definition 'p' can't refer to itself." + + +def test_is_not_self_reference_is_a_pre_existing_parse_error_not_semantic(): + # `is not ` never parses when the word isn't already a known + # predicate name — this is pre-existing, unrelated grammar behavior + # (identical for any unknown word, e.g. `define p: is not grownup`), + # not something this fix introduces or can reach. It is out of scope: + # the fix is analyzer-only and must not touch parsing (see + # `_check_self_referential_define`'s docstring). Documented here so the + # self-reference test group doesn't imply this line is unhandled. + s = Session() + r = s.run_line("define p: is not p") + assert r.status is ResultStatus.ERROR_PARSE + assert r.message == "After 'not' I expected 'above', 'below', or 'equal to'." + + +def test_bareword_differing_from_defined_name_stays_string_equality(): + s = Session() + s.run_line("define p: is above 1") + r = s.run_line("define q: is p") + assert r.status is ResultStatus.SUCCESS + + +def test_predicate_named_after_an_unrelated_fact_still_works(): + s = Session() + s.run_line('remember a string called status with "p"') + r = s.run_line("define matches-p: is p") + assert r.status is ResultStatus.SUCCESS