From a36521496c7db0209f72c7499dde11eb3b9145b9 Mon Sep 17 00:00:00 2001 From: rmichaelthomas Date: Fri, 11 Sep 2026 23:29:47 -0700 Subject: [PATCH] fix: `includes` refuses a field the schema says is a scalar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `includes` is a list-membership probe, and a non-list operand evaluates to false at runtime by decision (`test_includes_with_scalar_left_operand _is_false`). That is right where the type is whatever the value turned out to be. It is wrong at analysis time when the record schema has already said the field holds text: filter the orders where title includes "roof" validated clean, emptied a two-record list, and reported success. Reachable only since #72 admitted `includes` after `where`. The refusal that change replaced was safe and described the wrong problem — "I couldn't parse the condition after 'where'" — and what replaced it described nothing and was wrong. A misleading refusal beats a silent wrong answer, so this is the one I introduced and the one to fix. The analyzer now answers where the type is known and defers where it is not. A list-valued field is `unknown` statically, so it still reaches runtime and nothing that worked before stops working — the list-field case is tested alongside. The message says what is absent rather than what is malformed: "'includes' tests whether a list holds a value, and 'title' is text. Liminate has no text-contains test." That is the honest answer to a downstream consumer asking for `contains`, and v27 §56's locked meta-finding — the era of new verbs is ending — is why the answer is not to add one. A corpus case pins it, so the TypeScript port inherits the refusal rather than the silence. 1748 passed, 2 skipped; grammar projections regenerated. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01E6qcDj1e1dEYvc8jLnpGZy --- grammar/errors.json | 156 ++++++++++++---------- src/liminate/analyzer.py | 30 ++++- tests/fixtures/conformance-0.18.1.json | 20 +++ tests/fixtures/conformance_corpus.txt | 5 + tests/test_integration_includes_remove.py | 42 ++++++ tests/test_reorderer.py | 21 ++- 6 files changed, 194 insertions(+), 80 deletions(-) diff --git a/grammar/errors.json b/grammar/errors.json index b25d411..a9d4739 100644 --- a/grammar/errors.json +++ b/grammar/errors.json @@ -2,7 +2,7 @@ "format": 1, "generated_by": "grammar_gen.py", "note": "Every construction of _ParseError, _SemanticError, _RuntimeError, BuildError, LexError, ValueError, RuntimeError, or TypeError found by walking the AST of every .py file directly under src/liminate/. Liminate's errors carry no tag field -- unlike Planes' errors.json, there is no tags index here grouping entries by a shared identifier, because there is no shared identifier to group by. An id is assigned per entry (file.class.function[-N]) purely so a reader has something stable to point at; it is not a tag the source declares.", - "count": 360, + "count": 361, "literal_message_count": 91, "entries": [ { @@ -677,17 +677,29 @@ { "id": "analyzer._SemanticError._check_condition-2", "class": "_SemanticError", - "source": "analyzer.py:1398", + "source": "analyzer.py:1399", "raised_in": "_check_condition", "template": "Unknown comparison operator '{cond.op}'.", "slots": [ "cond.op" ] }, + { + "id": "analyzer._SemanticError._refuse_membership_over_a_known_scalar", + "class": "_SemanticError", + "source": "analyzer.py:1423", + "raised_in": "_refuse_membership_over_a_known_scalar", + "template": "'{word}' tests whether a list holds a value, and '{label}' is {_singular(t)}. Liminate has no text-contains test.", + "slots": [ + "word", + "label", + "_singular(t)" + ] + }, { "id": "analyzer._SemanticError._require_comparable", "class": "_SemanticError", - "source": "analyzer.py:1406", + "source": "analyzer.py:1434", "raised_in": "_require_comparable", "template": "'{op}' requires numbers or dates, but '{label}' is {_singular(t)}.", "slots": [ @@ -699,7 +711,7 @@ { "id": "analyzer._SemanticError._resolve_field-1", "class": "_SemanticError", - "source": "analyzer.py:1466", + "source": "analyzer.py:1494", "raised_in": "_resolve_field", "template": null, "slots": [], @@ -708,7 +720,7 @@ { "id": "analyzer._SemanticError._resolve_field-2", "class": "_SemanticError", - "source": "analyzer.py:1474", + "source": "analyzer.py:1502", "raised_in": "_resolve_field", "template": "In a list of {_plural(iterator.scalar_type or 'items')}, use 'each' to refer to the current item.", "slots": [ @@ -718,7 +730,7 @@ { "id": "analyzer._SemanticError._resolve_field-3", "class": "_SemanticError", - "source": "analyzer.py:1478", + "source": "analyzer.py:1506", "raised_in": "_resolve_field", "template": "Unexpected field reference.", "slots": [] @@ -726,7 +738,7 @@ { "id": "analyzer._SemanticError._resolve_value", "class": "_SemanticError", - "source": "analyzer.py:1527", + "source": "analyzer.py:1555", "raised_in": "_resolve_value", "template": "Unexpected value in condition.", "slots": [] @@ -734,7 +746,7 @@ { "id": "analyzer._SemanticError._check_sum-1", "class": "_SemanticError", - "source": "analyzer.py:1542", + "source": "analyzer.py:1570", "raised_in": "_check_sum", "template": "I can't find '{name}'. You might need to 'remember' it first.", "slots": [ @@ -744,7 +756,7 @@ { "id": "analyzer._SemanticError._check_sum-2", "class": "_SemanticError", - "source": "analyzer.py:1549", + "source": "analyzer.py:1577", "raised_in": "_check_sum", "template": "I can only sum numbers. '{name}' contains text.", "slots": [ @@ -754,7 +766,7 @@ { "id": "analyzer._SemanticError._check_sum-3", "class": "_SemanticError", - "source": "analyzer.py:1551", + "source": "analyzer.py:1579", "raised_in": "_check_sum", "template": "I can only sum numbers. '{name}' contains records.", "slots": [ @@ -764,7 +776,7 @@ { "id": "analyzer._SemanticError._check_sum-4", "class": "_SemanticError", - "source": "analyzer.py:1552", + "source": "analyzer.py:1580", "raised_in": "_check_sum", "template": "I can only sum numbers. '{name}' is {_singular(entry.type)}.", "slots": [ @@ -775,7 +787,7 @@ { "id": "analyzer._SemanticError._check_extrema-1", "class": "_SemanticError", - "source": "analyzer.py:1572", + "source": "analyzer.py:1600", "raised_in": "_check_extrema", "template": "'{node.word}' on a list of records needs a field \u2014 try: {node.word} of {node.target.name}.", "slots": [ @@ -787,7 +799,7 @@ { "id": "analyzer._SemanticError._check_extrema-2", "class": "_SemanticError", - "source": "analyzer.py:1578", + "source": "analyzer.py:1606", "raised_in": "_check_extrema", "template": "'{node.word} {node.field}' needs a list of records. '{node.target.name}' is {_singular(entry.type)} \u2014 try: {node.word} of {node.target.name}.", "slots": [ @@ -802,7 +814,7 @@ { "id": "analyzer._SemanticError._check_extrema-3", "class": "_SemanticError", - "source": "analyzer.py:1588", + "source": "analyzer.py:1616", "raised_in": "_check_extrema", "template": null, "slots": [], @@ -811,7 +823,7 @@ { "id": "analyzer._SemanticError._check_gather", "class": "_SemanticError", - "source": "analyzer.py:1603", + "source": "analyzer.py:1631", "raised_in": "_check_gather", "template": "That range is too large. The maximum is {GATHER_RANGE_CAP} items.", "slots": [ @@ -821,7 +833,7 @@ { "id": "analyzer._SemanticError._check_require_each", "class": "_SemanticError", - "source": "analyzer.py:1645", + "source": "analyzer.py:1673", "raised_in": "_check_require_each", "template": "The binding name can't be the same as the list name. Try: require each in {name} .", "slots": [ @@ -831,7 +843,7 @@ { "id": "analyzer._SemanticError._check_composition_call_shape-1", "class": "_SemanticError", - "source": "analyzer.py:1715", + "source": "analyzer.py:1743", "raised_in": "_check_composition_call_shape", "template": "I can't find a composition called '{node.name}'.", "slots": [ @@ -841,7 +853,7 @@ { "id": "analyzer._SemanticError._check_composition_call_shape-2", "class": "_SemanticError", - "source": "analyzer.py:1720", + "source": "analyzer.py:1748", "raised_in": "_check_composition_call_shape", "template": "'{node.name}' expects an input (from <{param}>). Try: {node.name} from or {node.name} from 50.", "slots": [ @@ -854,7 +866,7 @@ { "id": "analyzer._SemanticError._check_composition_call_shape-3", "class": "_SemanticError", - "source": "analyzer.py:1725", + "source": "analyzer.py:1753", "raised_in": "_check_composition_call_shape", "template": "'{node.name}' doesn't take an input. Call it on its own: {node.name}.", "slots": [ @@ -865,7 +877,7 @@ { "id": "analyzer._SemanticError._check_composition_call_shape-4", "class": "_SemanticError", - "source": "analyzer.py:1732", + "source": "analyzer.py:1760", "raised_in": "_check_composition_call_shape", "template": "I can't find '{node.arg}'. You might need to 'remember' it first.", "slots": [ @@ -875,7 +887,7 @@ { "id": "analyzer._SemanticError._check_choose_condition-1", "class": "_SemanticError", - "source": "analyzer.py:1831", + "source": "analyzer.py:1859", "raised_in": "_check_choose_condition", "template": "Unexpected condition shape.", "slots": [] @@ -883,7 +895,7 @@ { "id": "analyzer._SemanticError._check_choose_condition-2", "class": "_SemanticError", - "source": "analyzer.py:1855", + "source": "analyzer.py:1883", "raised_in": "_check_choose_condition", "template": "Unknown comparison operator '{cond.op}'.", "slots": [ @@ -893,7 +905,7 @@ { "id": "analyzer._SemanticError._check_predicate_application", "class": "_SemanticError", - "source": "analyzer.py:1881", + "source": "analyzer.py:1909", "raised_in": "_check_predicate_application", "template": "I don't know a definition for '{cond.predicate_name}'. Use 'define {cond.predicate_name}: ...' to create one.", "slots": [ @@ -904,7 +916,7 @@ { "id": "analyzer._SemanticError._check_self_referential_define-1", "class": "_SemanticError", - "source": "analyzer.py:1936", + "source": "analyzer.py:1964", "raised_in": "_check_self_referential_define", "template": "Definition '{name}' can't refer to itself.", "slots": [ @@ -914,7 +926,7 @@ { "id": "analyzer._SemanticError._check_self_referential_define-2", "class": "_SemanticError", - "source": "analyzer.py:1938", + "source": "analyzer.py:1966", "raised_in": "_check_self_referential_define", "template": "Definition '{name}' can't refer to itself.", "slots": [ @@ -924,7 +936,7 @@ { "id": "analyzer._SemanticError._check_define_condition-1", "class": "_SemanticError", - "source": "analyzer.py:1955", + "source": "analyzer.py:1983", "raised_in": "_check_define_condition", "template": "Unexpected condition shape.", "slots": [] @@ -932,7 +944,7 @@ { "id": "analyzer._SemanticError._check_define_condition-2", "class": "_SemanticError", - "source": "analyzer.py:1957", + "source": "analyzer.py:1985", "raised_in": "_check_define_condition", "template": "Unknown comparison operator '{cond.op}'.", "slots": [ @@ -942,7 +954,7 @@ { "id": "analyzer._SemanticError._check_predicate_definition_cycle-1", "class": "_SemanticError", - "source": "analyzer.py:1974", + "source": "analyzer.py:2002", "raised_in": "_check_predicate_definition_cycle", "template": "Definition '{name}' can't depend on itself.", "slots": [ @@ -952,7 +964,7 @@ { "id": "analyzer._SemanticError._check_predicate_definition_cycle-2", "class": "_SemanticError", - "source": "analyzer.py:1976", + "source": "analyzer.py:2004", "raised_in": "_check_predicate_definition_cycle", "template": "Definition '{name}' refers back to itself through {chain}. A definition can't depend on itself.", "slots": [ @@ -963,7 +975,7 @@ { "id": "analyzer._SemanticError._check_pack_verb-1", "class": "_SemanticError", - "source": "analyzer.py:2070", + "source": "analyzer.py:2098", "raised_in": "_check_pack_verb", "template": "'{slot_label}' expects a name.", "slots": [ @@ -973,7 +985,7 @@ { "id": "analyzer._SemanticError._check_pack_verb-2", "class": "_SemanticError", - "source": "analyzer.py:2075", + "source": "analyzer.py:2103", "raised_in": "_check_pack_verb", "template": "I can't find '{name}'. You might need to 'remember' it first.", "slots": [ @@ -983,7 +995,7 @@ { "id": "analyzer._SemanticError._check_pack_verb-3", "class": "_SemanticError", - "source": "analyzer.py:2094", + "source": "analyzer.py:2122", "raised_in": "_check_pack_verb", "template": "'{name}' is {shown}, not a {constraint}. '{slot_label}' expects a {constraint}.", "slots": [ @@ -997,7 +1009,7 @@ { "id": "analyzer._SemanticError._resolve_slot_target_name", "class": "_SemanticError", - "source": "analyzer.py:2141", + "source": "analyzer.py:2169", "raised_in": "_resolve_slot_target_name", "template": "Pack verb '{node.word}' needs a name for its target.", "slots": [ @@ -1007,7 +1019,7 @@ { "id": "analyzer._SemanticError._check_pack_substring-1", "class": "_SemanticError", - "source": "analyzer.py:2155", + "source": "analyzer.py:2183", "raised_in": "_check_pack_substring", "template": "'{node.word}' expects a name for the source text.", "slots": [ @@ -1017,7 +1029,7 @@ { "id": "analyzer._SemanticError._check_pack_substring-2", "class": "_SemanticError", - "source": "analyzer.py:2160", + "source": "analyzer.py:2188", "raised_in": "_check_pack_substring", "template": "I can't find '{name}'. You might need to 'remember' it first.", "slots": [ @@ -1027,7 +1039,7 @@ { "id": "analyzer._SemanticError._check_pack_substring-3", "class": "_SemanticError", - "source": "analyzer.py:2166", + "source": "analyzer.py:2194", "raised_in": "_check_pack_substring", "template": "'{node.word} from' expects text, but '{name}' is {_singular(entry.type)}.", "slots": [ @@ -1039,7 +1051,7 @@ { "id": "analyzer._SemanticError._check_pack_substring-4", "class": "_SemanticError", - "source": "analyzer.py:2174", + "source": "analyzer.py:2202", "raised_in": "_check_pack_substring", "template": "I can't find '{check_node.name}'. You might need to 'remember' it first.", "slots": [ @@ -1049,7 +1061,7 @@ { "id": "analyzer._SemanticError._check_pack_append", "class": "_SemanticError", - "source": "analyzer.py:2195", + "source": "analyzer.py:2223", "raised_in": "_check_pack_append", "template": "Pack verb '{node.word}' missing source value.", "slots": [ @@ -1059,7 +1071,7 @@ { "id": "analyzer._SemanticError._check_pack_set_field-1", "class": "_SemanticError", - "source": "analyzer.py:2216", + "source": "analyzer.py:2244", "raised_in": "_check_pack_set_field", "template": "I can't find '{target_name}'. You might need to 'remember' it first.", "slots": [ @@ -1069,7 +1081,7 @@ { "id": "analyzer._SemanticError._check_pack_set_field-2", "class": "_SemanticError", - "source": "analyzer.py:2222", + "source": "analyzer.py:2250", "raised_in": "_check_pack_set_field", "template": "'{node.word}' expects a record, but '{target_name}' is {_singular(entry.type)}.", "slots": [ @@ -1081,7 +1093,7 @@ { "id": "analyzer._SemanticError._check_pack_set_field-3", "class": "_SemanticError", - "source": "analyzer.py:2229", + "source": "analyzer.py:2257", "raised_in": "_check_pack_set_field", "template": "I can't find '{src.name}'. You might need to 'remember' it first.", "slots": [ @@ -1091,7 +1103,7 @@ { "id": "analyzer._SemanticError._check_pack_compare-1", "class": "_SemanticError", - "source": "analyzer.py:2246", + "source": "analyzer.py:2274", "raised_in": "_check_pack_compare", "template": "'{node.word}' expects a name for '{slot_name}'.", "slots": [ @@ -1102,7 +1114,7 @@ { "id": "analyzer._SemanticError._check_pack_compare-2", "class": "_SemanticError", - "source": "analyzer.py:2250", + "source": "analyzer.py:2278", "raised_in": "_check_pack_compare", "template": "I can't find '{value_node.name}'. You might need to 'remember' it first.", "slots": [ @@ -1112,7 +1124,7 @@ { "id": "analyzer._SemanticError._check_pack_numeric_extract-1", "class": "_SemanticError", - "source": "analyzer.py:2264", + "source": "analyzer.py:2292", "raised_in": "_check_pack_numeric_extract", "template": "'{node.word}' expects a name for the source text.", "slots": [ @@ -1122,7 +1134,7 @@ { "id": "analyzer._SemanticError._check_pack_numeric_extract-2", "class": "_SemanticError", - "source": "analyzer.py:2269", + "source": "analyzer.py:2297", "raised_in": "_check_pack_numeric_extract", "template": "I can't find '{name}'. You might need to 'remember' it first.", "slots": [ @@ -1132,7 +1144,7 @@ { "id": "analyzer._SemanticError._check_pack_numeric_extract-3", "class": "_SemanticError", - "source": "analyzer.py:2275", + "source": "analyzer.py:2303", "raised_in": "_check_pack_numeric_extract", "template": "'{node.word} from' expects text, but '{name}' is {_singular(entry.type)}.", "slots": [ @@ -1144,7 +1156,7 @@ { "id": "analyzer._SemanticError._check_pack_range_check-1", "class": "_SemanticError", - "source": "analyzer.py:2292", + "source": "analyzer.py:2320", "raised_in": "_check_pack_range_check", "template": "'{node.word}' expects a name for the reference window.", "slots": [ @@ -1154,7 +1166,7 @@ { "id": "analyzer._SemanticError._check_pack_range_check-2", "class": "_SemanticError", - "source": "analyzer.py:2297", + "source": "analyzer.py:2325", "raised_in": "_check_pack_range_check", "template": "I can't find '{name}'. You might need to 'remember' it first.", "slots": [ @@ -1164,7 +1176,7 @@ { "id": "analyzer._SemanticError._check_pack_range_check-3", "class": "_SemanticError", - "source": "analyzer.py:2303", + "source": "analyzer.py:2331", "raised_in": "_check_pack_range_check", "template": "'{node.word} from' expects text, but '{name}' is {_singular(entry.type)}.", "slots": [ @@ -1176,7 +1188,7 @@ { "id": "analyzer._SemanticError._check_pack_range_check-4", "class": "_SemanticError", - "source": "analyzer.py:2310", + "source": "analyzer.py:2338", "raised_in": "_check_pack_range_check", "template": "I can't find '{check_node.name}'. You might need to 'remember' it first.", "slots": [ @@ -1186,7 +1198,7 @@ { "id": "analyzer._SemanticError._check_pack_conformance-1", "class": "_SemanticError", - "source": "analyzer.py:2329", + "source": "analyzer.py:2357", "raised_in": "_check_pack_conformance", "template": "'{node.word}' expects a name for the shape.", "slots": [ @@ -1196,7 +1208,7 @@ { "id": "analyzer._SemanticError._check_pack_conformance-2", "class": "_SemanticError", - "source": "analyzer.py:2333", + "source": "analyzer.py:2361", "raised_in": "_check_pack_conformance", "template": "I can't find '{shape_node.name}'. You might need to 'remember' it first.", "slots": [ @@ -1206,7 +1218,7 @@ { "id": "analyzer._SemanticError._check_pack_conformance-3", "class": "_SemanticError", - "source": "analyzer.py:2339", + "source": "analyzer.py:2367", "raised_in": "_check_pack_conformance", "template": "'{node.word} to' expects a record shape, but '{shape_node.name}' is {_singular(shape_entry.type)}.", "slots": [ @@ -1218,7 +1230,7 @@ { "id": "analyzer._SemanticError._check_pack_conformance-4", "class": "_SemanticError", - "source": "analyzer.py:2349", + "source": "analyzer.py:2377", "raised_in": "_check_pack_conformance", "template": "'{node.word} each' can only be used inside an 'each' loop.", "slots": [ @@ -1228,7 +1240,7 @@ { "id": "analyzer._SemanticError._check_pack_conformance-5", "class": "_SemanticError", - "source": "analyzer.py:2353", + "source": "analyzer.py:2381", "raised_in": "_check_pack_conformance", "template": "'{node.word} each' expects a list of records to check against the shape.", "slots": [ @@ -1238,7 +1250,7 @@ { "id": "analyzer._SemanticError._check_pack_conformance-6", "class": "_SemanticError", - "source": "analyzer.py:2359", + "source": "analyzer.py:2387", "raised_in": "_check_pack_conformance", "template": "'{node.word}' expects a name for the record.", "slots": [ @@ -1248,7 +1260,7 @@ { "id": "analyzer._SemanticError._check_pack_conformance-7", "class": "_SemanticError", - "source": "analyzer.py:2363", + "source": "analyzer.py:2391", "raised_in": "_check_pack_conformance", "template": "I can't find '{record_node.name}'. You might need to 'remember' it first.", "slots": [ @@ -1258,7 +1270,7 @@ { "id": "analyzer._SemanticError._check_pack_conformance-8", "class": "_SemanticError", - "source": "analyzer.py:2369", + "source": "analyzer.py:2397", "raised_in": "_check_pack_conformance", "template": "'{node.word}' expects a record, but '{record_node.name}' is {_singular(record_entry.type)}.", "slots": [ @@ -1270,7 +1282,7 @@ { "id": "analyzer._SemanticError._check_finish", "class": "_SemanticError", - "source": "analyzer.py:2386", + "source": "analyzer.py:2414", "raised_in": "_check_finish", "template": "'finish' can only be used inside an event handler.", "slots": [] @@ -1278,7 +1290,7 @@ { "id": "analyzer._SemanticError._check_list_append-1", "class": "_SemanticError", - "source": "analyzer.py:2461", + "source": "analyzer.py:2489", "raised_in": "_check_list_append", "template": "'{target_name}' is a live value provided by the domain pack. '{verb}' modifies the list and can't be used on it \u2014 the domain pack controls this value.", "slots": [ @@ -1289,7 +1301,7 @@ { "id": "analyzer._SemanticError._check_list_append-2", "class": "_SemanticError", - "source": "analyzer.py:2467", + "source": "analyzer.py:2495", "raised_in": "_check_list_append", "template": "I can't find '{target_name}'. You might need to 'remember' it first.", "slots": [ @@ -1299,7 +1311,7 @@ { "id": "analyzer._SemanticError._check_list_append-3", "class": "_SemanticError", - "source": "analyzer.py:2473", + "source": "analyzer.py:2501", "raised_in": "_check_list_append", "template": "I can only {verb} {prep} a list. '{target_name}' is {_singular(entry.type)}.", "slots": [ @@ -1312,7 +1324,7 @@ { "id": "analyzer._SemanticError._check_list_append-4", "class": "_SemanticError", - "source": "analyzer.py:2479", + "source": "analyzer.py:2507", "raised_in": "_check_list_append", "template": "'{target_name}' is the list being iterated \u2014 you can't {verb} {prep} it while iterating. Try {verb}ing {prep} a different list.", "slots": [ @@ -1326,7 +1338,7 @@ { "id": "analyzer._SemanticError._check_list_append-5", "class": "_SemanticError", - "source": "analyzer.py:2497", + "source": "analyzer.py:2525", "raised_in": "_check_list_append", "template": "'{target_name}' is {_singular(entry.type)}. '{item_label}' is {_singular(item_type)} and can't be {action} it.", "slots": [ @@ -1340,7 +1352,7 @@ { "id": "analyzer._SemanticError._infer_add_item_type-1", "class": "_SemanticError", - "source": "analyzer.py:2535", + "source": "analyzer.py:2563", "raised_in": "_infer_add_item_type", "template": "I can't find '{item.name}'. You might need to 'remember' it first.", "slots": [ @@ -1350,7 +1362,7 @@ { "id": "analyzer._SemanticError._infer_add_item_type-2", "class": "_SemanticError", - "source": "analyzer.py:2545", + "source": "analyzer.py:2573", "raised_in": "_infer_add_item_type", "template": "Unexpected item for 'add': {type(item).__name__}.", "slots": [ @@ -1360,7 +1372,7 @@ { "id": "analyzer._SemanticError._resolve_choose_operand-1", "class": "_SemanticError", - "source": "analyzer.py:2567", + "source": "analyzer.py:2595", "raised_in": "_resolve_choose_operand", "template": "I can't find '{node.name}'. You might need to 'remember' it first.", "slots": [ @@ -1370,7 +1382,7 @@ { "id": "analyzer._SemanticError._resolve_choose_operand-2", "class": "_SemanticError", - "source": "analyzer.py:2592", + "source": "analyzer.py:2620", "raised_in": "_resolve_choose_operand", "template": "'each' only refers to the current item inside an 'each' or 'where' clause. In 'choose', name the value directly.", "slots": [] @@ -1378,7 +1390,7 @@ { "id": "analyzer._SemanticError._resolve_choose_operand-3", "class": "_SemanticError", - "source": "analyzer.py:2604", + "source": "analyzer.py:2632", "raised_in": "_resolve_choose_operand", "template": "Unexpected operand in 'choose' condition.", "slots": [] @@ -1386,7 +1398,7 @@ { "id": "analyzer._SemanticError._require_list-1", "class": "_SemanticError", - "source": "analyzer.py:2619", + "source": "analyzer.py:2647", "raised_in": "_require_list", "template": "I can't find '{name}'. You might need to 'remember' it first.", "slots": [ @@ -1396,7 +1408,7 @@ { "id": "analyzer._SemanticError._require_list-2", "class": "_SemanticError", - "source": "analyzer.py:2637", + "source": "analyzer.py:2665", "raised_in": "_require_list", "template": null, "slots": [], @@ -1405,7 +1417,7 @@ { "id": "analyzer._SemanticError._make_iterator", "class": "_SemanticError", - "source": "analyzer.py:2653", + "source": "analyzer.py:2681", "raised_in": "_make_iterator", "template": "'{name}' isn't a list I can iterate.", "slots": [ diff --git a/src/liminate/analyzer.py b/src/liminate/analyzer.py index c3079bb..ea0fde1 100644 --- a/src/liminate/analyzer.py +++ b/src/liminate/analyzer.py @@ -1388,7 +1388,8 @@ def _check_condition( if cond.op == "equal_to": return # any same-type comparison; analyzer doesn't enforce if cond.op in ("includes", "not_includes"): - return # list-membership — analyzer accepts any operand types + _refuse_membership_over_a_known_scalar(field_type, field_label, cond.op) + return if cond.op.startswith("not_"): inner = cond.op[len("not_"):] if inner in ("above", "below"): @@ -1398,6 +1399,33 @@ def _check_condition( raise _SemanticError(f"Unknown comparison operator '{cond.op}'.") +def _refuse_membership_over_a_known_scalar(t: str, label: str, op: str) -> None: + """Refuse `includes` where the schema already says the operand is a scalar. + + A non-list operand evaluates to false at runtime, deliberately — see + `test_includes_with_scalar_left_operand_is_false`. That is right where the + type is whatever the value turned out to be, and wrong here, where the + record schema has already said the field holds text: `filter the orders + where title includes "roof"` would validate clean, empty the list, and + report success. + + So: answer where the type is known, defer where it is not. A list-valued + field is `unknown` statically, so it still reaches runtime, and nothing + that worked before this stops working. + + Reachable only since the reorderer admitted `includes` after `where`. The + refusal that change replaced was safe and described the wrong problem; what + replaced it described nothing and was wrong. + """ + if t not in ("string", "number", "date"): + return + word = op.replace("not_", "not ") + raise _SemanticError( + f"'{word}' tests whether a list holds a value, and '{label}' is " + f"{_singular(t)}. Liminate has no text-contains test." + ) + + def _require_comparable(t: str, label: str, op: str) -> None: """Accept numbers or dates for ordered comparison; reject everything else. Mixed number/date is caught at runtime by _apply_op.""" diff --git a/tests/fixtures/conformance-0.18.1.json b/tests/fixtures/conformance-0.18.1.json index 60f064b..c0816f0 100644 --- a/tests/fixtures/conformance-0.18.1.json +++ b/tests/fixtures/conformance-0.18.1.json @@ -68,6 +68,26 @@ ], "source": "remember an event called e1 with title as launch and starts-at as 2026-03-15\nremember a list called events with e1\nfilter the events where starts-at is not below 2026-01-01\nfilter the events where starts-at is not above 2026-12-31" }, + { + "id": "membership over a text field is refused, because includes is not contains", + "results": [ + { + "canonical": "remember an order called o1 with title as roofing and total as 75", + "status": "success" + }, + { + "canonical": "remember a list called orders with o1", + "status": "success" + }, + { + "canonical": "filter the orders where title includes roof", + "errorKind": "semantic", + "errorMessage": "'includes' tests whether a list holds a value, and 'title' is text. Liminate has no text-contains test.", + "status": "error" + } + ], + "source": "remember an order called o1 with title as roofing and total as 75\nremember a list called orders with o1\nfilter the orders where title includes \"roof\"" + }, { "id": "a range comparison over text is refused", "results": [ diff --git a/tests/fixtures/conformance_corpus.txt b/tests/fixtures/conformance_corpus.txt index fe91a67..c357409 100644 --- a/tests/fixtures/conformance_corpus.txt +++ b/tests/fixtures/conformance_corpus.txt @@ -17,6 +17,11 @@ remember a list called events with e1 filter the events where starts-at is not below 2026-01-01 filter the events where starts-at is not above 2026-12-31 +# membership over a text field is refused, because includes is not contains +remember an order called o1 with title as roofing and total as 75 +remember a list called orders with o1 +filter the orders where title includes "roof" + # a range comparison over text is refused remember a string called title with "roof" require title is above 50 diff --git a/tests/test_integration_includes_remove.py b/tests/test_integration_includes_remove.py index 75761f0..118ac0e 100644 --- a/tests/test_integration_includes_remove.py +++ b/tests/test_integration_includes_remove.py @@ -268,3 +268,45 @@ def test_when_not_includes_fires_on_initial_evaluation(): fires = [r for r in results if r.status is ResultStatus.HANDLER_FIRE] assert len(fires) == 1 assert fires[0].output == ["flask not active"] + + +# `includes` over a field the schema says is a scalar — refused, not answered. +# +# `includes` is a list-membership probe and a non-list operand evaluates to +# false (see `test_includes_with_scalar_left_operand_is_false`). That rule is +# right at runtime, where the operand's type is whatever the value turned out +# to be. It is the wrong answer at analysis time when the record schema already +# says the field holds text: `filter the orders where title includes "roof"` +# then validates clean, empties the list, and reports success. +# +# Reachable only since the reorderer stopped refusing `includes` after `where`. +# The refusal it replaced was safe and said the wrong thing; what replaced it +# said nothing and was wrong. So the analyzer answers where it can, and defers +# where it cannot — a field whose type is `unknown` (which is what a +# list-valued field is, statically) still goes to runtime. + +def test_includes_over_a_text_field_is_refused_rather_than_answered(): + session, setup = run_lines([ + "remember an order called o1 with title as roofing and total as 75", + "remember an order called o2 with title as plumbing and total as 30", + "remember a list called orders with o1 and o2", + ]) + assert all(r.status is ResultStatus.SUCCESS for r in setup), [r.message for r in setup] + result = session.run_line('filter the orders where title includes "roof"') + assert result.status is ResultStatus.ERROR_SEMANTIC, ( + f"got {result.status.value}: a text field silently matched nothing" + ) + assert "includes" in (result.message or "") + assert len(session.symtab["orders"].value) == 2, "the list must not have been touched" + + +def test_includes_over_a_list_valued_field_still_works(): + session, setup = run_lines([ + 'remember a list called roof-tags with "urgent" and "roof"', + "remember an order called o1 with total as 75 and tags as roof-tags", + "remember a list called orders with o1", + ]) + assert all(r.status is ResultStatus.SUCCESS for r in setup), [r.message for r in setup] + result = session.run_line('filter the orders where tags includes "urgent"') + assert result.status is ResultStatus.SUCCESS, result.message + assert len(session.symtab["orders"].value) == 1 diff --git a/tests/test_reorderer.py b/tests/test_reorderer.py index 81dbf14..d2633e1 100644 --- a/tests/test_reorderer.py +++ b/tests/test_reorderer.py @@ -328,14 +328,18 @@ def test_where_includes_filters_a_list_valued_field_correctly(): ] -def test_where_includes_over_scalar_items_keeps_the_documented_answer(): +def test_where_includes_over_scalar_items_is_refused(): """The boundary, stated rather than discovered later. - `each` is an item, not a list, and `includes` over a non-list operand is - false by decision — see `test_includes_with_scalar_left_operand_is_false`. - So `where each includes "x"` empties the list rather than matching text. - Widening the gate does not change that and must not be read as making - `includes` a substring test; text-contains is not in the language. + `each` is an item, not a list, so `where each includes "x"` is a membership + test over a scalar. At runtime that is false by decision — see + `test_includes_with_scalar_left_operand_is_false` — which would have + emptied the list and reported success. + + The analyzer answers first where the type is known, so this is refused and + the list is untouched. Widening the gate must not be read as making + `includes` a substring test; text-contains is not in the language, and the + refusal now says so. """ from liminate.run import run @@ -344,7 +348,10 @@ def test_where_includes_over_scalar_items_keeps_the_documented_answer(): 'add "routine-check" to tags\n' ) result = run(base + 'filter tags where each includes "urgent"\nshow tags') - assert result.results[-1].output == [""] + refusal = result.results[-2] + assert refusal.status.value == "error_semantic", refusal.status.value + assert "no text-contains" in (refusal.message or "").lower() + assert result.results[-1].output == ["urgent-repair, routine-check"] def test_a_genuinely_scrambled_condition_is_still_rejected():