diff --git a/parser/sqlfn.py b/parser/sqlfn.py index e26099e..988ec80 100644 --- a/parser/sqlfn.py +++ b/parser/sqlfn.py @@ -617,6 +617,50 @@ def lint_positional_sqlfn(idl): return bad +# A MEOS-C function's name ends in the container it takes — `_tstzset` a timestamptz +# set, `_tstzspanset` a span set — and its @csqlfn must name a wrapper over that same +# container. A copy-paste from the neighbouring block names the sibling container's +# wrapper instead, and nothing catches it: the wrapper exists, it is reachable, and its +# arity matches, so the catalog silently carries the SQL surface of the wrong overload +# (trgeometry_at_tstzset answering atTime(trgeometry, tstzspanset), leaving the +# timestamptz-set overload named by nothing). +# A CONCRETE function name over a GENERIC wrapper is the norm rather than a mistag — +# `adjacent_span_timestamptz` names `Adjacent_span_value` because one wrapper serves +# every base type — so both sides are read as a container FAMILY and only a +# disagreement between families is reported. That is what separates the seven real +# mistags from the forty names whose suffixes merely differ. +_CONTAINER_FAMILY = { + "tstzspanset": "spanset", "tstzspan": "span", "tstzset": "set", + "timestamptz": "value", "spanset": "spanset", "span": "span", + "set": "set", "value": "value", +} +_CONTAINER_SUFFIX = tuple(sorted(_CONTAINER_FAMILY, key=len, reverse=True)) + + +def _container_family(name): + """The container family the name ends in, or None when it names no container.""" + low = name.lower() + for suf in _CONTAINER_SUFFIX: + if low.endswith("_" + suf): + return _CONTAINER_FAMILY[suf] + return None + + +def lint_container_family_csqlfn(idl): + """Return [(meos_c_name, wrapper)] where the container family the function's name + ends in contradicts the family its resolved wrapper ends in — a source @csqlfn + mistag naming the sibling container's wrapper.""" + bad = [] + for f in idl["functions"]: + wrapper = f.get("mdbC") + if not wrapper: + continue + fam_fn, fam_wrapper = _container_family(f["name"]), _container_family(wrapper) + if fam_fn and fam_wrapper and fam_fn != fam_wrapper: + bad.append((f["name"], wrapper)) + return bad + + def lint_sqlfn_case_collisions(idl, multi=None): """Return [(lower, [spelling, ...])] for @sqlfn names that collide case-insensitively but differ in case (e.g. tDistance vs tdistance). diff --git a/run.py b/run.py index 1cada55..906d4db 100644 --- a/run.py +++ b/run.py @@ -15,8 +15,9 @@ from parser.boundargs import merge_boundargs from parser.enrich import enrich_idl from parser.sqlfn import (attach_sqlfn_map, attach_aggfn_map, - attach_sqlaggfn_map, lint_ea_sqlfn, - lint_positional_sqlfn, lint_sqlfn_case_collisions) + attach_sqlaggfn_map, lint_container_family_csqlfn, + lint_ea_sqlfn, lint_positional_sqlfn, + lint_sqlfn_case_collisions) from parser.doxygroup import attach_groups from parser.extractors import find_unlisted_foreign_structs from parser.families import all_families, use_headers_dir @@ -233,6 +234,17 @@ def main(): file=sys.stderr) for cname, sf in pos_bad: print(f" {cname} -> @sqlfn {sf}", file=sys.stderr) + # Guard: a function whose name ends in the container it takes (_tstzset, _spanset) + # resolved to a wrapper over a DIFFERENT container. The wrapper exists and is + # reachable, so the name checks pass while the catalog carries the SQL surface of + # the sibling overload. The function name is the SoT; fix the @csqlfn at source. + fam_bad = lint_container_family_csqlfn(idl) + if fam_bad: + print(f" ⚠ {len(fam_bad)} @csqlfn container-family mismatch(es) (the wrapper " + f"takes a different container than the function — fix at source):", + file=sys.stderr) + for cname, wrapper in fam_bad: + print(f" {cname} -> {wrapper}", file=sys.stderr) # Now that both the @sqlfn/@sqlop map (step 4) and the portable bare-name map # (step 3) are attached, classify the shared bbox-topological BACKING tags diff --git a/tests/test_sqlfn_container_family.py b/tests/test_sqlfn_container_family.py new file mode 100644 index 0000000..c0638b0 --- /dev/null +++ b/tests/test_sqlfn_container_family.py @@ -0,0 +1,79 @@ +"""Regression tests for the container-family @csqlfn lint in parser/sqlfn.py. + +A MEOS function's name ends in the container it takes, and its @csqlfn must name +a wrapper over that same container. Naming the sibling container's wrapper passes +every other check — the wrapper exists, it is reachable, its arity matches — so +the catalog silently carries the SQL surface of a different overload. + +The refuting cases matter as much as the flagged one. A CONCRETE function name +over a GENERIC wrapper is how the whole value surface is written, so a lint that +reads suffixes literally reports the tree rather than a defect: forty names +disagree by suffix and seven by container family. + +Plain unittest, no pytest dependency; synthetic catalog records. +""" +import unittest + +from parser.sqlfn import _container_family, lint_container_family_csqlfn + + +def _idl(records): + return {"functions": [dict(name=n, mdbC=w) for n, w in records]} + + +class ContainerFamilyTests(unittest.TestCase): + + def test_sibling_container_wrapper_is_flagged(self): + """The defect: a Set function naming the span-set wrapper.""" + bad = lint_container_family_csqlfn(_idl([ + ("trgeometry_at_tstzset", "Temporal_at_tstzspanset"), + ])) + self.assertEqual(bad, [("trgeometry_at_tstzset", "Temporal_at_tstzspanset")]) + + def test_generic_wrapper_of_a_concrete_name_is_not_flagged(self): + """A timestamptz function naming the generic value wrapper is the norm. + + This is the case that makes a literal suffix comparison useless: one + wrapper serves every base type, so its name says `value` where the + function's says `timestamptz`. + """ + self.assertEqual(lint_container_family_csqlfn(_idl([ + ("adjacent_span_timestamptz", "Adjacent_span_value"), + ("union_set_timestamptz", "Union_set_value"), + ])), []) + + def test_same_family_spelled_differently_is_not_flagged(self): + """`tstzset` and `set` are one family, as are `tstzspanset` and `spanset`.""" + self.assertEqual(lint_container_family_csqlfn(_idl([ + ("distance_tstzset_tstzset", "Distance_set_set"), + ("distance_tstzspanset_tstzspan", "Distance_spanset_span"), + ])), []) + + def test_a_name_ending_in_no_container_is_not_flagged(self): + """`union_set_pcpoint` ends in a type, not a container, so it says nothing. + + Its commuted twin `union_pcpoint_set` does end in one, which is why the + pointcloud pairs flag one side and not the other. + """ + self.assertEqual(lint_container_family_csqlfn(_idl([ + ("union_set_pcpoint", "Union_set_value"), + ("temporal_start_instant", "Temporal_start_instant"), + ])), []) + + def test_a_function_naming_no_wrapper_is_not_flagged(self): + """An untagged function has nothing to disagree with.""" + self.assertEqual(lint_container_family_csqlfn( + {"functions": [{"name": "trgeometry_at_tstzset"}]}), []) + + def test_the_families_a_name_can_end_in(self): + self.assertEqual(_container_family("x_tstzspanset"), "spanset") + self.assertEqual(_container_family("x_tstzspan"), "span") + self.assertEqual(_container_family("x_tstzset"), "set") + self.assertEqual(_container_family("x_timestamptz"), "value") + self.assertEqual(_container_family("X_Set_Value"), "value") + self.assertIsNone(_container_family("x_pcpoint")) + self.assertIsNone(_container_family("tstzset")) + + +if __name__ == "__main__": + unittest.main()