opencrbasic 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. opencrbasic/__init__.py +3 -0
  2. opencrbasic/__main__.py +5 -0
  3. opencrbasic/analysis.py +184 -0
  4. opencrbasic/bindings.py +1034 -0
  5. opencrbasic/catalog.py +173 -0
  6. opencrbasic/checkconfig.py +144 -0
  7. opencrbasic/checker/__init__.py +977 -0
  8. opencrbasic/checker/cr3xx.py +3118 -0
  9. opencrbasic/checker/ess.py +81 -0
  10. opencrbasic/checker/expressions.py +3581 -0
  11. opencrbasic/checker/instr_data.py +1802 -0
  12. opencrbasic/checker/instr_measure.py +1547 -0
  13. opencrbasic/checker/instr_rules.py +3540 -0
  14. opencrbasic/checker/instructions.py +17 -0
  15. opencrbasic/checker/intervals.py +678 -0
  16. opencrbasic/checker/longconv.py +133 -0
  17. opencrbasic/checker/scan_time.py +580 -0
  18. opencrbasic/checker/storage.py +456 -0
  19. opencrbasic/checker/structs.py +240 -0
  20. opencrbasic/checker/structure.py +6213 -0
  21. opencrbasic/checker/symbols.py +609 -0
  22. opencrbasic/checker/terminals.py +249 -0
  23. opencrbasic/checker/unused.py +186 -0
  24. opencrbasic/checker/var_types.py +564 -0
  25. opencrbasic/checker/warnings.py +661 -0
  26. opencrbasic/checks.py +491 -0
  27. opencrbasic/cli.py +391 -0
  28. opencrbasic/commands.py +128 -0
  29. opencrbasic/compiler.py +320 -0
  30. opencrbasic/consteval.py +327 -0
  31. opencrbasic/data/background_calibration.json +4 -0
  32. opencrbasic/data/before_beginprog.json +99 -0
  33. opencrbasic/data/cr1000x/background_calibration.json +4 -0
  34. opencrbasic/data/cr1000x/before_beginprog.json +138 -0
  35. opencrbasic/data/cr1000x/ess_variables.json +1087 -0
  36. opencrbasic/data/cr1000x/fnotch.json +29 -0
  37. opencrbasic/data/cr1000x/functions.json +1337 -0
  38. opencrbasic/data/cr1000x/instructions.json +4836 -0
  39. opencrbasic/data/cr1000x/measure.json +121 -0
  40. opencrbasic/data/cr1000x/mode.json +111 -0
  41. opencrbasic/data/cr1000x/pipeline_tasks.json +88 -0
  42. opencrbasic/data/cr1000x/settings_fields.json +321 -0
  43. opencrbasic/data/cr1000x/status_members.json +318 -0
  44. opencrbasic/data/cr1000x/symbols.json +872 -0
  45. opencrbasic/data/cr1000x/terminals.json +225 -0
  46. opencrbasic/data/cr300/before_beginprog.json +42 -0
  47. opencrbasic/data/cr300/ess_variables.json +1087 -0
  48. opencrbasic/data/cr300/fnotch.json +22 -0
  49. opencrbasic/data/cr300/functions.json +1261 -0
  50. opencrbasic/data/cr300/instructions.json +2837 -0
  51. opencrbasic/data/cr300/measure.json +33 -0
  52. opencrbasic/data/cr300/mode.json +121 -0
  53. opencrbasic/data/cr300/settings_fields.json +259 -0
  54. opencrbasic/data/cr300/status_members.json +303 -0
  55. opencrbasic/data/cr300/symbols.json +584 -0
  56. opencrbasic/data/cr300/terminals.json +193 -0
  57. opencrbasic/data/cr350/before_beginprog.json +44 -0
  58. opencrbasic/data/cr350/ess_variables.json +1087 -0
  59. opencrbasic/data/cr350/fnotch.json +22 -0
  60. opencrbasic/data/cr350/functions.json +1262 -0
  61. opencrbasic/data/cr350/instructions.json +2848 -0
  62. opencrbasic/data/cr350/measure.json +35 -0
  63. opencrbasic/data/cr350/mode.json +121 -0
  64. opencrbasic/data/cr350/settings_fields.json +292 -0
  65. opencrbasic/data/cr350/status_members.json +303 -0
  66. opencrbasic/data/cr350/symbols.json +572 -0
  67. opencrbasic/data/cr350/terminals.json +190 -0
  68. opencrbasic/data/ess_variables.json +1087 -0
  69. opencrbasic/data/fnotch.json +34 -0
  70. opencrbasic/data/functions.json +1337 -0
  71. opencrbasic/data/instructions.json +4965 -0
  72. opencrbasic/data/measure.json +123 -0
  73. opencrbasic/data/mode.json +121 -0
  74. opencrbasic/data/notices_cr6.json +890 -0
  75. opencrbasic/data/pipeline_tasks.json +84 -0
  76. opencrbasic/data/settings_fields.json +322 -0
  77. opencrbasic/data/status_members.json +319 -0
  78. opencrbasic/data/status_table.json +684 -0
  79. opencrbasic/data/symbols.json +902 -0
  80. opencrbasic/data/terminals.json +195 -0
  81. opencrbasic/data/type_codes.json +249 -0
  82. opencrbasic/diagnostics.py +196 -0
  83. opencrbasic/editing.py +827 -0
  84. opencrbasic/formatting.py +310 -0
  85. opencrbasic/helpdocs.py +677 -0
  86. opencrbasic/hover.py +667 -0
  87. opencrbasic/inlay.py +169 -0
  88. opencrbasic/lexer.py +183 -0
  89. opencrbasic/lsp/__init__.py +5 -0
  90. opencrbasic/lsp/__main__.py +7 -0
  91. opencrbasic/lsp/config.py +202 -0
  92. opencrbasic/lsp/features.py +602 -0
  93. opencrbasic/lsp/server.py +741 -0
  94. opencrbasic/lsp/session.py +1189 -0
  95. opencrbasic/lsp/worker.py +128 -0
  96. opencrbasic/mode.py +143 -0
  97. opencrbasic/navigation.py +401 -0
  98. opencrbasic/parser.py +3264 -0
  99. opencrbasic/positions.py +274 -0
  100. opencrbasic/preprocess.py +269 -0
  101. opencrbasic/profiles.py +266 -0
  102. opencrbasic/provenance.py +17 -0
  103. opencrbasic/quickfix.py +331 -0
  104. opencrbasic/rename.py +662 -0
  105. opencrbasic/signature.py +33 -0
  106. opencrbasic/source.py +600 -0
  107. opencrbasic/sources.py +125 -0
  108. opencrbasic/syntax.py +337 -0
  109. opencrbasic/tables.py +2173 -0
  110. opencrbasic/tables_hook.py +14 -0
  111. opencrbasic/tdf.py +142 -0
  112. opencrbasic/timing.py +565 -0
  113. opencrbasic-1.0.0.dist-info/METADATA +223 -0
  114. opencrbasic-1.0.0.dist-info/RECORD +117 -0
  115. opencrbasic-1.0.0.dist-info/WHEEL +4 -0
  116. opencrbasic-1.0.0.dist-info/entry_points.txt +3 -0
  117. opencrbasic-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,3581 @@
1
+ """Name resolution and type-code-driven value checks for instruction arguments. Rules and oracle
2
+ evidence: `docs/checker.md`.
3
+ """
4
+
5
+ import json
6
+ import re
7
+ from functools import lru_cache
8
+ from pathlib import Path
9
+ from typing import cast
10
+
11
+ from .. import consteval
12
+ from .. import syntax as S
13
+ from ..diagnostics import Diagnostic, Diagnostics
14
+ from ..profiles import DottedName
15
+ from . import instr_data, symbols
16
+
17
+ _TYPE_CODES_PATH = Path(__file__).resolve().parent.parent / "data" / "type_codes.json"
18
+
19
+ # `type_codes.json` `kind`s that route an argument through the real expression/named-constant
20
+ # resolver (`FUN_004b8700`/`FUN_004b5c50` and their `const`-only wrappers, `docs/type-codes.md`):
21
+ # an unresolved name anywhere inside one of these positions is what produces `Invalid symbol in
22
+ # expression: %s.`. Every other kind either never runs general symbol resolution at all (`6`
23
+ # identifier/raw text such as `DataTable`'s or `ConstTable`'s own name, captured verbatim and
24
+ # validated -- or not -- by a later, different pass) or resolves a fixed keyword list through a
25
+ # separate function (`4`/`16`/`17`/`19` `FUN_004b5db0`, a different failure message). Restricting
26
+ # to this explicit include-list (rather than excluding the handful of kinds known *not* to
27
+ # resolve) is deliberate: the default for an unrecognised kind is "don't check", so a gap in this
28
+ # list only under-reports, never falsely rejects (AGENTS.md's priority order); the `ConstTable`
29
+ # regression this session found during development (`crbasic/Main.CR6`'s
30
+ # `ConstTable(GlobalConfigTable)`, kind `expr_marker`) is the concrete case that shape of bug
31
+ # looks like.
32
+ _RESOLVED_KINDS = frozenset(
33
+ {
34
+ "const_expr",
35
+ "expr",
36
+ "const_expr_only",
37
+ "bool_expr",
38
+ "variable_or_array",
39
+ "expr_or_name",
40
+ "expr_typed",
41
+ "expr_special",
42
+ "expr_pair",
43
+ "expr_list",
44
+ "datatype_kw",
45
+ }
46
+ )
47
+
48
+ # The compile-time-constant-only family (`docs/type-codes.md` codes 1/39, 5/38, 20 --
49
+ # `FUN_004b8700`/its `const`-only wrappers) is checked in its own pass, ahead of every other
50
+ # resolved kind, on CR6/CR1000X -- oracle-confirmed (round 4, this session): `WriteIO(1,BazQux,
51
+ # FooBar)` (`params=[1,25,101]`: position 0 code 1 = valid literal, position 1 code 25 = `BazQux`,
52
+ # position 2 code 101 = optional code 1 = `FooBar`) reports only `FooBar` (position 2, the later
53
+ # *const-family* position), never `BazQux` (position 1, the textually-earlier *general* position)
54
+ # -- and the reverse instruction shape, `CardTest(GenBad,ConstBad)` (`params=[8,1]`: position 0
55
+ # general, position 1 const), reports only `ConstBad` (the const-family position) even though it
56
+ # is the *later* argument. Within one family, the ordering is the ordinary ascending textual
57
+ # position (`WriteIO(FooBar,BazQux,1)` -> `FooBar`, the first *const*-family position; `WriteIO(1,
58
+ # BazQux,FooBar)` with only position 1 bad -> `BazQux`). Read together, this is one const-family
59
+ # pre-pass over every const-coded position (ascending) before the general-family pass runs at all,
60
+ # not a per-position textual scan -- plausibly the same constant-folding pre-pass CRBasic needs to
61
+ # size buffers/arrays from other arguments before it can even parse the rest of the call. Not
62
+ # traced to the decompilation; `docs/checker.md` records the oracle transcript.
63
+ _CONST_FAMILY_KINDS = frozenset({"const_expr", "const_expr_only", "datatype_kw"})
64
+
65
+ # CR300/CR350 do not share this pre-pass at all: their own position order is the plain ascending
66
+ # textual order, oracle-confirmed with the same `InternalTemp(GenBad,ConstBad)` shape
67
+ # (`params=[8,20]`: position 0 general, position 1 const-family `datatype_kw`) reporting the
68
+ # textually-*first* position (`GenBad is not defined.`) and then, unlike CR6/CR1000X, abandoning
69
+ # the rest of the statement's own parameter list entirely rather than moving on to check position 1
70
+ # at all (`Too many parameters: ConstBad.` -- position 1's own token was never consumed). They also
71
+ # echo every undefined name found *inside* one compound expression as its own separate diagnostic
72
+ # line, not one whole-expression echo (`docs/checker.md`, "Round 4"): `Randomize(X + FooBar +
73
+ # BazQux)` is one `Invalid symbol in expression: X + FooBar + BazQux.` line on CR6/CR1000X but two
74
+ # separate `FooBar is not defined.` / `BazQux is not defined.` lines on CR300/CR350, in the source
75
+ # (left-to-right) order `_undefined_names` already walks in. Modelled below as a plain per-logger
76
+ # branch on `profile.name` (both loggers in each pair share identical behaviour, oracle-confirmed
77
+ # side by side) rather than a new `Profile` field, to keep this session's change inside
78
+ # `checker/expressions.py`/`checker/symbols.py` (`PLAN.md`'s ownership split).
79
+ _WHOLE_EXPR_ECHO_LOGGERS = frozenset({"cr6", "cr1000x"})
80
+
81
+ # `NAN`/`INF` are accepted as predefined float constants on all four loggers (oracle-confirmed,
82
+ # this session, `docs/checker.md`) despite being absent from every `data/<logger>/symbols.json` --
83
+ # that table is the compiler's *named-constant* lookup (`FUN_004ee4f0`, `docs/type-codes.md`), and
84
+ # `NAN`/`INF` evidently resolve through a separate, special-cased path in the expression compiler
85
+ # itself (a floating-point literal spelling, not a named constant) that this session did not trace
86
+ # to the decompilation. Added here rather than to the extracted `symbols.json` files so as not to
87
+ # misrepresent a value as extracted-from-the-binary-table when it demonstrably is not one of that
88
+ # table's rows (AGENTS.md: label inference and unverified behaviour explicitly; here the *acceptance*
89
+ # is oracle-verified even though the *mechanism* is not).
90
+ _EXTRA_PREDEFINED_SYMBOLS = frozenset({"NAN", "INF"})
91
+
92
+ # Constant-only-and-rejects-a-variable, the narrower half of `_CONST_FAMILY_KINDS` (`docs/
93
+ # type-codes.md` codes 1/39 `const_expr`, 5/38 `const_expr_only` -- **not** 20 `datatype_kw`,
94
+ # excluded below): oracle-confirmed uniform across all four loggers, identical message text and no
95
+ # per-logger branch needed (unlike the undefined-symbol family) -- `WriteIO(X,1)` ->
96
+ # `Variable illegal in Parameter: X.` and `Scan(X+1,Sec,0,0)` -> `Variable illegal in Parameter:
97
+ # X+1.` both reproduce byte-for-byte on CR6/CR1000X/CR300/CR350, whole-expression echo included
98
+ # (round 4, this session, `docs/checker.md`).
99
+ #
100
+ # Deliberately narrower than the type code alone would suggest, on two axes, both to route around
101
+ # round 3's own found regression (`Sample`'s `DataType` argument, also coded `1`, legitimately
102
+ # accepting a `StructureType` name, `structuretype_1.CR6`):
103
+ #
104
+ # 1. **`datatype_kw` (code 20) is excluded from this set entirely** -- it is a keyword-validated
105
+ # slot (`thunk_FUN_00523010`, not `FUN_004b8700`) with its own per-instruction legal-value list
106
+ # this project has not traced (`docs/type-codes.md` "inferred" confidence only), so no oracle
107
+ # evidence here supports even the ordinary "rejects a variable" half of the rule for it.
108
+ # 2. **The trigger is "references a declared runtime variable" (`symbols.declared_variable_names`
109
+ # -- `Public`/`Dim`/`Static`/a `Sub`/`Function` parameter), not "fails to fold to a constant."**
110
+ # A `StructureType` name is neither: it is not a live variable (so this check leaves it alone,
111
+ # unlike a generic `not consteval.is_constant(...)` test would) and it is not expected to fold
112
+ # (so `Sample(1,S,VWSensor)` is simply never flagged by this narrower trigger, without needing
113
+ # a `Sample`-specific carve-out at all). A corpus-wide scan this session ran (every `chm`/`repo`
114
+ # const-only-family argument that is a bare `Name` resolving to a declared *variable*) found
115
+ # exactly one candidate, and it turned out to be an unrelated CHM-prose-as-code parsing
116
+ # artifact (`straincalc_1.CR6`'s `Average Strain` line, a stray heading fragment, not a real
117
+ # statement) rather than a genuine override -- some further override may still exist outside
118
+ # this corpus's ~400-instruction coverage, so this stays a scoped, corpus-verified rule, not a
119
+ # claim that every const-only position everywhere is now covered.
120
+ _CONST_ONLY_VARIABLE_REJECT_KINDS = frozenset({"const_expr", "const_expr_only"})
121
+
122
+
123
+ def _has_dotted_field(expr: S.Expr | None) -> bool:
124
+ """Whether `expr` holds a `Member` on a plain name (`Status.Battery`, `T.Field`)."""
125
+ if isinstance(expr, S.Member):
126
+ return isinstance(expr.base, S.Name) or _has_dotted_field(expr.base)
127
+ if isinstance(expr, S.Unary):
128
+ return _has_dotted_field(expr.operand)
129
+ if isinstance(expr, S.Binary):
130
+ return _has_dotted_field(expr.left) or _has_dotted_field(expr.right)
131
+ if isinstance(expr, S.Index):
132
+ return any(_has_dotted_field(a) for a in expr.args)
133
+ return False
134
+
135
+
136
+ def _variable_refs(expr: S.Expr | None, variable_names: set[str]) -> list[str]:
137
+ """Every reference to a genuine runtime variable (`variable_names`) anywhere inside `expr`,
138
+ in source order -- the const-only-family counterpart of `_undefined_names` above, same
139
+ recursion shape (walks `Unary`/`Binary`/`Index`'s own name and args/`Member`'s base/
140
+ `ArrayLiteral` items; `Member.member` text untouched, same reasoning as `_undefined_names`).
141
+ A name that resolves to anything *other* than a declared variable (a `Const`, a
142
+ `StructureType` name, a predefined symbol, ...) is not itself a live variable, so it is left
143
+ alone here even though it may still be flagged separately by `_undefined_names` if it is
144
+ undefined outright.
145
+ """
146
+ if expr is None:
147
+ return []
148
+ if isinstance(expr, S.Name):
149
+ return [expr.name] if expr.name.upper() in variable_names else []
150
+ if isinstance(expr, S.Unary):
151
+ return _variable_refs(expr.operand, variable_names)
152
+ if isinstance(expr, S.Binary):
153
+ return _variable_refs(expr.left, variable_names) + _variable_refs(expr.right, variable_names)
154
+ if isinstance(expr, S.Index):
155
+ head = [expr.name] if expr.name.upper() in variable_names else []
156
+ for a in expr.args:
157
+ head += _variable_refs(a, variable_names)
158
+ return head
159
+ if isinstance(expr, S.Member):
160
+ return _variable_refs(expr.base, variable_names)
161
+ if isinstance(expr, S.ArrayLiteral):
162
+ out: list[str] = []
163
+ for item in expr.items:
164
+ out += _variable_refs(item, variable_names)
165
+ return out
166
+ return [] # Number, String: nothing to resolve
167
+
168
+
169
+ # Variable-required kinds (`docs/type-codes.md` codes 8, 27, 29, 33, 41, 47, 48): the shared
170
+ # "variable or array" handler accepts a bare name, an array reference, or any expression that
171
+ # involves a real variable (silently promoted to a hidden temporary) -- but rejects an argument
172
+ # that is *itself* a compile-time constant (a bare literal, a named `Const`, or a pure constant
173
+ # expression) with `Invalid Variable or Array: %s.`, **unless the constant is a string**
174
+ # (oracle-confirmed this session: `Battery(5)`, `Battery(1+2)`, and `Battery(SomeConst)` -- a
175
+ # `Const SomeConst = 5` -- all rejected this way; `Battery("x")` compiles fine, no type check at
176
+ # all on a string constant here). Still **not** generically checked here, unlike the const-only
177
+ # family above: round 3 found two overriding instructions (`Move`'s third argument, `ModBusClient`'s
178
+ # last argument, both accepting a bare literal), and round 4's own corpus scan (this session) found
179
+ # a *third*, independent of either -- `MQTTPublishTable`'s optional `Longitude`/`Latitude`/
180
+ # `Altitude` arguments (code `148`, `mqttpublishtable_1.CR6` line 17, oracle-confirmed accepted in
181
+ # isolation on both CR6 and CR1000X: `MQTTPublishTable(1, 0, 5, Min, 2, -111.85, 41.76, 1)`). Three
182
+ # confirmed overrides found from a ~523-file corpus sampling only a fraction of each logger's
183
+ # ~400-instruction table make a blanket rule too risky to enable without a plausibly-still-
184
+ # incomplete per-instruction exception list; unlike the const-only-family rule above, no version of
185
+ # this trigger (bare-literal vs. "fails to fold", posed several ways during round 3 and again this
186
+ # round) has been found that reliably separates the genuine cases from the overrides using only the
187
+ # type-code table. Left unimplemented (AGENTS.md: never reject an accepted program); a future
188
+ # session would need to check each variable-required-coded parameter individually across all four
189
+ # loggers' instruction tables (or find the compiler's own per-instruction override flag) before
190
+ # enabling this safely.
191
+ _VARIABLE_REQUIRED_KINDS = frozenset({"variable_or_array"})
192
+
193
+ # Per-instruction overrides of a `variable_or_array` (kind 8) `Dest` position: `AddPrecise`'s and
194
+ # `MovePrecise`'s own first argument (both `params=[8, 2]`, adjacent table indices 25/26) accepts
195
+ # an *undeclared* name outright -- oracle-confirmed (round 7, this session, a saved fuzz false-
196
+ # reject reproducer, `tests/corpus/fuzz/cr6/false_reject/fac2798b6778.CR6`):
197
+ # `AddPrecise(UndeclaredFuzzName123, .000000111111)`/`MovePrecise(UndeclaredMoveDest, .111111)`
198
+ # alone both compile clean, unlike `VoltDiff`'s or `Erase`'s own kind-8 `Dest` (same code, oracle-
199
+ # swept the same session: both still reject an undeclared name there with the ordinary `Invalid
200
+ # symbol in expression: %s.`). **Not a general auto-declaration**: the name does not become usable
201
+ # anywhere else in the program (oracle: `AddPrecise(NewVar123,...)` followed by `NewVar123 =
202
+ # NewVar123 + 1` still fails, `structure.py`'s own "target does not resolve" placement check,
203
+ # `Invalid, or out of place expression: ...`) -- this is narrowly "this one argument position never
204
+ # runs the undefined-symbol check at all," the same shape round 3/4's own `Move`/`ModBusClient`/
205
+ # `MQTTPublishTable` overrides of the *variable-required* rule already document (`_VARIABLE_
206
+ # REQUIRED_KINDS`'s own docstring above), just for the *undefined-name* check instead. Modelled as a
207
+ # position exemption (rather than adding the name to `known`) so it costs nothing outside these two
208
+ # instructions' own first argument.
209
+ # CR6/CR1000X instructions whose compile function reports every undefined argument instead of the
210
+ # first one only, in the listed 0-based position order (oracle, 2026-10-04, all positions
211
+ # undefined: `Move(a,b,c,d)` reports b, a, d, c; the `*Spa` family b, c, a; `GOESSetup` reports
212
+ # its tenth argument before the eighth and ninth; `MoveBytes` 1,2,4,5,3). Positions are independent (subset probes).
213
+ _MULTI_REPORT_ORDER: dict[str, tuple[int, ...]] = {
214
+ "MOVE": (1, 0, 3, 2),
215
+ "RMSSPA": (1, 2, 0),
216
+ "STDDEVSPA": (1, 2, 0),
217
+ "MAXSPA": (1, 2, 0),
218
+ "MINSPA": (1, 2, 0),
219
+ "AVGSPA": (1, 2, 0),
220
+ "CPIFILESEND": (0, 1, 2, 3, 4),
221
+ "GOESSETUP": (0, 1, 2, 3, 4, 5, 6, 9, 7, 8),
222
+ "GOESGPS": (0, 1),
223
+ "CH400SENDOS": (0, 1),
224
+ "MOVEBYTES": (0, 1, 3, 4, 2),
225
+ "READIO": (1, 0),
226
+ "IPROUTE": (0, 1, 2),
227
+ "MQTTPUBLISHMETA": (0, 1, 2, 3, 4, 5),
228
+ # Round 14 (oracle sweep, `docs/checker.md` "Round 14 multi-report order"): positions listed
229
+ # are those the real compiler reports, in its order; a replaced position missing here is not
230
+ # reported (or not before another one).
231
+ "ARGOSDATAREPEAT": (0, 3),
232
+ "AVGRUN": (0, 2, 4, 5),
233
+ "AVW200": (4, 0),
234
+ "PRT": (0, 2),
235
+ "STRAINCALC": (0, 2, 3),
236
+ "CDM_TCSE": (7, 2),
237
+ "DEBUG": (2, 3, 4),
238
+ "DEWPOINT": (0, 1, 2),
239
+ "SENDVARIABLES": (0, 1, 8),
240
+ "EC100CONFIGURE": (0, 3),
241
+ "GETVARIABLES": (0, 8),
242
+ "GPS": (4, 0),
243
+ "HYDRAPROBE": (0, 1),
244
+ "MATRIX": (1, 2, 3),
245
+ "PEAKVALLEY": (0, 1, 3),
246
+ "SATVP": (0, 1),
247
+ "TIMEDCONTROL": (4, 5),
248
+ "SDMCD16MASK": (0, 1),
249
+ "SDMGENERIC": (4, 0),
250
+ "SDMIO16": (0, 1),
251
+ "WETDRYBULB": (0, 1, 2, 3),
252
+ "SERIALINRECORD": (1, 5),
253
+ "SORTSPA": (0, 2),
254
+ "SORTSPAINDEXED": (0, 2),
255
+ "SPLITSTR": (1, 0),
256
+ "TCPACTIVECONNECTIONS": (0, 1),
257
+ "TOTALRUN": (0, 2, 4),
258
+ "VAPORPRESSURE": (0, 1, 2),
259
+ "MODBUSCLIENT": (0, 5),
260
+ "STDDEVRUN": (0, 2, 4, 5),
261
+ "TCDIFF": (5, 0),
262
+ "PORTBRIDGE": (1, 0),
263
+ "SDMAO4A": (0, 1),
264
+ "FFTSPA": (2, 0),
265
+ }
266
+
267
+ _UNDEFINED_CHECK_EXEMPT_POSITIONS: dict[str, frozenset[int]] = {
268
+ "ADDPRECISE": frozenset({0, 1}),
269
+ "MOVEPRECISE": frozenset({0, 1}),
270
+ "MODEMCALLBACK": frozenset({0}), # CR6/CR1000X; fuzz cr1000x/false_reject/5fe667412d48
271
+ }
272
+
273
+ # Round 14 (oracle, CR6/CR1000X only, `docs/checker.md` "Round 14 multi-report order"): an undeclared
274
+ # name here compiles clean (CR300/CR350 still report `NewFile(A,"x",Nope)`: `Nope is not defined.`).
275
+ # `StdDevRun` StdDevType (position 8) is never resolved either (`StdDevRun(...,D(Nope))` compiles;
276
+ # 2026-10-24, round 34). `SDMSIO4` position 0 is resolved unless its value count is 0
277
+ # (`check`, `_sdmsio4_count`).
278
+ # `IPNetPower`, `MonitorComms` and `LogIP` have empty compile functions, so their deferred argument
279
+ # text is never resolved (decompiled `FUN_004352e0`/`FUN_00434e80`/`FUN_00434e90`, oracle round 58,
280
+ # docs/checker.md#arguments-that-are-never-resolved-round-58).
281
+ _EXEMPT_WHOLE_ECHO: dict[str, frozenset[int]] = {
282
+ "MONITORCOMMS": frozenset({0, 1, 2}),
283
+ "MODEMCALLBACK": frozenset({0}),
284
+ "IPNETPOWER": frozenset({1, 2}),
285
+ "LOGIP": frozenset({0, 1}),
286
+ "NEWFILE": frozenset({2}),
287
+ "STDDEVRUN": frozenset({8}),
288
+ "TIMEDCONTROL": frozenset({0, 1}), # `check_timedcontrol_constants`
289
+ }
290
+
291
+ # Built-in function call arity (this session, oracle-confirmed against `CR6Comp.exe`, byte-for-byte
292
+ # re-confirmed on `cr1000xcomp.exe` for the same probe batch -- `docs/checker.md`): a call to a
293
+ # `data/functions.json` built-in inside an expression (a `Name(args)` the parser resolves as
294
+ # `syntax.Index`, or a bare `syntax.Call` when the whole statement is just the call) with fewer
295
+ # arguments than that function's own `nargs` reports `Expression expects , : <echo>.`; more than
296
+ # `nargs` reports `No Matching, or out of place ) in expression: <echo>.`. Both are genuinely
297
+ # **pass 1**: oracle-confirmed to fire alongside an unrelated, earlier pass-1 `Variable not named.`,
298
+ # and themselves to suppress an unrelated *pass-2* `Invalid symbol in expression` finding on a
299
+ # different, otherwise-clean statement elsewhere in the same file -- the same "pass 1 found
300
+ # something anywhere in the file, pass 2 does not run at all" architecture already documented for
301
+ # `instr_rules.py`'s instruction-argument checks (`docs/checker.md` "Pass model").
302
+ #
303
+ # `nargs` is **not** a safe universal minimum or maximum, so neither direction is applied to every
304
+ # function with a decoded `argtypes`: a 92-function oracle sweep (every `data/functions.json` entry
305
+ # with `argtypes is not None`, tried at `nargs-1` and `nargs+1` arguments each) found real
306
+ # exceptions on both sides -- `NetworkTimeProtocol`/`PingIP`/`HttpPost`/`HttpPut`/`Sprintf`/`Date`
307
+ # all accept *fewer* than `nargs` (an undocumented optional trailing argument each); `SerialOpen`
308
+ # accepts `nargs+1` (a 6th, undocumented optional argument -- oracle: `SerialOpen(ComC1,9600,0,0,
309
+ # 1000,1)` compiles clean on CR6/CR1000X; CR350's own extracted table already lists `nargs=6` for
310
+ # it, so this is specifically a CR6/CR1000X table gap, not a real difference in compiler behaviour);
311
+ # `Sprintf` is genuinely variadic (2..9 arguments all compile clean; 10 hits an unrelated
312
+ # `Expression nesting level exceeded, break up expression.`, not an arity rejection). A one-
313
+ # argument-short call to a **zero- or one-argument** function is also excluded from the "too few"
314
+ # side entirely: at `nargs` 0 or 1 the "one too few" case is zero arguments, `NAME()`, which the
315
+ # oracle sweep found is a *different*, inconsistent message family there (most often the ordinary
316
+ # bare-call fallback `Invalid, or out of place expression: %s.`, sometimes a genuinely different
317
+ # pass-1 message, `Incomplete expression: %s.`, and for a handful -- e.g. `Sprintf()` itself,
318
+ # `PppOpen(1)` -- silently accepted) rather than this rule's own two messages, so it is not modelled
319
+ # here (AGENTS.md: implement only the confirmed subset).
320
+ #
321
+ # Both directions are therefore restricted to an explicit, oracle-verified allow-list per direction
322
+ # (`_ARITY_MIN_SAFE`/`_ARITY_MAX_SAFE`) -- exactly the functions this sweep found reject `nargs-1`/
323
+ # `nargs+1` with the expected message and nothing else (no unrelated crash, no different message, no
324
+ # silent accept), cross-checked identically on `cr1000xcomp.exe` for the whole probe batch (the only
325
+ # difference found across the two loggers in that re-run was an unrelated port-range message,
326
+ # `CheckPort`'s own `Port must be C1..C4 or U1..U12.` vs `Port must be C1..C8.`, nothing to do with
327
+ # arity). CR300/CR350 were spot-checked on `Round`/`Instr` only (not the full 92-function sweep,
328
+ # `docs/checker.md`); see `_ARITY_TRAILING_NEWLINE_LOGGERS` below for the one real formatting
329
+ # difference found there.
330
+ #
331
+ # Both maps are keyed by name **and** the `nargs` value this session's oracle sweep actually tested
332
+ # (the default/`cr6`/`cr1000x` `data/functions.json`, byte-identical between those two) -- a
333
+ # *value*, not just a name allow-list -- because a full-corpus difftest re-run (`tools/difftest.py
334
+ # --logger cr350`) caught a real false reject this session first got wrong: CR350's own
335
+ # `functions.json` gives `SerialOpen` `nargs=6` (its own table already bakes in the undocumented
336
+ # 6th argument CR6/CR1000X's table omits, see above), so a name-only allow-list applied `SerialOpen`
337
+ # to `_ARITY_MIN_SAFE` using *CR350's* `nargs` (6) and rejected the ordinary 5-argument form
338
+ # (`tests/corpus/chm/serialflush_1.CR6`, `examples/SBD_example_hirise*.CR6`) that CR350 genuinely
339
+ # accepts. `MQTTSubscribe`/`NetworkTimeProtocol` have the same shape on CR300/CR350 (a smaller
340
+ # `nargs` there than CR6/CR1000X, `docs/checker.md`'s own earlier per-logger `functions.json` diff),
341
+ # not yet oracle-tested for whether their *own* true min/max still matches their own smaller
342
+ # `nargs` the way `SerialOpen`'s doesn't -- so rather than assume either way, `_call_arity_problem`
343
+ # only fires when the profile's own `nargs` for that name is **exactly** the value this sweep
344
+ # tested, and silently does not check a name whose profile disagrees (AGENTS.md: under-report
345
+ # rather than guess at an unverified cross-logger transfer). This is why the two maps below are
346
+ # `{name: nargs}`, not plain `frozenset`s.
347
+ #
348
+ # **Argument-*kind* mismatches are not implemented**: the same sweep tried swapping a string literal
349
+ # where `argtypes` says a float/int expression and vice versa (`Round("x",1)`, `Round(1.5,"x")`,
350
+ # `Left(1,2)`, `Mid(1,2,3)`, `Replace(1,"x","y")`, `InStr("x","x","x",1.5)`) and the real compiler
351
+ # compiled every one of them clean -- `argtypes`'s 0/1/2/3 codes evidently only select how the value
352
+ # is *coerced* at the call site, not a compile-time type check, so there is no "wrong kind"
353
+ # diagnostic to reproduce (AGENTS.md: never invent a rejection with no oracle evidence for it).
354
+ _ARITY_MIN_SAFE = {
355
+ "CHECKSUM": 3,
356
+ "DAYLIGHTSAVING": 8,
357
+ "DIALMODEM": 4,
358
+ "EMAILRECV": 6,
359
+ "EMAILSEND": 6,
360
+ "ENCRYPTION": 7,
361
+ "FILECOPY": 2,
362
+ "FILELIST": 2,
363
+ "FILEOPEN": 3,
364
+ "FILEREAD": 3,
365
+ "FILEREADLINE": 3,
366
+ "FILERENAME": 2,
367
+ "FILEWRITE": 3,
368
+ "FINDSPA": 4,
369
+ "FORMATFLOAT": 2,
370
+ "FORMATLONG": 2,
371
+ "FTPCLIENT": 6,
372
+ "HTTPGET": 3,
373
+ "IIF": 3,
374
+ "INSTR": 4,
375
+ "IPINFO": 2,
376
+ "LEFT": 2,
377
+ "MID": 3,
378
+ "MQTTPUBLISH": 4,
379
+ "MQTTSUBSCRIBE": 4,
380
+ "REPLACE": 3,
381
+ "RIGHT": 2,
382
+ "ROUND": 2,
383
+ "SECSSINCE1990": 2,
384
+ "SERIALINBLOCK": 3,
385
+ "SERIALOPEN": 5,
386
+ "SERIALOUT": 5,
387
+ "SMSRECV": 3,
388
+ "STRCOMP": 2,
389
+ "TCPOPEN": 3,
390
+ "TIMEISBETWEEN": 4,
391
+ "UDPDATAGRAM": 6,
392
+ "UDPOPEN": 3,
393
+ "WATCHDOGTIMER": 2,
394
+ "XMLPARSE": 6,
395
+ # A second, smaller oracle sweep (this session, `docs/checker.md`) of every `argtypes is None`
396
+ # entry (the uniform-float-argument math functions, `kind` 295) found the same rule holds for
397
+ # its own `nargs>=2` members, `TIMER`'s own excepted below:
398
+ "AVE": 2,
399
+ "PWR": 2,
400
+ "ATN2": 2,
401
+ "IFTIME": 3,
402
+ "TIMEINTOINTERVAL": 3,
403
+ # `TIMER(...)` at `nargs-1` gets `Parameter must be a constant integer: %s.` instead (its own
404
+ # arguments are constant-integer-checked before arity would ever matter) -- deliberately not in
405
+ # this map.
406
+ }
407
+ _ARITY_MAX_SAFE = {
408
+ "ARRAYINDEX": 1,
409
+ "ASCII": 1,
410
+ "CHECKPORT": 1,
411
+ "CHR": 1,
412
+ "CHR$": 1,
413
+ "COMPORTISACTIVE": 1,
414
+ "DAYLIGHTSAVING": 8,
415
+ "DAYLIGHTSAVINGUS": 1,
416
+ "ENCRYPTION": 7,
417
+ "FILECLOSE": 1,
418
+ "FILECOPY": 2,
419
+ "FILEENCRYPT": 1,
420
+ "FILELIST": 2,
421
+ "FILEOPEN": 3,
422
+ "FILEREAD": 3,
423
+ "FILEREADLINE": 3,
424
+ "FILERENAME": 2,
425
+ "FILESIZE": 1,
426
+ "FILETIME": 1,
427
+ "FILEWRITE": 3,
428
+ "FINDSPA": 4,
429
+ "FORMATFLOAT": 2,
430
+ "FORMATLONG": 2,
431
+ "FORMATLONGLONG": 1,
432
+ "HEX": 1,
433
+ "HEXTODEC": 1,
434
+ "IIF": 3,
435
+ "INSTR": 4,
436
+ "IPINFO": 2,
437
+ "IPTRACE": 1,
438
+ "LEFT": 2,
439
+ "LEN": 1,
440
+ "LOADFIELDCAL": 1,
441
+ "LOWERCASE": 1,
442
+ "LTRIM": 1,
443
+ "MID": 3,
444
+ "MQTTUNSUBSCRIBE": 1,
445
+ "NEWFIELDCAL": 0,
446
+ "PINGIP": 3,
447
+ "PPPCLOSE": 0,
448
+ "REPLACE": 3,
449
+ "RIGHT": 2,
450
+ "ROUND": 2,
451
+ "ROUTE": 1,
452
+ "RTRIM": 1,
453
+ "SECSSINCE1990": 2,
454
+ "SERIALCLOSE": 1,
455
+ "SERIALFLUSH": 1,
456
+ "SERIALINBLOCK": 3,
457
+ "SERIALINCHK": 1,
458
+ "SERIALOUT": 5,
459
+ "SMSRECV": 3,
460
+ "STRCOMP": 2,
461
+ "TCPCLOSE": 1,
462
+ "TIMEISBETWEEN": 4,
463
+ "TRIM": 1,
464
+ "UPPERCASE": 1,
465
+ "VOICEKEY": 1,
466
+ "VOICENUMBER": 1,
467
+ "WATCHDOGTIMER": 2,
468
+ # Same second sweep, `nargs+1` this time: every plain single-float math function tried rejects
469
+ # cleanly, plus the three shared with `_ARITY_MIN_SAFE` above and the two zero-argument
470
+ # functions this specific pair happened to also reject cleanly (`Rnd`/`ClockChange`, unlike the
471
+ # inconsistent zero-argument functions in the first sweep, `docs/checker.md`'s own top-of-file
472
+ # comment) -- `Abs`, `Ave` (this direction only) and `Timer` excepted below.
473
+ "SIN": 1,
474
+ "COS": 1,
475
+ "LOG": 1,
476
+ "LN": 1,
477
+ "SQR": 1,
478
+ "EXP": 1,
479
+ "CSGN": 1,
480
+ "INT": 1,
481
+ "FRAC": 1,
482
+ "SGN": 1,
483
+ "ATN": 1,
484
+ "TAN": 1,
485
+ "FIX": 1,
486
+ "ASIN": 1,
487
+ "ACOS": 1,
488
+ "LOG10": 1,
489
+ "SINH": 1,
490
+ "TANH": 1,
491
+ "COSH": 1,
492
+ "FLOOR": 1,
493
+ "CEILING": 1,
494
+ "RND": 0,
495
+ "CLOCKCHANGE": 0,
496
+ "PWR": 2,
497
+ "ATN2": 2,
498
+ "IFTIME": 3,
499
+ "TIMEINTOINTERVAL": 3,
500
+ # `Abs(x,y)` gets `Incomplete expression: %s.` instead (a different message, not modelled by
501
+ # this rule); `Ave(x,y,z)` gets `Parameter must be a constant integer: %s.` (its own later
502
+ # arguments are constant-integer-checked, same shape as `Timer`); `Timer` itself gets that same
503
+ # constant-integer message on *this* side too. None of the three are in this map.
504
+ }
505
+
506
+ # A small group of function shapes reaches a different parser diagnostic before the ordinary
507
+ # argument-count handler. Each entry is restricted to the directly observed boundary: a string
508
+ # in a constant-integer position, or one argument beyond the extracted count.
509
+ _FUNCTION_CONST_INTEGER_STRING_ARGS = {
510
+ "SERIALOPEN": frozenset({3, 4}),
511
+ "TIMER": frozenset({1}),
512
+ }
513
+ _CONST_INTEGER_OVERFLOW_STRING = {"TCPOPEN": 3}
514
+ # Argument positions that must be an integer constant: a string, `NAN`, a float literal (`1.0`) or an
515
+ # integer beyond 32 bits is `Parameter must be a constant integer` (oracle, all four loggers,
516
+ # 2026-10-10: `TCPOpen(Addr,6785,"fuzz")`; an integer constant expression and `Hr` compile)
517
+ # `SerialOpen`'s 4th and 5th arguments (oracle, all four loggers, 2026-10-11: `1.0`, `999999999999`
518
+ # -> `Parameter must be a constant integer`; `Hr`, `1+1`, `-1` compile on CR300 and also CR6/CR1000X
519
+ # for `-1` only the range message `usec delay before transmit is illegal`, not modelled).
520
+ _CONST_INTEGER_BAD_ARGS = {"TCPOPEN": frozenset({2}), "SERIALOPEN": frozenset({3, 4})}
521
+
522
+
523
+ # Outside an earlier `IfTime`/`TimeIntoInterval` (`intervals.timeout_gate`; set per statement by
524
+ # `check_function_arity`, `None` elsewhere so other callers never apply the rule) the trailing
525
+ # `TimeOut` of `HTTPPut` (9th) and `FTPClient` (11th) argument, a string (also `""`), `NAN` or a
526
+ # float literal, is `Parameter must be a constant integer`, before the argument-count check (oracle,
527
+ # all four loggers). After one, the same arguments give a truncate warning / `Parameter must be
528
+ # integral.` instead, reported by `intervals.check`. Overflowing literals and Consts are the constant-
529
+ # integer error too when `intervals.timeout_value` classifies them as float (`_TIMEOUT_WIDE`, set per
530
+ # program by `check_function_arity`). Not modelled: a variable or function call there.
531
+ _TIMEOUT_GATE: list = [None]
532
+ _TIMEOUT_WIDE: list = [frozenset()]
533
+ _CONST_INTEGER_TYPE_ARGS = {"HTTPPUT": frozenset({8}), "FTPCLIENT": frozenset({10})}
534
+ # `Timer`'s third argument is the same error whatever came before (it resets the mode first; oracle,
535
+ # CR6/CR350, 2026-10-16: also after an `IfTime`).
536
+ _CONST_INTEGER_TYPE_ARGS_ALWAYS = {"TIMER": frozenset({2})}
537
+ # `Timer`'s Units and Option (second and third arguments) must be integer-typed constants: a
538
+ # variable, a function call, `/`, `^`, a float literal, `INF` or a `Const` whose value is not an
539
+ # integer or is above INT_MAX is `Parameter must be a constant integer`; an integer Option outside
540
+ # 0..4 is `Legal options are 0..4` (oracle, all four loggers, 2026-10-25, `docs/checker.md` "Timer
541
+ # arguments"). `(env, declared variables)` is set per program by `check_function_arity`.
542
+ _TIMER_CONTEXT: list = [None]
543
+
544
+
545
+ def _timer_arg(arg, env, declared: frozenset[str]):
546
+ """`("int", value)`, `"float"` (the constant-integer error) or `None` (not judged here).
547
+ An integer past the signed 32-bit range is float on CR6/CR1000X; CR300/CR350 take it as out of
548
+ range up to magnitude 2**32. `&H` and `&B` literals wrap (`&HFFFFFFFF` is -1)."""
549
+
550
+ def wide(value: int):
551
+ return ("int", -(2**31)) if env.profile.name in ("cr300", "cr350") and abs(value) < 2**32 else "float"
552
+
553
+ if isinstance(arg, S.Number):
554
+ if isinstance(arg.value, float):
555
+ return "float"
556
+ if arg.text[:1] == "&":
557
+ return ("int", (arg.value + 2**31) % 2**32 - 2**31)
558
+ return ("int", arg.value) if -(2**31) <= arg.value < 2**31 else wide(arg.value)
559
+ if isinstance(arg, S.Name):
560
+ name = arg.name.upper()
561
+ if name in ("INF", "NAN"):
562
+ return "float"
563
+ if name in declared:
564
+ return "float"
565
+ try:
566
+ value = consteval.value_of(arg, env)
567
+ except consteval.NotConstant:
568
+ return None
569
+ if isinstance(value, str):
570
+ return None
571
+ if value != value or value != int(value):
572
+ return "float"
573
+ return wide(int(value)) if value > 2**31 - 1 else ("int", int(value))
574
+ if isinstance(arg, S.Index):
575
+ return "float" if arg.name.upper() in declared or arg.name.upper() in env.profile.functions else None
576
+ if isinstance(arg, S.Unary):
577
+ inner = _timer_arg(arg.operand, env, declared)
578
+ if inner is None or inner == "float":
579
+ return inner
580
+ value = {"-": -inner[1], "+": inner[1]}.get(arg.op, ~inner[1])
581
+ return ("int", (value + 2**31) % 2**32 - 2**31)
582
+ if isinstance(arg, S.Binary):
583
+ sides = [_timer_arg(arg.left, env, declared), _timer_arg(arg.right, env, declared)]
584
+ if None in sides:
585
+ return None
586
+ if "float" in sides or arg.op in ("/", "^"):
587
+ return "float"
588
+ try:
589
+ value = consteval.value_of(arg, env)
590
+ except consteval.NotConstant:
591
+ return None
592
+ return ("int", (int(value) + 2**31) % 2**32 - 2**31)
593
+ return None
594
+
595
+
596
+ def _timeout_wide(arg, profile) -> bool:
597
+ """Whether a `TimeOut` argument is a float-classified constant (`intervals.timeout_value`)."""
598
+ from . import intervals # lazy: `intervals` imports this package's modules
599
+
600
+ return intervals.timeout_value(arg, None, profile, _TIMEOUT_WIDE[0]) == ("wide", 0)
601
+
602
+
603
+ def _not_integer_type(arg) -> bool:
604
+ if isinstance(arg, S.Number):
605
+ return isinstance(arg.value, float)
606
+ return isinstance(arg, S.String) or (isinstance(arg, S.Name) and arg.name.upper() == "NAN")
607
+
608
+
609
+ def _not_constant_integer(arg) -> bool:
610
+ if isinstance(arg, S.String):
611
+ return True
612
+ if isinstance(arg, S.Name):
613
+ return arg.name.upper() == "NAN"
614
+ if isinstance(arg, S.Number):
615
+ return isinstance(arg.value, float) or not -(2**31) <= arg.value < 2**31
616
+ return False
617
+
618
+
619
+ _INCOMPLETE_OVERFLOW = {"ABS": 1, "MQTTPUBLISH": 4}
620
+ # Observed (min, max) argument counts where they differ from the table's `nargs`, keyed by that
621
+ # profile `nargs` (oracle, 2026-10-03, all four loggers).
622
+ # `GZip` (table nargs 1) takes exactly 2 (`Filename, ZippedFilename`; CR6/CR1000X oracle 2026-10-13:
623
+ # 1 -> `Expression expects ,`, 3+ -> `No Matching, or out of place )`; absent on CR300/CR350).
624
+ # `SerialOpen` (table nargs 5, CR350 6) takes 5 or 6 arguments, CR350 5 to 8 (oracle 2026-10-13: more is
625
+ # `No Matching, or out of place )`, 4 or fewer `Expression expects ,`; CR350 accepted 4 or fewer).
626
+ _ARITY_RANGE = {
627
+ "NETWORKTIMEPROTOCOL": {5: (3, 4), 3: (3, 3)},
628
+ "GZIP": {1: (2, 2)},
629
+ "SERIALOPEN": {5: (5, 6), 6: (5, 8)},
630
+ "TIMER": {3: (3, 3)},
631
+ }
632
+ # (`Timer` takes exactly 3: fewer `Expression expects ,`, more `No Matching, or out of place )`; oracle,
633
+ # all four loggers, 2026-10-13, `N = Timer(1,sec)`, `Timer(1,2)`, `N = Timer(1,sec,0,0)`)
634
+ # Argument counts a call accepts when they are not one range, keyed by profile `nargs` (oracle,
635
+ # 2026-10-10, all four loggers): `HTTPPut` takes 4, 8 or 9 (2, 3, 5, 6, 7 are `few`, 10+ `many`).
636
+ # `FTPClient` (table nargs 6) takes 6, 10 or 11 (oracle, all four loggers, 2026-10-11: 7-9 are
637
+ # `Expression expects , :`, 12+ `No Matching, or out of place )`).
638
+ # `EmailSend` (table nargs 6) takes 9, 13 or 14; `EmailRecv` 9-13; `UDPDatagram` 6-8; `XMLParse` 8;
639
+ # `HTTPGet` 3-4; `UDPOpen` 3-4; `TCPOpen` 3, 4 or 6; `HTTPPost` 4, 8 or 9; `CheckSum` 3, 4 or 6
640
+ # (CR6 host sweep 2026-10-11, `tests/fixtures/r13_function_arity.json`; fewer is `few`, more `many`).
641
+ _ARITY_ALLOWED = {
642
+ "HTTPPUT": {5: frozenset({4, 8, 9})},
643
+ "FTPCLIENT": {6: frozenset({6, 10, 11})},
644
+ "EMAILSEND": {6: frozenset({9, 13, 14})},
645
+ "EMAILRECV": {6: frozenset(range(9, 14))},
646
+ "UDPDATAGRAM": {6: frozenset({6, 7, 8})},
647
+ "XMLPARSE": {6: frozenset({8})},
648
+ "HTTPGET": {3: frozenset({3, 4})},
649
+ "UDPOPEN": {3: frozenset({3, 4})},
650
+ "TCPOPEN": {3: frozenset({3, 4, 6})},
651
+ "HTTPPOST": {5: frozenset({4, 8, 9})},
652
+ "CHECKSUM": {3: frozenset({3, 4, 6})},
653
+ "EMAILRELAY": {0: frozenset({4, 5, 9, 10})},
654
+ }
655
+ # (`EmailRelay`, table nargs 0, takes 4, 5, 9 or 10; oracle, all four loggers, 2026-10-25)
656
+
657
+ # CR300/CR350 echo this rule's messages with the offending statement's text followed by a literal
658
+ # embedded newline, then the closing `.` alone on the next line -- CR6/CR1000X put the `.`
659
+ # immediately after the text on one line (oracle, `Round(X)`/`Round(X,1,2)` alone, both directions,
660
+ # `docs/checker.md`). The same split this file already uses for other per-logger message-shape
661
+ # differences (`_WHOLE_EXPR_ECHO_LOGGERS`); reused directly rather than adding a new set, since the
662
+ # two happen to be the same two loggers on each side for this rule too.
663
+ _ARITY_TRAILING_NEWLINE_LOGGERS = frozenset({"cr300", "cr350"})
664
+
665
+
666
+ @lru_cache(maxsize=1)
667
+ def _type_code_kinds() -> dict[int, str]:
668
+ data = json.loads(_TYPE_CODES_PATH.read_text())
669
+ return {int(code): entry["kind"] for code, entry in data.items()}
670
+
671
+
672
+ def _position_kinds(params: list[int], keyword: str = "", profile=None) -> dict[int, str]:
673
+ """0-based argument index -> `type_codes.json` `kind`, for every position whose type code
674
+ routes through the general symbol/expression compiler (`_RESOLVED_KINDS`), minus any position
675
+ `_UNDEFINED_CHECK_EXEMPT_POSITIONS` names for this instruction's own keyword."""
676
+ kinds = _type_code_kinds()
677
+ exempt = _UNDEFINED_CHECK_EXEMPT_POSITIONS.get(keyword.upper(), frozenset())
678
+ if profile is not None and profile.name in _WHOLE_EXPR_ECHO_LOGGERS:
679
+ exempt = exempt | _EXEMPT_WHOLE_ECHO.get(keyword.upper(), frozenset())
680
+ out = {}
681
+ for i, code in enumerate(params):
682
+ if i in exempt:
683
+ continue
684
+ base = code - 100 if code >= 100 else code
685
+ kind = kinds.get(base)
686
+ if kind in _RESOLVED_KINDS:
687
+ out[i] = kind
688
+ return out
689
+
690
+
691
+ def _undefined_names(expr: S.Expr | None, known: set[str]) -> list[str]:
692
+ """`_undefined_names_walk`, minus the dotted names after the first plain one: once a name is
693
+ `is not defined.`, a later `Undeclared.member` is not reported (oracle, CR300, 2026-10-11)."""
694
+ out = _undefined_names_walk(expr, known)
695
+ for i, n in enumerate(out):
696
+ if not isinstance(n, DottedName):
697
+ return out[: i + 1] + [m for m in out[i + 1 :] if not isinstance(m, DottedName)]
698
+ return out
699
+
700
+
701
+ def _undefined_names_walk(expr: S.Expr | None, known: set[str]) -> list[str]:
702
+ """Every undeclared name referenced anywhere inside `expr` (in source order), recursing into
703
+ unary/binary operators, array indices and array-literal items. `Member` (`Table.Field`,
704
+ `Struct.member`) only recurses into its own base, never its `member` text -- that is a
705
+ field/member name resolved against the base's own type, not a bare symbol lookup, and this
706
+ session found no oracle evidence for how (or whether) it is checked, so it is left alone
707
+ (AGENTS.md: under-report rather than guess). A name used as a `Call`-like `Index`'s own
708
+ function/array name *is* checked, alongside its arguments, matching how the existing bare-name
709
+ case already treated a direct `Index` argument before this session's own extension.
710
+ """
711
+ if expr is None:
712
+ return []
713
+ if isinstance(expr, S.Name):
714
+ if expr.name.endswith(".") and len(expr.name) > 1 and (expr.name[0].isalpha() or expr.name[0] == "_"):
715
+ return [] # `T.` / `X.` / `Zork.`: `check_digit_members` owns a trailing-dot name
716
+ upper = expr.name.upper()
717
+ if id(expr) in getattr(known, "out_of_scope", ()):
718
+ return [expr.name]
719
+ return [] if upper in known and upper not in getattr(known, "tables", ()) else [expr.name]
720
+ if isinstance(expr, S.Unary):
721
+ return _undefined_names_walk(expr.operand, known)
722
+ if isinstance(expr, S.Binary):
723
+ return _undefined_names_walk(expr.left, known) + _undefined_names_walk(expr.right, known)
724
+ if isinstance(expr, S.Index):
725
+ head = [] if expr.name.upper() in known and id(expr) not in getattr(known, "out_of_scope", ()) else [expr.name]
726
+ for a in expr.args:
727
+ head += _undefined_names_walk(a, known)
728
+ return head
729
+ if isinstance(expr, S.Member):
730
+ # ... but its index arguments are (`Status.TimeStamp(Nope,1)`, oracle 2026-09-28). A
731
+ # table name is fine as the base (`T.Field`).
732
+ base = expr.base
733
+ out = (
734
+ []
735
+ if isinstance(base, S.Name)
736
+ and base.name.upper() in known
737
+ and id(base) not in getattr(known, "out_of_scope", ())
738
+ else _undefined_names_walk(base, known)
739
+ )
740
+ if out and isinstance(base, S.Name) and getattr(known, "dotted_tables", False):
741
+ out = [DottedName(base.name)]
742
+ for a in getattr(expr, "args", None) or []:
743
+ out += _undefined_names_walk(a, known)
744
+ return out
745
+ if isinstance(expr, S.ArrayLiteral):
746
+ out: list[str] = []
747
+ for item in expr.items:
748
+ out += _undefined_names_walk(item, known)
749
+ return out
750
+ return [] # Number, String: nothing to resolve
751
+
752
+
753
+ def _names_through_over_index(expr: S.Expr | None, known: set[str], dim_counts: dict) -> list[str]:
754
+ """The undefined names of `expr` up to and including the arguments of its first over-indexed
755
+ subscript (`_over_indexed`): the compile of the statement ends there (oracle, CR300/CR350,
756
+ 2026-10-12: `x = R(4,Zork) + R(5,Zork2)` for `Public R(4)` names only `Zork`)."""
757
+ out: list[str] = []
758
+
759
+ def walk(e: S.Expr | None) -> bool:
760
+ if e is None:
761
+ return False
762
+ if isinstance(e, S.Unary):
763
+ return walk(e.operand)
764
+ if isinstance(e, S.Binary):
765
+ return walk(e.left) or walk(e.right)
766
+ if isinstance(e, S.Index):
767
+ if e.name.upper() not in known:
768
+ out.append(e.name)
769
+ for a in e.args:
770
+ if walk(a):
771
+ return True
772
+ count = dim_counts.get(e.name.upper())
773
+ return (
774
+ count is not None
775
+ and len(e.args) > count
776
+ and not (len(e.args) == 3 and e.name.upper() in getattr(dim_counts, "strings", ()))
777
+ )
778
+ out.extend(_undefined_names_walk(e, known))
779
+ return False
780
+
781
+ walk(expr)
782
+ return out
783
+
784
+
785
+ def _dim_limit(profile) -> int | None:
786
+ """`_over_indexed`'s `limit`: CR6/CR1000X reject only more than three subscripts (round 16 of
787
+ `docs/checker.md`), CR300/CR350 any beyond the declared dimensions."""
788
+ return 3 if profile.name in _WHOLE_EXPR_ECHO_LOGGERS else None
789
+
790
+
791
+ def _over_indexed(expr: S.Expr | None, dim_counts: dict[str, int], limit: int | None = None) -> bool:
792
+ """Whether `expr` indexes a declared variable with more subscripts than it has dimensions
793
+ (`limit`: more than `limit` subscripts instead). CR300/CR350 reject `R(2,1)` for `Public R(5)`
794
+ and `X(1)` for a scalar; CR6/CR1000X only more than 3 (oracle, 2026-09-28)."""
795
+ if expr is None:
796
+ return False
797
+ if isinstance(expr, S.Index):
798
+ count = dim_counts.get(expr.name.upper())
799
+ if (
800
+ count is not None
801
+ and len(expr.args) > (count if limit is None else limit)
802
+ and not (len(expr.args) == 3 and expr.name.upper() in getattr(dim_counts, "strings", ()))
803
+ ):
804
+ return True # (a String takes three subscripts: CR300 accepts `S(1,2,3)`, 2026-10-01)
805
+ return any(_over_indexed(a, dim_counts, limit) for a in expr.args)
806
+ if isinstance(expr, S.Unary):
807
+ return _over_indexed(expr.operand, dim_counts, limit)
808
+ if isinstance(expr, S.Binary):
809
+ return _over_indexed(expr.left, dim_counts, limit) or _over_indexed(expr.right, dim_counts, limit)
810
+ if isinstance(expr, S.Member):
811
+ return any(_over_indexed(a, dim_counts, limit) for a in (expr.args or []))
812
+ return False
813
+
814
+
815
+ _NO_NAMES: frozenset = frozenset()
816
+
817
+
818
+ class DimsState:
819
+ """The CR300/CR350 compiler's running "dimension count" (oracle, CR300, 2026-10-03,
820
+ `docs/checker.md` "Dimensions not declared"): every reference to a declared variable, in text
821
+ order and across statements, sets it to that variable's number of dimensions (a scalar 0, a
822
+ `String` one more than declared); constants, predefined names and undefined names leave it
823
+ alone. An undefined name with more subscripts than the current count is
824
+ `Dimensions not declared`. `var_dims` maps names to counts (None: counted differently in
825
+ two declarations), `before` maps `id(stmt)` to the count when the statement starts."""
826
+
827
+ def __init__(self, var_dims: dict, before: dict | None = None, dim_counts: dict | None = None):
828
+ self.var_dims = var_dims
829
+ self.before = before or {}
830
+ self.dim_counts = dim_counts # `symbols.declared_dim_counts(program, strings=True)`
831
+
832
+
833
+ def _stmt_exprs(stmt: S.Stmt) -> list:
834
+ if isinstance(stmt, S.Assignment):
835
+ return [stmt.target, stmt.value]
836
+ if isinstance(stmt, (S.If, S.ElseIf, S.DoLoop)):
837
+ return [stmt.cond]
838
+ if isinstance(stmt, S.For):
839
+ return [stmt.var, stmt.start, stmt.end, stmt.step]
840
+ if isinstance(stmt, S.Call) and stmt.tail_expr is not None:
841
+ return [stmt.tail_expr]
842
+ if isinstance(stmt, (S.Instruction, S.Call)):
843
+ return list(stmt.args)
844
+ return []
845
+
846
+
847
+ def program_dims(program: S.Program, profile, known) -> DimsState:
848
+ """The one `DimsState` every consumer shares: single-pass loggers (CR300/CR350) stop a
849
+ statement at its first undefined name (`known`), the others simulate over declared variables."""
850
+ return dims_state(program, known if profile.single_pass else _NO_NAMES)
851
+
852
+
853
+ def dims_state(program: S.Program, known=_NO_NAMES) -> DimsState:
854
+ """`DimsState` for `program`: variable dimension counts, and the count at each statement's
855
+ start, simulated over every statement in order (one-line `If` bodies included)."""
856
+ var_dims: dict[str, int | None] = {}
857
+
858
+ def declare(name: str, count: int) -> None:
859
+ name = name.upper()
860
+ var_dims[name] = count if var_dims.get(name, count) == count else None
861
+
862
+ for stmt in program.statements:
863
+ if isinstance(stmt, S.Declaration):
864
+ for v in stmt.vars:
865
+ declare(v.name, len(v.dims) + ((v.type or "").upper() == "STRING"))
866
+ elif isinstance(stmt, S.SubDef):
867
+ for prm in stmt.params:
868
+ declare(prm.name, prm.dims + ((prm.type or "").upper() == "STRING"))
869
+ ordered: list[S.Stmt] = []
870
+ present = {id(st) for st in program.statements}
871
+ for stmt in program.statements:
872
+ ordered.append(stmt)
873
+ if isinstance(stmt, S.If):
874
+ ordered.extend(st for st in (stmt.inline or []) + (stmt.inline_else or []) if id(st) not in present)
875
+ before: dict[int, int | None] = {}
876
+ state: list = [0]
877
+ for stmt in ordered:
878
+ before[id(stmt)] = state[0]
879
+ for expr in _stmt_exprs(stmt):
880
+ if _dims_walk(expr, known, var_dims, state):
881
+ break
882
+ return DimsState(var_dims, before, symbols.declared_dim_counts(program, strings=True))
883
+
884
+
885
+ def _dims_walk(expr: S.Expr | None, known, var_dims: dict, state: list) -> bool:
886
+ """Walks `expr` in text order updating `state[0]`; True when an undefined name has more
887
+ subscripts than the current count. With `known` empty every name counts as undefined, so only
888
+ declared variables (`var_dims`) move the state: that is the simulation mode."""
889
+ if expr is None:
890
+ return False
891
+ oos = id(expr) in getattr(known, "out_of_scope", ()) # another routine's `Dim`: undefined here
892
+ if isinstance(expr, S.Name):
893
+ upper = expr.name.upper()
894
+ if upper in var_dims and not oos:
895
+ state[0] = var_dims[upper]
896
+ return False
897
+ if isinstance(expr, S.Index):
898
+ upper = expr.name.upper()
899
+ if upper in var_dims and not oos:
900
+ state[0] = var_dims[upper]
901
+ elif (upper not in known or oos) and known is not _NO_NAMES:
902
+ count = sum(1 for a in expr.args if a is not None)
903
+ if state[0] is not None and count > state[0]:
904
+ return True
905
+ return any(_dims_walk(a, known, var_dims, state) for a in expr.args)
906
+ if isinstance(expr, S.Unary):
907
+ return _dims_walk(expr.operand, known, var_dims, state)
908
+ if isinstance(expr, S.Binary):
909
+ return _dims_walk(expr.left, known, var_dims, state) or _dims_walk(expr.right, known, var_dims, state)
910
+ if isinstance(expr, S.Member):
911
+ return _dims_walk(expr.base, known, var_dims, state) or any(
912
+ _dims_walk(a, known, var_dims, state) for a in expr.args or []
913
+ )
914
+ if isinstance(expr, S.ArrayLiteral):
915
+ return any(_dims_walk(a, known, var_dims, state) for a in expr.items)
916
+ return False
917
+
918
+
919
+ def _has_undefined_index(
920
+ expr: S.Expr | None, known: set[str], ds: DimsState | None = None, stmt: S.Stmt | None = None, prior: tuple = ()
921
+ ) -> bool:
922
+ """Whether CR300/CR350 follow the unresolved-name lines with `Dimensions not declared: %s`:
923
+ the walk over `prior` (the statement's earlier arguments, or the assignment target) then
924
+ `expr`, starting from the count at `stmt`'s start (`DimsState`), meets an undefined name with
925
+ more subscripts than the current count. Without `ds` the count is 0 (any subscript)."""
926
+ state = [ds.before.get(id(stmt), 0) if ds and stmt is not None else 0]
927
+ var_dims = ds.var_dims if ds else {}
928
+ return any(_dims_walk(e, known, var_dims, state) for e in (*prior, expr))
929
+
930
+
931
+ def _names_and_dims(
932
+ known: set[str], ds: DimsState | None, stmt: S.Stmt | None, prior: tuple, *exprs: S.Expr | None
933
+ ) -> tuple[list[str], bool]:
934
+ """The undefined names of `exprs` and whether `Dimensions not declared` follows them
935
+ (`_has_undefined_index`). When it does, the walk ends at the triggering subscripted name, so
936
+ the names after it are not reported (oracle, CR300, 2026-10-03: `Battery(Nope(1)+Nope2(1))`
937
+ names only `Nope`)."""
938
+ dims = _has_undefined_index(None, known, ds, stmt, (*prior, *exprs))
939
+ if not dims:
940
+ return [n for e in exprs for n in _undefined_names(e, known)], False
941
+ state = [ds.before.get(id(stmt), 0) if ds and stmt is not None else 0]
942
+ var_dims = ds.var_dims if ds else {}
943
+ for e in prior:
944
+ _dims_walk(e, known, var_dims, state)
945
+ out: list[str] = []
946
+
947
+ def walk(e: S.Expr | None) -> bool:
948
+ if e is None:
949
+ return False
950
+ oos = id(e) in getattr(known, "out_of_scope", ())
951
+ if isinstance(e, S.Name):
952
+ if e.name.upper() in var_dims and not oos:
953
+ state[0] = var_dims[e.name.upper()]
954
+ if e.name.upper() not in known or oos or e.name.upper() in getattr(known, "tables", ()):
955
+ out.append(e.name)
956
+ return False
957
+ if isinstance(e, S.Index):
958
+ upper = e.name.upper()
959
+ if upper in var_dims and not oos:
960
+ state[0] = var_dims[upper]
961
+ if upper not in known or oos:
962
+ out.append(e.name)
963
+ count = sum(1 for a in e.args if a is not None)
964
+ if state[0] is not None and count > state[0]:
965
+ for a in e.args: # its own arguments are still named
966
+ walk(a)
967
+ return True
968
+ for a in e.args:
969
+ if walk(a):
970
+ return True
971
+ return False
972
+ children: list[S.Expr | None] = []
973
+ if isinstance(e, S.Unary):
974
+ children = [e.operand]
975
+ elif isinstance(e, S.Binary):
976
+ children = [e.left, e.right]
977
+ elif isinstance(e, S.Member):
978
+ children = ([] if isinstance(e.base, S.Name) and e.base.name.upper() in known else [e.base]) + (
979
+ e.args or []
980
+ )
981
+ elif isinstance(e, S.ArrayLiteral):
982
+ children = e.items
983
+ for child in children:
984
+ if walk(child):
985
+ return True
986
+ return False
987
+
988
+ for e in exprs:
989
+ if walk(e):
990
+ break
991
+ return out, True
992
+
993
+
994
+ def dimensions_message(text: str, profile, whole_statement: bool, at_eol: bool = True) -> str:
995
+ sep = "\n" if whole_statement and profile.fallback_echo_newline and at_eol else ""
996
+ return f"Dimensions not declared: {text}{sep}."
997
+
998
+
999
+ def _arg_text(stmt: S.Instruction, i: int) -> str:
1000
+ """The argument's own verbatim source text (`Instruction.arg_spans`, `parser.py`), for the
1001
+ messages below that echo a whole expression rather than a single name (oracle-confirmed,
1002
+ `docs/checker.md`: `Scan(X+1,...)` echoes `X+1`, not a re-derived or spacing-normalised form).
1003
+ Falls back to the bare name (still correct for the common single-name case) if a span was
1004
+ somehow not captured.
1005
+ """
1006
+ if i == 0 and getattr(stmt, "keyword", "").upper() == "SELECT":
1007
+ m = re.match(r"(?i)\s*Select\s+Case(.*)$", stmt.text or "")
1008
+ if m:
1009
+ return m.group(1) # every space after `Case` kept (oracle: `Select Case X+Nope`)
1010
+ spans = getattr(stmt, "arg_spans", None)
1011
+ if spans and i < len(spans) and spans[i] is not None:
1012
+ start, end = spans[i]
1013
+ return stmt.text[start:end]
1014
+ arg = stmt.args[i]
1015
+ return arg.name if isinstance(arg, S.Name) else ""
1016
+
1017
+
1018
+ def _call_arg_text(text: str, index: int) -> str:
1019
+ """Source text of top-level argument `index` of a call statement (`S.Call` keeps no spans)."""
1020
+ depth, quote, start, n = 0, False, None, 0
1021
+ for pos, ch in enumerate(text):
1022
+ if ch == '"':
1023
+ quote = not quote
1024
+ elif quote:
1025
+ continue
1026
+ elif ch == "(":
1027
+ depth += 1
1028
+ if depth == 1 and start is None:
1029
+ start = pos + 1
1030
+ elif ch == ")" or ch == ",":
1031
+ if ch == ")":
1032
+ depth -= 1
1033
+ if (ch == "," and depth == 1) or depth == 0:
1034
+ if n == index:
1035
+ return text[start:pos].lstrip()
1036
+ n, start = n + 1, pos + 1
1037
+ return ""
1038
+
1039
+
1040
+ _IF_COND_RE = re.compile(r"(?is)^\s*(?:Else)?If(?:\s|(?=\())(.*?)\bThen\b") # `If(` too (round 36)
1041
+
1042
+
1043
+ def _if_condition_text(stmt: S.Stmt) -> str | None:
1044
+ """The echo the real compilers use for an `If` condition: the text between `If ` and `Then`,
1045
+ trailing space included (oracle: `If X > 1 And Nope Then` -> `X > 1 And Nope .`)."""
1046
+ m = _IF_COND_RE.match(stmt.text or "")
1047
+ if m is None and isinstance(stmt, S.If) and stmt.inline is None:
1048
+ # no `Then`: the condition runs to the end of the statement (oracle, 2026-10-08)
1049
+ m = re.match(r"(?is)^\s*If(?:\s|(?=\())(.*)$", stmt.text or "")
1050
+ return m.group(1) if m else None
1051
+
1052
+
1053
+ _IF_NO_THEN_RE = re.compile(r"(?is)^\s*If(?:\s|(?=\())([^'_]*)$")
1054
+
1055
+
1056
+ def _if_condition_text_no_then(stmt: S.Stmt) -> str | None:
1057
+ """`If Nope = True` with no `Then` (accepted when the names resolve): the echo is the rest of
1058
+ the line (oracle, all four loggers, 2026-10-08). Lines with a comment or continuation are left
1059
+ alone."""
1060
+ text = stmt.text or ""
1061
+ if re.search(r"(?i)\bThen\b", text):
1062
+ return None
1063
+ m = _IF_NO_THEN_RE.match(text)
1064
+ return m.group(1) if m else None
1065
+
1066
+
1067
+ # Instruction names resolve in no expression position (`X = Battery(X)`, `If Delay(1) Then`,
1068
+ # `Battery`, argument positions) except the ones that are also functions (`Randomize`, which is in
1069
+ # `profile.functions` anyway) and the block keywords, whose real message is `Incomplete expression`
1070
+ # (oracle, all four loggers, 2026-10-08: every instruction name probed, `docs/checker.md`).
1071
+ _CONTROL_KEYWORDS = frozenset({"CASE", "DO", "FOR", "FUNCTION", "IF", "SCAN", "SELECT", "SUB", "SUBSCAN", "WHILE"})
1072
+
1073
+
1074
+ def _known_names(program: S.Program, profile) -> KnownNames:
1075
+ """Every name a checked expression position may legally reference: this program's own
1076
+ declarations (`symbols.declared_names`), this logger's predefined symbols/functions/
1077
+ instruction keywords (`profile.*`), its always-or-conditionally-valid system table names
1078
+ (`symbols.system_table_names`), the two floating-point special values found this session to
1079
+ resolve outside the extracted symbol table entirely (`_EXTRA_PREDEFINED_SYMBOLS`), and, when
1080
+ the program declares `ESSVariables`, whichever `ess`-prefixed names it actually references
1081
+ (`symbols.ess_variable_names` -- round 5, `docs/checker.md`).
1082
+ """
1083
+ resolvable = (
1084
+ set(profile.symbols)
1085
+ | set(profile.functions)
1086
+ | (set(profile.instructions) & _CONTROL_KEYWORDS)
1087
+ | symbols.system_table_names(program)
1088
+ | symbols.ess_variable_names(program)
1089
+ | _EXTRA_PREDEFINED_SYMBOLS
1090
+ )
1091
+ known = symbols.declared_names(program) | resolvable
1092
+ if any(isinstance(st, S.Instruction) and st.keyword.upper() == "CH400STATUS" for st in program.statements):
1093
+ known.add("CH400STATUS") # the instruction names the table `Sample(1, CH400Status, ...)` reads
1094
+ known -= _failed_consts(program, known, profile)
1095
+ if profile.single_pass:
1096
+ known -= _unclosed_structure_variables(program)
1097
+ # an `Alias` whose subscript names something undefined is not registered (oracle, CR300/
1098
+ # CR350, 2026-10-03: `Alias Q(2,Nope) = NetDir`, then `NetDir` is not defined), nor is one
1099
+ # whose source was never declared (`Quad not yet declared so cannot be aliased.`; oracle,
1100
+ # CR300, 2026-10-04, `tests/corpus/fuzz/cr300/message_diff/829cfa80394a`). A name that a
1101
+ # declaration or another valid `Alias` registers stays known (oracle, 2026-10-04:
1102
+ # `Public x` / `Alias Nope = x` and `Alias x = a` / `Alias Nope = a`, then `a`/`x` resolve).
1103
+ declared = symbols.declared_variable_names(program)
1104
+ failed, valid = set(), set(declared)
1105
+ for st in program.statements:
1106
+ if not (
1107
+ isinstance(st, S.Instruction)
1108
+ and st.keyword.upper() == "ALIAS"
1109
+ and len(st.args) == 2
1110
+ and isinstance(st.args[1], S.Name)
1111
+ and isinstance(st.args[0], (S.Name, S.Index))
1112
+ ):
1113
+ continue
1114
+ src = st.args[0]
1115
+ bad = src.name.upper() not in declared or (
1116
+ isinstance(src, S.Index) and any(_undefined_names(a, known) for a in src.args if a is not None)
1117
+ )
1118
+ if not bad and src.name.upper() in declared:
1119
+ # `Alias is illegally dimensioned.` (empty `Q()`, a parenthesised index item
1120
+ # `Q((1+2))`) registers no name; the out-of-range
1121
+ # `Illegally dimensioned aliased name` one does (oracle, CR300/CR350, 2026-10-12)
1122
+ from . import structure
1123
+
1124
+ open_at = st.text.find("(")
1125
+ items = structure._group_items(st.text, open_at) if open_at >= 0 else None
1126
+ bad = (isinstance(src, S.Index) and not src.args) or bool(
1127
+ items
1128
+ and isinstance(src, S.Index)
1129
+ and any(i.strip().startswith("(") and i.strip().endswith(")") for i in items)
1130
+ )
1131
+ (failed if bad else valid).add(st.args[1].name.upper())
1132
+ # a dimensioned name from the one whose `Alias is illegally dimensioned.` ends the
1133
+ # statement on is not registered (round 30, `Alias X(3) = Y(9)` on `X(6)`); reuse the
1134
+ # declaration check over the preceding declarations of the alias's scope, with String
1135
+ # storage. The names it registered before stopping stay (round 30 review).
1136
+ from . import structure
1137
+
1138
+ env = consteval.Env(profile, dict(program.consts))
1139
+ for pos, st in enumerate(program.statements):
1140
+ if (
1141
+ isinstance(st, S.Instruction)
1142
+ and st.keyword.upper() == "ALIAS"
1143
+ and any(isinstance(a, S.Index) for a in st.args[1:])
1144
+ ):
1145
+ view = symbols.scoped_view(S.Program("", program.statements[: pos + 1]), st)
1146
+ scratch, registered = Diagnostics(), set()
1147
+ structure._check_alias_dims(
1148
+ st,
1149
+ scratch,
1150
+ env,
1151
+ symbols.declared_dim0_sizes(view, env),
1152
+ symbols.declared_all_dims(view, env),
1153
+ registered,
1154
+ {},
1155
+ None,
1156
+ profile,
1157
+ structure._alias_string_slots(view, env),
1158
+ )
1159
+ if any(d.message == "Alias is illegally dimensioned." for d in scratch.items):
1160
+ failed |= {a.name.upper() for a in st.args[1:] if isinstance(a, S.Index)} - registered
1161
+ # a Const keeps its own binding (round 30 review, `Alias X = K(2)` with `Const K`)
1162
+ known -= failed - valid - set(program.consts)
1163
+ if profile.name in _WHOLE_EXPR_ECHO_LOGGERS:
1164
+ # a `Sub` is no value (`X = V`, `If V Then`, `Delay(1,V,mSec)`; oracle, CR6/CR1000X, round 71)
1165
+ known -= (
1166
+ {st.name.upper() for st in program.statements if isinstance(st, S.SubDef) and st.keyword.upper() == "SUB"}
1167
+ - symbols.declared_variable_names(program)
1168
+ - set(program.consts)
1169
+ - set(profile.symbols)
1170
+ )
1171
+ return KnownNames(
1172
+ known,
1173
+ tables=_value_less_tables(program, profile),
1174
+ dotted_tables=profile.single_pass,
1175
+ out_of_scope=symbols.out_of_scope_nodes(program, resolvable),
1176
+ )
1177
+
1178
+
1179
+ def _unclosed_structure_variables(program: S.Program) -> set[str]:
1180
+ """CR300/CR350: upper-case names of variables declared `As <T>` where `StructureType T` never
1181
+ reaches `EndStructureType`; they are `is not defined.` (oracle 2026-10-03,
1182
+ `docs/instructions.md` "Unclosed StructureType")."""
1183
+ open_type = None
1184
+ unclosed: set[str] = set()
1185
+ for st in program.statements:
1186
+ if (
1187
+ isinstance(st, S.Instruction)
1188
+ and st.keyword.upper() == "STRUCTURETYPE"
1189
+ and st.args
1190
+ and isinstance(st.args[0], S.Name)
1191
+ ):
1192
+ open_type = st.args[0].name.upper()
1193
+ elif isinstance(st, S.Instruction) and st.keyword.upper() == "ENDSTRUCTURETYPE":
1194
+ open_type = None
1195
+ if open_type is not None:
1196
+ for st in program.statements:
1197
+ if isinstance(st, S.Declaration) and st.kind in ("PUBLIC", "DIM", "STATIC"):
1198
+ unclosed.update(v.name.upper() for v in st.vars if (v.type or "") == open_type)
1199
+ return unclosed
1200
+
1201
+
1202
+ class KnownNames(set[str]):
1203
+ """`_known_names`, plus the `DataTable` names that are known only as table references: a bare
1204
+ `T` used as a value is undefined (`X = T`, `Battery(T)`, `Foo(X, T)`; oracle, all four
1205
+ loggers, 2026-09-28) while `T.Field` and `CallTable T` resolve."""
1206
+
1207
+ def __init__(
1208
+ self,
1209
+ names=(),
1210
+ tables: frozenset = frozenset(),
1211
+ dotted_tables: bool = False,
1212
+ out_of_scope: frozenset = frozenset(),
1213
+ ):
1214
+ super().__init__(names)
1215
+ self.out_of_scope = out_of_scope # `symbols.out_of_scope_nodes`: ids of other-routine `Dim` uses
1216
+ self.tables = tables
1217
+ self.dotted_tables = dotted_tables # CR300/CR350: `Undeclared.member` names a table
1218
+
1219
+ def without(self, names) -> "KnownNames":
1220
+ return KnownNames(self - names, self.tables, self.dotted_tables, self.out_of_scope) if names else self
1221
+
1222
+
1223
+ def _alias_names(program: S.Program) -> set[str]:
1224
+ """`Alias` names: a variable for the const-only positions (`Sample(1,IEEE4,Ux)` with
1225
+ `Alias A(1) = Ux` is `Variable illegal in Parameter: Ux.`; oracle, CR6/CR1000X, 2026-10-25)."""
1226
+ return {
1227
+ a.name.upper()
1228
+ for st in program.statements
1229
+ if isinstance(st, S.Instruction) and st.keyword.upper() == "ALIAS"
1230
+ for a in st.args[1:]
1231
+ if isinstance(a, S.Name)
1232
+ }
1233
+
1234
+
1235
+ def _value_less_tables(program: S.Program, profile=None) -> frozenset:
1236
+ tables = {
1237
+ st.args[0].name.upper()
1238
+ for st in program.statements
1239
+ if isinstance(st, S.Instruction)
1240
+ and st.keyword.upper() == "DATATABLE"
1241
+ and st.args
1242
+ and isinstance(st.args[0], S.Name)
1243
+ }
1244
+ if profile is not None: # `DataTable(True,...)` leaves `True` a value (oracle, round 54)
1245
+ tables -= {n.upper() for n in profile.symbols}
1246
+ # `Status` and `Public` as a bare value are undefined on all four loggers, `Settings` on CR300/
1247
+ # CR350 (oracle, 2026-10-08: `X = Status`, `Battery(Public)`; CR6/CR1000X accept `X = Settings`)
1248
+ tables |= {"STATUS", "PUBLIC"} | ({"SETTINGS"} if profile is not None and profile.single_pass else set())
1249
+ # An `Alias D(3)=Status` makes `Status` a value too, even above the `Alias` (oracle, all four
1250
+ # loggers, 2026-10-24; a GEUS false reject, `docs/checker.md` "Aliases named Status").
1251
+ aliases = _alias_names(program)
1252
+ others = (
1253
+ symbols.declared_variable_names(program)
1254
+ | {n.upper() for n in program.consts}
1255
+ | {st.name.upper() for st in program.statements if isinstance(st, S.SubDef)}
1256
+ | aliases
1257
+ )
1258
+ return frozenset(tables - others)
1259
+
1260
+
1261
+ def _not_yet_declared(program: S.Program, profile) -> dict[int, frozenset[str]]:
1262
+ """Per top-level `ConstDecl` (by `id`): the names it may not use yet -- itself and every
1263
+ `Const` or variable first declared on a later top-level line (`Const J = J`, `Const K = L` /
1264
+ `Const L = 2`, `Const K = Y2` / `Public Y2`): `Invalid symbol in expression` /
1265
+ `<name> is not defined.` (oracle, all four loggers, 2026-10-18, docs/checker.md "Round 20").
1266
+ Names a logger predefines stay resolvable."""
1267
+ stmts = program.statements
1268
+ variables = symbols.declared_variable_names(program)
1269
+ base = set(profile.symbols) | set(profile.functions) | set(profile.instructions)
1270
+ seen: set[str] = set()
1271
+ out: dict[int, frozenset[str]] = {}
1272
+ consts_after: dict[int, set[str]] = {}
1273
+ pending: set[str] = set()
1274
+ for i in range(len(stmts) - 1, -1, -1):
1275
+ if isinstance(stmts[i], S.ConstDecl):
1276
+ pending = pending | {cast(S.ConstDecl, stmts[i]).name.upper()}
1277
+ consts_after[i] = pending
1278
+ in_routine = False
1279
+ for i, st in enumerate(stmts):
1280
+ if isinstance(st, S.SubDef):
1281
+ in_routine = True
1282
+ elif S.ends_routine(st):
1283
+ in_routine = False
1284
+ elif in_routine:
1285
+ continue # routine-local names stay invisible at top level, its own Consts keep the old rule
1286
+ elif isinstance(st, S.ConstDecl):
1287
+ later = (consts_after[i] | (variables - seen)) - seen - base
1288
+ out[id(st)] = frozenset(later)
1289
+ seen.add(st.name.upper())
1290
+ elif isinstance(st, S.Declaration):
1291
+ seen.update(v.name.upper() for v in st.vars)
1292
+ return out
1293
+
1294
+
1295
+ def _failed_consts(program: S.Program, known: set[str], profile=None) -> set[str]:
1296
+ """`Const` names whose value does not resolve, directly or through another failed `Const`:
1297
+ the real compilers never declare them, so every later use is itself unresolved (oracle, all
1298
+ four loggers: `Const P = Nope` / `Const R = P + 1` / `PortSet(R,1)` reports `Nope`, then `P + 1`
1299
+ on CR6 (`P is not defined.` on CR300), then `R`; `crbasic/AcclimaTDRLib.crb`'s `Const
1300
+ TDRComPort = U5` on CR1000X, which has no `U5`; `docs/checker.md` "Pass-1 instruction
1301
+ arguments")."""
1302
+ consts = [s for s in program.statements if isinstance(s, S.ConstDecl) and s.value is not None]
1303
+ failed: set[str] = set()
1304
+ if profile is not None:
1305
+ # a value with a bad built-in call (`4*ATN()`, `4*ATN(1,2)`) is a pass-1 error and the
1306
+ # constant is never declared (oracle, CR300/CR350, 2026-10-12)
1307
+ failed |= {
1308
+ c.name.upper()
1309
+ for c in consts
1310
+ if _find_arity_problem(c.value, profile) or _find_bare_function_ref(c.value, profile, frozenset())
1311
+ }
1312
+ unready = _not_yet_declared(program, profile) if profile is not None else {}
1313
+ changed = True
1314
+ while changed:
1315
+ changed = False
1316
+ for c in consts:
1317
+ name = c.name.upper()
1318
+ if name not in failed and _undefined_names(c.value, known - failed - unready.get(id(c), frozenset())):
1319
+ failed.add(name)
1320
+ changed = True
1321
+ return failed
1322
+
1323
+
1324
+ def _report_statement(
1325
+ stmt: S.Instruction,
1326
+ positions: dict[int, str],
1327
+ known: set[str],
1328
+ variable_names: set[str],
1329
+ profile,
1330
+ diags: Diagnostics,
1331
+ families: str = "all",
1332
+ dim_counts: dict[str, int] | None = None,
1333
+ ds: DimsState | None = None,
1334
+ ) -> bool:
1335
+ """One statement's own undefined-symbol and constant-only-family checks, at every position
1336
+ `_position_kinds` marks as routed through the general expression/symbol resolver -- a full
1337
+ expression-tree walk (`_undefined_names`), not just a direct bare-`Name` argument (round 4,
1338
+ this session: safe now that `_known_names` closes the two gaps -- `NAN`/`Status` -- that made
1339
+ this walk reject real programs when first tried in round 3, `docs/checker.md`).
1340
+
1341
+ Both the *order* positions are checked in and the *shape* of what gets reported differ by
1342
+ logger family (oracle-confirmed this session, `docs/checker.md` "Round 4" -- see
1343
+ `_CONST_FAMILY_KINDS`/`_WHOLE_EXPR_ECHO_LOGGERS` above for the evidence): CR6/CR1000X check
1344
+ every constant-only-family position (ascending) before any general-family position regardless
1345
+ of textual order; CR300/CR350 check every position in plain ascending textual order, no family
1346
+ split. Both stop at the first position with *any* problem (an undefined name, checked at every
1347
+ position, or a live-variable reference, checked at const-only-family positions only,
1348
+ `_CONST_ONLY_VARIABLE_REJECT_KINDS`) and report only that one -- **within one position**, the
1349
+ undefined-name check runs first (the two are mutually exclusive for a single name: a name is
1350
+ either undefined or resolves to something, so this ordering only matters for picking which
1351
+ message a position with *both* an undefined name and, elsewhere in the same expression, a live
1352
+ variable gets -- not evidenced either way this session, so undefined-first was kept as the
1353
+ simpler, already-existing rule). Oracle-confirmed cross-family interleaving, not just two
1354
+ independent passes (`WriteIO(X,BazQux)`, `X` a live variable at the const-family position 0,
1355
+ `BazQux` undefined at the general-family position 1: CR6/CR1000X report `Variable illegal in
1356
+ Parameter: X.`, never reaching `BazQux`'s own undefined-name error even though the *undefined-
1357
+ name* pass alone, run across both families before any variable-reject check, would reach
1358
+ `BazQux` first if the two checks were separate top-level passes over the statement -- they are
1359
+ not; `PRT(GenBad,X,3,3,3)` on CR300, `GenBad` undefined at the general-family position 0 (the
1360
+ textually-first position there), `X` a live variable at the const-family position 1: CR300
1361
+ reports only `GenBad is not defined.`, never reaching `X` at all, confirming CR300/CR350 have
1362
+ no family reordering for this combined check either). Neither family is known to check past the
1363
+ first bad position in the same statement -- under-reporting a second, independent bad position
1364
+ in the same call is the deliberate, safe side of that gap (AGENTS.md: never reject an accepted
1365
+ program), not a claim that the real compiler is silent about it. The variable-reject message
1366
+ itself (`Variable illegal in Parameter: %s.`) is oracle-confirmed uniform across all four
1367
+ loggers (`docs/checker.md`), so no per-logger branch for its own text.
1368
+ """
1369
+
1370
+ def _check_position(i: int) -> bool:
1371
+ """Checks position `i`, reports at most one diagnostic (CR6/CR1000X) or up to two
1372
+ (CR300/CR350, see below), returns whether it reported anything.
1373
+
1374
+ **CR300/CR350 do not share CR6/CR1000X's mutual exclusivity** between the undefined-name
1375
+ check and the const-only-family "rejects a variable" check, oracle-confirmed this round
1376
+ (`docs/loggers.md`): at a const-family position (`_CONST_ONLY_VARIABLE_REJECT_KINDS`), an
1377
+ *undefined* name gets **both** its own undefined-name line(s) *and* a `Variable illegal in
1378
+ Parameter: <whole arg text>.` line right after -- `TCDiff(Dest,1,BazQux,...)` on
1379
+ `cr300comp.exe` -> `BazQux is not defined.` then `Variable illegal in Parameter: BazQux.`,
1380
+ two lines, where CR6/CR1000X give only `Invalid symbol in expression: BazQux.` (one line,
1381
+ the existing rule, unchanged). A compound expression mixing an undefined name with a live
1382
+ variable (`TCDiff(Dest,1,X+BazQux,...)`, `X` declared) gives `BazQux is not defined.` (the
1383
+ undefined name only, per CR300/CR350's existing per-name echo) then `Variable illegal in
1384
+ Parameter: X+BazQux.` (the whole expression, since it is not a foldable constant either
1385
+ way) -- i.e. the second check's trigger is "argument contains any non-constant name
1386
+ reference at all" (undefined *or* a declared variable), not "is a declared variable"
1387
+ alone, and always echoes the whole argument once. Modelled below as one unified
1388
+ computation (`trigger = names or var_refs`) that happens to reduce to the pre-existing
1389
+ CR6/CR1000X mutual-exclusivity behaviour automatically, since `names` is always empty by
1390
+ the time CR6/CR1000X (`_WHOLE_EXPR_ECHO_LOGGERS`) reach the second check (a non-empty
1391
+ `names` already returned above, for that family only).
1392
+ """
1393
+ if i >= len(stmt.args) or stmt.args[i] is None:
1394
+ return False
1395
+ arg = stmt.args[i]
1396
+ names = _undefined_names(arg, known)
1397
+ whole_echo = profile.name in _WHOLE_EXPR_ECHO_LOGGERS
1398
+ dims = False
1399
+ if names and not whole_echo:
1400
+ names, dims = _names_and_dims(known, ds, stmt, tuple(stmt.args[:i]), arg)
1401
+ # CR300/CR350: an undefined name at a code-20 keyword slot is also `Variable illegal`
1402
+ # (oracle, 2026-10-01: `PanelTemp(X,Nope)`).
1403
+ is_const_family = (
1404
+ positions[i] in _CONST_ONLY_VARIABLE_REJECT_KINDS
1405
+ or (bool(names) and not whole_echo and positions[i] == "datatype_kw")
1406
+ or (
1407
+ # CR6/CR1000X `fN1` slot: a variable there is `Variable illegal in Parameter`
1408
+ # (oracle, 2026-10-02, `docs/instructions.md` "fN1 / fnotch").
1409
+ whole_echo
1410
+ and positions[i] == "datatype_kw"
1411
+ and profile.fnotch.get("instructions", {}).get(stmt.keyword.upper()) == i + 1
1412
+ )
1413
+ )
1414
+
1415
+ if names:
1416
+ if whole_echo:
1417
+ diags.error(stmt.line, profile.unresolved_symbol_message.format(name=_arg_text(stmt, i)), stmt.file)
1418
+ return True
1419
+ for name in names:
1420
+ diags.error(stmt.line, profile.unresolved_symbol_message.format(name=name), stmt.file)
1421
+ if dims:
1422
+ # CR300/CR350: `SplitStr(ResultString(1),SearchString,...)` -> `Dimensions not
1423
+ # declared: ResultString(1).` and the rest of the list collapses into `Too many
1424
+ # parameters: SearchString.`
1425
+ diags.error(stmt.line, dimensions_message(_arg_text(stmt, i).strip(), profile, False), stmt.file)
1426
+ if is_const_family: # (oracle, CR300, 2026-10-03: `Delay(Nope(1),2,Sec)`)
1427
+ diags.error(
1428
+ stmt.line, f"Variable illegal in Parameter: {_arg_text(stmt, i)}.", stmt.file, stmt=stmt
1429
+ )
1430
+ return True
1431
+ if i + 1 < len(stmt.args) and stmt.args[i + 1] is not None:
1432
+ from .instr_rules import _next_simple_token
1433
+
1434
+ token = _next_simple_token(_arg_text(stmt, i + 1))
1435
+ diags.error(stmt.line, f"Too many parameters: {token}.", stmt.file)
1436
+ return True
1437
+ if not is_const_family:
1438
+ return True
1439
+ # CR300/CR350 const-family position: fall through to also run the
1440
+ # "Variable illegal in Parameter" check below, independent of the undefined-name
1441
+ # report just made (oracle-confirmed; see this method's own docstring).
1442
+
1443
+ if dim_counts and _over_indexed(arg, dim_counts, _dim_limit(profile)):
1444
+ # CR300/CR350: `Battery(R(2,1))` for `Public R(5)` (oracle, 2026-09-28); the argument
1445
+ # list ends there (`SplitStr(S(1,2),T,...)` -> `Too many parameters: T.`, 2026-10-01).
1446
+ # CR6/CR1000X: past three subscripts, no follow-up (`Delay(1,B(1,1,1,1),mSec)`, round 16)
1447
+ text = _arg_text(stmt, i)
1448
+ diags.error(
1449
+ stmt.line,
1450
+ dimensions_message(text if stmt.keyword.upper() == "SELECT" else text.strip(), profile, False),
1451
+ stmt.file,
1452
+ )
1453
+ if not whole_echo and i + 1 < len(stmt.args) and stmt.args[i + 1] is not None:
1454
+ from .instr_rules import _next_simple_token
1455
+
1456
+ token = _next_simple_token(_arg_text(stmt, i + 1)) or _arg_text(stmt, i + 1)
1457
+ diags.error(stmt.line, f"Too many parameters: {token}.", stmt.file)
1458
+ return True
1459
+
1460
+ if is_const_family:
1461
+ # a `Status.`/`Settings.`/`Public.`/table field is a variable here too (oracle, all four
1462
+ # loggers, round 38: `Sample(1,B,Status.SerialNumber)`)
1463
+ var_refs = _variable_refs(arg, variable_names) or _has_dotted_field(arg)
1464
+ if names or var_refs:
1465
+ diags.error(stmt.line, f"Variable illegal in Parameter: {_arg_text(stmt, i)}.", stmt.file, stmt=stmt)
1466
+ return True
1467
+
1468
+ return bool(names)
1469
+
1470
+ multi = _MULTI_REPORT_ORDER.get(stmt.keyword.upper())
1471
+ if multi is not None and profile.name in _WHOLE_EXPR_ECHO_LOGGERS and families == "general":
1472
+ reported = False
1473
+ for i in multi:
1474
+ if i in positions and _check_position(i):
1475
+ reported = True
1476
+ if reported:
1477
+ return True
1478
+ # a position the all-undefined sweep did not list is still checked on its own (oracle,
1479
+ # 2026-10-24, `docs/checker.md` "Positions outside the multi-report order")
1480
+ for i in sorted(positions):
1481
+ if i not in multi and positions[i] not in _CONST_FAMILY_KINDS and _check_position(i):
1482
+ return True
1483
+ return False
1484
+ if profile.name in _WHOLE_EXPR_ECHO_LOGGERS:
1485
+ order = []
1486
+ if families in ("all", "const"):
1487
+ order += sorted(i for i, k in positions.items() if k in _CONST_FAMILY_KINDS)
1488
+ if families in ("all", "general"):
1489
+ order += sorted(i for i, k in positions.items() if k not in _CONST_FAMILY_KINDS)
1490
+ else:
1491
+ order = sorted(positions)
1492
+
1493
+ for i in order:
1494
+ if _check_position(i):
1495
+ return True
1496
+ return False
1497
+
1498
+
1499
+ def _member_index_text(args: list | None) -> str:
1500
+ """The `(N)` echoed for an absent DataTable member: 1 for no index, the truncated literal, and
1501
+ nothing for a variable, expression or negative index (oracle, CR300, docs/instructions.md)."""
1502
+ if not args or args[0] is None:
1503
+ return "(1)"
1504
+ index = args[0]
1505
+ if isinstance(index, S.Number) and index.value >= 0:
1506
+ return f"({int(index.value)})"
1507
+ return ""
1508
+
1509
+
1510
+ def _table_member_problem(
1511
+ expr: S.Expr | None, const_tables: dict[str, set[str]], data_tables: set[str], absent: frozenset[str] = frozenset()
1512
+ ) -> tuple[str, str, str] | None:
1513
+ if expr is None:
1514
+ return None
1515
+ if isinstance(expr, S.Member):
1516
+ if isinstance(expr.base, S.Name):
1517
+ table = expr.base.name.upper()
1518
+ member = expr.member.upper()
1519
+ if table in const_tables and member not in const_tables[table]:
1520
+ return expr.base.name, expr.member, "(1)"
1521
+ if table in data_tables and member == "TABLEBYTESX":
1522
+ return expr.base.name, expr.member, "(1)"
1523
+ fields = getattr(data_tables, "fields", {}).get(table)
1524
+ if fields is not None and member not in fields and not member[:1].isdigit():
1525
+ return expr.base.name, expr.member, _member_index_text(expr.args)
1526
+ if table in data_tables and f"{table}.{member}" in absent:
1527
+ return expr.base.name, expr.member, _member_index_text(expr.args)
1528
+ for arg in expr.args or []:
1529
+ found = _table_member_problem(arg, const_tables, data_tables, absent)
1530
+ if found:
1531
+ return found
1532
+ return _table_member_problem(expr.base, const_tables, data_tables, absent)
1533
+ if isinstance(expr, S.Index):
1534
+ for arg in expr.args:
1535
+ found = _table_member_problem(arg, const_tables, data_tables, absent)
1536
+ if found:
1537
+ return found
1538
+ elif isinstance(expr, S.Unary):
1539
+ return _table_member_problem(expr.operand, const_tables, data_tables, absent)
1540
+ elif isinstance(expr, S.Binary):
1541
+ return _table_member_problem(expr.left, const_tables, data_tables, absent) or _table_member_problem(
1542
+ expr.right, const_tables, data_tables, absent
1543
+ )
1544
+ elif isinstance(expr, S.ArrayLiteral):
1545
+ for item in expr.items:
1546
+ found = _table_member_problem(item, const_tables, data_tables, absent)
1547
+ if found:
1548
+ return found
1549
+ return None
1550
+
1551
+
1552
+ class _TablesWithFields(set):
1553
+ fields: dict[str, set[str]] = {}
1554
+
1555
+
1556
+ def _data_table_fields(program: S.Program, profile) -> dict[str, set[str]]:
1557
+ """Each DataTable's field names as the TDF names them, plus the table properties
1558
+ (`Tbl.NoSuchField` -> `Could not find Tbl.NoSuchField(1).`; oracle, all four loggers,
1559
+ 2026-10-24, round 34). Empty when the tables cannot be built: nothing is then checked."""
1560
+ from .. import tables
1561
+
1562
+ try:
1563
+ built, _, _ = tables.build_tables(program, profile, dict(program.consts))
1564
+ except Exception:
1565
+ return {}
1566
+ props = {p.upper() for p in tables.TABLE_PROPERTIES}
1567
+ names = {
1568
+ st.args[0].name.upper()
1569
+ for st in program.statements
1570
+ if isinstance(st, S.Instruction)
1571
+ and st.keyword.upper() == "DATATABLE"
1572
+ and st.args
1573
+ and isinstance(st.args[0], S.Name)
1574
+ }
1575
+ return {t.name.upper(): {f.name.upper() for f in t.fields} | props for t in built if t.name.upper() in names}
1576
+
1577
+
1578
+ def check_table_members(program: S.Program, profile, diags: Diagnostics) -> None:
1579
+ """Resolved ConstTable fields and, on CR6/CR1000X, DataTable fields (`_data_table_fields`)."""
1580
+ const_tables = symbols.const_table_fields(program)
1581
+ data_tables = {
1582
+ stmt.args[0].name.upper()
1583
+ for stmt in program.statements
1584
+ if isinstance(stmt, S.Instruction)
1585
+ and stmt.keyword.upper() == "DATATABLE"
1586
+ and stmt.args
1587
+ and isinstance(stmt.args[0], S.Name)
1588
+ }
1589
+ if not const_tables and not data_tables:
1590
+ return
1591
+ if not profile.single_pass:
1592
+ data_tables = _TablesWithFields(data_tables)
1593
+ data_tables.fields = _data_table_fields(program, profile)
1594
+ renamed: set[str] = set() # tables with a FieldNames entry exactly BytesPerRecord
1595
+ table = None
1596
+ for st in program.statements:
1597
+ if not isinstance(st, S.Instruction):
1598
+ continue
1599
+ kw = st.keyword.upper()
1600
+ if kw == "DATATABLE":
1601
+ table = st.args[0].name.upper() if st.args and isinstance(st.args[0], S.Name) else None
1602
+ elif kw == "ENDTABLE":
1603
+ table = None
1604
+ elif (
1605
+ kw == "FIELDNAMES"
1606
+ and table
1607
+ and any(
1608
+ isinstance(a, S.String) and "BYTESPERRECORD" in (f.strip().upper() for f in a.value.split(","))
1609
+ for a in st.args
1610
+ )
1611
+ ):
1612
+ renamed.add(table)
1613
+ absent = frozenset(f"{t}.BYTESPERRECORD" for t in data_tables - renamed) if profile.single_pass else frozenset()
1614
+ for stmt in program.statements:
1615
+ exprs: list[S.Expr | None] = []
1616
+ if isinstance(stmt, S.Assignment):
1617
+ exprs = [stmt.target, stmt.value]
1618
+ elif isinstance(stmt, S.If):
1619
+ exprs = [stmt.cond]
1620
+ elif isinstance(stmt, S.Instruction):
1621
+ exprs = stmt.args
1622
+ elif isinstance(stmt, S.Call):
1623
+ exprs = stmt.args
1624
+ for expr in exprs:
1625
+ found = _table_member_problem(expr, const_tables, data_tables, absent)
1626
+ if found:
1627
+ table, member, index = found
1628
+ diags.error(stmt.line, f"Could not find {table}.{member}{index}.", stmt.file)
1629
+ break
1630
+
1631
+
1632
+ def _dotted_members(expr: S.Expr | None) -> list[tuple[str, S.Member | None]]:
1633
+ """Every `Name.member` access (and `Name.` with a trailing dot, member `None`) in `expr`, in
1634
+ source order (`X.5`, `T.1(2)`, `Status.1`, `X.dat`, `T.`)."""
1635
+ if expr is None:
1636
+ return []
1637
+ if isinstance(expr, S.Name):
1638
+ return (
1639
+ [(expr.name[:-1], None)]
1640
+ if expr.name.endswith(".") and len(expr.name) > 1 and (expr.name[0].isalpha() or expr.name[0] == "_")
1641
+ else []
1642
+ )
1643
+ if isinstance(expr, S.Member):
1644
+ out = _dotted_members(expr.base)
1645
+ if isinstance(expr.base, S.Name) and not expr.base.name.endswith("."):
1646
+ out.append((expr.base.name, expr))
1647
+ for a in expr.args or []:
1648
+ out += _dotted_members(a)
1649
+ return out
1650
+ if isinstance(expr, S.Index):
1651
+ return [m for a in expr.args for m in _dotted_members(a)]
1652
+ if isinstance(expr, S.Unary):
1653
+ return _dotted_members(expr.operand)
1654
+ if isinstance(expr, S.Binary):
1655
+ return _dotted_members(expr.left) + _dotted_members(expr.right)
1656
+ if isinstance(expr, S.ArrayLiteral):
1657
+ return [m for item in expr.items for m in _dotted_members(item)]
1658
+ return []
1659
+
1660
+
1661
+ _TABLE_FIELDS = frozenset({"TIMESTAMP", "RECORD", "RECNUM"})
1662
+
1663
+
1664
+ def _unknown_status_member(m, profile) -> bool:
1665
+ return (
1666
+ not m.member[:1].isdigit() and bool(profile.status_members) and m.member.upper() not in profile.status_members
1667
+ )
1668
+
1669
+
1670
+ def _unknown_setting(m, profile) -> bool:
1671
+ return not m.member[:1].isdigit() and m.member.upper() not in {
1672
+ n.upper() for n in profile.settings_fields["settings"]
1673
+ }
1674
+
1675
+
1676
+ def _unknown_public_member(m, variables: set[str]) -> bool:
1677
+ return not m.member[:1].isdigit() and m.member.upper() not in variables | _TABLE_FIELDS
1678
+
1679
+
1680
+ def check_status_member_reads_late(program: S.Program, profile, diags: Diagnostics) -> None:
1681
+ """CR300/CR350: an unknown `Status.X` / `Public.X` read (see `check_digit_members`) is reported
1682
+ only in an otherwise clean file (oracle, 2026-10-12: a later `WatchdogTimer is not defined.`
1683
+ hides `Status.CardBytesFree`); run from the late stage."""
1684
+ if not profile.single_pass:
1685
+ return
1686
+ variables = {
1687
+ n for n in symbols.declared_variable_names(program) if not symbols.is_reserved_predefined_name(n, profile)
1688
+ }
1689
+ for stmt in program.statements:
1690
+ if isinstance(stmt, S.Assignment):
1691
+ exprs, target = [stmt.target, stmt.value], len(_dotted_members(stmt.target))
1692
+ elif isinstance(stmt, S.If):
1693
+ exprs, target = [stmt.cond], 0
1694
+ elif isinstance(stmt, (S.Instruction, S.Call)):
1695
+ exprs, target = stmt.args, 0
1696
+ else:
1697
+ continue
1698
+ for k, (base, m) in enumerate(mm for e in exprs for mm in _dotted_members(e)):
1699
+ if m is None or k < target:
1700
+ continue
1701
+ if (
1702
+ base.upper() == "STATUS"
1703
+ and _unknown_status_member(m, profile)
1704
+ or base.upper() == "PUBLIC"
1705
+ and _unknown_public_member(m, variables)
1706
+ ):
1707
+ diags.error(stmt.line, f"Could not find {base}.{m.member}{_member_index_text(m.args)}.", stmt.file)
1708
+
1709
+
1710
+ def check_undefined_dotted_bases(program: S.Program, profile, diags: Diagnostics, start: int) -> None:
1711
+ """CR6/CR1000X: a dotted reference whose base is not declared (`Tset1.X`, `Setings.PakBusAddress`,
1712
+ a misspelt structure variable `W.A`) is `Could not find Tset1.`, before the statement's other
1713
+ pass-2 lines (`diags.items[start:]`). The statement's `Invalid symbol` stays only for another
1714
+ undefined name; an assignment target adds `Invalid assignment in expression: <line>.` (oracle,
1715
+ round 59, docs/checker.md#undeclared-dotted-bases-round-59)."""
1716
+ if profile.single_pass:
1717
+ return
1718
+ known = _known_names(program, profile)
1719
+ for stmt in program.statements:
1720
+ if isinstance(stmt, S.Instruction) and stmt.keyword.upper() == "ALIAS":
1721
+ continue # `Alias Foo = Zed.W` compiles (alias_name_lists.json)
1722
+ exprs = _stmt_exprs(stmt)
1723
+ bases = [b for e in exprs for b, m in _dotted_members(e) if m is not None and b.upper() not in known]
1724
+ if not bases:
1725
+ continue
1726
+ others = [
1727
+ n for e in exprs for n in _undefined_names_walk(e, known) if n.upper() not in {b.upper() for b in bases}
1728
+ ]
1729
+ key = (stmt.line, stmt.file)
1730
+ generic = ("Invalid symbol in expression", "Invalid, or out of place expression")
1731
+ here = [d for d in diags.items[start:] if (d.line, d.file) == key]
1732
+ if any(d.severity == "error" and not d.message.startswith(generic) for d in here):
1733
+ continue # a placement error ends the statement first (`DisplayValue must be used within ...`)
1734
+ if not others:
1735
+ diags.items[start:] = [
1736
+ d for d in diags.items[start:] if not ((d.line, d.file) == key and d.message.startswith(generic))
1737
+ ]
1738
+ new = [Diagnostic("error", stmt.line, f"Could not find {bases[0]}.", stmt.file)]
1739
+ if isinstance(stmt, S.Assignment) and _dotted_members(stmt.target):
1740
+ new.append(Diagnostic("error", stmt.line, f"Invalid assignment in expression: {stmt.text}.", stmt.file))
1741
+ at = next(
1742
+ (
1743
+ i
1744
+ for i, d in enumerate(diags.items)
1745
+ if i >= start and (d.line, d.file) == key and any(d is h for h in here)
1746
+ ),
1747
+ len(diags.items),
1748
+ )
1749
+ diags.items[at:at] = new
1750
+
1751
+
1752
+ def check_digit_members(program: S.Program, profile, diags: Diagnostics) -> None:
1753
+ """A member of a declared name that no variable, data table or `Status` has (oracle, all four
1754
+ loggers, 2026-10-12, `docs/checker.md` "Digit-led members", "Letter-led members"):
1755
+
1756
+ - `ident.digits` (`X = X.5`, `A.1`, `T.1`, `Status.1`): `Could not find X.` for a declared
1757
+ variable (CR300/CR350 `Could not find DataTable X.`), `Could not find T.1(1).` for a data
1758
+ table or `Status` (the index echoed as `_member_index_text`), one line per access in source
1759
+ order, in assignment values, subscripts, `If` conditions, `For` headers and function
1760
+ arguments; an assignment target (`X.5 = 1`, `T.1 = 1`) adds `Invalid assignment in
1761
+ expression: <line>.`.
1762
+ - a letter-led member of a plain declared name (`X.Y`, `A.Y`, `S.Y`, `D.Y`, `K.Y`, an alias, a
1763
+ pointer, a routine parameter, a function name) is `Could not find X.` as well (structure-typed
1764
+ names and data tables / `Status` keep their members: field existence is not modelled).
1765
+ - a trailing dot (`T.`, `Status.`) is a valid operand for a data table or `Status`; any other
1766
+ name (`X.`, `Zork.`) is `Could not find X.` (CR300/CR350 `Could not find DataTable X.`).
1767
+
1768
+ Statements that also name an undefined symbol and structure-typed bases are not touched."""
1769
+ from . import structs
1770
+
1771
+ variables = {
1772
+ n for n in symbols.declared_variable_names(program) if not symbols.is_reserved_predefined_name(n, profile)
1773
+ }
1774
+ struct_types = structs._structure_members(program)
1775
+ kinds = structs._variable_kinds(program, struct_types)
1776
+ variables = {n for n in variables if not str(kinds.get(n, "")).startswith("STRUCT")}
1777
+ # (a `Sub S(C As Pt)` parameter keeps its members too; oracle, CR6/CR1000X, 2026-10-12)
1778
+ variables -= {
1779
+ p.name.upper()
1780
+ for st in program.statements
1781
+ if isinstance(st, S.SubDef)
1782
+ for p in st.params
1783
+ if p.type and p.type.upper() in struct_types
1784
+ }
1785
+ variables |= {str(n).upper() for n in program.consts}
1786
+ for stmt in program.statements:
1787
+ if (
1788
+ isinstance(stmt, S.Instruction)
1789
+ and stmt.keyword.upper() == "ALIAS"
1790
+ and len(stmt.args) == 2
1791
+ and isinstance(stmt.args[1], S.Name)
1792
+ and stmt.args[1].name.upper() != "PUBLIC"
1793
+ ): # `Public.X` still reads the table (2026-10-24)
1794
+ variables.add(stmt.args[1].name.upper()) # (`Alias Xal = X`: `Xal.Y` is `Could not find Xal.`)
1795
+ elif isinstance(stmt, S.SubDef) and stmt.keyword.upper() == "FUNCTION":
1796
+ variables.add(stmt.name.upper())
1797
+ data_tables = {
1798
+ stmt.args[0].name.upper()
1799
+ for stmt in program.statements
1800
+ if isinstance(stmt, S.Instruction)
1801
+ and stmt.keyword.upper() == "DATATABLE"
1802
+ and stmt.args
1803
+ and isinstance(stmt.args[0], S.Name)
1804
+ } | {"STATUS"}
1805
+ known = _known_names(program, profile)
1806
+ for stmt in program.statements:
1807
+ if isinstance(stmt, S.Assignment):
1808
+ exprs = [stmt.target, stmt.value]
1809
+ elif isinstance(stmt, S.If):
1810
+ exprs = [stmt.cond]
1811
+ elif isinstance(stmt, S.For):
1812
+ exprs = [stmt.var, stmt.start, stmt.end, stmt.step]
1813
+ elif isinstance(stmt, (S.Instruction, S.Call)):
1814
+ exprs = stmt.args
1815
+ else:
1816
+ continue
1817
+ members = [m for e in exprs for m in _dotted_members(e)]
1818
+ if not members or any(_undefined_names(e, known) for e in exprs):
1819
+ continue
1820
+ lines = []
1821
+ target = len(_dotted_members(stmt.target)) if isinstance(stmt, S.Assignment) else 0
1822
+ for k, (base, m) in enumerate(members):
1823
+ if k == target and lines:
1824
+ break # a bad member target ends the statement (`X.Q = X.R + 1`: one line)
1825
+ upper = base.upper()
1826
+ if m is None:
1827
+ if upper not in data_tables:
1828
+ lines.append(
1829
+ f"Could not find DataTable {base}." if profile.single_pass else f"Could not find {base}."
1830
+ )
1831
+ elif upper == "SETTINGS" and not profile.single_pass and _unknown_setting(m, profile):
1832
+ # `X = Settings.Nope`, `Settings.Nope = 1` (oracle, all four, 2026-10-24, round 34)
1833
+ lines.append(f"Could not find Settings.{m.member}{_member_index_text(m.args)}.")
1834
+ elif upper == "STATUS" and not profile.single_pass and _unknown_status_member(m, profile):
1835
+ # an unknown Status field read (`Y = Status.Nope`, `If Status.Nope`): oracle, all
1836
+ # four loggers, 2026-10-12; the names: data/**/status_members.json
1837
+ lines.append(f"Could not find Status.{m.member}{_member_index_text(m.args)}.")
1838
+ elif upper in data_tables:
1839
+ if m.member[:1].isdigit():
1840
+ lines.append(f"Could not find {base}.{m.member}{_member_index_text(m.args)}.")
1841
+ elif upper == "PUBLIC" and k >= target and not profile.single_pass and _unknown_public_member(m, variables):
1842
+ # `Y = Public.Nope(2)` -> `Could not find Public.Nope(2).` (oracle, all four, 2026-10-12)
1843
+ lines.append(f"Could not find Public.{m.member}{_member_index_text(m.args)}.")
1844
+ elif upper in variables:
1845
+ lines.append(f"Could not find DataTable {base}." if profile.single_pass else f"Could not find {base}.")
1846
+ if not lines:
1847
+ continue
1848
+ for line in lines:
1849
+ diags.error(stmt.line, line, stmt.file)
1850
+ if isinstance(stmt, S.Assignment) and any(m for _, m in _dotted_members(stmt.target)):
1851
+ sep = "\n" if profile.fallback_echo_newline and stmt.at_eol else ""
1852
+ diags.error(stmt.line, f"Invalid assignment in expression: {stmt.text}{sep}.", stmt.file)
1853
+
1854
+
1855
+ def check_const_family_args(program: S.Program, profile, diags: Diagnostics) -> None:
1856
+ """CR6/CR1000X pass 1: the constant-family instruction positions (`_CONST_FAMILY_KINDS`) are
1857
+ folded while the statement is parsed, so an unresolved name there (`Invalid symbol in
1858
+ expression: %s.`) or a live variable (`Variable illegal in Parameter: %s.`) is reported next
1859
+ to other pass-1 errors, while general positions stay pass 2 (`check`). Oracle, CR6, with a
1860
+ pass-1 `Dim Z banana` in the file: `PortSet(X,1)`, `PanelTemp(X, Nope)`, `VoltSe(X,1,Nope,...)`
1861
+ and `SDI12Recorder(X, Nope, ...)` are all still reported; `VoltSe(Nope,...)`, `Randomize(Nope)`
1862
+ and `Delay(0,Nope,mSec)` are not (`docs/checker.md` "Pass-1 instruction arguments")."""
1863
+ if profile.name not in _WHOLE_EXPR_ECHO_LOGGERS:
1864
+ return
1865
+ known = _known_names(program, profile)
1866
+ variable_names = {
1867
+ name
1868
+ for name in symbols.declared_variable_names(program)
1869
+ if not symbols.is_reserved_predefined_name(name, profile)
1870
+ } | {name for name in _alias_names(program) if name not in profile.symbols}
1871
+ scopes = symbols.statement_scopes(program, profile)
1872
+ for stmt in program.statements:
1873
+ if not isinstance(stmt, S.Instruction):
1874
+ continue
1875
+ record = profile.instructions.get(stmt.keyword.upper())
1876
+ if not record:
1877
+ continue
1878
+ if any(
1879
+ _find_arity_problem(a, profile) or _find_bare_function_ref(a, profile, frozenset(variable_names))
1880
+ for a in stmt.args
1881
+ if a is not None
1882
+ ):
1883
+ continue # a parse error inside an argument owns the statement (`check_function_arity`)
1884
+ positions = _position_kinds(record["params"], stmt.keyword, profile)
1885
+ if positions:
1886
+ params, later = scopes[id(stmt)]
1887
+ _report_statement(
1888
+ stmt,
1889
+ positions,
1890
+ known.without(later - params - variable_names),
1891
+ variable_names | params,
1892
+ profile,
1893
+ diags,
1894
+ families="const",
1895
+ )
1896
+
1897
+
1898
+ def check(program: S.Program, profile, diags: Diagnostics, env: consteval.Env, skip: frozenset = frozenset()) -> None:
1899
+ """Undefined-symbol checking at every `Instruction` argument position whose declared type code
1900
+ routes through the real expression/named-constant resolver (`_position_kinds`) -- a full
1901
+ expression-tree walk (`_undefined_names`), not restricted to a direct bare `Name` argument
1902
+ (round 4, this session; rules and evidence: `_report_statement` above, `docs/checker.md`).
1903
+
1904
+ Still deliberately narrow in scope, unchanged from round 3: only positions
1905
+ `_RESOLVED_KINDS` names (never an identifier/raw-text-kind position such as a `DataTable`/
1906
+ `ConstTable` name, `Member.member` text, or a `Sub`/`Function` argument list's own runtime
1907
+ scoping); `symbols.declared_names` stays whole-program and unscoped rather than tracking which
1908
+ names are visible inside which `Sub`/`Function` (both documented limitations, `docs/checker.md`
1909
+ "Not implemented" -- tightening either risks a false reject this session did not have the
1910
+ oracle evidence to rule out, so neither was attempted here).
1911
+
1912
+ `env` is accepted (unused) to keep this call site's signature stable for a future session
1913
+ that wires in the variable-required type-code check (still not implemented -- see the
1914
+ docstring above `_VARIABLE_REQUIRED_KINDS`; the const-only-family "rejects a variable" check
1915
+ is implemented, `_CONST_ONLY_VARIABLE_REJECT_KINDS`).
1916
+ """
1917
+ known = _known_names(program, profile)
1918
+ variable_names = symbols.declared_variable_names(program) | (
1919
+ {n for n in _alias_names(program) if n not in profile.symbols} # (`Alias X = Sec` fails)
1920
+ if profile.name in _WHOLE_EXPR_ECHO_LOGGERS
1921
+ else set()
1922
+ )
1923
+ ds = program_dims(program, profile, known)
1924
+ dim_counts = symbols.declared_dim_counts(program, strings=True)
1925
+ scopes = symbols.statement_scopes(program, profile)
1926
+
1927
+ seen_avw200 = False
1928
+ for stmt in program.statements:
1929
+ if not isinstance(stmt, S.Instruction) or id(stmt) in skip:
1930
+ continue
1931
+ record = profile.instructions.get(stmt.keyword.upper())
1932
+ if not record:
1933
+ continue
1934
+ if stmt.keyword.upper() == "SLOWSEQUENCE" and profile.name in ("cr6", "cr1000x"):
1935
+ continue # `SlowSequence junk` compiles: its argument is never resolved (oracle, 2026-10-10)
1936
+ positions = _position_kinds(record["params"], stmt.keyword, profile)
1937
+ if stmt.keyword.upper() == "SDMSIO4" and _sdmsio4_count(stmt, env) == 0:
1938
+ positions.pop(0, None) # no values: the destination is never resolved
1939
+ if stmt.keyword.upper() == "AVW200" and profile.name in _WHOLE_EXPR_ECHO_LOGGERS:
1940
+ if seen_avw200:
1941
+ # after an earlier `AVW200` the ComPort and NeighborAddr are not resolved
1942
+ # (oracle, 2026-10-25, round 35; positions 3..10 give garbled value messages)
1943
+ positions.pop(1, None)
1944
+ positions.pop(2, None)
1945
+ seen_avw200 = True
1946
+ if (
1947
+ positions
1948
+ and stmt.keyword.upper() == "SERIALOPEN"
1949
+ and stmt.args
1950
+ and stmt.args[0]
1951
+ and profile.name in _WHOLE_EXPR_ECHO_LOGGERS
1952
+ and _undefined_names(stmt.args[0], known)
1953
+ ):
1954
+ continue # `ComPort 0 illegal.` replaces every `Invalid symbol` (`cr3xx.check`)
1955
+
1956
+ if positions:
1957
+ # CR6/CR1000X: constant-family positions are pass 1 (`check_const_family_args`).
1958
+ reported = len(diags.items)
1959
+ here, here_variables = known, variable_names
1960
+ if profile.single_pass: # every position sees only earlier `Const`s (round 71)
1961
+ params, later = scopes[id(stmt)]
1962
+ here, here_variables = known.without(later - params - variable_names), variable_names | params
1963
+ _report_statement(
1964
+ stmt, positions, here, here_variables, profile, diags, families="general", dim_counts=dim_counts, ds=ds
1965
+ )
1966
+ if (
1967
+ stmt.keyword.upper() == "MATRIX"
1968
+ and profile.name in ("cr6", "cr1000x")
1969
+ and len(diags.items) > reported
1970
+ and all(d.message.startswith("Invalid symbol in expression: ") for d in diags.items[reported:])
1971
+ ):
1972
+ from . import instr_rules
1973
+
1974
+ rewritten = instr_rules.matrix_undefined_operands(stmt, program, env, known, profile)
1975
+ if rewritten is not None:
1976
+ del diags.items[reported:]
1977
+ for message in rewritten:
1978
+ diags.error(stmt.line, message, stmt.file)
1979
+
1980
+ # `_VARIABLE_REQUIRED_KINDS` (`Invalid Variable or Array: %s.`) is intentionally not
1981
+ # applied here despite oracle evidence that it is a real rule for *some* instructions
1982
+ # (`Battery(5)`, `Erase(5)`, `ReadIO(5,1)`, all code 8/27/29 on CR6, all oracle-
1983
+ # confirmed rejected). Found as a **regression** during round 3's own testing against
1984
+ # the full corpus on all four loggers: `Move`'s own third argument (code 41) accepts a
1985
+ # literal `0` (`embracer.cr6`), and `ModBusClient`'s own last, optional argument (also
1986
+ # code 27, the *same* code that correctly rejects a literal for `Erase`) also accepts a
1987
+ # literal `2` (`modbusclient_2.CR6`) -- both on CR1000X. So the same type code does not
1988
+ # behave uniformly across every instruction that uses it; at least `Move` and
1989
+ # `ModBusClient` override it (most likely their own `compile_fn`, similar to the
1990
+ # const-only kinds' `Sample`/`DataType` exception, `docs/checker.md`). Still not
1991
+ # implemented this session either: see `docs/checker.md` "Constant-only and
1992
+ # variable-required type codes" for the full evidence and what a future session would
1993
+ # need to verify first.
1994
+
1995
+ # An `Assignment`'s own RHS (`x = <value>`): not an `Instruction` at all, so untouched by the
1996
+ # loop above -- a previously-unaddressed gap on *every* logger, found via this round's own
1997
+ # CR300/CR350 repo-mismatch chase (`WDogTimer = WatchdogTimer(0,Sec)`, `WatchdogTimer` a real
1998
+ # CR6/CR1000X function absent from CR300/CR350 entirely, `docs/loggers.md`). Oracle-confirmed
1999
+ # pass 2, same gating as the rest of this function (`BazQux(0,Sec)` in place of `WatchdogTimer`
2000
+ # on CR6: alone, `line N: Invalid symbol in expression: <assignment's own text>.`; preceded by
2001
+ # an unrelated pass-1 `Variable not named.`, suppressed entirely -- `docs/loggers.md`). Two
2002
+ # differences from the instruction-argument case above, both oracle-confirmed:
2003
+ #
2004
+ # 1. The whole-expression echo (CR6/CR1000X) is the **entire assignment statement's own
2005
+ # text**, target and `=` included (`stmt.text`, already the raw segment text with its
2006
+ # original indentation -- the same field `structure.py`'s "Expr" fallback echoes), not just
2007
+ # the value sub-expression (unlike a `Const`'s `value_span`, there is no separate span to
2008
+ # prefer here since the oracle's own echo includes the target).
2009
+ # 2. CR300/CR350 additionally emit a `Dimensions not declared: <text>` follow-up line after
2010
+ # their own per-name `<name> is not defined.` line(s) -- not reproduced here (no oracle
2011
+ # evidence yet for its exact trigger/shape beyond this one example, and it is a second,
2012
+ # distinct message rather than a difference in this one's own text); the per-name line(s)
2013
+ # alone already turn the previously-silent false accept into a correct reject.
2014
+ #
2015
+ # **Round 5 addition, oracle-confirmed (`docs/checker.md` "Round 5")**: an assignment whose own
2016
+ # *target* itself does not resolve (`FuzzVar = 1`, `FuzzVar` never declared) is a completely
2017
+ # different message family -- the generic "Expr" fallback `structure.py`'s `check_pass2` already
2018
+ # owns for `Call`/`Unparsed` (`Invalid, or out of place expression: <whole statement>.`, plus a
2019
+ # before-`BeginProg` placement follow-up) -- never this module's own `Invalid symbol in
2020
+ # expression: %s.`, even when the RHS is *also* undefined (`FuzzVar1 = FuzzVar2` ->
2021
+ # `Invalid, or out of place expression: FuzzVar1 = FuzzVar2.` alone, no second, separate
2022
+ # "Invalid symbol" line for `FuzzVar2`). `_undefined_names` doubles as the target-resolution
2023
+ # test here (an `Index`'s own base name, or a `Member`'s base, exactly like a normal read
2024
+ # reference -- oracle-confirmed for an undeclared array base, `X(BadIdx) = 1` with `X`
2025
+ # undeclared, and for an undeclared *index* on an otherwise-declared array, `X(BadIdx) = 1`
2026
+ # with `X` declared and `BadIdx` not -- both land in the same "Invalid, or out of place
2027
+ # expression" bucket, `structure.py`'s job, not checked again here). Skipping these statements
2028
+ # entirely (rather than still checking the RHS) is what reproduces the oracle's "one message,
2029
+ # not two" behaviour without `structure.py` needing to know this module's own diagnostics.
2030
+ # An `If` condition (block or one-line) with an unresolved name, CR6/CR1000X, pass 2 (oracle,
2031
+ # 2026-09-27): `If Nope Then` -> `Invalid symbol in expression: Nope .`, `If Foo(X) > 20 Then
2032
+ # X = 1` -> `... Foo(X) > 20 .`; suppressed by a pass-1 error. CR300/CR350 print per-name
2033
+ # `%s is not defined.` lines instead (not modelled).
2034
+ for stmt in program.statements:
2035
+ if not isinstance(stmt, S.If):
2036
+ continue
2037
+ names = _undefined_names(stmt.cond, known)
2038
+ text = _if_condition_text(stmt)
2039
+ if text is None:
2040
+ text = _if_condition_text_no_then(stmt)
2041
+ if (
2042
+ text is not None
2043
+ and not names
2044
+ and _over_indexed(stmt.cond, symbols.declared_dim_counts(program, strings=True), _dim_limit(profile))
2045
+ ):
2046
+ # CR300/CR350: `If Q(1) Then` for a scalar `Q` (or `Public Q()`), `If Z(1,1) = 1 Then`
2047
+ # for `Public Z(3)` -> `Dimensions not declared: Q(1) .` (oracle, 2026-10-10)
2048
+ diags.error(stmt.line, dimensions_message(text, profile, False), stmt.file)
2049
+ continue
2050
+ if not names or text is None:
2051
+ continue
2052
+ names, dims = _names_and_dims(known, ds, stmt, (), stmt.cond)
2053
+ before = len(diags.items)
2054
+ if profile.name in _WHOLE_EXPR_ECHO_LOGGERS:
2055
+ diags.error(stmt.line, f"Invalid symbol in expression: {text}.", stmt.file)
2056
+ else:
2057
+ # CR300/CR350: `If Foo(X) > 20 Then` -> `Foo is not defined.`, `Dimensions not
2058
+ # declared: Foo(X) > 20 .`; `If Nope Then` -> `Nope is not defined.` (oracle)
2059
+ for name in names:
2060
+ diags.error(stmt.line, profile.unresolved_symbol_message.format(name=name), stmt.file)
2061
+ if dims:
2062
+ diags.error(stmt.line, dimensions_message(text, profile, False), stmt.file)
2063
+ if stmt.inline is not None:
2064
+ # the condition precedes the inline statements' own findings on the same line
2065
+ # (oracle, all four loggers, 2026-10-08)
2066
+ at = next(
2067
+ (i for i, d in enumerate(diags.items[:before]) if (d.line, d.file) == (stmt.line, stmt.file)), None
2068
+ )
2069
+ if at is not None:
2070
+ moved = diags.items[before:]
2071
+ del diags.items[before:]
2072
+ diags.items[at:at] = moved
2073
+
2074
+ # `Do While Nope` / `Loop Until (Nope <> 1)`: the condition after `While`/`Until`, like an
2075
+ # `If` condition (oracle, 2026-09-28: CR6 `Invalid symbol in expression: (Nope <> 1).`,
2076
+ # CR300 `Nope is not defined.`).
2077
+ open_do = 0
2078
+ for stmt in program.statements:
2079
+ if isinstance(stmt, S.DoLoop):
2080
+ if stmt.keyword.upper() == "DO":
2081
+ open_do += 1
2082
+ elif open_do:
2083
+ open_do -= 1
2084
+ elif profile.name in _WHOLE_EXPR_ECHO_LOGGERS:
2085
+ continue # `Loop Until Nope` with no `Do`: only `LOOP without DO.` (oracle, 2026-10-10)
2086
+ if not isinstance(stmt, S.DoLoop) or stmt.cond is None:
2087
+ continue
2088
+ names = _undefined_names(stmt.cond, known)
2089
+ m = re.search(r"(?i)\b(?:While|Until)\b\s*(.*)$", stmt.text or "")
2090
+ if (
2091
+ m
2092
+ and not names
2093
+ and _over_indexed(stmt.cond, symbols.declared_dim_counts(program, strings=True), _dim_limit(profile))
2094
+ ):
2095
+ diags.error(stmt.line, dimensions_message(m.group(1), profile, False), stmt.file)
2096
+ continue
2097
+ if not names or not m:
2098
+ continue
2099
+ if profile.name in _WHOLE_EXPR_ECHO_LOGGERS:
2100
+ diags.error(stmt.line, f"Invalid symbol in expression: {m.group(1)}.", stmt.file)
2101
+ else:
2102
+ for name in names:
2103
+ diags.error(stmt.line, profile.unresolved_symbol_message.format(name=name), stmt.file)
2104
+
2105
+ _check_for_headers(program, profile, diags, known)
2106
+ _check_elseif_and_case(program, profile, diags, known)
2107
+ _check_function_statements(program, profile, diags, known)
2108
+
2109
+ dim_counts = symbols.declared_dim_counts(program, strings=True)
2110
+ struct_vars = _struct_var_names(program)
2111
+ functions = {
2112
+ st.name.upper() for st in program.statements if isinstance(st, S.SubDef) and st.keyword.upper() == "FUNCTION"
2113
+ }
2114
+ for stmt in program.statements:
2115
+ if (
2116
+ isinstance(stmt, S.Call)
2117
+ and not stmt.explicit
2118
+ and stmt.name.upper() in functions
2119
+ and stmt.tail_expr is None
2120
+ and stmt.juxtaposed is None
2121
+ and not any(_undefined_names(a, known) for a in stmt.args)
2122
+ and any(_over_indexed(a, dim_counts, _dim_limit(profile)) for a in stmt.args)
2123
+ ):
2124
+ # CR300/CR350: `Fn(X(1,2))` is checked like `Y = Fn(X(1,2))` (oracle, 2026-10-10)
2125
+ diags.error(stmt.line, dimensions_message(stmt.text, profile, True, stmt.at_eol), stmt.file)
2126
+ if (
2127
+ isinstance(stmt, S.Call)
2128
+ and stmt.tail_expr is not None
2129
+ and not _undefined_names(stmt.tail_expr, known)
2130
+ and _over_indexed(stmt.tail_expr, dim_counts, _dim_limit(profile))
2131
+ ):
2132
+ # CR300/CR350: `Fn(1)+X(1,2)` is checked like `Y = Fn(1)+X(1,2)` (oracle, 2026-10-10)
2133
+ diags.error(stmt.line, dimensions_message(stmt.text, profile, True, stmt.at_eol), stmt.file)
2134
+ if not isinstance(stmt, S.Assignment):
2135
+ continue
2136
+ if _undefined_names(stmt.target, known):
2137
+ continue # target itself unresolved: `structure.check_pass2`'s job entirely, above.
2138
+ if _multi_indexed_struct(stmt.target, struct_vars) or _multi_indexed_struct(stmt.value, struct_vars):
2139
+ continue # already reported by `check_function_arity` (pass 1)
2140
+ if profile.single_pass and _function_target_message(stmt, profile, frozenset()):
2141
+ continue # a function-named target (even a local scalar `exp(1) = 2`): same
2142
+ names = _undefined_names(stmt.value, known)
2143
+ whole_echo = profile.name in _WHOLE_EXPR_ECHO_LOGGERS
2144
+ if _over_indexed(stmt.target, dim_counts, 3 if whole_echo else None):
2145
+ # `R(2,1) = 1` for `Public R(5)`: the fallback (CR6/CR1000X only past 3 subscripts)
2146
+ from . import structure
2147
+
2148
+ diags.error(
2149
+ stmt.line, structure._out_of_place(profile, stmt.text, stmt.at_eol), stmt.file, show_file="cr3xx"
2150
+ )
2151
+ continue
2152
+ if not names:
2153
+ if _over_indexed(stmt.value, dim_counts, _dim_limit(profile)):
2154
+ # CR300/CR350: `X = R(2,1)` -> `Dimensions not declared: X = R(2,1)` (oracle);
2155
+ # CR6/CR1000X past three subscripts, round 16
2156
+ diags.error(stmt.line, dimensions_message(stmt.text, profile, True, stmt.at_eol), stmt.file)
2157
+ continue
2158
+ if profile.name in _WHOLE_EXPR_ECHO_LOGGERS:
2159
+ diags.error(stmt.line, profile.unresolved_symbol_message.format(name=stmt.text), stmt.file, stmt=stmt)
2160
+ else:
2161
+ names, dims = _names_and_dims(known, ds, stmt, (stmt.target,), stmt.value)
2162
+ if not dims and _over_indexed(stmt.value, dim_counts):
2163
+ names = _names_through_over_index(stmt.value, known, dim_counts)
2164
+ for name in names:
2165
+ diags.error(stmt.line, profile.unresolved_symbol_message.format(name=name), stmt.file)
2166
+ if dims or _over_indexed(
2167
+ stmt.value, dim_counts
2168
+ ): # (names, then the over-indexed read: oracle, CR300, 2026-10-12)
2169
+ diags.error(stmt.line, dimensions_message(stmt.text, profile, True, stmt.at_eol), stmt.file)
2170
+
2171
+
2172
+ # the `Const ... As` types the real compilers accept (see `structure._CONST_TYPES`)
2173
+ _CONST_TYPES = frozenset({"LONG", "FLOAT", "DOUBLE", "STRING", "BOOLEAN"})
2174
+
2175
+
2176
+ def check_consts(program: S.Program, profile, diags: Diagnostics) -> None:
2177
+ """`Const <name> = <value>.` initializer resolution (this round's own new check, all four
2178
+ loggers): a `Const`'s value expression is folded eagerly, at declaration time, so an
2179
+ unresolved name inside it is diagnosed there and then rather than deferred to any later
2180
+ argument-checking pass -- oracle-confirmed **pass 1** (ungated, unconditional), not pass 2:
2181
+ `Const Foo = BazQux` reports its own diagnostic *alongside* an unrelated, earlier `Public`
2182
+ with no name (`Variable not named.`), on every logger, where an ordinary pass-2 finding (e.g.
2183
+ `expressions.check`'s own instruction-argument checks) would have been completely suppressed
2184
+ by that same earlier pass-1 error (`docs/checker.md` "Pass model"; probe transcript,
2185
+ `docs/loggers.md`). Message family and per-logger shape are identical to the general
2186
+ undefined-symbol check above, reusing the same `_WHOLE_EXPR_ECHO_LOGGERS` split: CR6/CR1000X
2187
+ report one whole-expression `Invalid symbol in expression: <value text>.` line; CR300/CR350
2188
+ report each undefined name in the expression as its own `<name> is not defined.` line (oracle,
2189
+ a compound expression, `docs/loggers.md`: `Const Foo = BazQux + Corge` -> one line, `Invalid
2190
+ symbol in expression: BazQux + Corge.`, on CR6; two lines, `BazQux is not defined.` then
2191
+ `Corge is not defined.`, on CR300 -- same shape as an instruction argument's own compound
2192
+ expression, `_report_statement` above). `ConstDecl.value_span` (new field, `syntax.py`/
2193
+ `parser.py`, same technique as `Instruction.arg_spans`) captures the value expression's own
2194
+ source columns so the whole-expression echo is the verbatim text, not a re-derived form.
2195
+
2196
+ Narrow and deliberately conservative, mirroring `check` above: only an outright-undefined
2197
+ name is flagged (`_undefined_names` against the same `known` universe, so a `Const` that
2198
+ legitimately forward/cross-references another declared name is never flagged); a `Const`
2199
+ referencing a *live* runtime variable is a real, different, oracle-confirmed rule of its own
2200
+ (`Variable used in CONST expression.`, all loggers, `docs/loggers.md`) but not implemented
2201
+ here -- zero occurrences of that message anywhere in this project's own corpus sample, so
2202
+ there is no evidence this specific project's programs need it, and guessing its trigger
2203
+ shape/gating without corpus signal risks a false reject for no measured benefit.
2204
+ """
2205
+ known = _known_names(program, profile)
2206
+ whole_echo = profile.name in _WHOLE_EXPR_ECHO_LOGGERS
2207
+ unready = _not_yet_declared(program, profile)
2208
+ for stmt in program.statements:
2209
+ if not isinstance(stmt, S.ConstDecl):
2210
+ continue
2211
+ if stmt.type and stmt.type.upper() not in _CONST_TYPES:
2212
+ continue # `unknown Const type` is the only message (oracle, 2026-10-10)
2213
+ visible = known - unready.get(id(stmt), frozenset())
2214
+ names = _undefined_names(stmt.value, visible)
2215
+ if not names or _find_arity_problem(stmt.value, profile, visible) not in (None, "deferred"):
2216
+ continue # a wrong-arity call read before the name wins (oracle, all four loggers, round 41)
2217
+ if whole_echo:
2218
+ if stmt.value_span is not None:
2219
+ text = stmt.text[stmt.value_span[0] : stmt.value_span[1]]
2220
+ else: # defensive: only reached if a `Const` was built without a span
2221
+ text = stmt.value.name if isinstance(stmt.value, S.Name) else names[0]
2222
+ diags.error(stmt.line, profile.unresolved_symbol_message.format(name=text), stmt.file)
2223
+ else:
2224
+ for name in names:
2225
+ diags.error(stmt.line, profile.unresolved_symbol_message.format(name=name), stmt.file)
2226
+
2227
+
2228
+ # `Data`/`DataLong` share one `compile_fn` in `data/instructions.json` (`0x406a32`) and were
2229
+ # oracle-confirmed identical this round (`DataLong Tables and Store Data`, same two-line message
2230
+ # below, byte-identical to `Data`'s own).
2231
+ _DATA_KEYWORDS = frozenset({"DATA", "DATALONG"})
2232
+
2233
+
2234
+ def check_data_params(program: S.Program, profile, diags: Diagnostics) -> None:
2235
+ """`Data`/`DataLong`'s own parameters (`docs/checker.md` "Data parameters"): each must be a
2236
+ constant expression referencing **no name at all**, defined or not -- oracle-confirmed with a
2237
+ from-scratch scratch probe set (`t.cr6` under Wine, no siblings/corpus file, `docs/checker.md`
2238
+ "Data parameters" records the transcript):
2239
+
2240
+ - `Data SomeVar` (`SomeVar` undeclared): two lines on CR6/CR1000X, `Invalid symbol in
2241
+ expression: SomeVar\\n.` then `Illegal Data parameter SomeVar\\n.`; CR300/CR350 give
2242
+ `SomeVar is not defined.` (the existing per-logger `unresolved_symbol_message` split,
2243
+ `_WHOLE_EXPR_ECHO_LOGGERS`, reused as-is) in place of the first line, same second line --
2244
+ the embedded raw `\\n` (not a formatting artifact here, a literal newline byte in the real
2245
+ compiler's own message, present in `Illegal Data parameter`'s own text on *every* logger,
2246
+ `docs/checker.md` "Data parameters") only appears when the bad parameter is the *last* one
2247
+ on its physical line (`Data 1,SomeVar,3` echoes plain `SomeVar.`, no embedded newline,
2248
+ since a comma follows it on the same line) -- the real compiler's own internal source
2249
+ buffer keeps the terminating newline; ours (`stmt.text`) does not, so `_data_param_text`
2250
+ below adds it back only for that one shape.
2251
+ - `Data Batt_Volt` (`Batt_Volt` declared `Public`): only `Illegal Data parameter Batt_Volt\\n.`
2252
+ -- no undefined-symbol line at all, on any logger, since the name itself resolves fine; it
2253
+ is simply not a constant. This is why the check below cannot reuse `_undefined_names` alone
2254
+ the way every other rule in this module does: a Data parameter is illegal whether or not the
2255
+ name it references is defined, `_variable_refs(arg, known)` (reusing `known`, the full
2256
+ resolvable-name universe, in place of its usual `variable_names` argument) doubling as "does
2257
+ this expression contain any name that *does* resolve" for exactly that reason.
2258
+ - `Data 1,2,3` / `Data 1+2`: no error (a pure literal/arithmetic constant).
2259
+
2260
+ Stops at the first bad parameter in a statement (no oracle evidence for two bad parameters in
2261
+ the same `Data`, and every other per-position check in this module already stops at the first
2262
+ problem the same way).
2263
+
2264
+ Ungated pass 1, same as `check_consts` above (`cdmbrfull_1.CR6`: this fires alongside an
2265
+ earlier, unrelated `Need '=' in units definition:` pass-1 error, not suppressed by it).
2266
+
2267
+ A `Data`/`DataLong` line the parser could not resolve into a clean argument list at all --
2268
+ the common CHM corpus shape, a `'`-less comment-continuation line the statement's own bare-
2269
+ argument-list grammar absorbs (`brfull6w_1.CR6`'s `Data Tables and Store Data`, no comma
2270
+ anywhere in it) -- never reaches the loop below as an `Instruction` at all: `parser.py`'s
2271
+ `_parse_bare_arglist` parses only the first word (`Tables`) as `Data`'s own first parameter,
2272
+ and the rest (`and Store Data`) is left as trailing input that turns the *whole statement*
2273
+ into an `S.Unparsed` (`Instruction.args`/`arg_spans` never populated). On CR6/CR1000X the
2274
+ oracle shows the real compiler still reports exactly this module's own two-line shape for it,
2275
+ spanning from right after the keyword to end of line (`Invalid symbol in expression: Tables
2276
+ and Store Data\\n.` / `Illegal Data parameter Tables and Store Data\\n.` -- not `Tables\\n.`
2277
+ alone, the substring our own parser would have captured as parameter 0's span had it not given
2278
+ up) -- `_check_unparsed_data` below reconstructs that same span textually (the whole line minus
2279
+ the keyword and its following whitespace) rather than trying to make the parser itself recover
2280
+ a partial argument list for this one shape. **CR300/CR350 are genuinely different here, not
2281
+ just a message-template swap**: oracle-confirmed (`docs/checker.md` "Data parameters"),
2282
+ `Data Tables and Store Data` there tokenises `and` as the real logical `AND` operator (a
2283
+ reserved word on those two loggers this shape happens to expose), so it becomes a compound
2284
+ `Tables AND (Store Data)` parse attempt -- three separate `%s is not defined.` lines (`Tables`,
2285
+ `Store`, `Data`, each its own undefined name) and `Incomplete expression: %s.` (not `Invalid
2286
+ symbol in expression`) as the wrapper, before the same `Illegal Data parameter %s.` line. Not
2287
+ implemented: reproducing it needs its own compound-expression walk this session did not build
2288
+ (AGENTS.md: under-report rather than guess), so `_check_unparsed_data` is scoped to
2289
+ `_WHOLE_EXPR_ECHO_LOGGERS` only and CR300/CR350 fall through unchanged to whatever later,
2290
+ already-existing pass would otherwise handle this `S.Unparsed` statement.
2291
+ """
2292
+ known = _known_names(program, profile)
2293
+ for stmt in program.statements:
2294
+ if isinstance(stmt, S.Instruction) and stmt.keyword.upper() in _DATA_KEYWORDS:
2295
+ _check_data_instruction(stmt, known, profile, diags)
2296
+ elif isinstance(stmt, S.Unparsed):
2297
+ _check_unparsed_data(stmt, diags, profile, program)
2298
+
2299
+
2300
+ def _data_param_text(stmt: S.Instruction, i: int) -> str:
2301
+ text = _arg_text(stmt, i)
2302
+ if i == len(stmt.args) - 1:
2303
+ text += "\n" # last parameter on the line: oracle-confirmed embedded newline, see above.
2304
+ return text
2305
+
2306
+
2307
+ def _check_data_instruction(stmt: S.Instruction, known: set[str], profile, diags: Diagnostics) -> None:
2308
+ for i, arg in enumerate(stmt.args):
2309
+ if arg is None:
2310
+ continue
2311
+ undefined = _undefined_names(arg, known)
2312
+ resolved_refs = _variable_refs(arg, known)
2313
+ if not undefined and not resolved_refs:
2314
+ continue # a pure constant expression: legal.
2315
+ text = _data_param_text(stmt, i)
2316
+ if undefined:
2317
+ if profile.name in _WHOLE_EXPR_ECHO_LOGGERS:
2318
+ diags.error(stmt.line, profile.unresolved_symbol_message.format(name=text), stmt.file)
2319
+ else:
2320
+ for name in undefined:
2321
+ diags.error(stmt.line, profile.unresolved_symbol_message.format(name=name), stmt.file)
2322
+ diags.error(stmt.line, f"Illegal Data parameter {text}.", stmt.file)
2323
+ return # stop at the first bad parameter, see docstring above.
2324
+
2325
+
2326
+ def _check_unparsed_data(stmt: S.Unparsed, diags: Diagnostics, profile, program) -> None:
2327
+ """`Data foo bar`: CR6/CR1000X `Invalid symbol in expression: foo bar` (whole text); CR300/
2328
+ CR350 each leading word as in a prose line (`Data Tables and Store Data` -> Tables, Store,
2329
+ Data), then `Incomplete expression: foo bar` (oracle, 2026-10-01). Both end with `Illegal Data
2330
+ parameter`."""
2331
+ if not stmt.reason.startswith("unexpected trailing input starting at"):
2332
+ return
2333
+ stripped = stmt.text.lstrip()
2334
+ upper = stripped.upper()
2335
+ for kw in ("DATALONG", "DATA"): # longer name first: "DATALONG" also starts with "DATA".
2336
+ if upper.startswith(kw) and len(stripped) > len(kw) and stripped[len(kw)].isspace():
2337
+ text = stripped[len(kw) :].lstrip() + "\n"
2338
+ if profile.name in _WHOLE_EXPR_ECHO_LOGGERS:
2339
+ diags.error(stmt.line, f"Invalid symbol in expression: {text}.", stmt.file)
2340
+ else:
2341
+ from . import cr3xx
2342
+
2343
+ for name in cr3xx._leading_unknown_words(text, cr3xx.prose_known(program, profile)):
2344
+ diags.error(stmt.line, profile.unresolved_symbol_message.format(name=name), stmt.file)
2345
+ diags.error(stmt.line, f"Incomplete expression: {text}.", stmt.file)
2346
+ diags.error(stmt.line, f"Illegal Data parameter {text}.", stmt.file)
2347
+ return
2348
+
2349
+
2350
+ def _call_arity_problem(name: str, args: list[S.Expr | None], profile) -> str | None:
2351
+ """`"few"`/`"many"`/`None` for one call's own argument count against `data/functions.json`'s
2352
+ `nargs`, restricted to `_ARITY_MIN_SAFE`/`_ARITY_MAX_SAFE` (the oracle-verified-safe subset,
2353
+ this module's own top-of-file comment) -- and, within that, only when *this profile's own*
2354
+ `nargs` for the name still matches the value the oracle sweep tested (`_ARITY_MIN_SAFE[name]`/
2355
+ `_ARITY_MAX_SAFE[name]`): a profile whose own table disagrees (`SerialOpen` on CR350, `docs/
2356
+ checker.md`) is silently left unchecked for that name, rather than trusting an unverified
2357
+ transfer of this session's CR6/CR1000X-only evidence. A name absent from `profile.functions`
2358
+ (not a built-in function at all -- a declared `Sub`/`Function`, or unresolved) is never this
2359
+ rule's concern.
2360
+ """
2361
+ record = profile.functions.get(name.upper())
2362
+ if not record:
2363
+ return None
2364
+ n = len(args)
2365
+ if name.upper() == "CTYPE": # table nargs is 0; the call takes (value, type) (oracle, 4 loggers)
2366
+ return None if None in args or n == 2 else "few" if n == 1 else "many" if n > 2 else None
2367
+ nargs = record.get("nargs")
2368
+ if nargs is None:
2369
+ return None
2370
+ upper = name.upper()
2371
+ if any(i < len(args) and isinstance(args[i], S.String) for i in _FUNCTION_CONST_INTEGER_STRING_ARGS.get(upper, ())):
2372
+ return "constint"
2373
+ if any(i < n and _not_constant_integer(args[i]) for i in _CONST_INTEGER_BAD_ARGS.get(upper, ())):
2374
+ return "constint"
2375
+ if _TIMEOUT_GATE[0] is False and any(
2376
+ i < n and (_not_integer_type(args[i]) or _timeout_wide(args[i], profile))
2377
+ for i in _CONST_INTEGER_TYPE_ARGS.get(upper, ())
2378
+ ):
2379
+ return "constint"
2380
+ if any(i < n and _not_integer_type(args[i]) for i in _CONST_INTEGER_TYPE_ARGS_ALWAYS.get(upper, ())):
2381
+ return "constint"
2382
+ if upper == "TIMER" and n == 3 and _TIMER_CONTEXT[0] is not None:
2383
+ kinds = [_timer_arg(a, *_TIMER_CONTEXT[0]) if a is not None else None for a in args[1:]]
2384
+ if "float" in kinds:
2385
+ return "constint"
2386
+ if kinds[1] is not None and not 0 <= kinds[1][1] <= 4:
2387
+ return "timer_option"
2388
+ if _CONST_INTEGER_OVERFLOW_STRING.get(upper) == nargs and len(args) == nargs + 1 and isinstance(args[-1], S.String):
2389
+ return "constint"
2390
+ if _INCOMPLETE_OVERFLOW.get(upper) == nargs and len(args) == nargs + 1:
2391
+ return "incomplete"
2392
+ if n == 0 and (nargs > 0 or record.get("argspec")):
2393
+ # `X = Int()`, `SerialFlush()`: the generic fallback on the whole statement, pass 1, all
2394
+ # four loggers (oracle, 2026-09-27).
2395
+ return "empty"
2396
+ if allowed := _ARITY_ALLOWED.get(upper, {}).get(nargs):
2397
+ return None if n in allowed else "few" if n < max(allowed) else "many"
2398
+ if bounds := _ARITY_RANGE.get(upper, {}).get(nargs):
2399
+ return "few" if n < bounds[0] else "many" if n > bounds[1] else None
2400
+ if n < nargs and _ARITY_MIN_SAFE.get(name.upper()) == nargs:
2401
+ return "few"
2402
+ if n > nargs and _ARITY_MAX_SAFE.get(name.upper()) == nargs:
2403
+ return "many"
2404
+ return None
2405
+
2406
+
2407
+ def _find_arity_problem(expr: S.Expr | None, profile, known: set[str] | None = None) -> str | None:
2408
+ """The first (left-to-right, outer-before-inner) built-in-function call anywhere inside `expr`
2409
+ with a bad argument count, `_call_arity_problem`'s own `"few"`/`"many"`/`None`. Same recursion
2410
+ shape as `_undefined_names`/`_variable_refs` above (`Unary`/`Binary`/`Index`'s own name and
2411
+ args/`Member.base`/`ArrayLiteral` items); a `Member`'s own `.member` args (`Status.Battery(1)`
2412
+ -- a *field* call, never a built-in function) are deliberately not checked, same reasoning as
2413
+ `_undefined_names` leaving `Member.member` alone.
2414
+
2415
+ Checks an `Index` node itself (a real parser-recognised call, or an array reference that
2416
+ happens to share a built-in function's name -- `_call_arity_problem` only fires when the name is
2417
+ genuinely in `profile.functions`, so an ordinary array reference is never affected) before
2418
+ recursing into its own arguments, matching the outer-call-first order the oracle showed for a
2419
+ single-argument-short outer call around an otherwise-fine inner one (`docs/checker.md`); a
2420
+ *doubly* bad-arity nested call (both the outer and an inner call wrong) is not oracle-tested
2421
+ either way and is left as this function's own documented order (outer first) rather than guessed
2422
+ at further.
2423
+
2424
+ With `known` (CR6/CR1000X), a bad call whose own arguments name something undefined is
2425
+ `"deferred"`: the statement goes to pass 2's `Invalid symbol in expression` instead (oracle,
2426
+ round 38: `S = Right(Nope)`, `Battery(Sin(Nope,1))`; `Sin(1,2) + Nope` and a name in a surplus
2427
+ argument keep the arity error).
2428
+ """
2429
+ if expr is None:
2430
+ return None
2431
+ if known is not None and isinstance(expr, (S.Name, S.Index)) and expr.name.upper() not in known:
2432
+ # read left to right: an undefined name before the failing call defers it too (round 39:
2433
+ # `X = Nope + Sin(1,2)`, `Round(Nope, Sin(1,2))`; each `Sub` argument is its own expression)
2434
+ return "deferred"
2435
+ if isinstance(expr, S.Index):
2436
+ direct = _call_arity_problem(expr.name, expr.args, profile)
2437
+ if direct and known is not None:
2438
+ # only names the parser reaches: a surplus argument is never read (`Right(S,10,Nope)`
2439
+ # keeps `No Matching`, round 38)
2440
+ read = expr.args
2441
+ while direct != "few" and read and _call_arity_problem(expr.name, read, profile) not in (None, "few"):
2442
+ read = read[:-1]
2443
+ if any(_undefined_names(a, known) for a in read):
2444
+ return "deferred"
2445
+ if direct:
2446
+ return direct
2447
+ for a in expr.args:
2448
+ found = _find_arity_problem(a, profile, known)
2449
+ if found:
2450
+ return found
2451
+ return None
2452
+ if isinstance(expr, S.Unary):
2453
+ return _find_arity_problem(expr.operand, profile, known)
2454
+ if isinstance(expr, S.Binary):
2455
+ return _find_arity_problem(expr.left, profile, known) or _find_arity_problem(expr.right, profile, known)
2456
+ if isinstance(expr, S.Member):
2457
+ if expr.args is not None and len(expr.args) > 2:
2458
+ # a table field takes at most (index, records back): `Tb.X(1,1,1)`, `Public.V(1,1,1)`
2459
+ # -> `No Matching, or out of place )` (oracle, all four loggers, 2026-10-01). Raised by
2460
+ # the pass-2 compile of the statement, not by pass 1 (message tracer, 2026-10-10), so
2461
+ # `check_function_arity` reports it through `held`.
2462
+ return "many_member"
2463
+ return _find_arity_problem(expr.base, profile, known)
2464
+ if isinstance(expr, S.ArrayLiteral):
2465
+ for item in expr.items:
2466
+ found = _find_arity_problem(item, profile, known)
2467
+ if found:
2468
+ return found
2469
+ return None
2470
+ return None # Number, Name, String: no call to check
2471
+
2472
+
2473
+ def _arity_message(kind: str, text: str, profile, at_eol: bool = True, whole_statement: bool = False) -> str:
2474
+ if kind == "timer_option":
2475
+ return "Legal options are 0..4"
2476
+ if kind in ("constint", "incomplete"):
2477
+ verb = "Parameter must be a constant integer: " if kind == "constint" else "Incomplete expression: "
2478
+ sep = "\n" if profile.name in _ARITY_TRAILING_NEWLINE_LOGGERS and at_eol else ""
2479
+ return f"{verb}{text}{sep}."
2480
+ if kind == "empty":
2481
+ sep = "\n" if whole_statement and at_eol and profile.fallback_echo_newline else ""
2482
+ return f"Invalid, or out of place expression: {text}{sep}."
2483
+ verb = "Expression expects , : " if kind == "few" else "No Matching, or out of place ) in expression: "
2484
+ if profile.name in _ARITY_TRAILING_NEWLINE_LOGGERS and at_eol: # not before a `'` comment
2485
+ return f"{verb}{text}\n."
2486
+ return f"{verb}{text}."
2487
+
2488
+
2489
+ def _is_bare_reference_problem(name: str, profile, declared_variables: frozenset[str]) -> bool:
2490
+ """True when `name` is a built-in function (`data/<logger>/functions.json`) that can take at
2491
+ least one argument -- a required one (`nargs>=1`) or a purely-optional one (`nargs==0` but
2492
+ `argspec` non-zero: `EMAILRELAY`'s own shape, the sole `nargs==0` outlier, oracle-confirmed
2493
+ identical across all four loggers' own tables -- `docs/checker.md`) -- so a bare reference to
2494
+ it with no `(` at all is `Expression expects ( : %s.`.
2495
+
2496
+ Unlike `_call_arity_problem`'s off-by-one arity check, this is not restricted to
2497
+ `_ARITY_MIN_SAFE`/`_ARITY_MAX_SAFE`: the oracle sweep for *that* rule needed a safe subset
2498
+ because some functions accept fewer/more than their own table `nargs` (an undocumented
2499
+ optional argument). This rule only asks whether the call has *no* argument list whatsoever,
2500
+ which every corpus example with `nargs>=1` -- `Sin`, `Timer`, `FindSpa`, `TCPOpen`,
2501
+ `SerialOpen`, `SerialOut`, `EmailSend`, `HTTPPut`, `IPTrace`, `SecsSince1990`,
2502
+ `DaylightSaving` -- confirms uniformly (`docs/checker.md`). A genuinely zero-argument function
2503
+ (`argspec` also 0: `Rnd`, `Signature`, `LineNum`, `PPPOpen`, `PPPClose`, `TimeUntilTransmit`,
2504
+ `IntDV`, `NewFieldCal`, `CType`, `AbsLong`, `ClockChange`) is oracle-confirmed to compile clean
2505
+ bare, with or without parens, on all four loggers -- this rule leaves those alone.
2506
+
2507
+ A name the program itself declares as a genuine runtime variable
2508
+ (`symbols.declared_variable_names` -- `Public`/`Dim`/`Static`/a `Sub`/`Function` parameter)
2509
+ shadows the built-in function of the same name for bare-identifier resolution and is never
2510
+ flagged here -- found by this session's own false-reject sweep (`examples/embracer.cr6`'s
2511
+ `Dim sgn As Long, exp As Long, mant As Long` followed by the plain arithmetic
2512
+ `... + (exp << 13) + ...`, which the real compiler resolves as the *variable* `exp`, not the
2513
+ math function `Exp`, and compiles clean).
2514
+ """
2515
+ if name.upper() in declared_variables:
2516
+ return False
2517
+ record = profile.functions.get(name.upper())
2518
+ if not record:
2519
+ return False
2520
+ nargs = record.get("nargs")
2521
+ if nargs is None:
2522
+ return False
2523
+ return nargs > 0 or bool(record.get("argspec"))
2524
+
2525
+
2526
+ def _find_bare_function_ref(expr: S.Expr | None, profile, declared_variables: frozenset[str]) -> bool:
2527
+ """Whether a `Name` node anywhere inside `expr` (same recursion shape as
2528
+ `_find_arity_problem`) bare-references a function `_is_bare_reference_problem` flags -- a
2529
+ built-in function used as a plain name, with no argument list at all, rather than as a call
2530
+ (`S.Index`). An `Index` node's own name is by definition already followed by `(`, so only its
2531
+ *arguments* are recursed into, matching `Randomize(Sin)` -> flags the nested `Sin`, not
2532
+ `Randomize` itself (oracle-confirmed, `docs/checker.md`).
2533
+ """
2534
+ if expr is None:
2535
+ return False
2536
+ if isinstance(expr, S.Name):
2537
+ return _is_bare_reference_problem(expr.name, profile, declared_variables)
2538
+ if isinstance(expr, S.Index):
2539
+ return any(_find_bare_function_ref(a, profile, declared_variables) for a in expr.args)
2540
+ if isinstance(expr, S.Unary):
2541
+ return _find_bare_function_ref(expr.operand, profile, declared_variables)
2542
+ if isinstance(expr, S.Binary):
2543
+ return _find_bare_function_ref(expr.left, profile, declared_variables) or _find_bare_function_ref(
2544
+ expr.right, profile, declared_variables
2545
+ )
2546
+ if isinstance(expr, S.Member):
2547
+ return _find_bare_function_ref(expr.base, profile, declared_variables)
2548
+ if isinstance(expr, S.ArrayLiteral):
2549
+ return any(_find_bare_function_ref(item, profile, declared_variables) for item in expr.items)
2550
+ return False # Number, String: no name to check
2551
+
2552
+
2553
+ def _bare_function_message(text: str, profile, whole_statement: bool, at_eol: bool = True) -> str:
2554
+ # `whole_statement`: CR300/CR350 embed a literal newline before the closing `.` only when
2555
+ # `text` is the *whole* logical statement (`Call`/`Assignment`, matching `_arity_message`'s
2556
+ # own `_ARITY_TRAILING_NEWLINE_LOGGERS` convention for their arity messages) -- **not** for an
2557
+ # `Instruction` argument's own sub-span. Oracle-confirmed, this session: `Randomize(Sin)` on
2558
+ # `cr300comp.exe`/`cr350comp.exe` gives the plain, single-line `Expression expects ( : Sin.`
2559
+ # (no embedded newline), while `X = Sin` alone gives `Expression expects ( : X = Sin\n.`
2560
+ # (`docs/checker.md`).
2561
+ if whole_statement and at_eol and profile.name in _ARITY_TRAILING_NEWLINE_LOGGERS:
2562
+ return f"Expression expects ( : {text}\n."
2563
+ return f"Expression expects ( : {text}."
2564
+
2565
+
2566
+ def _struct_var_names(program: S.Program) -> frozenset[str]:
2567
+ from . import structs # lazy: structs imports this module
2568
+
2569
+ types = structs._structure_members(program)
2570
+ if not types:
2571
+ return frozenset()
2572
+ return frozenset(n for n, k in structs._variable_kinds(program, types).items() if k.startswith("STRUCT:"))
2573
+
2574
+
2575
+ def _multi_indexed_struct(expr: S.Expr | None, struct_vars: frozenset[str]) -> bool:
2576
+ """True when `expr` contains a structure-variable reference with two or more indices
2577
+ (`s1(10,2)`, `s2(1,2).value`): a structure reference takes at most one (`s1(1).value(2)`
2578
+ is fine -- the member's own index is separate). Same walk shape as `_find_arity_problem`."""
2579
+ if expr is None:
2580
+ return False
2581
+ if isinstance(expr, S.Index):
2582
+ return (expr.name.upper() in struct_vars and len(expr.args) >= 2) or any(
2583
+ _multi_indexed_struct(a, struct_vars) for a in expr.args
2584
+ )
2585
+ if isinstance(expr, S.Unary):
2586
+ return _multi_indexed_struct(expr.operand, struct_vars)
2587
+ if isinstance(expr, S.Binary):
2588
+ return _multi_indexed_struct(expr.left, struct_vars) or _multi_indexed_struct(expr.right, struct_vars)
2589
+ if isinstance(expr, S.Member):
2590
+ return _multi_indexed_struct(expr.base, struct_vars) or any(
2591
+ _multi_indexed_struct(a, struct_vars) for a in expr.args or []
2592
+ )
2593
+ return False
2594
+
2595
+
2596
+ def _hold(held: list | None, diags: Diagnostics, problem: str) -> None:
2597
+ if held is not None and problem == "many_member":
2598
+ held.append(diags.items[-1])
2599
+
2600
+
2601
+ def check_function_arity(program: S.Program, profile, diags: Diagnostics, held: list | None = None) -> None:
2602
+ """Built-in function call arity (`Expression expects , : %s.` / `No Matching, or out of place )
2603
+ in expression: %s.`) *and* a bare function-name reference with no argument list at all
2604
+ (`Expression expects ( : %s.`, `_is_bare_reference_problem`/`_find_bare_function_ref`) --
2605
+ this module's own top-of-file comment for the arity oracle evidence, `docs/checker.md`
2606
+ ("Expression expects ( (bare function name)") for the bare-reference evidence, and the pass-1
2607
+ tier this needs (wired unconditionally in `checker/__init__.py`, alongside
2608
+ `instr_rules.check`/`check_too_many`, not inside the gated `check` above). The bare-reference
2609
+ check runs first in each branch below and, when it fires, is the statement's only diagnostic
2610
+ from this function (matching the oracle: a bare reference is a parse-time concern that
2611
+ preempts the arity check, which only ever sees a real call with a real, if wrong, argument
2612
+ count).
2613
+
2614
+ At most one diagnostic per statement, matching the oracle (a second, independent bad-arity call
2615
+ later in the *same* statement is never additionally reported -- confirmed both across two
2616
+ `Instruction` argument positions and, separately, within one `Call`'s own argument list):
2617
+
2618
+ - `Instruction`: the first argument position (ascending, plain textual order -- **not** the
2619
+ constant-family reordering `_report_statement` above uses for a *different* check; arity is a
2620
+ raw parse-time concern, oracle-confirmed unaffected by that semantic reordering, `docs/
2621
+ checker.md`) whose own expression tree contains a bad-arity call, echoed as *that argument's
2622
+ own verbatim text* (`_arg_text`, matching `Randomize(Round(X))` -> echoes `Round(X)`, not the
2623
+ whole statement -- oracle-confirmed, `docs/checker.md`).
2624
+ - `Call` (a bare statement, `Round(Number,1,2)` alone, or a builtin used for its side effect):
2625
+ the call's own arity (if `stmt.name` is itself a built-in function) or any nested call inside
2626
+ its own argument list, echoed as the *whole statement's own verbatim text* (`stmt.text` --
2627
+ oracle-confirmed, `WatchdogTimer(0,Round(X))` -> echoes the whole statement, unlike the
2628
+ `Instruction`-argument case above; `Call` has no per-argument span to prefer, and the oracle
2629
+ shows it uses the whole-statement span here even for a call nested inside one of its own
2630
+ arguments).
2631
+ - `Assignment`: `stmt.value`'s own expression tree, echoed as the whole statement's own verbatim
2632
+ text (`stmt.text`, target and `=` included -- same convention `check` above already uses for
2633
+ this module's other assignment-RHS echo, `docs/checker.md` "Round 5"). The target itself is
2634
+ not checked (a function call is never a valid assignment target; not oracle-tested, left
2635
+ alone rather than guessed at).
2636
+ """
2637
+ declared_variables = frozenset(symbols.declared_variable_names(program))
2638
+ if profile.single_pass:
2639
+ # CR300/CR350 accept a local `Dim exp` / parameter named like a built-in function but
2640
+ # resolve its bare uses as the function (oracle, 2026-10-08)
2641
+ declared_variables = declared_variables - frozenset(profile.functions)
2642
+ known: set[str] | None = None
2643
+ from . import intervals, structs, structure # lazy: all import this module
2644
+
2645
+ struct_types = structs._structure_members(program)
2646
+ kinds = structs._variable_kinds(program, struct_types) if struct_types else {}
2647
+ struct_vars = _struct_var_names(program)
2648
+ sub_names = {
2649
+ st.name.upper() for st in program.statements if isinstance(st, S.SubDef) and st.keyword.upper() == "SUB"
2650
+ }
2651
+ start = len(diags.items)
2652
+ mine: list = []
2653
+ table_mine: list = [] # the `mine` entries from DataTable output instructions
2654
+ in_table = False
2655
+ seen_timeint = False
2656
+ _TIMEOUT_WIDE[0] = intervals.wide_constants(program, profile)
2657
+ _TIMER_CONTEXT[0] = (consteval.Env(profile, dict(program.consts)), frozenset(n.upper() for n in declared_variables))
2658
+ arity_known = _known_names(program, profile) if profile.name in _WHOLE_EXPR_ECHO_LOGGERS else None
2659
+
2660
+ def arity(expr):
2661
+ problem = _find_arity_problem(expr, profile, arity_known)
2662
+ return None if problem == "deferred" else problem
2663
+
2664
+ for stmt in program.statements:
2665
+ _TIMEOUT_GATE[0], seen_timeint = intervals.timeout_gate(seen_timeint, intervals.gate_text(stmt))
2666
+ if isinstance(stmt, S.Instruction):
2667
+ kw = stmt.keyword.upper()
2668
+ in_table = kw == "DATATABLE" or (in_table and kw != "ENDTABLE")
2669
+ # outside a table the "must be used inside a table declaration" check wins
2670
+ table_only = kw in instr_data.TABLE_ONLY_INSTRUCTIONS and not in_table
2671
+ for i, arg in enumerate(stmt.args):
2672
+ if arg is None:
2673
+ continue
2674
+ if not table_only and _multi_indexed_struct(arg, struct_vars):
2675
+ diags.error(stmt.line, _arity_message("many", _arg_text(stmt, i), profile, False), stmt.file)
2676
+ mine.append(diags.items[-1])
2677
+ if in_table:
2678
+ table_mine.append(diags.items[-1])
2679
+ break
2680
+ if kw not in ("ALIAS", "UNITS") and _find_bare_function_ref(arg, profile, declared_variables):
2681
+ msg = _bare_function_message(_arg_text(stmt, i), profile, whole_statement=False)
2682
+ diags.error(stmt.line, msg, stmt.file)
2683
+ break
2684
+ problem = _find_arity_problem(arg, profile) # (not deferred: `PanelTemp(X,Sin(Nope,1))`)
2685
+ if problem == "empty":
2686
+ diags.error(stmt.line, _arity_message(problem, _arg_text(stmt, i), profile), stmt.file)
2687
+ break
2688
+ if problem in ("many_member", "constint", "timer_option") and table_only:
2689
+ break # the "must be used inside a table declaration" error wins
2690
+ if problem:
2691
+ diags.error(stmt.line, _arity_message(problem, _arg_text(stmt, i), profile, False), stmt.file)
2692
+ _hold(held, diags, problem)
2693
+ break
2694
+ elif isinstance(stmt, S.Call):
2695
+ # A bare call with no argument list at all (`TCPOpen` alone, no trailing `(`
2696
+ # anywhere in this statement's own text) is textually indistinguishable, once
2697
+ # parsed, from an explicit empty call (`TCPOpen()`, also `args=[]`) -- `stmt.text`
2698
+ # is this statement's own source segment (`parser.py`'s `seg_text`), so checking it
2699
+ # for `(` recovers exactly that distinction without touching the parser (`docs/
2700
+ # checker.md`: `SerialOpen`/`TCPOpen`/`Timer`/`IPTrace` alone, each split from its
2701
+ # own argument list onto the next source line, all `Expression expects (`).
2702
+ if (
2703
+ not stmt.args
2704
+ and "(" not in stmt.text
2705
+ and _is_bare_reference_problem(stmt.name, profile, declared_variables)
2706
+ ):
2707
+ diags.error(
2708
+ stmt.line,
2709
+ _bare_function_message(stmt.text, profile, whole_statement=True, at_eol=stmt.at_eol),
2710
+ stmt.file,
2711
+ )
2712
+ continue
2713
+ # a `Function` call statement continued as an expression (`Fn(1)+Sin`) is checked as
2714
+ # the whole expression, like `X = Fn(1)+Sin` (oracle, 2026-10-10)
2715
+ call_exprs = [stmt.tail_expr] if stmt.tail_expr is not None else stmt.args
2716
+ if any(_find_bare_function_ref(a, profile, declared_variables) for a in call_exprs):
2717
+ diags.error(
2718
+ stmt.line,
2719
+ _bare_function_message(stmt.text, profile, whole_statement=True, at_eol=stmt.at_eol),
2720
+ stmt.file,
2721
+ )
2722
+ continue
2723
+ bad = next((i for i, a in enumerate(stmt.args) if _multi_indexed_struct(a, struct_vars)), None)
2724
+ if bad is not None:
2725
+ # `Foo(A(1,2).V)`: the argument is echoed alone (oracle, 2026-10-03)
2726
+ diags.error(
2727
+ stmt.line, _arity_message("many", _call_arg_text(stmt.text, bad), profile, False), stmt.file
2728
+ )
2729
+ mine.append(diags.items[-1])
2730
+ continue
2731
+ problem = _call_arity_problem(stmt.name, stmt.args, profile)
2732
+ in_arg = None
2733
+ if not problem:
2734
+ for k, a in enumerate(call_exprs):
2735
+ problem = arity(a)
2736
+ if problem:
2737
+ # inside a `Sub` call the offending argument alone is echoed (oracle,
2738
+ # 2026-10-13, all four loggers); a `Function` statement echoes it whole
2739
+ if stmt.tail_expr is None and stmt.juxtaposed is None and stmt.name.upper() in sub_names:
2740
+ in_arg = k
2741
+ break
2742
+ if problem:
2743
+ if problem == "constint" and any(
2744
+ d.line == stmt.line
2745
+ and d.file == stmt.file
2746
+ and d.message.startswith("Parameter must be a constant integer:")
2747
+ for d in diags.items
2748
+ ):
2749
+ continue
2750
+ if (
2751
+ not profile.single_pass
2752
+ and stmt.name.upper() == "SERIALOPEN"
2753
+ and stmt.args
2754
+ and any(
2755
+ d.line == stmt.line and d.file == stmt.file and d.message.startswith("ComPort ")
2756
+ for d in diags.items
2757
+ )
2758
+ ):
2759
+ continue # the ComPort error ends the statement (oracle, CR6/CR1000X, round 37)
2760
+ if in_arg is not None:
2761
+ diags.error(
2762
+ stmt.line,
2763
+ _arity_message(problem, _call_arg_text(stmt.text, in_arg).strip(), profile, False),
2764
+ stmt.file,
2765
+ )
2766
+ _hold(held, diags, problem)
2767
+ continue
2768
+ diags.error(
2769
+ stmt.line, _arity_message(problem, stmt.text, profile, stmt.at_eol, whole_statement=True), stmt.file
2770
+ )
2771
+ _hold(held, diags, problem)
2772
+ elif isinstance(stmt, S.If):
2773
+ # All four loggers, pass 1 (oracle, 2026-09-27): `If TimeIsBetween(0,15,60,sec,1) Then`
2774
+ # -> `No Matching, or out of place ) in expression: TimeIsBetween(0,15,60,sec,1) .`
2775
+ # (the condition echo, no CR300/CR350 newline).
2776
+ problem = arity(stmt.cond)
2777
+ struct_cond = not problem and _multi_indexed_struct(stmt.cond, struct_vars)
2778
+ problem = problem or ("many" if struct_cond else None)
2779
+ text = _if_condition_text(stmt)
2780
+ if problem == "empty" and text is not None:
2781
+ diags.error(stmt.line, _arity_message(problem, text, profile), stmt.file)
2782
+ elif problem == "timer_option" and text is not None:
2783
+ diags.error(stmt.line, _arity_message(problem, text, profile), stmt.file)
2784
+ elif problem == "constint" and text is not None:
2785
+ diags.error(stmt.line, f"Parameter must be a constant integer: {text}.", stmt.file)
2786
+ elif problem and text is not None:
2787
+ verb = (
2788
+ "Expression expects , : " if problem == "few" else "No Matching, or out of place ) in expression: "
2789
+ )
2790
+ diags.error(stmt.line, f"{verb}{text}.", stmt.file)
2791
+ _hold(held, diags, problem)
2792
+ if struct_cond:
2793
+ mine.append(diags.items[-1])
2794
+ elif isinstance(stmt, S.ConstDecl) and stmt.value_span:
2795
+ # `Const CRLF = CHR(13,1) + CHR(10)` -> `No Matching, or out of place ) in expression:
2796
+ # CHR(13,1) + CHR(10).`, the value echoed, all four loggers (oracle, 2026-10-01)
2797
+ problem = arity(stmt.value) # (deferred: round 41)
2798
+ text = stmt.text[stmt.value_span[0] : stmt.value_span[1]]
2799
+ if problem == "empty":
2800
+ # `Const Pi = 4*ATN() 'c` -> `Invalid, or out of place expression: 4*ATN() .`,
2801
+ # the value to the comment, no CR300 newline (oracle, 2026-10-01)
2802
+ diags.error(
2803
+ stmt.line, f"Invalid, or out of place expression: {stmt.text[stmt.value_span[0] :]}.", stmt.file
2804
+ )
2805
+ elif problem:
2806
+ verb = (
2807
+ "Expression expects , : " if problem == "few" else "No Matching, or out of place ) in expression: "
2808
+ )
2809
+ diags.error(stmt.line, f"{verb}{text}.", stmt.file)
2810
+ elif isinstance(stmt, S.Assignment):
2811
+ if known is None:
2812
+ known = _known_names(program, profile)
2813
+ if _undefined_names(stmt.target, known):
2814
+ # An undeclared target defers the whole statement to pass 2 (the fallback and
2815
+ # its placement follow-up there; oracle: `Y=EXP()` before `BeginProg`).
2816
+ continue
2817
+ if _multi_indexed_struct(stmt.target, struct_vars):
2818
+ # a structure reference takes one index; oracle 2026-10-03, docs/checker.md
2819
+ diags.error(stmt.line, structure._out_of_place(profile, stmt.text, stmt.at_eol), stmt.file)
2820
+ mine.append(diags.items[-1])
2821
+ continue
2822
+ target_message = _function_target_message(stmt, profile, declared_variables)
2823
+ if target_message:
2824
+ diags.error(stmt.line, target_message, stmt.file)
2825
+ continue
2826
+ if _find_bare_function_ref(stmt.value, profile, declared_variables):
2827
+ diags.error(
2828
+ stmt.line,
2829
+ _bare_function_message(stmt.text, profile, whole_statement=True, at_eol=stmt.at_eol),
2830
+ stmt.file,
2831
+ )
2832
+ continue
2833
+ problem = arity(stmt.value)
2834
+ struct_value = not problem and _multi_indexed_struct(stmt.value, struct_vars)
2835
+ if struct_value:
2836
+ problem = "many"
2837
+ if (
2838
+ profile.single_pass
2839
+ and not stmt.compound
2840
+ and isinstance(stmt.value, S.Index)
2841
+ and structs._operand_kind(stmt.target, kinds, struct_types) == "S"
2842
+ ):
2843
+ diags.items.append(Diagnostic("error", stmt.line, structs._WRONG_TYPE, stmt.file, cascade=False))
2844
+ mine.append(diags.items[-1])
2845
+ if problem:
2846
+ diags.error(
2847
+ stmt.line,
2848
+ _arity_message(problem, stmt.text, profile, stmt.at_eol, whole_statement=True),
2849
+ stmt.file,
2850
+ stmt=stmt,
2851
+ )
2852
+ _hold(held, diags, problem)
2853
+ if struct_value:
2854
+ mine.append(diags.items[-1])
2855
+ _TIMEOUT_GATE[0] = None
2856
+ _TIMEOUT_WIDE[0] = frozenset()
2857
+ if not profile.single_pass and table_mine:
2858
+ # CR6/CR1000X: only the first DataTable instruction is reported, and none at all once a
2859
+ # statement outside a table has one (oracle, 2026-10-03)
2860
+ outside = any(d not in table_mine for d in mine)
2861
+ keep = [] if outside else table_mine[:1]
2862
+ drop = [d for d in table_mine if d not in keep]
2863
+ diags.items[start:] = [d for d in diags.items[start:] if d not in drop]
2864
+ mine[:] = [d for d in mine if d not in drop]
2865
+ if profile.single_pass and any(d not in mine for d in diags.items[start:]):
2866
+ # CR300/CR350: any call-arity error in the file suppresses every structure-index one,
2867
+ # before or after it (oracle, 2026-10-03)
2868
+ diags.items[start:] = [d for d in diags.items[start:] if d not in mine]
2869
+
2870
+
2871
+ def _function_target_message(stmt: S.Assignment, profile, declared: frozenset[str]) -> str | None:
2872
+ """An assignment whose target is a built-in function (oracle, all four loggers, 2026-10-08):
2873
+ `Sin = 1` / `Timer = X` -> `Expression expects ( : <stmt>`; `Rnd = 2`, `Sin(X) = 3`,
2874
+ `Round(1,2) = 3` -> `Invalid assignment in expression: <stmt>`; a call with a wrong argument
2875
+ count gets that count's message (`Sin(1,2) = 3`, `Round(1) = 2`, `Sin() = 3`). Only arities
2876
+ this module already trusts (`_call_arity_problem`, or exactly the table `nargs`) are reported."""
2877
+ target = stmt.target
2878
+ if stmt.compound or not isinstance(target, (S.Name, S.Index)):
2879
+ return None
2880
+ name = target.name.upper()
2881
+ record = profile.functions.get(name)
2882
+ if not record or name in declared:
2883
+ return None
2884
+ sep = "\n" if profile.fallback_echo_newline and stmt.at_eol else ""
2885
+ if isinstance(target, S.Name):
2886
+ if _is_bare_reference_problem(target.name, profile, declared):
2887
+ return _bare_function_message(stmt.text, profile, whole_statement=True, at_eol=stmt.at_eol)
2888
+ return (
2889
+ f"Invalid assignment in expression: {stmt.text}{sep}."
2890
+ if record.get("nargs") == 0 and not record.get("argspec")
2891
+ else None
2892
+ )
2893
+ problem = _call_arity_problem(target.name, target.args, profile)
2894
+ if problem in ("few", "many", "empty"):
2895
+ return _arity_message(problem, stmt.text, profile, stmt.at_eol, whole_statement=True)
2896
+ if problem is None and None not in target.args and record.get("nargs") == len(target.args) and record.get("nargs"):
2897
+ return f"Invalid assignment in expression: {stmt.text}{sep}."
2898
+ return None
2899
+
2900
+
2901
+ def check_function_call_counts(program: S.Program, profile, diags: Diagnostics) -> None:
2902
+ """A user `Function` called with too few arguments -> `Call argument required.` then the
2903
+ fallback; with too many -> `No Matching, or out of place ) in expression: %s.`. The echo is the
2904
+ assignment, the `If` condition, or the instruction argument holding the call. Pass 1 on
2905
+ CR6/CR1000X (a later `X = Q` is not reported), all four loggers (oracle, 2026-09-28,
2906
+ `docs/checker.md` "Function argument counts")."""
2907
+ from . import structure
2908
+
2909
+ functions = {
2910
+ st.name.upper(): st for st in program.statements if isinstance(st, S.SubDef) and st.keyword == "FUNCTION"
2911
+ }
2912
+ if not functions:
2913
+ return
2914
+ known = _known_names(program, profile)
2915
+ missing = [1]
2916
+
2917
+ def problem(expr: S.Expr | None) -> str | None:
2918
+ for node in _index_nodes(expr):
2919
+ fn = functions.get(node.name.upper())
2920
+ if fn is None:
2921
+ continue
2922
+ required = sum(1 for p in fn.params if p.default is None)
2923
+ if len(node.args) < required:
2924
+ # one message per missing required argument, nested calls in the arguments add
2925
+ # theirs, a later sibling call adds none (oracle, 2026-10-12)
2926
+ missing[0] = 0
2927
+ for inner in _index_nodes(node):
2928
+ f2 = functions.get(inner.name.upper())
2929
+ if f2 is not None:
2930
+ missing[0] += max(0, sum(1 for p in f2.params if p.default is None) - len(inner.args))
2931
+ return "few"
2932
+ if len(node.args) > len(fn.params):
2933
+ return "many"
2934
+ return None
2935
+
2936
+ def report(stmt: S.Stmt, kind: str, echo: str, whole: bool) -> None:
2937
+ if kind == "few":
2938
+ for _ in range(missing[0]): # one per missing required argument (oracle, 2026-10-12)
2939
+ diags.error(stmt.line, "Call argument required.", stmt.file)
2940
+ text = (
2941
+ structure._out_of_place(profile, echo, stmt.at_eol)
2942
+ if whole
2943
+ else f"Invalid, or out of place expression: {echo}."
2944
+ )
2945
+ else:
2946
+ sep = "\n" if whole and profile.fallback_echo_newline and stmt.at_eol else ""
2947
+ text = f"No Matching, or out of place ) in expression: {echo}{sep}."
2948
+ diags.error(stmt.line, text, stmt.file)
2949
+
2950
+ def visit(stmt: S.Stmt) -> None:
2951
+ if isinstance(stmt, S.Assignment):
2952
+ kind = problem(stmt.target) or problem(stmt.value)
2953
+ if (
2954
+ kind
2955
+ and _undefined_names(stmt.target, known)
2956
+ and not (
2957
+ profile.single_pass
2958
+ and (
2959
+ isinstance(stmt.target, S.Name)
2960
+ or (isinstance(stmt.target, S.Index) and stmt.target.name.upper() in known)
2961
+ )
2962
+ )
2963
+ ):
2964
+ kind = None # an undefined target never reaches the value's call (oracle, all four
2965
+ # loggers, 2026-10-11, docs/checker.md "Function calls after an undefined target")
2966
+ if kind:
2967
+ report(stmt, kind, stmt.text, True)
2968
+ elif isinstance(stmt, S.If):
2969
+ kind = problem(stmt.cond)
2970
+ echo = _if_condition_text(stmt)
2971
+ if kind and echo is not None:
2972
+ report(stmt, kind, echo, False)
2973
+ for inner in (stmt.inline or []) + (stmt.inline_else or []):
2974
+ visit(inner)
2975
+ elif isinstance(stmt, S.Instruction):
2976
+ for i, arg in enumerate(stmt.args):
2977
+ kind = problem(arg)
2978
+ if kind:
2979
+ report(stmt, kind, _arg_text(stmt, i).strip(), False)
2980
+ break
2981
+ elif isinstance(stmt, S.Call):
2982
+ # `F1(1,2)` as a statement echoes the whole line like `X = F1(1,2)` (oracle,
2983
+ # 2026-10-02, all four loggers, `docs/checker.md` "Function argument counts").
2984
+ if not stmt.explicit and stmt.tail_expr is not None and (kind := problem(stmt.tail_expr)):
2985
+ report(stmt, kind, stmt.text, True)
2986
+ return
2987
+ fn = functions.get(stmt.name.upper())
2988
+ if fn is None and stmt.tail_expr is None and stmt.juxtaposed is None:
2989
+ # a `Sub` call: a user `Function` called wrongly inside one argument echoes that
2990
+ # argument (oracle, 2026-10-13, all four loggers)
2991
+ for i, arg in enumerate(stmt.args):
2992
+ if kind := problem(arg):
2993
+ report(stmt, kind, _call_arg_text(stmt.text, i).strip(), False)
2994
+ return
2995
+ bare = not stmt.args and not (stmt.text or "").lstrip()[len(stmt.name) :].lstrip().startswith("(")
2996
+ if not stmt.explicit and fn is not None and not bare: # a bare `Fn` (no parentheses) is accepted (oracle)
2997
+ required = sum(1 for p in fn.params if p.default is None)
2998
+ kind = "few" if len(stmt.args) < required else "many" if len(stmt.args) > len(fn.params) else None
2999
+ missing[0] = max(1, required - len(stmt.args))
3000
+ if kind:
3001
+ report(stmt, kind, stmt.text, True)
3002
+ elif (
3003
+ m := re.match(r"\s*" + re.escape(stmt.name) + r"\s*(?=\()", stmt.text or "", re.I)
3004
+ ) and structure._group_end(stmt.text, m.end()) is None:
3005
+ # a user `Function` called as a statement needs its `)` (a `Sub` call does
3006
+ # not): oracle, 2026-10-13, all four loggers
3007
+ report(stmt, "many", stmt.text, True)
3008
+ else:
3009
+ # a wrong call nested in an argument: the whole statement is echoed (oracle,
3010
+ # 2026-10-13, all four loggers)
3011
+ for arg in stmt.args:
3012
+ if kind := problem(arg):
3013
+ report(stmt, kind, stmt.text, True)
3014
+ return
3015
+
3016
+ for stmt in program.statements:
3017
+ visit(stmt)
3018
+
3019
+
3020
+ def _index_nodes(expr: S.Expr | None):
3021
+ """Every `Index` (call or subscript) inside `expr`, outermost first."""
3022
+ if expr is None:
3023
+ return
3024
+ if isinstance(expr, S.Index):
3025
+ yield expr
3026
+ for a in expr.args:
3027
+ yield from _index_nodes(a)
3028
+ elif isinstance(expr, S.Unary):
3029
+ yield from _index_nodes(expr.operand)
3030
+ elif isinstance(expr, S.Binary):
3031
+ yield from _index_nodes(expr.left)
3032
+ yield from _index_nodes(expr.right)
3033
+ elif isinstance(expr, S.Member):
3034
+ for a in expr.args or []:
3035
+ yield from _index_nodes(a)
3036
+
3037
+
3038
+ _FOR_HEADER_RE = re.compile(r"(?i)^\s*For\s(.*?)=(.*?)\sTo\s(.*?)(?:\sStep\s(.*))?$")
3039
+
3040
+
3041
+ def _check_for_headers(program: S.Program, profile, diags: Diagnostics, known) -> None:
3042
+ """Undefined names in a `For` header (oracle, 2026-10-01). CR6/CR1000X: `Invalid symbol in
3043
+ expression: %s.` for the start, end and step text (each echoed raw between its keywords, one
3044
+ separating space dropped either side: `For X = A to 2` -> ` A`), and for the loop variable on
3045
+ its matching `Next` line (`For j = 1 To 2` -> `j .`). CR300/CR350: per-name `%s is not
3046
+ defined.` on the `For` line in source order, plus `Dimensions not declared: j(1) .` for an
3047
+ undefined indexed loop variable."""
3048
+ ds = program_dims(program, profile, known)
3049
+ pending: list[tuple[S.For, str]] = []
3050
+ for stmt in program.statements:
3051
+ if (
3052
+ isinstance(stmt, S.Instruction)
3053
+ and stmt.keyword.upper() == "NEXT"
3054
+ and pending
3055
+ and not re.match(r"(?i)\s*Next\s+(Sub)?Scan\b", stmt.text or "")
3056
+ ):
3057
+ loop, var_text = pending.pop()
3058
+ if var_text:
3059
+ diags.error(stmt.line, f"Invalid symbol in expression: {var_text}.", stmt.file)
3060
+ continue
3061
+ if not isinstance(stmt, S.For):
3062
+ continue
3063
+ m = _FOR_HEADER_RE.match(stmt.text or "")
3064
+ parts = [
3065
+ (stmt.var, m.group(1) if m else None),
3066
+ (stmt.start, m.group(2) if m else None),
3067
+ (stmt.end, m.group(3) if m else None),
3068
+ (stmt.step, m.group(4) if m else None),
3069
+ ]
3070
+ whole_echo = profile.name in _WHOLE_EXPR_ECHO_LOGGERS
3071
+ var_text = ""
3072
+ for i, (expr, text) in enumerate(parts):
3073
+ names = _undefined_names(expr, known) if expr is not None else []
3074
+ if not names:
3075
+ if (
3076
+ i
3077
+ and text is not None
3078
+ and _over_indexed(expr, symbols.declared_dim_counts(program, strings=True), _dim_limit(profile))
3079
+ ):
3080
+ # `For N = 1 To B(1,1,1,1)`: the header part echoed (oracle, 2026-10-13)
3081
+ diags.error(stmt.line, dimensions_message(text, profile, False), stmt.file)
3082
+ continue
3083
+ if whole_echo:
3084
+ if text is None:
3085
+ continue
3086
+ if i == 0:
3087
+ var_text = text
3088
+ else:
3089
+ diags.error(stmt.line, f"Invalid symbol in expression: {text}.", stmt.file)
3090
+ else:
3091
+ for name in names:
3092
+ diags.error(stmt.line, profile.unresolved_symbol_message.format(name=name), stmt.file)
3093
+ if i == 0 and text is not None and _has_undefined_index(expr, known, ds, stmt):
3094
+ diags.error(stmt.line, dimensions_message(text, profile, False), stmt.file)
3095
+ pending.append((stmt, var_text))
3096
+
3097
+
3098
+ def _check_elseif_and_case(program: S.Program, profile, diags: Diagnostics, known) -> None:
3099
+ """`ElseIf` conditions and `Case` values (oracle, 2026-10-01). An `ElseIf` is reported like an
3100
+ `If`. A `Case` echoes the text after `Case ` on CR6/CR1000X, preceded by one `Case outside
3101
+ Select.` per item up to the first item whose value (or `To` lower bound) is undefined (none
3102
+ when an `Is` comparison or an upper bound fails first): `Case 1, 2, Nope` -> three.
3103
+ CR300/CR350 print each undefined name; there a `Select Case` with an undefined name opens no
3104
+ block, so its `Case`s give `Case outside Select.` and its end `EndSelect w/o Select`."""
3105
+ ds = program_dims(program, profile, known)
3106
+ whole_echo = profile.name in _WHOLE_EXPR_ECHO_LOGGERS
3107
+ orphans: list[bool] = [] # per open Select: True when CR300 did not open it
3108
+ open_ifs = 0
3109
+ for stmt in program.statements:
3110
+ kw = stmt.keyword.upper() if isinstance(stmt, S.Instruction) else ""
3111
+ if isinstance(stmt, S.Instruction) and (kw == "SELECT"):
3112
+ # (an over-indexed selector opens no block either, on all four loggers)
3113
+ orphans.append(
3114
+ bool(stmt.args)
3115
+ and (
3116
+ (not whole_echo and bool(_undefined_names(stmt.args[0], known)))
3117
+ or (
3118
+ not _undefined_names(stmt.args[0], known)
3119
+ and _over_indexed(
3120
+ stmt.args[0], symbols.declared_dim_counts(program, strings=True), _dim_limit(profile)
3121
+ )
3122
+ )
3123
+ )
3124
+ )
3125
+ continue
3126
+ if kw == "ENDSELECT" or (kw == "END" and re.match(r"(?i)\s*End\s+Select\b", stmt.text or "")):
3127
+ if orphans and orphans.pop():
3128
+ diags.error(stmt.line, "EndSelect w/o Select", stmt.file)
3129
+ continue
3130
+ if isinstance(stmt, S.If) and stmt.inline is None:
3131
+ open_ifs += 1
3132
+ elif kw == "ENDIF" or (kw == "END" and re.match(r"(?i)\s*End\s+If\b", stmt.text or "")):
3133
+ open_ifs = max(0, open_ifs - 1)
3134
+ if isinstance(stmt, S.ElseIf):
3135
+ if whole_echo and not open_ifs:
3136
+ continue # `ElseIf Nope Then` with no `If`: only `Case with no Select.`
3137
+ names = _undefined_names(stmt.cond, known)
3138
+ text = _if_condition_text(stmt)
3139
+ if (
3140
+ text is not None
3141
+ and not names
3142
+ and _over_indexed(stmt.cond, symbols.declared_dim_counts(program, strings=True), _dim_limit(profile))
3143
+ ):
3144
+ diags.error(stmt.line, dimensions_message(text, profile, False), stmt.file)
3145
+ continue
3146
+ if not names or text is None:
3147
+ continue
3148
+ if whole_echo:
3149
+ diags.error(stmt.line, f"Invalid symbol in expression: {text}.", stmt.file)
3150
+ else:
3151
+ for name in names:
3152
+ diags.error(stmt.line, profile.unresolved_symbol_message.format(name=name), stmt.file)
3153
+ if _has_undefined_index(stmt.cond, known, ds, stmt):
3154
+ diags.error(stmt.line, dimensions_message(text, profile, False), stmt.file)
3155
+ continue
3156
+ if not isinstance(stmt, S.Case) or stmt.is_else or re.match(r"(?i)\s*Case\s+Else", stmt.text or ""):
3157
+ continue # (`Case Elsewhere` is a `Case Else`, `checker/structure.py`)
3158
+ if orphans and orphans[-1]:
3159
+ diags.error(stmt.line, "Case outside Select.", stmt.file)
3160
+ names: list[str] = []
3161
+ outside = 0
3162
+ for index, item in enumerate(stmt.items, 1):
3163
+ if item[0] == "value":
3164
+ lower, upper = item[1], None
3165
+ elif item[0] == "range":
3166
+ lower, upper = item[1], item[2]
3167
+ else:
3168
+ lower, upper = None, item[2]
3169
+ low, high = _undefined_names(lower, known), _undefined_names(upper, known)
3170
+ if low and not outside and not names:
3171
+ outside = index
3172
+ names += low + high
3173
+ if not names:
3174
+ cm = re.match(r"(?i)\s*Case\s(.*)$", stmt.text or "")
3175
+ if cm:
3176
+ counts = symbols.declared_dim_counts(program, strings=True)
3177
+ if any(
3178
+ _over_indexed(e, counts, _dim_limit(profile))
3179
+ for item in stmt.items
3180
+ for e in item[1:]
3181
+ if isinstance(e, S.Expr)
3182
+ ):
3183
+ # the whole item list after `Case ` is echoed (oracle, 2026-10-13)
3184
+ diags.error(stmt.line, dimensions_message(cm.group(1), profile, False), stmt.file)
3185
+ continue
3186
+ if whole_echo:
3187
+ m = re.match(r"(?i)\s*Case\s(.*)$", stmt.text or "")
3188
+ if not m:
3189
+ continue
3190
+ for _ in range(outside):
3191
+ diags.error(stmt.line, "Case outside Select.", stmt.file)
3192
+ diags.error(stmt.line, f"Invalid symbol in expression: {m.group(1)}.", stmt.file)
3193
+ else:
3194
+ for name in names:
3195
+ diags.error(stmt.line, profile.unresolved_symbol_message.format(name=name), stmt.file)
3196
+
3197
+
3198
+ def _check_function_statements(program: S.Program, profile, diags: Diagnostics, known) -> None:
3199
+ """A built-in function written as a statement (`Timer(Nope,Sec,0)`) with an undefined name in
3200
+ its arguments (oracle, 2026-10-01): CR6/CR1000X `Invalid symbol in expression: <statement>.`
3201
+ (indentation and trailing spaces kept); CR300/CR350 each name, then `Dimensions not declared:
3202
+ <statement>` when an undefined name is indexed."""
3203
+ ds = program_dims(program, profile, known)
3204
+ first_on_line: dict[tuple, S.Stmt] = {}
3205
+ for stmt in program.statements:
3206
+ first_on_line.setdefault((stmt.line, stmt.file), stmt)
3207
+ for stmt in program.statements:
3208
+ if not isinstance(stmt, S.Call) or stmt.explicit or stmt.name.upper() not in profile.functions:
3209
+ continue
3210
+ args = stmt.args
3211
+ if stmt.name.upper() == "SERIALOPEN" and profile.name in _WHOLE_EXPR_ECHO_LOGGERS:
3212
+ from .cr3xx import bare_unknown_delay
3213
+
3214
+ if bare_unknown_delay(stmt, known):
3215
+ args = args[:3] + args[4:] # recovered by the parser (`cr3xx.check`)
3216
+ names = [n for a in args if a is not None for n in _undefined_names(a, known)]
3217
+ # (its own `Expr illegally occurs before BeginProg.` does not hide it, unless an earlier
3218
+ # statement of the line owns that placement error: `X = 1 : Timer(Nope,1,0)`)
3219
+ if not names or any(
3220
+ (d.line, d.file) == (stmt.line, stmt.file)
3221
+ and not (
3222
+ d.message == "Expr illegally occurs before BeginProg." and first_on_line[(stmt.line, stmt.file)] is stmt
3223
+ )
3224
+ for d in diags.errors
3225
+ ):
3226
+ continue # (`SerialOpen(COMC1,...)` is already reported by `cr3xx.check`)
3227
+ if profile.name in _WHOLE_EXPR_ECHO_LOGGERS:
3228
+ diags.error(stmt.line, f"Invalid symbol in expression: {stmt.text}.", stmt.file)
3229
+ continue
3230
+ names, dims = _names_and_dims(known, ds, stmt, (), *stmt.args)
3231
+ for name in names:
3232
+ diags.error(stmt.line, profile.unresolved_symbol_message.format(name=name), stmt.file)
3233
+ if dims:
3234
+ diags.error(stmt.line, dimensions_message(stmt.text, profile, True, stmt.at_eol), stmt.file)
3235
+
3236
+
3237
+ # Parameter codes whose plain array-element argument is bounds-checked, and instructions whose own
3238
+ # compile function takes that argument over (oracle, 2026-10-01: `AddPrecise(X,A(9))` compiles).
3239
+ _BOUNDS_CHECKED_CODES = frozenset({2, 8, 24, 27, 28, 29, 41})
3240
+ _BOUNDS_UNCHECKED = frozenset({"ADDPRECISE", "MOVEPRECISE"})
3241
+ # Codes 27/29 only for oracle-probed instructions (2026-10-02, docs/checker.md "Destination bounds").
3242
+ _BOUNDS_PROBED = {
3243
+ 24: frozenset({"SDMIO16", "PORTPAIRCONFIG"}), # (Float and Long, oracle 2026-10-12)
3244
+ 27: frozenset({"MOVE", "ERASE", "GETRECORD", "SPLITSTR", "MOVEBYTES", "SDI12RECORDER"}),
3245
+ 29: frozenset(
3246
+ {
3247
+ "REALTIME",
3248
+ "READIO",
3249
+ "PORTGET",
3250
+ "AVGSPA",
3251
+ "MAXSPA",
3252
+ "MINSPA",
3253
+ "RMSSPA",
3254
+ "STDDEVSPA",
3255
+ "SORTSPA",
3256
+ "SORTSPAINDEXED",
3257
+ }
3258
+ ),
3259
+ }
3260
+
3261
+
3262
+ def check_instruction_index_bounds(program: S.Program, profile, diags: Diagnostics, env: consteval.Env) -> None:
3263
+ """`Battery(A(9))` for `Public A(5)` -> `Variable A out of bounds.` (oracle, 2026-10-01): pass 2
3264
+ on CR6/CR1000X, the late stage on CR300/CR350 (only in a file with no other error). Only an
3265
+ argument that is itself an element of a Float array, indexed by number literals: index 0 or
3266
+ past its dimension (a single index on a multi-dimensional array against the first dimension).
3267
+ On CR6/CR1000X a scalar is shape `(1,)` and subscripts beyond the declared dimensions count as
3268
+ size-1 dimensions (`R(1,2)`, `X(2)`, `B(1,1,2)` are out of bounds; `R(4,1)`, `X(1,1)` compile);
3269
+ CR300/CR350 report those as `Dimensions not declared`. An expression (`A(6)+1`), `A(-1)`, a
3270
+ Long array and an assignment (`X = A(6)`, checked at run time) all compile."""
3271
+ padded = profile.name in _WHOLE_EXPR_ECHO_LOGGERS
3272
+ tables = symbols.scope_tables(program, env, scalars=padded)
3273
+ for stmt in program.statements:
3274
+ if (
3275
+ isinstance(stmt, S.Instruction)
3276
+ and stmt.keyword.upper() == "WHILE"
3277
+ and len(stmt.args) == 1
3278
+ and isinstance(stmt.args[0], S.Index)
3279
+ ):
3280
+ candidates = [(2, stmt.args[0])]
3281
+ elif isinstance(stmt, (S.If, S.ElseIf, S.DoLoop)) and isinstance(stmt.cond, S.Index):
3282
+ # a condition that is one element (`If A(3) Then`, `While A(0)`, `ElseIf A(5) Then`):
3283
+ # checked like a code-2 argument (oracle, all four loggers, 2026-10-12; `If A(3) > 1`
3284
+ # and `Y = A(3)` compile)
3285
+ candidates = [(2, stmt.cond)]
3286
+ elif (
3287
+ isinstance(stmt, S.Instruction)
3288
+ and stmt.keyword.upper() not in _BOUNDS_UNCHECKED
3289
+ and (record := profile.instructions.get(stmt.keyword.upper()))
3290
+ ):
3291
+ candidates = [
3292
+ (record["params"][i] if i < len(record["params"]) else None, arg)
3293
+ for i, arg in enumerate(stmt.args)
3294
+ # (`FieldCal` options 0 and 4 never resolve `KnownVar`, oracle 2026-10-12:
3295
+ # `Meter(3)` of `Meter(2)` compiles)
3296
+ if not (
3297
+ stmt.keyword.upper() == "FIELDCAL"
3298
+ and i == 6
3299
+ and stmt.args
3300
+ and isinstance(stmt.args[0], S.Number)
3301
+ and stmt.args[0].value in (0, 4)
3302
+ )
3303
+ ]
3304
+ else:
3305
+ continue
3306
+ scope = tables(stmt)
3307
+ dims, types, arrays = scope.dims, scope.types, scope.arrays
3308
+ for code, arg in candidates:
3309
+ if code not in _BOUNDS_CHECKED_CODES or not isinstance(arg, S.Index) or not arg.args:
3310
+ continue
3311
+ if (
3312
+ code in _BOUNDS_PROBED
3313
+ and isinstance(stmt, S.Instruction)
3314
+ and stmt.keyword.upper() not in _BOUNDS_PROBED[code]
3315
+ ):
3316
+ continue
3317
+ name = arg.name.upper()
3318
+ shape = dims.get(name)
3319
+ # Code 27 (Move/Erase/GetRecord/SplitStr destinations) also checks Long and String
3320
+ # arrays (oracle, 2026-10-02); the other codes are only probed for Float.
3321
+ if (
3322
+ not shape
3323
+ or (
3324
+ types.get(name) != "FLOAT"
3325
+ and not (code == 27 and name in arrays)
3326
+ and not (code in (24, 29) and types.get(name) == "LONG")
3327
+ )
3328
+ or not all(isinstance(a, S.Number) and isinstance(a.value, int) for a in arg.args)
3329
+ ):
3330
+ continue
3331
+ index = [cast(S.Number, a).value for a in arg.args]
3332
+ if len(index) == 1 and len(shape) > 1:
3333
+ # against the first dimension, not the element count (oracle, all four loggers,
3334
+ # round 38: `Battery(M(4))` of `M(3,4)` is out of bounds)
3335
+ bad = not 1 <= index[0] <= shape[0]
3336
+ elif len(index) == len(shape) or (padded and len(index) > len(shape)):
3337
+ shape = shape + (1,) * (len(index) - len(shape))
3338
+ bad = any(not 1 <= k <= n for k, n in zip(index, shape))
3339
+ else:
3340
+ continue
3341
+ if bad:
3342
+ diags.error(stmt.line, f"Variable {arg.name} out of bounds.", stmt.file)
3343
+
3344
+
3345
+ def check_splitstr_element_reps(program: S.Program, profile, diags: Diagnostics, env) -> None:
3346
+ """`SplitStr(F(i),...,Reps,...)` into an element of a one-dimensional non-String array:
3347
+ `Variable F out of bounds.` when `i + Reps - 1` exceeds its size, whatever the option (oracle,
3348
+ all four loggers, 2026-10-12: `Public F(3)` with `F(1)`/Reps 6 or `F(2)`/Reps 3, Long arrays too,
3349
+ and `G((1+2)) As String * 10`, a parenthesised dimension making a Float array; a String array
3350
+ `T(3)` compiles with Reps 6)."""
3351
+ strings = {
3352
+ v.name.upper()
3353
+ for st in program.statements
3354
+ if isinstance(st, S.Declaration)
3355
+ for v in st.vars
3356
+ if (v.type or "").upper() == "STRING" and not v.paren_dim
3357
+ }
3358
+ tables = symbols.scope_tables(program, env)
3359
+ for stmt in program.statements:
3360
+ if not isinstance(stmt, S.Instruction) or stmt.keyword.upper() != "SPLITSTR" or len(stmt.args) < 4:
3361
+ continue
3362
+ dest = stmt.args[0]
3363
+ if (
3364
+ not isinstance(dest, S.Index)
3365
+ or len(dest.args) != 1
3366
+ or not isinstance(dest.args[0], S.Number)
3367
+ or not isinstance(dest.args[0].value, int)
3368
+ ):
3369
+ continue
3370
+ name = dest.name.upper()
3371
+ shape = tables(stmt).dims.get(name)
3372
+ if not shape or len(shape) != 1 or name in strings or name not in tables(stmt).arrays:
3373
+ continue
3374
+ try:
3375
+ reps = consteval.value_of(stmt.args[3], env)
3376
+ except consteval.NotConstant, consteval.DivideByZero:
3377
+ continue
3378
+ if isinstance(reps, bool) or not isinstance(reps, (int, float)) or reps != int(reps) or reps < 1:
3379
+ continue
3380
+ if dest.args[0].value + int(reps) - 1 > shape[0]:
3381
+ diags.error(stmt.line, f"Variable {dest.name} out of bounds.", stmt.file)
3382
+
3383
+
3384
+ def _sdmsio4_count(stmt: S.Instruction, env) -> int | None:
3385
+ """`SDMSIO4`'s value count (eighth argument), when constant."""
3386
+ if len(stmt.args) < 8 or stmt.args[7] is None:
3387
+ return None
3388
+ try:
3389
+ n = consteval.value_of(stmt.args[7], env)
3390
+ except consteval.NotConstant, consteval.DivideByZero:
3391
+ return None
3392
+ return int(n) if isinstance(n, (int, float)) and not isinstance(n, bool) and n == int(n) else None
3393
+
3394
+
3395
+ def check_sdmsio4_count(program: S.Program, profile, diags: Diagnostics, env) -> None:
3396
+ """`SDMSIO4(D(i),...,N,...)` stores N values from `D(i)`: `Variable D out of bounds.` when
3397
+ `i + N - 1` exceeds a one-dimensional `D` (a scalar holds one); oracle, CR6/CR1000X,
3398
+ 2026-10-24: `D(4)` takes 4 from `D(1)` and 3 from `D(2)`, not 5 or 4; `X` takes 1, not 2."""
3399
+ tables = symbols.scope_tables(program, env, scalars=True)
3400
+ for stmt in program.statements:
3401
+ if not isinstance(stmt, S.Instruction) or stmt.keyword.upper() != "SDMSIO4" or not stmt.args:
3402
+ continue
3403
+ dest, n = stmt.args[0], _sdmsio4_count(stmt, env)
3404
+ if not isinstance(dest, (S.Name, S.Index)) or not n or n < 1:
3405
+ continue
3406
+ shape = tables(stmt).dims.get(dest.name.upper())
3407
+ if not shape or len(shape) != 1:
3408
+ continue
3409
+ start = 1
3410
+ if isinstance(dest, S.Index):
3411
+ if len(dest.args) != 1 or not isinstance(dest.args[0], S.Number) or not isinstance(dest.args[0].value, int):
3412
+ continue
3413
+ start = dest.args[0].value
3414
+ if start + n - 1 > shape[0]:
3415
+ diags.error(stmt.line, f"Variable {dest.name} out of bounds.", stmt.file)
3416
+
3417
+
3418
+ def check_timedcontrol_constants(program: S.Program, profile, diags: Diagnostics) -> None:
3419
+ """CR6/CR1000X `TimedControl` Size and Interval (positions 0 and 1) must be constants: a
3420
+ variable or an undeclared name gives `Size must be a constant.` / `Interval must be a
3421
+ constant.`, both when both are bad (oracle, 2026-10-24, round 34)."""
3422
+ if profile.name not in _WHOLE_EXPR_ECHO_LOGGERS:
3423
+ return
3424
+ consts = {str(n).upper() for n in program.consts} | set(profile.symbols)
3425
+ for stmt in program.statements:
3426
+ if not isinstance(stmt, S.Instruction) or stmt.keyword.upper() != "TIMEDCONTROL":
3427
+ continue
3428
+ for i, what in ((0, "Size"), (1, "Interval")):
3429
+ if i < len(stmt.args) and stmt.args[i] is not None and _names_in(stmt.args[i]) - consts:
3430
+ diags.error(stmt.line, f"{what} must be a constant.", stmt.file)
3431
+
3432
+
3433
+ def _names_in(expr) -> set[str]:
3434
+ if isinstance(expr, S.Name):
3435
+ return {expr.name.upper()}
3436
+ if isinstance(expr, S.Index):
3437
+ return {expr.name.upper()}.union(*(_names_in(a) for a in expr.args if a is not None))
3438
+ if isinstance(expr, S.Unary):
3439
+ return _names_in(expr.operand)
3440
+ if isinstance(expr, S.Binary):
3441
+ return _names_in(expr.left) | _names_in(expr.right)
3442
+ if isinstance(expr, S.Member):
3443
+ return _names_in(expr.base)
3444
+ return set()
3445
+
3446
+
3447
+ _CALIBRATE_MIN = {"cr6": 6, "cr1000x": 6, "cr300": 51, "cr350": 51}
3448
+
3449
+
3450
+ def check_calibrate_size(program: S.Program, profile, diags: Diagnostics, env) -> None:
3451
+ """`Calibrate(Dest)` needs at least 6 Float elements from `Dest` to the end of its array on
3452
+ CR6/CR1000X, 51 on CR300/CR350 (whole array, `Dest()` or `Dest(i)`), else `Variable Dest out of
3453
+ bounds.` (oracle, 2026-10-12: sizes 1..5 fail and 6..100 compile on CR6; 1..50 fail and 51..100
3454
+ compile on CR300/CR350; `Public K()` is one element)."""
3455
+ need = _CALIBRATE_MIN.get(profile.name)
3456
+ if need is None:
3457
+ return
3458
+ tables = symbols.scope_tables(program, env, scalars=True)
3459
+ for stmt in program.statements:
3460
+ if not isinstance(stmt, S.Instruction) or stmt.keyword.upper() != "CALIBRATE" or not stmt.args:
3461
+ continue
3462
+ dest = stmt.args[0]
3463
+ if not isinstance(dest, (S.Name, S.Index)):
3464
+ continue
3465
+ shape = tables(stmt).dims.get(dest.name.upper())
3466
+ if not shape or len(shape) != 1:
3467
+ continue
3468
+ start = 1
3469
+ if isinstance(dest, S.Index) and dest.args:
3470
+ if len(dest.args) != 1 or not isinstance(dest.args[0], S.Number) or not isinstance(dest.args[0].value, int):
3471
+ continue
3472
+ start = dest.args[0].value
3473
+ if shape[0] - start + 1 < need:
3474
+ diags.error(stmt.line, f"Variable {dest.name} out of bounds.", stmt.file)
3475
+
3476
+
3477
+ def check_splitstr_whole_array(program: S.Program, profile, diags: Diagnostics, env) -> None:
3478
+ """`SplitStr(A(),...,Reps,...)` with `A` an array and no subscript: `Variable A out of bounds.`
3479
+ when `Reps` exceeds the element count (oracle, 2026-10-11, all four loggers: `Public S(5)` with
3480
+ Reps 6 or 20, `Public M(2,3)` with 7, `Public S((2+3))` with 6 are out of bounds; Reps 5 and 6
3481
+ for `M(2,3)` compile; Reps 0, 1 and -1 compile). Float, Long and String arrays alike. A
3482
+ subscripted destination (`SplitStr(S(1),...,6,7)` compiles) is not this rule. Run after
3483
+ `instr_rules.check_option_values`: `SplitStr(S(),...,6,-1)` prints `Invalid option -1` first."""
3484
+ tables = symbols.scope_tables(program, env)
3485
+ for stmt in program.statements:
3486
+ if not isinstance(stmt, S.Instruction) or stmt.keyword.upper() != "SPLITSTR":
3487
+ continue
3488
+ dims, arrays = tables(stmt).dims, tables(stmt).arrays
3489
+ if not stmt.args or not isinstance(stmt.args[0], S.Index) or stmt.args[0].args or len(stmt.args) < 4:
3490
+ continue
3491
+ dest = stmt.args[0]
3492
+ name = dest.name.upper()
3493
+ shape = dims.get(name)
3494
+ if not shape or name not in arrays:
3495
+ continue
3496
+ try:
3497
+ reps = consteval.value_of(stmt.args[3], env)
3498
+ except consteval.DivideByZero as exc:
3499
+ # oracle, 2026-10-12: with integer operands CR6/CR1000X name the Reps expression (`1/0`,
3500
+ # `1 / 0`, `(1/0)`, `1/0+1`, `-1/0`, `0/0`, `K/(K-3)`); every other case is a plus
3501
+ # infinity (out of bounds, all four loggers: CR6/CR1000X `2.5/0`, `1/0.0`; CR300/CR350
3502
+ # also `1/0`, `5/0`) when the Reps is itself `positive/0`, and `0/0`, `-1/0` compile on
3503
+ # CR300/CR350. `1/0+1` on CR300/CR350 is out of bounds too (not modelled).
3504
+ text = _arg_text(stmt, 3).strip()
3505
+ if "." not in text and not profile.single_pass:
3506
+ diags.error(stmt.line, f"Divide by zero in expression: {text}.", stmt.file)
3507
+ elif (
3508
+ isinstance(stmt.args[3], S.Binary)
3509
+ and stmt.args[3].op == "/"
3510
+ and isinstance(exc.numerator, (int, float))
3511
+ and exc.numerator > 0
3512
+ ):
3513
+ diags.error(stmt.line, f"Variable {dest.name} out of bounds.", stmt.file)
3514
+ continue
3515
+ except consteval.NotConstant:
3516
+ continue
3517
+ if not isinstance(reps, int) or isinstance(reps, bool):
3518
+ continue
3519
+ total = 1
3520
+ for n in shape:
3521
+ total *= n
3522
+ if reps > total:
3523
+ diags.error(stmt.line, f"Variable {dest.name} out of bounds.", stmt.file)
3524
+
3525
+
3526
+ def check_subcall_element_bounds(program: S.Program, profile, diags: Diagnostics, env) -> None:
3527
+ """`Sub` call with an array element literal argument out of its declared range: `S1(P(10))` for
3528
+ `Public P(9)` -> `Variable P out of bounds.` (oracle, 2026-10-11, all four loggers; `Call S1(P(10))`
3529
+ and `S1 (P(0))` too, `S1(R(4))` for `Public R(3)`, `S1(S(6))` for `Public S(5) As String * 20`,
3530
+ `S1(F9(10))` for `Public F9(9)`). Valid: 1..n per dimension. Checked for a single-subscript or
3531
+ per-dimension subscript on a 1-D or multi-dimensional array with number-literal subscripts only;
3532
+ a negative literal (`P(-1)`), an expression (`P(10)+1`, `P(I)`), a single subscript on a
3533
+ multi-dimensional array (`M(6)` on `M(2,3)` is out of bounds on the real compiler but not
3534
+ modelled) and `P(1,1)` on a 1-D array (`Dimensions not declared` on CR300/CR350) are not this rule.
3535
+ Functions are not checked (`X = FN(P(10))`)."""
3536
+ subs = symbols.declared_subs(program)
3537
+ tables = symbols.scope_tables(program, env)
3538
+ for stmt in program.statements:
3539
+ if not isinstance(stmt, S.Call) or stmt.name.upper() not in subs:
3540
+ continue
3541
+ dims = tables(stmt).dims
3542
+ for arg in stmt.args:
3543
+ if not isinstance(arg, S.Index) or not arg.args:
3544
+ continue
3545
+ shape = dims.get(arg.name.upper())
3546
+ index = [a.value for a in arg.args if isinstance(a, S.Number) and not isinstance(a.value, float)]
3547
+ if not shape or len(index) != len(arg.args) or len(index) != len(shape) or any(k < 0 for k in index):
3548
+ continue
3549
+ if any(not 1 <= k <= n for k, n in zip(index, shape)):
3550
+ diags.error(stmt.line, f"Variable {arg.name} out of bounds.", stmt.file)
3551
+
3552
+
3553
+ def check_recursive_functions(program: S.Program, profile, diags: Diagnostics) -> None:
3554
+ """A `Function` called inside its own body (to its `EndFunction`, or the end of the file when
3555
+ there is none) -> `Cannot recursively call functions.`, pass 1, all four loggers (oracle,
3556
+ 2026-10-01: `Return F(a)`, `X = F(a) + 1`, `If F(a) > 1 Then`; a `Sub` calling itself and
3557
+ `F = 1` compile)."""
3558
+ current: str | None = None
3559
+ for stmt in program.statements:
3560
+ if isinstance(stmt, S.SubDef):
3561
+ current = stmt.name.upper() if stmt.keyword == "FUNCTION" else None
3562
+ continue
3563
+ if isinstance(stmt, S.Instruction) and (
3564
+ stmt.keyword.upper() in ("ENDFUNCTION", "ENDSUB")
3565
+ or re.match(r"(?i)\s*End\s+(Function|Sub)\b", stmt.text or "")
3566
+ ):
3567
+ current = None
3568
+ continue
3569
+ if current is None:
3570
+ continue
3571
+ exprs: list[S.Expr | None] = []
3572
+ if isinstance(stmt, S.Assignment):
3573
+ exprs.append(stmt.value)
3574
+ elif isinstance(stmt, (S.If, S.ElseIf, S.DoLoop)):
3575
+ exprs.append(stmt.cond)
3576
+ elif isinstance(stmt, (S.Instruction, S.Call)):
3577
+ exprs += list(stmt.args)
3578
+ elif isinstance(stmt, S.For):
3579
+ exprs += [stmt.start, stmt.end, stmt.step]
3580
+ if any(node.name.upper() == current for e in exprs for node in _index_nodes(e)):
3581
+ diags.error(stmt.line, "Cannot recursively call functions.", stmt.file)