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,3118 @@
1
+ """CR300/CR350-specific checks that do not generalise to CR6/CR1000X: message variants and
2
+ validations these two loggers' own smaller instruction/function set needs. Owned separately from
3
+ the shared `checker/*` modules (`PLAN.md`); `check` below is a no-op on every other logger, so
4
+ `checker/__init__.py`'s own call site can stay a single unconditional line. Rules and oracle
5
+ evidence: `docs/loggers.md`.
6
+ """
7
+
8
+ import collections
9
+ import dataclasses
10
+ import re
11
+ from collections.abc import Set as AbstractSet
12
+ from typing import TypedDict
13
+
14
+ from .. import bindings, consteval, lexer
15
+ from .. import syntax as S
16
+ from ..diagnostics import Diagnostic, Diagnostics
17
+ from . import expressions, instr_rules, structure, symbols
18
+
19
+ # `SerialOpen`'s own `ComPort` argument (position 0, the only position checked here) is the one
20
+ # `Serial*` function on CR300/CR350 whose compile-time validator additionally requires a genuine
21
+ # compile-time constant, reporting `ComPort 0 illegal.` then `Parameter must be a constant
22
+ # integer: %s.` when it is not -- oracle-confirmed (`docs/loggers.md`): an undefined name there
23
+ # gets the ordinary per-name `%s is not defined.` line(s) first (the same shape every other
24
+ # unresolved-name position on these two loggers already gets), **plus** these two extra lines,
25
+ # which no other `Serial*` function shares (`SerialClose`/`SerialFlush`/`SerialOut`/`SerialIn`/...
26
+ # report only the plain per-name undefined-name line(s) for the same argument shape, nothing
27
+ # else -- oracle-confirmed each individually). A live, declared *variable* in this position also
28
+ # triggers the same two extra lines with no undefined-name line (it is not itself undefined) --
29
+ # "not a compile-time constant" is the real trigger, not "undefined" specifically (oracle:
30
+ # `SerialOpen(MyPort,...)`, `MyPort` a declared `Public`, still gets `ComPort 0 illegal.`/
31
+ # `Parameter must be a constant integer: ...`). Deliberately **not** modelled here: a genuine
32
+ # constant that is merely out of range (oracle: `SerialOpen(99,...)` gives `ComPort 99 illegal.`
33
+ # plus a *different* second line, `No Matching, or out of place ) in expression: ...`, not this
34
+ # one) -- the valid `ComPort` value set for CR300/CR350's own smaller hardware was not oracle-
35
+ # pinned down this round, and guessing it risks flagging a value this project cannot confirm is
36
+ # actually invalid (AGENTS.md: never guess into a false reject). Only the unambiguous "not a
37
+ # constant at all" trigger (an undefined name or a live variable, never a resolvable value) is
38
+ # implemented, which never risks flagging a value the real compiler would accept.
39
+ _SERIALOPEN = "SERIALOPEN"
40
+ _SERIALOPEN_PORTS = {
41
+ "cr6": frozenset(range(1, 19)) | frozenset(range(31, 100)),
42
+ "cr1000x": frozenset(range(1, 15)) | frozenset(range(31, 100)),
43
+ }
44
+ _COMPORT_ARG_POSITION = 0
45
+ # Every `Serial*` function whose first argument is a `ComPort` (`data/<logger>/functions.json`:
46
+ # same `argtypes[0]` shape as `SerialOpen`'s own position 0) -- oracle-confirmed for `SerialOpen`/
47
+ # `SerialClose`/`SerialFlush`/`SerialOut` individually; `SerialIn`/`SerialInBlock`/`SerialInChk`/
48
+ # `SerialBrk` added on the same documented convention (a `Serial*` function's own first argument
49
+ # is always its `ComPort`, per every CHM `Syntax` line for this family), not each individually
50
+ # oracle-probed -- safe either way, since an inclusion here only ever *adds* an already-correct-
51
+ # for-`SerialOpen`-style undefined-name diagnostic (never the `SerialOpen`-only ComPort/constant
52
+ # pair, gated separately below), so a wrong inclusion costs nothing worse than under-reporting.
53
+ _SERIAL_FUNCTIONS_WITH_COMPORT_ARG = frozenset(
54
+ {
55
+ "SERIALOPEN",
56
+ "SERIALCLOSE",
57
+ "SERIALFLUSH",
58
+ "SERIALOUT",
59
+ "SERIALIN",
60
+ "SERIALINBLOCK",
61
+ "SERIALINCHK",
62
+ "SERIALBRK",
63
+ }
64
+ )
65
+
66
+
67
+ # Baud rates `SerialOpen` accepts on `ComRS232` (oracle, 2026-10-01; CR6 from the decompiled
68
+ # `FUN_004735b0`, which compares the absolute value: 300 only on ports above ComRS232, 230400 and
69
+ # 460800 only on it). CR300/CR350 also reject 300, 600 and any negative rate.
70
+ _BAUDS = frozenset({0, 600, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 76800, 115200, 230400, 460800})
71
+
72
+
73
+ # CR6/CR1000X `SerialOpen` CommsMode values per control port (oracle, 2026-10-24, round 34, 0..20
74
+ # and -1 swept): ComC1/ComC5 take 0..7, ComC3/ComC7 0..4 and 6, else an error; the RS232, ME, U
75
+ # and SDC ports warn for anything but 1 and 2. The port is the predefined constant's value.
76
+ _COMMS_MODES = {
77
+ "COMC1": frozenset(range(8)),
78
+ "COMC5": frozenset(range(8)),
79
+ "COMC3": frozenset({0, 1, 2, 3, 4, 6}),
80
+ "COMC7": frozenset({0, 1, 2, 3, 4, 6}),
81
+ }
82
+
83
+
84
+ def _commsmode_problem(
85
+ stmt: S.Call, profile, consts: dict | None, index: int = 5, mode: object = None
86
+ ) -> tuple[bool, str] | None:
87
+ """`index` is the CommsMode argument, `mode` a value to use instead of it."""
88
+ if len(stmt.args) <= index or stmt.args[index] is None:
89
+ return None
90
+ env = consteval.Env(profile, dict(consts or {}))
91
+ try:
92
+ port = consteval.value_of(stmt.args[0], env)
93
+ if mode is None:
94
+ mode = consteval.value_of(stmt.args[index], env)
95
+ except consteval.NotConstant, consteval.DivideByZero, TypeError, ValueError:
96
+ return None
97
+ if isinstance(mode, bool) or not isinstance(mode, (int, float)) or mode != int(mode):
98
+ return None
99
+ for key, modes in _COMMS_MODES.items():
100
+ record = profile.symbols.get(key)
101
+ if record and record["value"] == port:
102
+ return (
103
+ (True, f"CommsMode Option {int(mode)} not supported on {record['name']}.")
104
+ if int(mode) not in modes
105
+ else None
106
+ )
107
+ if int(mode) in (1, 2):
108
+ return None
109
+ for key, record in profile.symbols.items():
110
+ if record["value"] == port and (key == "COMRS232" or key == "COMME" or re.fullmatch(r"COM(U|SDC)\d+", key)):
111
+ # a warning only (`COM232`, `COMME` as printed)
112
+ shown = {"COMRS232": "COM232", "COMME": "COMME"}.get(key, record["name"])
113
+ return False, f"CommsMode Option {int(mode)} not supported on {shown}."
114
+ return None
115
+
116
+
117
+ def bare_unknown_delay(stmt: S.Call, known: set[str]) -> bool:
118
+ """CR6/CR1000X `SerialOpen` whose 4th argument is a bare undeclared name and that has a 5th."""
119
+ return len(stmt.args) >= 5 and isinstance(stmt.args[3], S.Name) and stmt.args[3].name.upper() not in known
120
+
121
+
122
+ def _format_needs_com1_4(stmt: S.Call, profile, consts: dict | None) -> bool:
123
+ """CR6/CR1000X: an integer `SerialOpenFormat` of 16 or more on a port numbered 1..8 (ComRS232,
124
+ ComME, the SDC ports; oracle, round 58, docs/checker.md#serialopen-format-options-round-58)."""
125
+ env = consteval.Env(profile, dict(consts or {}))
126
+ try:
127
+ port, fmt = consteval.value_of(stmt.args[0], env), consteval.value_of(stmt.args[2], env)
128
+ except consteval.NotConstant, consteval.DivideByZero, TypeError, ValueError:
129
+ return False
130
+ if not isinstance(port, int) or not isinstance(fmt, int) or isinstance(port, bool) or isinstance(fmt, bool):
131
+ return False
132
+ return 1 <= port <= 8 and 16 <= fmt < 2**31
133
+
134
+
135
+ def _non_numeric(expr, profile, consts: dict | None, variable_names) -> bool:
136
+ if expressions._variable_refs(expr, variable_names):
137
+ return True
138
+ try:
139
+ value = consteval.value_of(expr, consteval.Env(profile, dict(consts or {})))
140
+ except consteval.NotConstant, consteval.DivideByZero, TypeError, ValueError:
141
+ return False
142
+ # no constant integer: a string, a float literal, or beyond 32 bits (round 58)
143
+ return isinstance(value, str) or expressions._not_constant_integer(expr)
144
+
145
+
146
+ def _check_baud(stmt: S.Call, profile, diags: Diagnostics, consts: dict | None = None) -> None:
147
+ port = stmt.args[0] if stmt.args else None
148
+ rs232 = isinstance(port, S.Name) and port.name.upper() == "COMRS232"
149
+ # CR6/CR1000X check every valid port (oracle, 2026-10-25, round 35: ComUSB, ComC1, ComSDC7,
150
+ # ComME, 32); an unknown port name is `ComPort 0 illegal.` only
151
+ known_port = isinstance(port, S.Number) or (
152
+ isinstance(port, S.Name)
153
+ and (port.name.upper() in profile.symbols or port.name.upper() in {str(c).upper() for c in consts or {}})
154
+ )
155
+ if len(stmt.args) < 2 or not (rs232 or (not profile.single_pass and known_port)):
156
+ return
157
+ arg = stmt.args[1]
158
+ sign = 1
159
+ if isinstance(arg, S.Unary) and arg.op == "-":
160
+ sign, arg = -1, arg.operand
161
+ if not (isinstance(arg, S.Number) and isinstance(arg.value, int)):
162
+ # a constant expression is evaluated (oracle, all four loggers, 2026-10-10: `Hr` -> `4 is
163
+ # illegal baud rate.`, `Hr+1` -> 5, `True` -> -1, `uSec` -> 0 is fine; a string is accepted)
164
+ try:
165
+ value = consteval.value_of(stmt.args[1], consteval.Env(profile, dict(consts or {})))
166
+ except consteval.NotConstant, TypeError, ValueError, ZeroDivisionError:
167
+ return
168
+ if isinstance(arg, S.Number) or isinstance(value, str) or value != int(value):
169
+ return
170
+ arg, sign = S.Number(stmt.line, stmt.file, abs(int(value)), str(abs(int(value)))), (-1 if value < 0 else 1)
171
+ baud = sign * arg.value
172
+ if not -(2**31) <= baud < 2**31:
173
+ return # `Parameter must be integral.` instead (`checker/intervals.py`)
174
+ if profile.name in ("cr300", "cr350"):
175
+ ok = baud in _BAUDS - {600}
176
+ else:
177
+ ok = abs(baud) in _BAUDS
178
+ if not ok:
179
+ diags.error(stmt.line, f"{baud} is illegal baud rate.", stmt.file)
180
+
181
+
182
+ def check_dropped_tail(program: S.Program, profile, diags: Diagnostics) -> None:
183
+ """CR300/CR350: the second, side-by-side expression of an unclosed `Abs`-like call (`Y = Abs(X
184
+ Q`, `If Abs(X Q Then`, `Abs(X Then`) is name-resolved, unlike on CR6/CR1000X: `Q is not defined.`,
185
+ `Then is not defined.` (oracle, 2026-10-12, `docs/checker.md` "Round 15"). The parser drops it
186
+ from the statement and keeps the tokens in `dropped_tail`; a call (`Q(1)`) or a dotted name in
187
+ it is left alone (those shapes have their own messages)."""
188
+ if profile.name not in ("cr300", "cr350"):
189
+ return
190
+ known = None
191
+ for stmt in program.statements:
192
+ tail = getattr(stmt, "dropped_tail", None)
193
+ if not tail or any(t.kind == "OP" and t.text in ("(", ".", ",") for t in tail):
194
+ continue
195
+ if known is None:
196
+ known = expressions._known_names(program, profile)
197
+ for t in tail:
198
+ if t.kind == "IDENT" and t.upper not in known:
199
+ diags.error(stmt.line, profile.unresolved_symbol_message.format(name=t.text), stmt.file)
200
+
201
+
202
+ def check(program: S.Program, profile, diags: Diagnostics) -> None:
203
+ """`SerialOpen`'s `ComPort`: a string or variable on all four loggers; on CR300/CR350 also an
204
+ undefined name, and the other `Serial*` functions' undefined first argument. Oracle-confirmed
205
+ **ungated** (fires
206
+ alongside unrelated pass-1/pass-2 errors elsewhere in the same file, `docs/loggers.md`:
207
+ `acceptdatarecords_2.CR6`'s own `SerialOpen(COMC1,...)` diagnostic prints together with a
208
+ separate, later `CallTable`-shaped issue in the same file) -- called unconditionally,
209
+ alongside `expressions.check_consts`.
210
+ """
211
+ cr3 = profile.name in ("cr300", "cr350")
212
+ known = expressions._known_names(program, profile)
213
+ variable_names = symbols.declared_variable_names(program)
214
+ for stmt in program.statements:
215
+ if not isinstance(stmt, S.Call):
216
+ continue
217
+ name = stmt.name.upper()
218
+ if name not in _SERIAL_FUNCTIONS_WITH_COMPORT_ARG:
219
+ continue
220
+ if name == _SERIALOPEN and not stmt.explicit and not stmt.args:
221
+ diags.error(
222
+ stmt.line, "ComPort 0 illegal.", stmt.file
223
+ ) # bare `SerialOpen[()]`, all four loggers (oracle, 2026-10-08)
224
+ if _COMPORT_ARG_POSITION >= len(stmt.args) or stmt.args[_COMPORT_ARG_POSITION] is None:
225
+ continue
226
+ arg = stmt.args[_COMPORT_ARG_POSITION]
227
+ names = expressions._undefined_names(arg, known)
228
+ for undefined_name in names if cr3 else []:
229
+ diags.error(stmt.line, profile.unresolved_symbol_message.format(name=undefined_name), stmt.file)
230
+ if name != _SERIALOPEN:
231
+ continue
232
+ is_variable = bool(expressions._variable_refs(arg, variable_names))
233
+ ports = _SERIALOPEN_PORTS.get(profile.name)
234
+ if ports and not (names or is_variable or isinstance(arg, S.String)):
235
+ # a constant ComPort outside the port codes ends the statement (oracle, round 36/37,
236
+ # docs/checker.md#serialopen-comport-codes-round-37)
237
+ try:
238
+ port = consteval.value_of(arg, consteval.Env(profile, dict(program.consts or {})))
239
+ except consteval.NotConstant, TypeError, ValueError, ZeroDivisionError:
240
+ port = None
241
+ if consteval.is_finite_number(port) and not isinstance(port, bool):
242
+ if port != int(port) or not -(2**31) <= port < 2**31:
243
+ diags.error(stmt.line, "ComPort 0 illegal.", stmt.file)
244
+ diags.error(stmt.line, f"Parameter must be a constant integer: {stmt.text}.", stmt.file)
245
+ continue
246
+ if int(port) not in ports:
247
+ diags.error(stmt.line, f"ComPort {int(port)} illegal.", stmt.file)
248
+ diags.error(stmt.line, f"No Matching, or out of place ) in expression: {stmt.text}.", stmt.file)
249
+ continue
250
+ _check_baud(stmt, profile, diags, program.consts)
251
+ constant_port = not (names or is_variable or isinstance(arg, S.String))
252
+ # CR6/CR1000X: a String or variable `SerialOpenFormat` makes the 5th argument the CommsMode, with
253
+ # no TXDelay range and at most 5 arguments (oracle, 2026-10-28, round 57,
254
+ # docs/checker.md#serialopen-format-and-commsmode-round-57)
255
+ shifted = (
256
+ not cr3
257
+ and constant_port
258
+ and len(stmt.args) > 2
259
+ and stmt.args[2] is not None
260
+ and _non_numeric(stmt.args[2], profile, program.consts, variable_names)
261
+ )
262
+ # a bare undeclared TXDelay is lost in the same comma recovery (decompiled `FUN_00494aa0`,
263
+ # oracle round 58, docs/checker.md#serialopen-recovery-round-58)
264
+ shifted = shifted or (not cr3 and constant_port and bare_unknown_delay(stmt, known))
265
+ if (
266
+ not cr3
267
+ and constant_port
268
+ and not shifted
269
+ and stmt.args[2:3]
270
+ and stmt.args[2] is not None
271
+ and _format_needs_com1_4(stmt, profile, program.consts)
272
+ ):
273
+ diags.error(stmt.line, "Format option only available on COM1..COM4", stmt.file)
274
+ if constant_port and not shifted and len(stmt.args) > 3 and stmt.args[3] is not None:
275
+ # `TXDelay` (oracle, all four loggers, 2026-10-12): a constant outside 0..14000000 is
276
+ # `%d usec delay before transmit is illegal, must be 0..14000000`; a variable is
277
+ # `Parameter must be a constant integer: <statement>.`
278
+ delay = stmt.args[3]
279
+ if expressions._variable_refs(delay, variable_names):
280
+ sep = "\n" if cr3 and stmt.at_eol else ""
281
+ diags.error(stmt.line, f"Parameter must be a constant integer: {stmt.text}{sep}.", stmt.file)
282
+ continue
283
+ try:
284
+ value = consteval.value_of(delay, consteval.Env(profile, dict(program.consts or {})))
285
+ except consteval.NotConstant, TypeError, ValueError, ZeroDivisionError:
286
+ value = None
287
+ if (
288
+ consteval.is_finite_number(value)
289
+ and not isinstance(value, bool)
290
+ and value == int(value)
291
+ and -(2**31) <= value < 2**31
292
+ and not 0 <= value <= 14000000
293
+ ):
294
+ # (beyond 32 bits: `Parameter must be a constant integer` only, `intervals.py`)
295
+ diags.error(
296
+ stmt.line, f"{int(value)} usec delay before transmit is illegal, must be 0..14000000", stmt.file
297
+ )
298
+ # a 6th argument that is no constant integer counts as mode 0, then is rejected (same probes)
299
+ bad_mode = (
300
+ not cr3
301
+ and constant_port
302
+ and not shifted
303
+ and len(stmt.args) > 5
304
+ and stmt.args[5] is not None
305
+ and (
306
+ expressions._not_constant_integer(stmt.args[5])
307
+ or bool(expressions._variable_refs(stmt.args[5], variable_names))
308
+ )
309
+ )
310
+ if (
311
+ not cr3
312
+ and constant_port
313
+ and (
314
+ problem := _commsmode_problem(
315
+ stmt, profile, program.consts, 4 if shifted else 5, mode=0 if bad_mode else None
316
+ )
317
+ )
318
+ ):
319
+ if problem[0]:
320
+ diags.error(stmt.line, problem[1], stmt.file)
321
+ else:
322
+ diags.warning(stmt.line, problem[1], stmt.file)
323
+ if bad_mode:
324
+ diags.error(stmt.line, f"Parameter must be a constant integer: {stmt.text}.", stmt.file)
325
+ continue
326
+ if shifted and len(stmt.args) > 5:
327
+ diags.error(stmt.line, f"No Matching, or out of place ) in expression: {stmt.text}.", stmt.file)
328
+ continue
329
+ if (
330
+ not (cr3 or names or is_variable or isinstance(arg, S.String))
331
+ and len(stmt.args) > 4
332
+ and expressions._variable_refs(stmt.args[4], variable_names)
333
+ ):
334
+ # a variable `BufferSize` (oracle, CR6/CR1000X, 2026-10-24, round 34)
335
+ diags.error(stmt.line, f"Parameter must be a constant integer: {stmt.text}.", stmt.file)
336
+ continue
337
+ if names or is_variable or isinstance(arg, S.String):
338
+ # A string or variable ComPort, all four loggers, pass 1 (oracle, 2026-10-01:
339
+ # `SerialOpen("fuzz",...)`). CR300/CR350 echo the line end (`docs/loggers.md`).
340
+ diags.error(stmt.line, "ComPort 0 illegal.", stmt.file)
341
+ if not cr3 and names and not is_variable:
342
+ continue # CR6/CR1000X: an undefined name gives only this line (docs/checker.md)
343
+ sep = "\n" if cr3 and stmt.at_eol else ""
344
+ diags.error(stmt.line, f"Parameter must be a constant integer: {stmt.text}{sep}.", stmt.file)
345
+
346
+
347
+ # `check_too_many_cascade` (Round 7b, broadened this round -- see `docs/loggers.md` "Too many
348
+ # parameters cascade, widened" for the oracle transcript): once any undefined-name-class error
349
+ # fires anywhere in the file (an instruction-argument or assignment-RHS name `expressions.check`/
350
+ # `check_consts` could not resolve, the const-family "rejects a variable" `Variable illegal in
351
+ # Parameter: %s.` finding, **or now also a genuine `Too many parameters: %s.` finding from
352
+ # `instr_rules.check_too_many` itself** -- oracle-confirmed self-chaining, scratch probe `t3.cr6`/
353
+ # `t4.cr6`: a real `PortSet(C1,1,2,3)` excess taints the *next* statement the same way an undefined
354
+ # name does), CR300/CR350's own parser leaves some shared state corrupted for the **rest of the
355
+ # file, not just the next statement** (widened from Round 7b's own single-shot model, which a
356
+ # three-and-four-statement scratch chain, `t.cr6`/`t3.cr6`, and the corpus's own `dewpoint_1.CR6`
357
+ # (3 in a row), `runprogram_1.CR6`/`filemanage_1.CR6`/`goesdata_1.CR6` (2 in a row) all show is too
358
+ # narrow -- Round 7b's own corpus sample never happened to have more than one qualifying follow-up
359
+ # statement, so "stops after one" was indistinguishable from "keeps going" until this round):
360
+ # **every** subsequent statement that is a recognised `S.Instruction` written with two or more
361
+ # arguments gets its own spurious `Too many parameters: <its own second argument's verbatim
362
+ # text>.`, `checker/instr_rules.py`'s own real message reused for a shape that instruction's own
363
+ # argument count never earns on its own (`WriteIO(1,2)`, `PortSet(1,2)`, ... all compile clean
364
+ # standalone) -- oracle-confirmed (scratch `t.cr6`: an undefined name followed by three `PortSet`s
365
+ # and a `Delay`, all four flagged, each echoing its own second argument, not the first one skipped
366
+ # over). This **replaces** whatever that tainted statement's own checks would otherwise have found
367
+ # for it, rather than merely filling a silent gap -- oracle-confirmed (`t2.cr6`: `PortSet(C1,
368
+ # BadVar2)` after an earlier undefined name gets only `Too many parameters: BadVar2.`, never a
369
+ # second `BadVar2 is not defined.` of its own; `t3.cr6`: a tainted `PortSet(C1,1,2,3)`, which would
370
+ # genuinely overflow on its own (2 declared params, 4 given), still echoes its own second argument
371
+ # (`1`), never the true excess position (`2`) `instr_rules.check_too_many` would compute for it
372
+ # standalone). Confirmed **not** anchored to the *specific* failing statement -- when several
373
+ # statements in a row each have their own undefined-name error, only the search from the *last* one
374
+ # starts the cascade (untouched from Round 7b) -- and the search itself skips forward, without
375
+ # breaking the chain, over any clean statement that does not itself qualify (fewer than two
376
+ # arguments, or not a recognised instruction at all -- a plain assignment or a one-argument call
377
+ # such as `Battery(BattV)` is silently passed over, not itself flagged and not a stopping point, and
378
+ # neither is an *unresolved* bare call such as `Move(...)`/`Routes(...)` naming an instruction this
379
+ # project's own table has no record of on this logger -- corpus: `routes_1.CR6`, where `MyRoute is
380
+ # not defined.` keeps firing normally on those two lines, untouched by the taint, because neither
381
+ # parses to a recognised `S.Instruction` at all) -- confirmed with a bare assignment and a
382
+ # one-argument call each skipped in the middle, and a `'comment` line skipped for free (comments are
383
+ # never `program.statements` entries at all). **CR6/CR1000X do not share this at all**
384
+ # (oracle-confirmed with the same shape, an undefined const-family name immediately followed by a
385
+ # two-argument instruction: no extra line).
386
+ #
387
+ # Because the taint can originate from a pass-1/ungated finding (`cr3xx.check`'s own `SerialOpen`/
388
+ # `ComPort` finding, or `instr_rules.check_too_many`'s own genuine finding -- corpus:
389
+ # `acceptdatarecords_1.CR6`, `SerialOpen (COMC1,...)`'s `COMC1 is not defined.` taints every
390
+ # following two-plus-argument instruction to end of file, `Scan`'s own `Sec` and
391
+ # `AcceptDataRecords`'s own `32769`, despite that finding being **ungated** and thus, in this
392
+ # project's own pass model, skipping tier B -- where the *previous*, narrower version of this
393
+ # function lived -- entirely), this is no longer called from inside the `if not diags.errors:`
394
+ # tier-B block the way `check_expr_cascade` was: `checker/__init__.py` now calls it once,
395
+ # unconditionally, after every other diagnostic-producing pass has run, so it can see -- and rewrite
396
+ # -- the complete, final diagnostic list regardless of which tier first tainted the file. It is
397
+ # still a no-op when nothing in the file ever taints (no `_is_cascade_trigger` match and no
398
+ # `Too many parameters:` message already present).
399
+ _UNRESOLVED_SUFFIX = " is not defined."
400
+ _LEFTOVER_LEAD_RE = re.compile(r"\s*(?:\(\s*([A-Za-z_]\w*)\s*[,(]|([A-Za-z_]\w*)\s*,)")
401
+ _VARIABLE_ILLEGAL_PREFIX = "Variable illegal in Parameter:"
402
+ _TOO_MANY_PREFIX = "Too many parameters:"
403
+
404
+
405
+ # Declaration errors taint the same way (oracle, CR300 and CR350, 2026-09-27: each of these
406
+ # followed by a clean `Scan (1,Sec,0,0)` gives `Too many parameters: Sec.` on it).
407
+ _DECLARATION_TRIGGERS = (
408
+ "instruction must be in a ConstTable",
409
+ "already declared as a variable.",
410
+ "is already declared as a CONST.",
411
+ "illegal, starts with a number.",
412
+ "not yet declared.",
413
+ "so cannot be aliased.",
414
+ "not supported",
415
+ "Variable not named.",
416
+ " is illegal baud rate.",
417
+ "already defined as an internal function.",
418
+ "is a predefined CONST.",
419
+ "Only one ConstTable allowed.",
420
+ "EndConstTable with no ConstTable.",
421
+ "Missing variable name.",
422
+ "; cannot be numeric.",
423
+ "Too few parameters in instruction.",
424
+ "Const with no assignment.",
425
+ "CONST needs assignment.",
426
+ " unknown Const type",
427
+ "Cannot Alias an Alias.",
428
+ ) # (round 31)
429
+ _DECLARATION_TRIGGER_PREFIXES = (
430
+ "Too many Slow Sequences",
431
+ "Duplicate Alias",
432
+ "Need closing )",
433
+ "Illegal END or EXIT ",
434
+ "Illegally dimensioned aliased name",
435
+ "Alias is illegally dimensioned.",
436
+ "Invalid Variable declaration",
437
+ "EndSelect w/o Select",
438
+ "EndStructure without Structure",
439
+ "String without end quote",
440
+ "Call argument required",
441
+ "No Matching, or out of place )",
442
+ "Expression expects ",
443
+ "Incomplete expression:",
444
+ "ESS variable ",
445
+ "Invalid or missing parameter.",
446
+ "Parameter must be a constant integer:",
447
+ "Invalid, or out of place expression:",
448
+ "Invalid assignment in expression:",
449
+ "Illegal destination variable type",
450
+ "Invalid Variable or Array:",
451
+ "Cannot recursively call functions.",
452
+ "Parameter must be integral.",
453
+ "Interval (",
454
+ "Illegally dimension: ",
455
+ "Scan interval must be integrally divisible",
456
+ "Dimensions not declared:",
457
+ )
458
+
459
+
460
+ def _is_short_instruction(stmt, profile) -> bool:
461
+ """An instruction call written with fewer arguments than it requires."""
462
+ record = profile.instructions.get(stmt.keyword.upper()) if isinstance(stmt, S.Instruction) else None
463
+ return record is not None and instr_rules.given_count(stmt, profile) < instr_rules._required_count(
464
+ instr_rules._real_params(record["params"])
465
+ )
466
+
467
+
468
+ def _is_cascade_trigger(diag) -> bool:
469
+ return (
470
+ diag.severity == "error"
471
+ and diag.cascade
472
+ and (
473
+ diag.trigger
474
+ or diag.message.endswith(_UNRESOLVED_SUFFIX)
475
+ or diag.message == _TCPOPEN_HANDLE
476
+ or diag.message.startswith(_VARIABLE_ILLEGAL_PREFIX)
477
+ or diag.message.startswith(_TOO_MANY_PREFIX)
478
+ or diag.message in ("Miss match of types", "Illegal object id.", "Illegal OID since already declared.")
479
+ or diag.message.startswith("SNMP OID must be greater than") # SNMPVariable (oracle, CR300/CR350)
480
+ or diag.message.endswith(_DECLARATION_TRIGGERS)
481
+ or diag.message.startswith(_DECLARATION_TRIGGER_PREFIXES)
482
+ )
483
+ )
484
+
485
+
486
+ def _reword_prose(program: S.Program, profile, diags: Diagnostics, stmt: S.Unparsed, gone: set[str]) -> None:
487
+ """A prose line after the cascade has started: a name whose declaration the cascade dropped is
488
+ undefined there too (oracle, CR300, 2026-10-08: `Current` declared after the first error, then
489
+ prose mentioning `current`). Re-derives the leading-word lines of the generic fallback."""
490
+ key = (stmt.line, stmt.file)
491
+ own = [i for i, d in enumerate(diags.items) if (d.line, d.file) == key]
492
+ echoes = {_echo_key(stmt.text), _echo_key(structure.condition_part(stmt.text) or stmt.text)}
493
+ fb = next(
494
+ (
495
+ i
496
+ for i in own
497
+ if diags.items[i].message.startswith(_GENERIC_FALLBACK_PREFIX)
498
+ and _echo_key(diags.items[i].message[len(_GENERIC_FALLBACK_PREFIX) : -1]) in echoes
499
+ ),
500
+ None,
501
+ )
502
+ if fb is None or not all(diags.items[i].message.endswith(_UNRESOLVED_SUFFIX) for i in own if i < fb):
503
+ return
504
+ text = diags.items[fb].message[len(_GENERIC_FALLBACK_PREFIX) : -1]
505
+ lead = _INSTRUCTION_PROSE_RE.match(text)
506
+ info = profile.instructions.get(lead.group(1).upper()) if lead else None
507
+ if _TABLE_LINE_RE.match(text) or (info and info["params"]):
508
+ return
509
+ known = prose_known(program, profile) - declared_later(first_declarations(program), stmt.line, stmt.file) - gone
510
+ words = _leading_unknown_words(text, known)
511
+ old = [diags.items[i].message for i in own if i < fb]
512
+ new = [profile.unresolved_symbol_message.format(name=w) for w in words]
513
+ if old != new:
514
+ fresh = [Diagnostic("error", stmt.line, m, stmt.file, True) for m in new]
515
+ diags.items[own[0] if own else fb : fb] = fresh
516
+
517
+
518
+ def _tainted_prose_instruction(stmt: S.Unparsed, profile) -> str | None:
519
+ """`""`: the prose is an instruction whose first parameter takes the text unread (no output);
520
+ a token: the surplus second parameter; `None`: not such a line."""
521
+ lead = _INSTRUCTION_PROSE_RE.match(stmt.text or "")
522
+ info = profile.instructions.get(lead.group(1).upper()) if lead else None
523
+ if not lead or not info or not info["params"] or lead.group(1).upper() in ("DATA", "DATALONG"):
524
+ return None
525
+ codes, rest = info["params"], lead.group(2)
526
+ if codes[0] % 100 == 11 and "," not in rest and len(codes) > 1 and codes[1] < 100:
527
+ return ""
528
+ if codes[0] % 100 == 6 and len(codes) > 1:
529
+ name_tok = instr_rules._next_simple_token(rest)
530
+ return instr_rules._next_simple_token(rest[rest.index(name_tok) + len(name_tok) :]) or None
531
+ return None
532
+
533
+
534
+ def _inline_owner(statements, index: int):
535
+ """The one-line `If` whose body holds `statements[index]`, if any (on the same source line,
536
+ however many `:`-separated statements the body has; review R14C-6)."""
537
+ stmt = statements[index]
538
+ for i in range(index - 1, -1, -1):
539
+ prev = statements[i]
540
+ if (prev.line, prev.file) != (stmt.line, stmt.file):
541
+ break
542
+ if isinstance(prev, S.If) and any(x is stmt for x in (prev.inline or []) + (prev.inline_else or [])):
543
+ return prev
544
+ return None
545
+
546
+
547
+ def _names_in(cond, profile, message: str) -> bool:
548
+ """Whether `message` is the unresolved-symbol line for a name written in `cond`."""
549
+ if cond is None:
550
+ return False
551
+ return any(message == profile.unresolved_symbol_message.format(name=n) for n in _expr_names(cond))
552
+
553
+
554
+ def check_too_many_cascade(program: S.Program, profile, diags: Diagnostics) -> None:
555
+ """CR300/CR350 only; a no-op on every other logger and when nothing in the file ever taints
556
+ (see this module's comment above for the oracle evidence and the exact shape implemented).
557
+ Called from `checker/__init__.py` once, unconditionally, after every other pass -- see that
558
+ comment for why this can no longer be tier-B-only the way `check_expr_cascade` was.
559
+ """
560
+ if profile.name not in ("cr300", "cr350"):
561
+ return
562
+ # A single left-to-right sweep, **not** "find the last trigger line, then walk forward from
563
+ # it": `tainted` latches on the first qualifying statement whose own (pre-existing, untouched)
564
+ # diagnostics are trigger-shaped, and then stays on for every remaining statement, rewriting
565
+ # each qualifying one in turn -- oracle-confirmed this is the right shape, not a re-scan, by
566
+ # `t2.cr6` (`docs/loggers.md`): a tainted `PortSet(C1,BadVar2)` is *itself* trigger-shaped (its
567
+ # own, pre-taint `BadVar2 is not defined.`), but must still be **rewritten**, not treated as a
568
+ # fresh anchor that leaves it untouched and only starts the chain after it -- a "find the last
569
+ # trigger" re-scan gets this backwards (picks that statement as the new anchor and leaves its
570
+ # own stale diagnostic standing). A statement is only ever left as the untouched anchor when
571
+ # `tainted` was still `False` on entry to it (the *first* trigger in the file, matching
572
+ # `dewpoint_1.CR6`'s own first `VoltSE` keeping its real `mv5000C is not defined.` pair while
573
+ # its identical-shaped successor is swallowed).
574
+ tainted = False
575
+ taint_at = 0
576
+ stash: dict[tuple, list] = {} # inline-`If` condition diagnostics set aside while the body is rewritten
577
+ inline_bodies = {
578
+ id(b) for st in program.statements if isinstance(st, S.If) for b in (st.inline or []) + (st.inline_else or [])
579
+ }
580
+ dropped: set[int] = set() # tainted declarations: their names are never registered
581
+ tainted_subs: set[int] = set() # tainted `Sub`/`Function` headers: parameters dropped
582
+ rewritten: set[int] = set()
583
+ dropped_names: set[str] = set()
584
+ registered_names: set[str] = set()
585
+ include_errors = sorted(program.include_error_at) # `Include` extension / nesting errors trigger it too
586
+ for index, stmt in enumerate(program.statements):
587
+ key = (stmt.line, stmt.file)
588
+ if not tainted and include_errors and include_errors[0] <= index:
589
+ tainted = True # (oracle, 2026-10-12: the statements after the error are rewritten)
590
+ taint_at = index
591
+ if not tainted and isinstance(stmt, S.Declaration):
592
+ registered_names.update(v.name.upper() for v in stmt.vars)
593
+ existing = [d for d in diags.items if (d.line, d.file) == key]
594
+ if isinstance(stmt, S.If) and stmt.inline is not None: # (a body hoisted out of it too)
595
+ # only the condition's own lines belong to the `If`; the body's are its statements'
596
+ existing = [d for d in existing if _names_in(stmt.cond, profile, d.message)]
597
+ elif (owner0 := _inline_owner(program.statements, index)) is not None and not tainted:
598
+ existing = [d for d in existing if not _names_in(owner0.cond, profile, d.message)]
599
+ if tainted:
600
+ owner = _inline_owner(program.statements, index)
601
+ if owner is not None:
602
+ # the body of a one-line `If` is rewritten like any statement, the condition's own
603
+ # `X is not defined.` lines stay (oracle, CR300/CR350, 2026-10-12)
604
+ held = [d for d in existing if d.severity == "error" and _names_in(owner.cond, profile, d.message)]
605
+ if held:
606
+ stash.setdefault(key, []).extend(held)
607
+ diags.items[:] = [d for d in diags.items if not any(d is h for h in held)]
608
+ if (
609
+ isinstance(stmt, S.Call)
610
+ and stmt.name.upper() == "SERIALOPEN"
611
+ and len(stmt.args) > 1
612
+ and isinstance(stmt.args[1], (S.Unary, S.Binary))
613
+ ):
614
+ # a negative or computed baud is swallowed once the cascade has started, a plain
615
+ # illegal one still prints (oracle, CR300/CR350, 2026-10-10)
616
+ diags.items[:] = [
617
+ d
618
+ for d in diags.items
619
+ if (d.line, d.file) != key or not d.message.endswith(" is illegal baud rate.")
620
+ ]
621
+ lead = _LEFTOVER_LEAD_RE.match(stmt.text) if isinstance(stmt, S.Unparsed) else None
622
+ lead_name = (lead.group(1) or lead.group(2)) if lead else None
623
+ if lead_name and lead_name.upper() in dropped_names - registered_names:
624
+ # a missing-parameter leftover `(Name,1,...)` whose first name is a cascade-dropped
625
+ # declaration reports it first (oracle, CR300/CR350, 2026-10-08)
626
+ at = next(
627
+ (
628
+ i
629
+ for i, d in enumerate(diags.items)
630
+ if (d.line, d.file) == key and d.message.startswith(_GENERIC_FALLBACK_PREFIX)
631
+ ),
632
+ None,
633
+ )
634
+ if at is not None:
635
+ diags.items.insert(
636
+ at,
637
+ Diagnostic(
638
+ "error", stmt.line, profile.unresolved_symbol_message.format(name=lead_name), stmt.file
639
+ ),
640
+ )
641
+ continue
642
+ if isinstance(stmt, S.Unparsed) and dropped_names - registered_names:
643
+ _reword_prose(program, profile, diags, stmt, dropped_names - registered_names)
644
+ if isinstance(stmt, S.Unparsed) and (_TABLE_LINE_RE.match(stmt.text) or _SIGNED_TABLE_RE.match(stmt.text)):
645
+ diags.items[:] = [
646
+ d
647
+ for d in diags.items
648
+ if (d.line, d.file) != key
649
+ or not (
650
+ d.message.startswith("Could not find DataTable ")
651
+ or d.message == "Expr illegally occurs before BeginProg."
652
+ or (d.message.startswith(_GENERIC_FALLBACK_PREFIX) and stmt.text.strip() in d.message)
653
+ )
654
+ ]
655
+ continue
656
+ if isinstance(stmt, S.Declaration) or (
657
+ isinstance(stmt, S.Unparsed) and stmt.text.lstrip().upper().startswith(("PUBLIC ", "DIM "))
658
+ ):
659
+ # A tainted declaration's own error is dropped (oracle: `Public A As StringX * 50`
660
+ # then `Dim Z banana` prints no `Invalid Variable declaration banana.`); one with
661
+ # two or more variables reads like an instruction (`Dim A(), B` -> `Too many
662
+ # parameters: B.`).
663
+ if not (isinstance(stmt, S.Declaration) and not stmt.vars):
664
+ # (`Public` alone keeps `Variable not named.`: oracle, 2026-10-01, `asin_1`)
665
+ diags.items[:] = [d for d in diags.items if (d.line, d.file) != key]
666
+ if isinstance(stmt, S.Declaration) and len(stmt.vars) >= 2:
667
+ diags.error(stmt.line, f"Too many parameters: {stmt.vars[1].name}.", stmt.file)
668
+ if isinstance(stmt, S.Declaration) and stmt.kind in ("PUBLIC", "DIM", "STATIC"):
669
+ dropped.add(id(stmt))
670
+ dropped_names.update(v.name.upper() for v in stmt.vars)
671
+ continue
672
+ alias_src = None
673
+ if (
674
+ isinstance(stmt, S.Instruction)
675
+ and stmt.keyword.upper() == "ALIAS"
676
+ and stmt.args
677
+ and isinstance(stmt.args[0], (S.Index, S.Name))
678
+ ):
679
+ alias_src = stmt.args[0].name
680
+ elif isinstance(stmt, S.Unparsed): # `Alias A(1) =` with the name on the next line
681
+ m_alias = re.match(r"(?i)\s*Alias\s+([A-Za-z_]\w*)\s*(?:\([^()]*\))?\s*=\s*$", stmt.text)
682
+ alias_src = m_alias.group(1) if m_alias else None
683
+ if alias_src and alias_src.upper() in dropped_names - registered_names:
684
+ # a cascade-dropped declaration is never registered, so its aliases name an
685
+ # undeclared source (oracle, CR300/CR350, 2026-10-04)
686
+ diags.items[:] = [d for d in diags.items if (d.line, d.file) != key]
687
+ at = next(
688
+ (i for i, d in enumerate(diags.items) if d.file == stmt.file and d.line > stmt.line),
689
+ len(diags.items),
690
+ )
691
+ diags.items.insert(
692
+ at, Diagnostic("error", stmt.line, f"{alias_src} not yet declared so cannot be aliased.", stmt.file)
693
+ )
694
+ # the alias name is not registered either (prose that mentions it: `current`)
695
+ if isinstance(stmt, S.Instruction):
696
+ dropped_names.update(a.name.upper() for a in stmt.args[1:] if isinstance(a, S.Name))
697
+ continue
698
+ if isinstance(stmt, S.SubDef):
699
+ tainted_subs.add(id(stmt))
700
+ # `Sub Foo (A, B)` -> `Too many parameters: B.`, `Function F (x) As Long` ->
701
+ # `... As.`: the second token after the name (oracle, 2026-09-28)
702
+ m = re.search(r"\((.*)$", stmt.text or "") # `(x) As Long` -> x, As
703
+ # a parameter's own dimensions are no token (oracle, 2026-10-22: a tainted
704
+ # `Sub T(Q(-1))` or `Sub T(Q(K))` prints nothing, round 29)
705
+ tokens = [t for t in re.split(r"[\s,()=]+", re.sub(r"\([^()]*\)", "", m.group(1))) if t] if m else []
706
+ if m:
707
+ # with two or more parameters the second parameter's name is echoed (oracle,
708
+ # CR300, 2026-10-10: `(a As Long!, rst As Long)` -> `rst`, not `As`)
709
+ parts, depth, cur = [], 0, ""
710
+ for ch in m.group(1):
711
+ depth += (ch == "(") - (ch == ")")
712
+ if depth < 0:
713
+ break
714
+ if ch == "," and depth == 0:
715
+ parts.append(cur)
716
+ cur = ""
717
+ else:
718
+ cur += ch
719
+ parts.append(cur)
720
+ second = re.match(r"[\s]*([^\s,()=]+)", parts[1]) if len(parts) > 1 else None
721
+ if second:
722
+ tokens = [tokens[0] if tokens else "", second.group(1)]
723
+ elif len(parts) == 1 and not parts[0].strip():
724
+ tokens = [] # `F( ) As Long`: no parameters, nothing to echo
725
+ if len(tokens) >= 2:
726
+ diags.items[:] = [d for d in diags.items if (d.line, d.file) != key]
727
+ diags.error(stmt.line, f"Too many parameters: {tokens[1]}.", stmt.file)
728
+ continue
729
+ if isinstance(stmt, S.Unparsed) and (taken := _tainted_prose_instruction(stmt, profile)) is not None:
730
+ # an instruction written as prose, after the cascade started: a deferred-text first
731
+ # parameter loses its shortfall line, a name token is followed by the surplus second
732
+ # parameter (oracle, CR300/CR350, 2026-10-11: `Histogram instruction.`, `CallTable
733
+ # instruction is used to ...`)
734
+ diags.items[:] = [d for d in diags.items if (d.line, d.file) != key]
735
+ if taken:
736
+ at = next(
737
+ (i for i, d in enumerate(diags.items) if d.file == stmt.file and d.line > stmt.line),
738
+ len(diags.items),
739
+ )
740
+ diags.items.insert(at, Diagnostic("error", stmt.line, f"Too many parameters: {taken}.", stmt.file))
741
+ continue
742
+ if isinstance(stmt, S.Unparsed) and re.fullmatch(
743
+ r"(?i)\s*(?:do|loop)\s+(?:while|until)\s*", stmt.text or ""
744
+ ):
745
+ # (`Do While` with nothing after it: the incomplete-expression line is gone)
746
+ diags.items[:] = [
747
+ d
748
+ for d in diags.items
749
+ if (d.line, d.file) != key or not d.message.startswith("Incomplete expression:")
750
+ ]
751
+ continue
752
+ m = (
753
+ re.search(r"(?i)\b(?:While|Until)\b\s*(.*)$", stmt.text or "")
754
+ if isinstance(stmt, S.DoLoop) and stmt.cond is not None
755
+ else re.match(r"(?i)\s*(?:do|loop)\s+(?:while|until)\b\s*(.*\S.*)$", stmt.text or "")
756
+ if isinstance(stmt, S.Unparsed)
757
+ else None
758
+ ) # (`Do Until X >`: unparsable too)
759
+ if m:
760
+ # a `Do`/`Loop` condition is rewritten like a second argument (oracle, CR300,
761
+ # 2026-10-04: `Loop Until X=-1` -> `Too many parameters: X.`)
762
+ text = instr_rules._next_simple_token(m.group(1)) or m.group(1)
763
+ diags.items[:] = [d for d in diags.items if (d.line, d.file) != key]
764
+ at = next(
765
+ (i for i, d in enumerate(diags.items) if d.file == stmt.file and d.line > stmt.line),
766
+ len(diags.items),
767
+ )
768
+ diags.items.insert(at, Diagnostic("error", stmt.line, f"Too many parameters: {text}.", stmt.file))
769
+ rewritten.add(id(stmt))
770
+ continue
771
+ trigger_short = (
772
+ taint_at < index
773
+ and (program.statements[taint_at].line, program.statements[taint_at].file) == key
774
+ and _is_short_instruction(program.statements[taint_at], profile)
775
+ )
776
+ kept_shortfall = (
777
+ [d for d in diags.items if (d.line, d.file) == key and d.message in _SHORTFALL_MESSAGES][:1]
778
+ if trigger_short
779
+ else []
780
+ )
781
+ if (
782
+ isinstance(stmt, S.Instruction)
783
+ and instr_rules.given_count(stmt, profile) == 1
784
+ and _is_short_instruction(stmt, profile)
785
+ ):
786
+ if id(stmt) in inline_bodies:
787
+ continue # (a one-line `If` body keeps its shortfall, oracle 2026-10-12: `I = Nope` then `If X Then Delay`)
788
+ # a short instruction after the cascade started has no second argument to echo and
789
+ # its own shortfall disappears (oracle, CR300, 2026-10-10: `Delay(1)` then
790
+ # `PanelTemp(X)` prints one line; on a shared line the trigger's own line stays)
791
+ diags.items[:] = [
792
+ d
793
+ for d in diags.items
794
+ if (d.line, d.file) != key
795
+ or d.message not in _SHORTFALL_MESSAGES
796
+ or any(d is k for k in kept_shortfall)
797
+ ]
798
+ continue
799
+ if (
800
+ isinstance(stmt, S.Instruction)
801
+ and len(stmt.args) >= 2
802
+ and stmt.args[1] is not None
803
+ and stmt.keyword.upper() not in ("UNITS", "READONLY", "ALIAS", "CONSTTABLE", "SNMPVARIABLE")
804
+ ):
805
+ # (`Units X = m` is never rewritten: repository libraries, oracle; nor is an
806
+ # `Alias` or a `ConstTable (T,0)`, oracle 2026-10-03.)
807
+ # the next simple token, like a genuine overflow (oracle: `MainScanSecs * 4` ->
808
+ # `MainScanSecs`, `SearchString()` -> `SearchString`)
809
+ text = instr_rules._next_simple_token(expressions._arg_text(stmt, 1)) or expressions._arg_text(stmt, 1)
810
+ diags.items[:] = [d for d in diags.items if (d.line, d.file) != key]
811
+ at = next(
812
+ (i for i, d in enumerate(diags.items) if d.file == stmt.file and d.line > stmt.line),
813
+ len(diags.items),
814
+ )
815
+ diags.items[at:at] = kept_shortfall
816
+ diags.items.insert(
817
+ at + len(kept_shortfall),
818
+ Diagnostic("error", stmt.line, f"Too many parameters: {text}.", stmt.file),
819
+ )
820
+ rewritten.add(id(stmt))
821
+ continue
822
+ if any(_is_cascade_trigger(d) for d in existing):
823
+ tainted = True
824
+ taint_at = index
825
+ for (line, file), held in stash.items(): # condition diagnostics go back before the body's
826
+ at = next((i for i, d in enumerate(diags.items) if d.file == file and d.line >= line), len(diags.items))
827
+ diags.items[at:at] = held
828
+ diags.cascade_gone = dropped_names - registered_names # (names the cascade left unregistered)
829
+ if rewritten:
830
+ _report_rewritten_first_arg(program, profile, diags, dropped, rewritten)
831
+ if dropped or tainted_subs:
832
+ _report_unregistered(program, profile, diags, taint_at, dropped, tainted_subs, rewritten)
833
+
834
+
835
+ _FLOAT_DEST = 8 # `variable_or_array` "Dest" parameter (`docs/type-codes.md`)
836
+ _DEST_MESSAGE = "Illegal destination variable type, expecting Float."
837
+
838
+
839
+ _variable_types = symbols.declared_variable_types
840
+
841
+
842
+ def _destination_types(program: S.Program) -> tuple[dict[str, str], dict[int, dict[str, str]]]:
843
+ """`declared_variable_types` plus each alias name with its declared source's type, so a Long,
844
+ Boolean or String alias is no Float destination (round 31). A chain `Alias Z=Y` on an alias
845
+ `Z` fails, so only a declared variable passes its type on. An alias inside a routine is that
846
+ routine's only: the second result maps `id(statement)` to its routine's alias types (oracle,
847
+ CR300/CR350, 2026-10-24, `tests/fixtures/r31_review_fixes.json` f1)."""
848
+ types = _variable_types(program)
849
+ declared = {v.name.upper() for st in program.statements if isinstance(st, S.Declaration) for v in st.vars}
850
+ scoped: dict[int, dict[str, str]] = {}
851
+ local: dict[str, str] | None = None
852
+ for st in program.statements:
853
+ if isinstance(st, S.SubDef):
854
+ local = {}
855
+ elif S.ends_routine(st):
856
+ local = None
857
+ if local is not None:
858
+ scoped[id(st)] = local
859
+ if (
860
+ isinstance(st, S.Instruction)
861
+ and st.keyword.upper() == "ALIAS"
862
+ and len(st.args) > 1
863
+ and isinstance(st.args[0], (S.Name, S.Index))
864
+ and st.args[0].name.upper() in types
865
+ ):
866
+ for alias in st.args[1:]:
867
+ if isinstance(alias, (S.Name, S.Index)) and alias.name.upper() not in declared:
868
+ (types if local is None else local).setdefault(alias.name.upper(), types[st.args[0].name.upper()])
869
+ return types, scoped
870
+
871
+
872
+ def _is_bad_destination(expr: S.Expr, types: dict[str, str]) -> bool | None:
873
+ """True when a defined first argument is not a Float variable, None when unknown."""
874
+ if isinstance(expr, (S.Name, S.Index)):
875
+ kind = types.get(expr.name.upper())
876
+ return None if kind is None else kind != "FLOAT"
877
+ return isinstance(expr, (S.Number, S.String, S.Binary, S.Unary))
878
+
879
+
880
+ def check_float_destinations(program: S.Program, profile, diags: Diagnostics) -> None:
881
+ """CR300/CR350: a code-8 first argument that fails ends the argument list, so the next
882
+ argument is reported as `Too many parameters` and nothing else on the statement is: an
883
+ undefined name keeps its `is not defined.` line, any other non-Float destination (a Long or
884
+ String variable, a literal, an expression) gives `Illegal destination variable type,
885
+ expecting Float.` (oracle, CR300, 2026-10-01: `PanelTemp(L,60)`, `PanelTemp(Nope,60)`,
886
+ `VoltSe(L,1,mV1000,...)`, `Battery(L)`)."""
887
+ if profile.name not in ("cr300", "cr350"):
888
+ return
889
+ types: dict[str, str] | None = None
890
+ scoped: dict[int, dict[str, str]] = {}
891
+ known = ds = None
892
+ params: dict[str, str] = {} # the enclosing routine's parameters hide a global (round 31)
893
+ per_line = collections.Counter((st.line, st.file) for st in program.statements)
894
+ for stmt in program.statements:
895
+ if isinstance(stmt, S.SubDef):
896
+ params = {p.name.upper(): (p.type or "FLOAT").upper() for p in stmt.params}
897
+ elif S.ends_routine(stmt):
898
+ params = {}
899
+ if (
900
+ not isinstance(stmt, S.Instruction)
901
+ or not stmt.args
902
+ or stmt.args[0] is None
903
+ or per_line[(stmt.line, stmt.file)] > 1
904
+ ):
905
+ continue
906
+ info = profile.instructions.get(stmt.keyword.upper())
907
+ literal = (
908
+ next(
909
+ (
910
+ i
911
+ for i, (code, a) in enumerate(zip(info["params"], stmt.args))
912
+ if code in (27, 29) and a is not None and not isinstance(a, S.String) and not _expr_names(a)
913
+ ),
914
+ None,
915
+ )
916
+ if info and info["params"]
917
+ else None
918
+ )
919
+ if (
920
+ literal is not None
921
+ and per_line[(stmt.line, stmt.file)] == 1
922
+ and not any((d.line, d.file) == (stmt.line, stmt.file) for d in diags.errors)
923
+ ):
924
+ # `RealTime(5)`, `Erase(5)`, `Matrix(5,(1+2),0,R)` -> `Invalid Variable or Array:
925
+ # 5.`, then the argument list ends (oracle, CR300, 2026-10-01: `Move(5,1,X,1)` ->
926
+ # `Too many parameters: 1.`)
927
+ at = next(
928
+ (i for i, d in enumerate(diags.items) if d.file == stmt.file and d.line > stmt.line), len(diags.items)
929
+ )
930
+ new = [
931
+ Diagnostic(
932
+ "error",
933
+ stmt.line,
934
+ f"Invalid Variable or Array: {expressions._arg_text(stmt, literal).strip()}.",
935
+ stmt.file,
936
+ )
937
+ ]
938
+ if len(stmt.args) > literal + 1 and stmt.args[literal + 1] is not None:
939
+ text = instr_rules._next_simple_token(
940
+ expressions._arg_text(stmt, literal + 1)
941
+ ) or expressions._arg_text(stmt, literal + 1)
942
+ new.append(Diagnostic("error", stmt.line, f"Too many parameters: {text}.", stmt.file))
943
+ diags.items[at:at] = new
944
+ continue
945
+ if not info or not info["params"] or info["params"][0] != _FLOAT_DEST:
946
+ continue
947
+ if types is None:
948
+ (types, scoped), known = _destination_types(program), expressions._known_names(program, profile)
949
+ ds = expressions.program_dims(program, profile, known)
950
+ assert known is not None and ds is not None
951
+ arg = stmt.args[0]
952
+ if isinstance(arg, S.Name) and arg.name.upper() in profile.functions:
953
+ continue # a bare function name is `Expression expects (` instead (expressions.py)
954
+ key = (stmt.line, stmt.file)
955
+ undefined = [n for n in _expr_names(arg) if n.upper() not in known]
956
+ expression = False
957
+ dims = False
958
+ if undefined:
959
+ if isinstance(arg, (S.Binary, S.Unary)):
960
+ # an arithmetic expression is no destination: after its names, `Dimensions not
961
+ # declared` or the destination-type error (oracle, CR300, 2026-10-03)
962
+ expression = True
963
+ undefined, dims = expressions._names_and_dims(known, ds, stmt, (), arg)
964
+ elif not isinstance(arg, (S.Name, S.Index)):
965
+ continue
966
+ keep = [profile.unresolved_symbol_message.format(name=n) for n in undefined]
967
+ elif _is_bad_destination(arg, {**types, **scoped.get(id(stmt), {}), **params}):
968
+ keep = []
969
+ else:
970
+ continue
971
+ own = [d for d in diags.items if (d.line, d.file) == key and d.severity == "error"]
972
+ if not keep and any(d.message.startswith("Dimensions not declared") for d in own):
973
+ continue # (`Battery(L(2))` for a Long scalar: over-indexing is reported first)
974
+ if keep and [d.message for d in own[: len(keep)]] != keep:
975
+ continue
976
+ at = (
977
+ diags.items.index(own[0])
978
+ if own
979
+ else next(
980
+ (i for i, d in enumerate(diags.items) if d.file == stmt.file and d.line > stmt.line), len(diags.items)
981
+ )
982
+ )
983
+ new = list(own[: len(keep)]) or [Diagnostic("error", stmt.line, _DEST_MESSAGE, stmt.file)]
984
+ if keep and (dims if expression else expressions._has_undefined_index(arg, known, ds, stmt)):
985
+ # (oracle, CR300, 2026-10-03: `Battery(Nope(1))`, `PanelTemp(Nope(1),15000)`)
986
+ new.append(
987
+ Diagnostic(
988
+ "error",
989
+ stmt.line,
990
+ expressions.dimensions_message(expressions._arg_text(stmt, 0).strip(), profile, False),
991
+ stmt.file,
992
+ )
993
+ )
994
+ elif keep and expression:
995
+ new.append(Diagnostic("error", stmt.line, _DEST_MESSAGE, stmt.file))
996
+ if len(stmt.args) >= 2 and stmt.args[1] is not None:
997
+ text = instr_rules._next_simple_token(expressions._arg_text(stmt, 1)) or expressions._arg_text(stmt, 1)
998
+ new.append(Diagnostic("error", stmt.line, f"Too many parameters: {text}.", stmt.file))
999
+ diags.items[:] = (
1000
+ [d for d in diags.items[:at] if (d.line, d.file) != key or d.severity != "error"]
1001
+ + new
1002
+ + [d for d in diags.items[at:] if (d.line, d.file) != key or d.severity != "error"]
1003
+ )
1004
+
1005
+
1006
+ # Parameter codes resolved as a runtime expression or variable: an undefined name there ends the
1007
+ # argument list on CR300/CR350 (`docs/type-codes.md`; code 1 constants report `Variable illegal`).
1008
+ _ABORTING_PARAMS = frozenset({2, 8, 24, 25, 26, 27, 28, 29, 33, 40, 41, 47})
1009
+
1010
+
1011
+ def drop_too_many_after_variable_illegal(program: S.Program, profile, diags: Diagnostics) -> None:
1012
+ """CR300/CR350: `Variable illegal in Parameter` ends the argument list, so the statement's
1013
+ genuine `Too many parameters` is not reached (oracle, 2026-10-01: `PortSet(C4,1,1)`)."""
1014
+ if profile.name not in ("cr300", "cr350"):
1015
+ return
1016
+ per_line = collections.Counter((st.line, st.file) for st in program.statements)
1017
+ # (a routine header too: `Sub S(P(K),Q,R)` -> `... R.`, oracle, 2026-10-22, round 29)
1018
+ declarations = {(st.line, st.file) for st in program.statements if isinstance(st, (S.Declaration, S.SubDef))}
1019
+ bad = {
1020
+ (d.line, d.file)
1021
+ for d in diags.errors
1022
+ if d.message.startswith(_VARIABLE_ILLEGAL_PREFIX)
1023
+ and per_line[(d.line, d.file)] == 1
1024
+ and (d.line, d.file) not in declarations
1025
+ } # (a declaration names the variable two on)
1026
+ if bad:
1027
+ diags.items[:] = [
1028
+ d for d in diags.items if not ((d.line, d.file) in bad and d.message.startswith(_TOO_MANY_PREFIX))
1029
+ ]
1030
+ # The excess argument is reached after the earlier ones' names: `Battery(foo, bar)` ->
1031
+ # `foo is not defined.`, then `Too many parameters: bar.` (oracle, CR300, 2026-10-01).
1032
+ items = diags.items
1033
+ for i in range(len(items) - 1, -1, -1):
1034
+ d = items[i]
1035
+ if d.severity != "error" or not d.message.startswith(_TOO_MANY_PREFIX) or per_line[(d.line, d.file)] != 1:
1036
+ continue
1037
+ later = [
1038
+ j
1039
+ for j in range(i + 1, len(items))
1040
+ if (items[j].line, items[j].file) == (d.line, d.file) and items[j].message.endswith(_UNRESOLVED_SUFFIX)
1041
+ ]
1042
+ if later:
1043
+ items.insert(later[-1], items.pop(i)) # (`later` indices shift by one after the pop)
1044
+
1045
+
1046
+ def check_undefined_arg_aborts(program: S.Program, profile, diags: Diagnostics) -> None:
1047
+ """CR300/CR350: the first argument with an undefined name, at a runtime-expression parameter,
1048
+ ends the argument list: its `is not defined.` lines are followed by `Too many parameters:
1049
+ <next argument>.` and nothing else on the statement is reported (oracle, CR300, 2026-10-01:
1050
+ `Delay(0,Nope,Sec)` -> `Too many parameters: Sec.`, `Move(A(1),2,Nope,1)` -> `... 1.`)."""
1051
+ if profile.name not in ("cr300", "cr350"):
1052
+ return
1053
+ known = None
1054
+ per_line = collections.Counter((st.line, st.file) for st in program.statements)
1055
+ for stmt in program.statements:
1056
+ if not isinstance(stmt, S.Instruction) or per_line[(stmt.line, stmt.file)] > 1:
1057
+ continue
1058
+ info = profile.instructions.get(stmt.keyword.upper())
1059
+ if not info or not info["params"]:
1060
+ continue
1061
+ if known is None:
1062
+ known = expressions._known_names(program, profile)
1063
+ for index, arg in enumerate(stmt.args):
1064
+ names = [n for n in _expr_names(arg) if n.upper() not in known]
1065
+ if not names:
1066
+ continue
1067
+ code = info["params"][index] if index < len(info["params"]) else None
1068
+ if (
1069
+ code not in _ABORTING_PARAMS
1070
+ or (index == 0 and code == _FLOAT_DEST)
1071
+ or index + 1 >= len(stmt.args)
1072
+ or stmt.args[index + 1] is None
1073
+ ):
1074
+ break
1075
+ key = (stmt.line, stmt.file)
1076
+ wanted = {profile.unresolved_symbol_message.format(name=n) for n in names}
1077
+ own = [d for d in diags.items if (d.line, d.file) == key and d.severity == "error"]
1078
+ keep = [d for d in own if d.message in wanted]
1079
+ if not keep or any(d.message.startswith("Dimensions not declared") for d in own):
1080
+ break # (`expressions._report_statement` already ended an undefined `A(i)`)
1081
+ too_many = [d for d in own if d.message.startswith(_TOO_MANY_PREFIX)]
1082
+ if too_many:
1083
+ # a genuine overflow found earlier still follows the names (`SW12(SW12_1,1)`)
1084
+ rest = [d for d in diags.items if d not in keep]
1085
+ at = rest.index(too_many[0])
1086
+ followup = []
1087
+ if stmt.keyword.upper() == "SERIALIN" and index == 0 and isinstance(stmt.args[0], S.Name):
1088
+ followup.append(Diagnostic("error", stmt.line, instr_rules._SERIALIN_DEST_MESSAGE, stmt.file))
1089
+ diags.items[:] = rest[:at] + keep + followup + rest[at:]
1090
+ break
1091
+ text = instr_rules._next_simple_token(expressions._arg_text(stmt, index + 1)) or expressions._arg_text(
1092
+ stmt, index + 1
1093
+ )
1094
+ at = diags.items.index(keep[-1]) + 1
1095
+ if stmt.keyword.upper() == "SERIALIN" and index == 0 and isinstance(stmt.args[0], S.Name):
1096
+ diags.items.insert(at, Diagnostic("error", stmt.line, instr_rules._SERIALIN_DEST_MESSAGE, stmt.file))
1097
+ at += 1
1098
+ diags.items.insert(at, Diagnostic("error", stmt.line, f"Too many parameters: {text}.", stmt.file))
1099
+ drop = {id(d) for d in own if d not in keep}
1100
+ diags.items[:] = [d for d in diags.items if id(d) not in drop]
1101
+ break
1102
+
1103
+
1104
+ def _expr_names(expr: S.Expr | None) -> list[str]:
1105
+ if isinstance(expr, S.Name):
1106
+ return [expr.name]
1107
+ if isinstance(expr, S.Index):
1108
+ return [expr.name] + [n for a in expr.args for n in _expr_names(a)]
1109
+ if isinstance(expr, S.Binary):
1110
+ return _expr_names(expr.left) + _expr_names(expr.right)
1111
+ if isinstance(expr, S.Unary):
1112
+ return _expr_names(expr.operand)
1113
+ return []
1114
+
1115
+
1116
+ # Parameter type codes whose argument is resolved as an expression or variable
1117
+ # (`docs/type-codes.md`); identifiers, declarations and deferred text are not.
1118
+ _RESOLVED_FIRST_PARAM = frozenset({1, 2, 5, 7, 8, 24, 25, 27, 28, 29, 33, 38, 39, 40, 41, 47})
1119
+ _CONST_FIRST_PARAM = frozenset({1, 5, 38, 39}) # constant-only (`docs/type-codes.md`)
1120
+
1121
+
1122
+ def _report_rewritten_first_arg(
1123
+ program: S.Program, profile, diags: Diagnostics, dropped: set[int], rewritten: set[int]
1124
+ ) -> None:
1125
+ """A rewritten instruction's first argument is still checked before its `Too many
1126
+ parameters` line: each undefined name (unregistered ones included), then `Dimensions not
1127
+ declared: A(I).` for an undefined indexed name (oracle, CR300, 2026-10-01: `PanelTemp(PTemp,
1128
+ 15000)` after `Dim Z banana` then `Public PTemp`)."""
1129
+ import dataclasses
1130
+
1131
+ known = expressions._known_names(
1132
+ dataclasses.replace(program, statements=[st for st in program.statements if id(st) not in dropped]), profile
1133
+ )
1134
+ tables = getattr(known, "tables", ())
1135
+ types, scoped = _destination_types(program)
1136
+
1137
+ def undefined(name: str) -> bool:
1138
+ return name.upper() not in known or name.upper() in tables
1139
+
1140
+ for stmt in program.statements:
1141
+ if not isinstance(stmt, S.Instruction) or id(stmt) not in rewritten or not stmt.args or stmt.args[0] is None:
1142
+ continue
1143
+ info = profile.instructions.get(stmt.keyword.upper())
1144
+ if not info or not info["params"] or info["params"][0] not in _RESOLVED_FIRST_PARAM:
1145
+ continue
1146
+ messages: list[str] = []
1147
+
1148
+ def walk(expr: S.Expr | None) -> None:
1149
+ if isinstance(expr, S.Name):
1150
+ if undefined(expr.name):
1151
+ messages.append(profile.unresolved_symbol_message.format(name=expr.name))
1152
+ elif isinstance(expr, S.Index):
1153
+ bad = undefined(expr.name)
1154
+ if bad:
1155
+ messages.append(profile.unresolved_symbol_message.format(name=expr.name))
1156
+ for a in expr.args:
1157
+ walk(a)
1158
+ if bad and any(a is not None for a in expr.args):
1159
+ messages.append(f"Dimensions not declared: {expressions._arg_text(stmt, 0)}.")
1160
+ elif isinstance(expr, S.Binary):
1161
+ walk(expr.left)
1162
+ walk(expr.right)
1163
+ elif isinstance(expr, S.Unary):
1164
+ walk(expr.operand)
1165
+
1166
+ walk(stmt.args[0])
1167
+ if messages and info["params"][0] in _CONST_FIRST_PARAM:
1168
+ # a constant-only parameter ends the list: `PortSet(C4,1)` -> `C4 is not defined.`,
1169
+ # `Variable illegal in Parameter: C4.`, no `Too many` (oracle, CR300, 2026-10-01)
1170
+ messages = [m for m in messages if m.endswith(" is not defined.")]
1171
+ messages.append(f"Variable illegal in Parameter: {expressions._arg_text(stmt, 0).strip()}.")
1172
+ diags.items[:] = [
1173
+ d
1174
+ for d in diags.items
1175
+ if not ((d.line, d.file) == (stmt.line, stmt.file) and d.message.startswith(_TOO_MANY_PREFIX))
1176
+ ]
1177
+ at = next(
1178
+ (i for i, d in enumerate(diags.items) if d.file == stmt.file and d.line > stmt.line), len(diags.items)
1179
+ )
1180
+ diags.items[at:at] = [Diagnostic("error", stmt.line, m, stmt.file, cascade=False) for m in messages]
1181
+ continue
1182
+ if info["params"][0] == _FLOAT_DEST:
1183
+ arg = stmt.args[0]
1184
+ names_ok = not messages
1185
+ if not isinstance(arg, (S.Name, S.Index)) or (
1186
+ names_ok and _is_bad_destination(arg, {**types, **scoped.get(id(stmt), {})})
1187
+ ):
1188
+ messages.append(_DEST_MESSAGE)
1189
+ if stmt.keyword.upper() == "SERIALIN" and (
1190
+ instr_rules._serialin_bad_destination(stmt.args[0], instr_rules._serialin_types(program, profile))
1191
+ or (messages and isinstance(stmt.args[0], S.Name))
1192
+ ):
1193
+ messages.append(instr_rules._SERIALIN_DEST_MESSAGE)
1194
+ if not messages:
1195
+ continue
1196
+ at = next(
1197
+ i
1198
+ for i, d in enumerate(diags.items)
1199
+ if (d.line, d.file) == (stmt.line, stmt.file) and d.message.startswith(_TOO_MANY_PREFIX)
1200
+ )
1201
+ diags.items[at:at] = [Diagnostic("error", stmt.line, m, stmt.file, cascade=False) for m in messages]
1202
+
1203
+
1204
+ def _report_unregistered(
1205
+ program: S.Program,
1206
+ profile,
1207
+ diags: Diagnostics,
1208
+ taint_at: int,
1209
+ dropped: set[int],
1210
+ tainted_subs: set[int],
1211
+ rewritten: set[int],
1212
+ ) -> None:
1213
+ """Names a tainted declaration would have registered are undefined afterwards: `Public A`
1214
+ after `Dim Z banana` makes a later `X = A` print `A is not defined.`, and a tainted `Function
1215
+ F (x)` loses its parameters (`F(1)` -> `No Matching, or out of place ) ...`; `X = a` in a
1216
+ tainted `Sub S (a)` -> `a is not defined.`). A `Const` is still registered (oracle, CR300,
1217
+ 2026-09-28, `docs/loggers.md` "Tainted declarations"). The tier-B name checks are re-run on
1218
+ a copy of the program without those declarations, keeping only their findings on statements
1219
+ after the taint that use an unregistered name or a tainted `Function`."""
1220
+ import dataclasses
1221
+
1222
+ from .. import consteval
1223
+ from . import structure, unused
1224
+
1225
+ statements = []
1226
+ for st in program.statements:
1227
+ if id(st) in dropped:
1228
+ continue
1229
+ statements.append(
1230
+ dataclasses.replace(st, params=[]) if isinstance(st, S.SubDef) and id(st) in tainted_subs else st
1231
+ )
1232
+ modified = dataclasses.replace(program, statements=statements)
1233
+ unregistered = expressions._known_names(program, profile) - expressions._known_names(modified, profile)
1234
+ functions = {
1235
+ st.name.upper()
1236
+ for st in program.statements
1237
+ if isinstance(st, S.SubDef) and id(st) in tainted_subs and st.keyword == "FUNCTION"
1238
+ }
1239
+ affected: set[tuple[int, str]] = set()
1240
+ for st in program.statements[taint_at + 1 :]:
1241
+ if id(st) in dropped or id(st) in rewritten or isinstance(st, S.SubDef):
1242
+ continue
1243
+ names: set[str] = set()
1244
+ unused._walk_stmt(st, names)
1245
+ if names & unregistered or (not isinstance(st, S.Call) and names & functions):
1246
+ affected.add((st.line, st.file))
1247
+ if not affected:
1248
+ return
1249
+ scratch = Diagnostics()
1250
+ env = consteval.Env(profile, dict(program.consts))
1251
+ structure.check_pass2(modified, profile, scratch, env)
1252
+ expressions.check(modified, profile, scratch, env)
1253
+ expressions.check_function_call_counts(modified, profile, scratch)
1254
+ existing = collections.Counter((e.line, e.file, e.message) for e in diags.items)
1255
+ for key in sorted(affected, key=lambda k: (k[1] or "", k[0])):
1256
+ fresh = [d for d in scratch.items if (d.line, d.file) == key and d.severity == "error"]
1257
+ at = [i for i, d in enumerate(diags.items) if (d.line, d.file) == key]
1258
+ old = collections.Counter(diags.items[i].message for i in at)
1259
+ if (
1260
+ at
1261
+ and at == list(range(at[0], at[-1] + 1))
1262
+ and all(diags.items[i].severity == "error" for i in at)
1263
+ and not old - collections.Counter(d.message for d in fresh)
1264
+ ):
1265
+ # the names are reported in text order: a newly undefined name (`For i = 1 To nv`
1266
+ # with `i` dropped) goes before an already reported one (oracle, CR300, 2026-10-04)
1267
+ diags.items[at[0] : at[-1] + 1] = fresh
1268
+ for d in fresh:
1269
+ existing[(d.line, d.file, d.message)] -= 1
1270
+ continue
1271
+ for d in fresh:
1272
+ if existing[(d.line, d.file, d.message)]:
1273
+ existing[(d.line, d.file, d.message)] -= 1
1274
+ else:
1275
+ diags.items.append(d)
1276
+ _respliced_call_args(modified, profile, diags, scratch, affected)
1277
+
1278
+
1279
+ def _respliced_call_args(
1280
+ modified: S.Program, profile, diags: Diagnostics, scratch: Diagnostics, affected: set[tuple[int, str]]
1281
+ ) -> None:
1282
+ """An unresolved call's argument names, rechecked without the unregistered declarations
1283
+ (`CDM_Battery(CDM_A108,1,Batt_Volt)` after the taint also reports `Batt_Volt`)."""
1284
+ check_undefined_call_args(modified, profile, scratch)
1285
+ calls = {(st.line, st.file) for st in modified.statements if isinstance(st, S.Call) and not st.explicit}
1286
+ for key in affected & calls:
1287
+ fresh = [d for d in scratch.items if (d.line, d.file) == key and d.severity == "error"]
1288
+ at = [i for i, d in enumerate(diags.items) if (d.line, d.file) == key]
1289
+ if not at or at != list(range(at[0], at[0] + len(at))) or len(fresh) <= len(at):
1290
+ continue
1291
+ if [d.message for d in diags.items[at[0] : at[-1] + 1]] != [
1292
+ d.message for d in fresh if d.message in {e.message for e in diags.items[at[0] : at[-1] + 1]}
1293
+ ]:
1294
+ continue
1295
+ diags.items[at[0] : at[-1] + 1] = fresh
1296
+
1297
+
1298
+ # `check_unknown_line_words` (this round): a line that parses as nothing recognisable at all --
1299
+ # every corpus instance found this round is CHM example prose left uncommented, e.g. `To force
1300
+ # double-precision arithmetic, declare ...` -- becomes an `S.Unparsed` statement and hits
1301
+ # `checker/structure.py`'s own generic `Invalid, or out of place expression: %s.` fallback on
1302
+ # every logger. CR300/CR350 additionally print one `%s is not defined.` line (`profile.
1303
+ # unresolved_symbol_message`) per *leading* bare word of that statement's own source text, ahead of
1304
+ # the generic line, following a "resolve one syntactic chain, plus one trailing word" shape --
1305
+ # oracle-confirmed this round (`docs/loggers.md` "Round 8", scratch probes, `t.cr6`, both
1306
+ # `cr300comp.exe`/`cr350comp.exe`, identical): starting at the statement's first word,
1307
+ # 1. A run of bare words joined only by a single connecting *operator* character (any of
1308
+ # `+-*/^&<>=`, immediately adjacent, no surrounding space -- `alpha-beta-gamma delta` flags
1309
+ # `alpha`, `beta`, `gamma`, all three, i.e. the chain is not capped) all get checked, in
1310
+ # order, as if each were a real value reference.
1311
+ # 2. Once that chain stops (the next thing is *not* one more `<op><word>` link), exactly one more
1312
+ # bare word is checked if -- and only if -- it is separated from the chain by plain
1313
+ # whitespace and nothing else (`foo bar baz qux` flags only `foo`, `bar`; `foo, bar` flags
1314
+ # only `foo`, no peek at `bar` -- a comma is not "plain whitespace"; `Part 1.` flags only
1315
+ # `Part` -- the peek must itself be a bare word, not a number). This one word is checked
1316
+ # whether or not it was actually reachable as valid syntax; it, too, only reports if
1317
+ # unresolved.
1318
+ # 3. Whatever is or is not flagged, scanning stops there for good -- nothing further along the
1319
+ # line is ever checked (`foo bar baz` never touches `baz`; `This is not defined test` never
1320
+ # touches `not`/`defined`/`test`, because `is` is one of this project's own extracted
1321
+ # `functions.json` names -- see below -- and already terminates the one-word peek).
1322
+ # "Resolved" here is `expressions._known_names(program, profile)`, the *same* set every other
1323
+ # CR300/CR350 undefined-name check in this project already uses -- confirmed sufficient with no
1324
+ # separate reserved-word list needed, because this project's own extracted per-logger tables
1325
+ # already carry CRBasic's syntax keywords alongside its predefined symbols (`Double`/`Long`/
1326
+ # `String`/`Boolean` in `symbols.json`, `To`/`Is` in `functions.json`, `Public` in
1327
+ # `instructions.json` -- confirmed present for CR300; not re-checked for CR350 individually, but
1328
+ # both loggers' own tables come from the same `extract_tables.py` process and every shape probed
1329
+ # above (`Double foo`, `This is not defined test`, ...) came back byte-identical on both compilers
1330
+ # this round, so the same table shape is assumed, not re-verified name-by-name).
1331
+ #
1332
+ # What this deliberately does *not* attempt, for lack of oracle coverage this round (documented
1333
+ # rather than guessed at, `AGENTS.md`): a connecting operator with surrounding *whitespace*
1334
+ # (`alpha - beta`, untested -- only the no-space `alpha-beta` form was probed); any operator other
1335
+ # than the eight tested characters; and a leading word that is itself the very first token of the
1336
+ # *whole statement* and already resolves (`Double foo` was probed and *does* still flag `foo` --
1337
+ # handled correctly by this algorithm, since resolution is checked only when deciding whether to
1338
+ # *report* a word, never when deciding whether to keep walking -- but no case where the walk
1339
+ # itself needs to treat a resolved word differently from an unresolved one was found, so none is
1340
+ # implemented).
1341
+ _GENERIC_FALLBACK_PREFIX = "Invalid, or out of place expression: "
1342
+ _LINE_WORD_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
1343
+ # Instructions without parameters: text after the name is a surplus parameter (oracle, CR300 and
1344
+ # CR350, 2026-10-11, one probe each; `EndProg` ignores its tail and `Alias`, `Const`, `Data`, ... read
1345
+ # it themselves, so they are not listed).
1346
+ _TAIL_REJECTING = frozenset(
1347
+ n.upper()
1348
+ for n in (
1349
+ "BeginProg PipeLineMode SequentialMode EndIf WEND OpenInterval NEXTSCAN NEXTSUBSCAN ENDTABLE "
1350
+ "FillStop ExitScan ContinueScan ExitSub ExitDo ExitFor Restore AngleDegrees ShutdownBegin "
1351
+ "ShutdownEnd EndSequence ExitFunction AbortScan SampleFieldCal WaitTriggerSequence "
1352
+ "PulseCountReset WebPageEnd TableHide DHCPRenew Restart PreserveVariables "
1353
+ "ApplyAndRestartSequence EndApplyAndRestartSequence"
1354
+ ).split()
1355
+ )
1356
+ _ZERO_TAIL_RE = re.compile(r"\s*([A-Za-z_]\w*)(?=[\s,(])(.*)$", re.S)
1357
+ # A parameter text that starts with a binary operator word: `Invalid, or out of place expression`
1358
+ # after the next operand is named (`is`/`mod` stop at once), not `Incomplete expression`.
1359
+ _LEAD_OP_WORD_RE = re.compile(r"(?i)(is|mod|and|or|xor|imp|eqv|to)(?![A-Za-z0-9_])")
1360
+ _LEAD_OP_NO_OPERAND = frozenset({"IS", "MOD"})
1361
+ _LEAD_OP_SKIP_RE = re.compile(r"(?i)^[ \t]*(?:(?:and|or|xor|imp|eqv|to|not|mod|is)(?![A-Za-z0-9_])[ \t]*)*")
1362
+ _INSTRUCTION_PROSE_RE = re.compile(r"\s*([A-Za-z_][A-Za-z0-9_]*)[ \t]+([A-Za-z_][^(]*)$", re.S)
1363
+ _LINE_OPERAND_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*|\d+(?:\.\d+)?\b|\"[^\"]*\"")
1364
+ # Keyword operators join the chain like `+` (oracle, CR300, 2026-10-01: `foo and bar baz qux` ->
1365
+ # foo, bar, baz; `Is` and `Not` do not).
1366
+ _LINE_OP_WORD_RE = re.compile(r"[ \t]+(?i:and|or|xor|eqv|imp|mod|to)[ \t]+(?=[A-Za-z_0-9\"])")
1367
+ # A line starting `word.` reads as a table field: `Could not find DataTable word.` (even for a
1368
+ # declared name), and nothing at all once the cascade has started (oracle, CR300, 2026-10-01).
1369
+ _TABLE_LINE_RE = re.compile(r"\s*([A-Za-z_][A-Za-z0-9_]*)\.")
1370
+ # the same with a sign in front (`+ Test.TCTemp(1)`, a wrapped string concatenation, oracle,
1371
+ # CR300/CR350, 2026-10-11): `Could not find DataTable` for an unknown table, nothing after the cascade
1372
+ _SIGNED_TABLE_RE = re.compile(r"\s*[-+][ \t]*([A-Za-z_][A-Za-z0-9_]*)\.(?=[A-Za-z_])")
1373
+ _LINE_OP_RUN_RE = re.compile(r"(?:[ \t]*(?:<>|<=|>=|[-+*/^&<>=])[ \t]*)(?=[A-Za-z_0-9\"])|(?:<>|<=|>=|[-+*/^&<>=])")
1374
+ _LINE_WS_RE = re.compile(r"[ \t]+")
1375
+ _LINE_PAREN_RE = re.compile(r"[ \t]*\(")
1376
+
1377
+
1378
+ def _split_top_commas(text: str) -> list[str]:
1379
+ parts, depth, cur = [], 0, ""
1380
+ for ch in text:
1381
+ depth += (ch == "(") - (ch == ")")
1382
+ if ch == "," and depth <= 0:
1383
+ parts.append(cur)
1384
+ cur = ""
1385
+ else:
1386
+ cur += ch
1387
+ return parts + [cur]
1388
+
1389
+
1390
+ def _inside_table(program: S.Program, line: int, file: str) -> bool:
1391
+ """The statement at `line` sits in a `DataTable` body."""
1392
+ inside = False
1393
+ for st in program.statements:
1394
+ kw = st.keyword.upper() if isinstance(st, S.Instruction) else ""
1395
+ if (st.line, st.file) == (line, file):
1396
+ return inside
1397
+ if kw == "DATATABLE":
1398
+ inside = True
1399
+ elif kw == "ENDTABLE":
1400
+ inside = False
1401
+ return False
1402
+
1403
+
1404
+ def _loose_before_beginprog(program: S.Program, line: int, file: str) -> bool:
1405
+ """The statement at `line` precedes `BeginProg` outside any Sub/Function/table body."""
1406
+ depth = 0
1407
+ for st in program.statements:
1408
+ kw = st.keyword.upper() if isinstance(st, S.Instruction) else ""
1409
+ if (st.line, st.file) == (line, file):
1410
+ return depth == 0
1411
+ if kw == "BEGINPROG":
1412
+ return False
1413
+ if isinstance(st, S.SubDef) or kw in ("DATATABLE", "CONSTTABLE", "DISPLAYMENU", "STRUCTURETYPE"):
1414
+ depth += 1
1415
+ elif S.ends_routine(st) or kw in ("ENDTABLE", "ENDCONSTTABLE", "ENDMENU", "ENDSTRUCTURETYPE"):
1416
+ depth = max(0, depth - 1)
1417
+ return False
1418
+
1419
+
1420
+ def prose_known(program: S.Program, profile) -> set[str]:
1421
+ """Names a prose word resolves to: an instruction keyword that is not also a symbol or function
1422
+ is reported (oracle, CR300, 2026-10-01: `foo Battery` -> `Battery is not defined.`, `foo Sin`
1423
+ and `foo Scan` are not)."""
1424
+ instruction_only = set(profile.instructions) - set(profile.symbols) - set(profile.functions)
1425
+ known = expressions._known_names(program, profile)
1426
+ return set(known) - instruction_only - set(known.tables)
1427
+
1428
+
1429
+ def prose_known_tables(program: S.Program, profile) -> set[str]:
1430
+ """Upper-cased user `DataTable` names (a table field line is not an unknown table)."""
1431
+ return set(getattr(expressions._known_names(program, profile), "tables", ()))
1432
+
1433
+
1434
+ def first_declarations(program: S.Program) -> dict[str, tuple[str, int]]:
1435
+ """Upper-cased variable/`Const`/`Alias` name -> (file, line) of its first declaration."""
1436
+ first: dict[str, tuple[str, int]] = {}
1437
+ for st in program.statements:
1438
+ names: list[str] = []
1439
+ if isinstance(st, S.Declaration):
1440
+ names = [v.name for v in st.vars]
1441
+ elif isinstance(st, S.ConstDecl):
1442
+ names = [st.name]
1443
+ elif isinstance(st, S.Instruction) and st.keyword.upper() == "ALIAS":
1444
+ names = [a.name for a in st.args[1:] if isinstance(a, S.Name)]
1445
+ for n in names:
1446
+ first.setdefault(n.upper(), (st.file, st.line))
1447
+ return first
1448
+
1449
+
1450
+ def declared_later(first: dict[str, tuple[str, int]], line: int, file: str) -> set[str]:
1451
+ """Names whose first declaration comes after `line`: single-pass compilers do not know them
1452
+ yet when a prose line mentions them (oracle, CR300, 2026-10-08)."""
1453
+ return {n for n, (f, l) in first.items() if f == file and l > line}
1454
+
1455
+
1456
+ def _paren_items_words(inner: str, known: set[str]) -> list[str]:
1457
+ """The undefined names of the text after a call's `(`: with top-level commas, the first three
1458
+ items are walked like a line (words, chain and peek) and the fourth only for its first name,
1459
+ the rest is not read (oracle, CR300, 2026-10-11: `cv(zz, yy, 6, ww) text` names `zz`, `yy`,
1460
+ `ww`; `cv(4, zz yy, 6)` names both). An item starting `&` is a hex literal (`&h0F`) or, in
1461
+ operand position, a name made of the `&` and what follows it up to a blank (`& 6` -> `&`,
1462
+ `&6` -> `&6`); the fourth item's `&h0F` names `h0F`, its other `&` forms name nothing."""
1463
+ items, depth, cur = [], 0, ""
1464
+ for ch in inner:
1465
+ if ch == "(":
1466
+ depth += 1
1467
+ elif ch == ")":
1468
+ if depth == 0:
1469
+ break
1470
+ depth -= 1
1471
+ if ch == "," and depth == 0:
1472
+ items.append(cur)
1473
+ cur = ""
1474
+ else:
1475
+ cur += ch
1476
+ items.append(cur)
1477
+ if len(items) == 1 and "&" not in inner:
1478
+ return _leading_unknown_words(inner, known)
1479
+ words: list[str] = []
1480
+ for idx, item in enumerate(items[:4]):
1481
+ item = item.lstrip(" \t")
1482
+ amp = re.match(r"&([A-Za-z0-9_]*)", item)
1483
+ if amp:
1484
+ hexed = re.match(r"&[hH][0-9A-Fa-f]+", item)
1485
+ if idx == 3:
1486
+ if hexed and amp.group(1) not in known:
1487
+ words.append(amp.group(1))
1488
+ continue
1489
+ if not hexed:
1490
+ words.append(amp.group(0))
1491
+ continue
1492
+ item = "0" + item[hexed.end() :]
1493
+ walked: dict = {}
1494
+ found = _leading_unknown_words(item, known, state=walked)
1495
+ words += found[:1] if idx == 3 else found
1496
+ if walked.get("peeked"):
1497
+ break
1498
+ return words
1499
+
1500
+
1501
+ def _leading_unknown_words(
1502
+ text: str, known: set[str], callables: frozenset = frozenset(), state: dict | None = None
1503
+ ) -> list[str]:
1504
+ """The ordered list of `text`'s own leading bare words this project believes CR300/CR350
1505
+ would report as `%s is not defined.` ahead of the generic fallback for it -- see this
1506
+ module's `check_unknown_line_words` comment above for the oracle evidence and the exact walk
1507
+ this implements. `known` is the same upper-cased name set `expressions._known_names` builds;
1508
+ a word already in it is walked over (it can still be part of the chain) but never returned.
1509
+ """
1510
+ lead_op = re.match(r"[ \t]*(?:<>|<=|>=|<<|>>|[=<>])[ \t]*(?=[A-Za-z_])", text)
1511
+ if lead_op:
1512
+ # a line that starts with a comparison operator reads the next word as its first operand
1513
+ # and stops there (oracle, CR300/CR350, 2026-10-11: `= Year ` -> `Year`)
1514
+ word = _LINE_WORD_RE.match(text, lead_op.end())
1515
+ assert word is not None # (`lead_op` ends before a letter)
1516
+ return [word.group(0)] if word.group(0).upper() not in known else []
1517
+ # The chain starts at the first word or number (a leading `2 when the` reports `when`).
1518
+ pos = len(text) - len(text.lstrip(" \t"))
1519
+ if re.match(r"-\d", text[pos : pos + 2]):
1520
+ pos += 1 # a signed number starts the chain like a number (oracle, CR300, 2026-10-10: `-1 Then`)
1521
+ elif text[pos : pos + 1] == '"' and text.find('"', pos + 1) > 0:
1522
+ text = text[:pos] + "0" + text[text.find('"', pos + 1) + 1 :] # a string literal likewise
1523
+ if not _LINE_OPERAND_RE.match(text, pos):
1524
+ return []
1525
+ if text[pos : pos + 3].upper() == "IS " or text[pos:].upper() == "IS":
1526
+ return [] # a leading `Is` ends the walk before any word (oracle, CR300, 2026-10-08)
1527
+ words: list[str] = []
1528
+ extending = True # False once we're consuming the single trailing "peek" word.
1529
+ while True:
1530
+ operand = _LINE_OPERAND_RE.match(text, pos) if extending else _LINE_WORD_RE.match(text, pos)
1531
+ if not operand:
1532
+ break
1533
+ pos = operand.end()
1534
+ if operand.group(0)[0].isdigit() or operand.group(0)[0] == '"':
1535
+ pass # a number or a string is part of the chain but never reported
1536
+ elif text.startswith(".", pos):
1537
+ break # `word.` is a table field reference, not a name (oracle, 2026-10-01)
1538
+ else:
1539
+ words.append(operand.group(0))
1540
+ if not extending:
1541
+ break
1542
+ paren = None if operand.group(0)[0].isdigit() or operand.group(0)[0] == '"' else _LINE_PAREN_RE.match(text, pos)
1543
+ if paren and len(words) == 1 and (words[0].upper() not in known or words[0].upper() in callables):
1544
+ # `device (8 channels)`: the first word's parenthesis opens a second walk of the same
1545
+ # shape, which ends at the first `)`, and nothing after it is examined
1546
+ # (oracle, CR300, 2026-10-08).
1547
+ words += _paren_items_words(
1548
+ text[paren.end() :], known
1549
+ ) # (a declared variable or function keeps the walk too, oracle 2026-10-10: `X (foo bar)`)
1550
+ break
1551
+ op_match = _LINE_OP_RUN_RE.match(text, pos) or _LINE_OP_WORD_RE.match(text, pos)
1552
+ if op_match:
1553
+ pos = op_match.end()
1554
+ continue
1555
+ ws_match = _LINE_WS_RE.match(text, pos)
1556
+ if ws_match and _LINE_WORD_RE.match(text, ws_match.end()):
1557
+ pos = ws_match.end()
1558
+ extending = False
1559
+ if state is not None:
1560
+ state["peeked"] = True # (a juxtaposed word: the walk is over)
1561
+ continue
1562
+ break
1563
+ return [w for w in words if w.upper() not in known]
1564
+
1565
+
1566
+ # `Instruction(a, b c, d)`: the first argument with adjacent operands (oracle, CR300/CR350,
1567
+ # 2026-10-12, a sweep of every instruction and position, `tests/fixtures/r15_cr3xx_call_adjacent_arg.json`):
1568
+ # the arguments before it are read normally; its own first two operands are looked up, then
1569
+ # `Incomplete expression: <arg>.`. A constant-only parameter (codes 1, 5, 20, 38, 39) adds
1570
+ # `Variable illegal in Parameter: <arg>.` when one of those two operands is a variable or an
1571
+ # undefined name; any other parameter is followed by `Too many parameters: <first token of the
1572
+ # next argument>.` when an argument follows. The arguments after it are never read.
1573
+ _CALL_ADJACENT_EXCLUDED = frozenset(
1574
+ {
1575
+ "DATA",
1576
+ "DATALONG",
1577
+ "ALIAS",
1578
+ "UNITS",
1579
+ "SUB",
1580
+ "FUNCTION",
1581
+ "IF",
1582
+ "ELSEIF",
1583
+ "WHILE",
1584
+ "UNTIL",
1585
+ "SELECT",
1586
+ "CASE",
1587
+ "FOR",
1588
+ "DO",
1589
+ "LOOP",
1590
+ }
1591
+ )
1592
+ _ADJACENT_CONST_CODES = frozenset({1, 5, 20, 38, 39})
1593
+ _ADJACENT_EXPR_CODES = frozenset({2, 8, 24, 25, 26, 27, 28, 29, 33, 40, 41, 47, 48})
1594
+ _ADJACENT_RE = re.compile(r"[\w\")]\s+[\w\"]")
1595
+ _ADJACENT_OP_WORD_RE = re.compile(r"(?i)\b(?:and|or|xor|eqv|imp|mod|is|to|not)\b")
1596
+
1597
+
1598
+ def _split_args_quoted(text: str) -> list[str] | None:
1599
+ """The top-level comma-separated arguments of a call's text after the `(` (the call's own
1600
+ closing parenthesis stays on the last one); `None` when the parentheses do not balance as one
1601
+ call would."""
1602
+ parts, depth, cur, quoted = [], 0, "", False
1603
+ for ch in text:
1604
+ if ch == '"':
1605
+ quoted = not quoted
1606
+ elif not quoted:
1607
+ if ch == "(":
1608
+ depth += 1
1609
+ elif ch == ")":
1610
+ depth -= 1
1611
+ if depth < -1:
1612
+ return None
1613
+ elif ch == "," and depth == 0:
1614
+ parts.append(cur)
1615
+ cur = ""
1616
+ continue
1617
+ cur += ch
1618
+ parts.append(cur)
1619
+ if quoted:
1620
+ return None
1621
+ return parts
1622
+
1623
+
1624
+ def _adjacent_operands(arg: str) -> bool:
1625
+ stripped = re.sub(r'"[^"]*"', '"', arg)
1626
+ return bool(_ADJACENT_RE.search(stripped)) and not _ADJACENT_OP_WORD_RE.search(arg)
1627
+
1628
+
1629
+ _ADJACENT_TABLE_SOURCES = frozenset({"SAMPLE", "AVERAGE", "MAXIMUM", "MINIMUM", "TOTALIZE", "STDDEV"})
1630
+
1631
+
1632
+ def _call_adjacent_arg_messages(
1633
+ inner: str, record: dict, here: set[str], variables: AbstractSet[str], profile, in_table: bool
1634
+ ) -> list[str] | None:
1635
+ params = record["params"]
1636
+ args = _split_args_quoted(inner)
1637
+ if args is None:
1638
+ return None
1639
+ for k, arg in enumerate(args):
1640
+ last = k == len(args) - 1
1641
+ if last and arg.rstrip().endswith(")") and arg.count("(") < arg.count(")"):
1642
+ arg = arg.rstrip()[:-1]
1643
+ if _adjacent_operands(arg):
1644
+ break
1645
+ if k < len(params) and params[k] % 100 in (6, 11):
1646
+ continue # a name or deferred text: not looked at while the statement is read
1647
+ if not arg.strip() or arg.count("(") != arg.count(")") or _leading_unknown_words(arg, here):
1648
+ return None # an earlier argument with problems of its own: not this shape
1649
+ else:
1650
+ return None
1651
+ if k >= len(params):
1652
+ return None
1653
+ code = params[k] % 100
1654
+ if code == 11:
1655
+ # a table source with adjacent operands: its words and the incomplete expression, in a table
1656
+ # (outside one the placement error replaces them); a shortfall hides them
1657
+ if not in_table or record["name"].upper() not in _ADJACENT_TABLE_SOURCES:
1658
+ return None
1659
+ carg = arg.strip() if last else arg.lstrip()
1660
+ short = instr_rules.shortfall_message(record, instr_rules.given_count_of(args))
1661
+ if short:
1662
+ return [short]
1663
+ return [profile.unresolved_symbol_message.format(name=w) for w in _leading_unknown_words(carg, here)] + [
1664
+ f"Incomplete expression: {carg}."
1665
+ ]
1666
+ if code not in _ADJACENT_CONST_CODES and code not in _ADJACENT_EXPR_CODES:
1667
+ return None
1668
+ carg = arg.strip() if last else arg.lstrip()
1669
+ out = [profile.unresolved_symbol_message.format(name=w) for w in _leading_unknown_words(carg, here)]
1670
+ out.append(f"Incomplete expression: {carg}.")
1671
+ if code in _ADJACENT_CONST_CODES:
1672
+ chain = _leading_unknown_words(carg, set(), frozenset(profile.functions))
1673
+ if any(w.upper() in variables or w.upper() not in here for w in chain if w.upper() not in profile.functions):
1674
+ out.append(f"Variable illegal in Parameter: {carg}.")
1675
+ elif not last:
1676
+ token = instr_rules._next_simple_token(",".join(args[k + 1 :]))
1677
+ if token:
1678
+ out.append(f"Too many parameters: {token}.")
1679
+ return out
1680
+
1681
+
1682
+ # `check_undefined_call_args` (this round): a `Name(args)`/`Name args` call whose own name never
1683
+ # resolves to a `Sub`, a built-in function or an instruction gets, on CR6/CR1000X, only the generic
1684
+ # `Invalid, or out of place expression: %s.` fallback (`structure._check_call_pass2`'s "resolving
1685
+ # to nothing" branch); CR300/CR350 additionally print `{name} is not defined.` for the call's own
1686
+ # name first (`profile.undefined_call_message`, already implemented in `structure.py`) -- but, not
1687
+ # yet modelled anywhere until this round, they *also* print one `%s is not defined.` line per
1688
+ # undefined argument name the call was written with, spliced in between that first line and the
1689
+ # generic fallback -- oracle-confirmed this round (`docs/loggers.md` "CDM_* argument names", both
1690
+ # `cr300comp.exe`/`cr350comp.exe`, identical), corpus: every `CDM_*` instruction this project's own
1691
+ # tables have no record of on CR300/CR350 (`cdmbrfull6w_1.CR6`, `cdmexcitev_1.CR6`, `cdmdelay_1.CR6`,
1692
+ # ... -- CDM support requires hardware these two loggers' own smaller product line never carries),
1693
+ # plus scratch probe `t3.cr6`.
1694
+ #
1695
+ # The rule, walking the call's arguments in source order (oracle probes 2026-10-01):
1696
+ # - Arguments 1-3 are walked fully: bare names, names inside operator expressions (left then
1697
+ # right; unary operands), and names inside index arguments. A declared name is skipped and the
1698
+ # scan continues; a bare DataTable name counts as undefined. Literals and empty slots are skipped.
1699
+ # - An undefined indexed name `B(...)` is reported, then its own arguments are walked, then the
1700
+ # whole scan stops. A defined indexed name `X(...)` has its arguments walked and the scan goes on.
1701
+ # - Argument 4 contributes only its leftmost term; arguments 5+ are never examined.
1702
+ # Examples (undefined names B0.., declared array X):
1703
+ # Foo(B0, B1, B2, B3, B4, B5) -> B0 B1 B2 B3
1704
+ # Foo(1, 2, 3, B3+B4) -> B3
1705
+ # Foo(B0+B1, B2+B3, B4+B5) -> all six
1706
+ # Foo(B0(B9), B1) -> B0 B9
1707
+ # Foo(B0, X(B1), B2) -> B0 B1 B2
1708
+ # Foo(X, 0, Bad0, 2, Bad1) -> Bad0 only (Bad1 is argument 5)
1709
+ # Corpus: `cdmbrhalf3w_1.CR6` (`mv200` is argument 5, never reported).
1710
+ MEMBER_STOP = "\0member" # (oracle, CR300, 2026-10-01: `Foo(Bad,Tb.X)` -> Foo, Bad, nothing else)
1711
+
1712
+
1713
+ def _call_arg_undefined_names(
1714
+ args: list[S.Expr | None], known: set[str], routines: frozenset[str] = frozenset()
1715
+ ) -> list[str]:
1716
+ """The undefined names among an unresolved call's arguments, in source order -- see
1717
+ `check_undefined_call_args`'s module comment above for the oracle evidence and the rule."""
1718
+ names: list[str] = []
1719
+ tables = getattr(known, "tables", ())
1720
+
1721
+ def undefined(name: str) -> bool:
1722
+ return name.upper() not in known or name.upper() in tables
1723
+
1724
+ def walk(expr: S.Expr | None) -> bool:
1725
+ """Append `expr`'s undefined names; False once an undefined indexed name or a table
1726
+ member (`Tb.X`, then no fallback line either: `MEMBER_STOP`) ends the scan."""
1727
+ if isinstance(expr, S.Member):
1728
+ names.append(MEMBER_STOP)
1729
+ return False
1730
+ if isinstance(expr, S.Name):
1731
+ if undefined(expr.name):
1732
+ names.append(expr.name)
1733
+ elif isinstance(expr, S.Index):
1734
+ bad = undefined(expr.name)
1735
+ if bad:
1736
+ names.append(expr.name)
1737
+ if not all(walk(a) for a in expr.args) or bad:
1738
+ return False
1739
+ elif isinstance(expr, S.Binary):
1740
+ return walk(expr.left) and walk(expr.right)
1741
+ elif isinstance(expr, S.Unary):
1742
+ return walk(expr.operand)
1743
+ return True
1744
+
1745
+ for arg in args[:3]:
1746
+ if not walk(arg):
1747
+ return names
1748
+ if len(args) > 3 and isinstance(args[3], S.Number) and args[3].text[:2].lower() == "&h":
1749
+ if (
1750
+ undefined(args[3].text[1:]) or args[3].text[1:].upper() in routines
1751
+ ): # (a declared variable `h0F` is not named, a Sub's name is: oracle, CR300/CR350, 2026-10-12)
1752
+ names.append(args[3].text[1:]) # (oracle, CR300, 2026-10-11: a fourth `&h0000` names `h0000`)
1753
+ elif len(args) > 3:
1754
+ first = args[3]
1755
+ while isinstance(first, (S.Binary, S.Unary)):
1756
+ first = first.left if isinstance(first, S.Binary) else first.operand
1757
+ if isinstance(first, (S.Name, S.Index)) and undefined(first.name):
1758
+ names.append(first.name)
1759
+ return names
1760
+
1761
+
1762
+ def check_undefined_call_args(program: S.Program, profile, diags: Diagnostics) -> None:
1763
+ """CR300/CR350 only; a no-op on every other logger. Called from `checker/__init__.py`, right
1764
+ after `check_unknown_line_words`, in the same tier-B block -- see this module's
1765
+ `check_undefined_call_args` module comment above for the oracle evidence and the exact rule
1766
+ implemented (and its deliberate limits).
1767
+
1768
+ Reacts to `diags.items` already emitted above (the same after-the-fact pattern
1769
+ `check_unknown_line_words` already uses) rather than re-deriving *why* `structure.py` chose to
1770
+ report a given `S.Call` as unresolved: finds each `S.Call` this program's own `program.
1771
+ statements` shows was written without the `Call` keyword and never resolves to a declared
1772
+ `Sub` (`symbols.declared_subs`) or a built-in function (`profile.functions`) -- the same
1773
+ "resolves to nothing" condition `structure._check_call_pass2` itself gates its own
1774
+ `{name} is not defined.` + generic-fallback pair on -- then, only where that exact two-line
1775
+ pair is already sitting in `diags.items` at that statement's own `(line, file)` (confirming
1776
+ `structure.py` actually reported it there, not truncated by an earlier placement cutoff or
1777
+ left unreported for some other reason this module does not re-derive), splices in this
1778
+ statement's own undefined-argument-name lines between them.
1779
+ """
1780
+ if profile.name not in ("cr300", "cr350"):
1781
+ return
1782
+ if not diags.items:
1783
+ return
1784
+ subs = symbols.declared_subs(program)
1785
+ calls_by_key: dict[tuple[int, str], S.Call] = {}
1786
+ for stmt in program.statements:
1787
+ if not isinstance(stmt, S.Call) or stmt.explicit:
1788
+ continue
1789
+ name = stmt.name.upper()
1790
+ if name in subs or name in profile.functions:
1791
+ continue
1792
+ calls_by_key[(stmt.line, stmt.file)] = stmt
1793
+ if not calls_by_key:
1794
+ return
1795
+ known: set[str] | None = None
1796
+ items = diags.items
1797
+ new_items: list[Diagnostic] = []
1798
+ changed = False
1799
+ i = 0
1800
+ n = len(items)
1801
+ while i < n:
1802
+ d = items[i]
1803
+ stmt = calls_by_key.get((d.line, d.file))
1804
+ nxt = items[i + 1] if i + 1 < n else None
1805
+ if (
1806
+ stmt is not None
1807
+ and d.severity == "error"
1808
+ and d.message == profile.unresolved_symbol_message.format(name=stmt.name)
1809
+ and nxt is not None
1810
+ and nxt.severity == "error"
1811
+ and (nxt.line, nxt.file) == (d.line, d.file)
1812
+ and nxt.message.startswith(_GENERIC_FALLBACK_PREFIX)
1813
+ ):
1814
+ if known is None:
1815
+ known = expressions._known_names(program, profile)
1816
+ routines = frozenset(
1817
+ st.name.upper() for st in program.statements if isinstance(st, S.SubDef)
1818
+ ) - symbols.declared_variable_names(program)
1819
+ names = _call_arg_undefined_names(stmt.args, known, routines)
1820
+ new_items.append(d)
1821
+ for undefined_name in names:
1822
+ if undefined_name == MEMBER_STOP:
1823
+ i += 1 # a table member ends the call: no fallback line
1824
+ changed = True
1825
+ break
1826
+ new_items.append(
1827
+ Diagnostic(
1828
+ "error", d.line, profile.unresolved_symbol_message.format(name=undefined_name), d.file, True
1829
+ )
1830
+ )
1831
+ changed = True
1832
+ i += 1
1833
+ continue
1834
+ new_items.append(d)
1835
+ i += 1
1836
+ if changed:
1837
+ diags.items[:] = new_items
1838
+
1839
+
1840
+ def order_email_destination(program: S.Program, profile, diags: Diagnostics) -> None:
1841
+ """CR300/CR350: `A string variable destination is required` (`instr_rules.
1842
+ check_emailrecv_destination`, a pass-1 style check) follows the statement's own `X is not
1843
+ defined.` lines (oracle, 2026-10-08)."""
1844
+ if profile.name not in ("cr300", "cr350"):
1845
+ return
1846
+ items = diags.items
1847
+ for i, d in enumerate(items):
1848
+ if d.message != instr_rules._SERIALIN_DEST_MESSAGE:
1849
+ continue
1850
+ j = i + 1
1851
+ while (
1852
+ j < len(items)
1853
+ and (items[j].line, items[j].file) == (d.line, d.file)
1854
+ and items[j].message.endswith(_UNRESOLVED_SUFFIX)
1855
+ ):
1856
+ j += 1
1857
+ if j > i + 1:
1858
+ items[i:j] = items[i + 1 : j] + [d]
1859
+
1860
+
1861
+ _SHORTFALL_MESSAGES = ("Invalid or missing parameter.", "Too few parameters in instruction.")
1862
+
1863
+
1864
+ def drop_shortfall_with_names(program: S.Program, profile, diags: Diagnostics) -> None:
1865
+ """CR300/CR350: an undefined name hides the too-few-arguments message of its own statement and
1866
+ of every later statement written with arguments, but not of an earlier one (oracle, 2026-10-08/10: `PanelTemp(Nope)`,
1867
+ `Delay(1,Nope)`, `VoltDiff (X,1,mV200,1)` print only the name lines; `PanelTemp(X) : X=Nope`,
1868
+ `PanelTemp(X)` then `PanelTemp(Nope)` keep the first shortfall; `X=Nope : PanelTemp(X)` and
1869
+ `X=Nope` then `PanelTemp(X)` on the next line hide it)."""
1870
+ if profile.name not in ("cr300", "cr350"):
1871
+ return
1872
+ named = {(d.line, d.file) for d in diags.items if d.severity == "error" and d.message.endswith(_UNRESOLVED_SUFFIX)}
1873
+ # `Variable illegal in Parameter` ends the statement like an undefined name does, and hides the
1874
+ # shortfalls of the statement and of the later ones (oracle, CR300, 2026-10-12: `Delay(y)`)
1875
+ illegal = {
1876
+ (d.line, d.file)
1877
+ for d in diags.items
1878
+ if d.severity == "error" and d.message.startswith(_VARIABLE_ILLEGAL_PREFIX)
1879
+ }
1880
+ named |= illegal
1881
+ if not named:
1882
+ return
1883
+ stmts = program.statements
1884
+ known = expressions._known_names(program, profile)
1885
+
1886
+ def has_names(stmt) -> bool:
1887
+ exprs = (
1888
+ stmt.args
1889
+ if isinstance(stmt, S.Instruction)
1890
+ else [stmt.target, stmt.value]
1891
+ if isinstance(stmt, S.Assignment)
1892
+ else []
1893
+ )
1894
+ if isinstance(stmt, S.Instruction) and (stmt.line, stmt.file) in illegal:
1895
+ return True
1896
+ return any(expressions._undefined_names(e, known) for e in exprs)
1897
+
1898
+ def is_short(stmt) -> bool:
1899
+ record = profile.instructions.get(stmt.keyword.upper()) if isinstance(stmt, S.Instruction) else None
1900
+ return record is not None and instr_rules.given_count(stmt, profile) < instr_rules._required_count(
1901
+ instr_rules._real_params(record["params"])
1902
+ )
1903
+
1904
+ first = next((i for i, st in enumerate(stmts) if (st.line, st.file) in named and has_names(st)), None)
1905
+ # A line whose name errors come from no instruction/assignment (prose, `S.Unparsed`) keeps the
1906
+ # per-line rule: it hides only its own line's shortfall.
1907
+ with_names = {(st.line, st.file) for st in stmts if has_names(st)}
1908
+ line_only = named - with_names
1909
+ first_key = None if first is None else (stmts[first].line, stmts[first].file)
1910
+
1911
+ seen: collections.Counter = collections.Counter() # shortfall diagnostics met per line
1912
+
1913
+ def hidden(d: Diagnostic) -> bool:
1914
+ if d.severity != "error" or d.message not in _SHORTFALL_MESSAGES:
1915
+ return False
1916
+ key = (d.line, d.file)
1917
+ idxs = [i for i, st in enumerate(stmts) if (st.line, st.file) == key]
1918
+ if any(isinstance(st := stmts[i], S.Unparsed) and st.reason == structure._FOR_MISSING for i in idxs):
1919
+ return False # a `For` without `=` is never hidden by names (oracle, CR300, 2026-10-10)
1920
+ short = [i for i in idxs if is_short(stmts[i])]
1921
+ nth = seen[key]
1922
+ seen[key] += 1
1923
+ if key in line_only or (key in named and (not idxs or (not short and key == first_key))):
1924
+ return True
1925
+ if first is None:
1926
+ return False
1927
+ cand = short[nth] if nth < len(short) else (short or idxs)[0]
1928
+ # A statement written with no argument at all is never hidden (`X=Nope` then `PanelTemp`).
1929
+ return cand >= first and bool(getattr(stmts[cand], "args", True))
1930
+
1931
+ diags.items[:] = [d for d in diags.items if not hidden(d)]
1932
+ # a `For` without `=` or a short instruction that ends a line whose earlier segment named
1933
+ # undefined words prints after them (oracle, CR300/CR350, 2026-10-10: `X=Nope : PanelTemp`)
1934
+ for i, st in enumerate(stmts):
1935
+ if not ((isinstance(st, S.Unparsed) and st.reason == structure._FOR_MISSING) or is_short(st)):
1936
+ continue
1937
+ key = (st.line, st.file)
1938
+ if i + 1 < len(stmts) and (stmts[i + 1].line, stmts[i + 1].file) == key:
1939
+ continue
1940
+ if not any((o.line, o.file) == key and has_names(o) for o in stmts[:i]):
1941
+ continue
1942
+ if any(
1943
+ (o.line, o.file) == key
1944
+ and o is not st
1945
+ and (is_short(o) or (isinstance(o, S.Unparsed) and o.reason == structure._FOR_MISSING))
1946
+ for o in stmts
1947
+ ):
1948
+ continue # (only a line's single shortfall moves)
1949
+ moved = [d for d in diags.items if (d.line, d.file) == key and d.message in _SHORTFALL_MESSAGES]
1950
+ if moved:
1951
+ at = max(j for j, d in enumerate(diags.items) if (d.line, d.file) == key)
1952
+ diags.items[:] = [d for d in diags.items if d is not moved[0]]
1953
+ diags.items.insert(at, moved[0])
1954
+
1955
+
1956
+ def check_for_header(program: S.Program, profile, diags: Diagnostics) -> None:
1957
+ """CR300/CR350: a `For` header whose `To`/`Step` half has adjacent operands (`structure.
1958
+ _for_header_incomplete`, CR6 pass 1) prints the half's leading undefined words after the
1959
+ `From` expression's, then `Incomplete expression: <half>.` (oracle, CR300/CR350, 2026-10-10:
1960
+ `To 1 Step-1` -> `Step is not defined.`, `Nope To 12 Step-1` -> `Nope`, `Step`, then the line;
1961
+ `Step-Nope` names only `Step`)."""
1962
+ if profile.name not in ("cr300", "cr350"):
1963
+ return
1964
+ known = None
1965
+ for stmt in program.statements:
1966
+ text = getattr(stmt, "text", None) or ""
1967
+ if not re.match(r"\s*For\s", text, re.I):
1968
+ continue
1969
+ found = structure._for_header_incomplete(text, profile)
1970
+ kind = "incomplete"
1971
+ if found is None:
1972
+ parts = structure._for_header_parts(text, profile)
1973
+ if not parts:
1974
+ continue
1975
+ kind = parts[0][0]
1976
+ init = structure._FOR_INIT_RE.match(text)
1977
+ found = (parts[0][1], bool(init) and parts[0][1] == init.group(1))
1978
+ echo, is_init = found
1979
+ key = (stmt.line, stmt.file)
1980
+ own = [d for d in diags.items if (d.line, d.file) == key]
1981
+ if not all(
1982
+ d.message.endswith(_UNRESOLVED_SUFFIX)
1983
+ or d.message.startswith(
1984
+ (_GENERIC_FALLBACK_PREFIX, "Incomplete expression:", "No Matching, or out of place )")
1985
+ )
1986
+ for d in own
1987
+ ):
1988
+ continue
1989
+ if known is None:
1990
+ known = prose_known(program, profile)
1991
+ here = known - declared_later(first_declarations(program), stmt.line, stmt.file)
1992
+ header = structure._FOR_TO_RE.match(text)
1993
+ assert header is not None # (`_for_header_incomplete` matched it)
1994
+ start = header.start(1)
1995
+ eq = re.search(r"=(.*?)\sTo\s", text[:start], re.I | re.S)
1996
+ words = ([] if is_init or not eq else _leading_unknown_words(eq.group(1), here)) + _leading_unknown_words(
1997
+ echo, here
1998
+ )
1999
+ at = next((i for i, d in enumerate(diags.items) if (d.line, d.file) == key), len(diags.items))
2000
+ diags.items[:] = [d for d in diags.items if (d.line, d.file) != key]
2001
+ diags.items[at:at] = [
2002
+ Diagnostic("error", stmt.line, profile.unresolved_symbol_message.format(name=w), stmt.file, True)
2003
+ for w in words
2004
+ ] + [Diagnostic("error", stmt.line, structure._for_header_message(kind, echo), stmt.file)]
2005
+
2006
+
2007
+ def _echo_key(text: str) -> str:
2008
+ """An echoed statement compared with its `S.Unparsed` text (the echo drops outer parentheses)."""
2009
+ return text.replace("(", "").replace(")", "").strip()
2010
+
2011
+
2012
+ def check_unknown_line_words(program: S.Program, profile, diags: Diagnostics) -> None:
2013
+ """CR300/CR350 only; a no-op on every other logger, and on a file with no `S.Unparsed`
2014
+ statement at all. Called from `checker/__init__.py`, right after `check_expr_cascade`, once
2015
+ `structure.check_pass2` (and `expressions.check`) have had a chance to emit the generic
2016
+ `Invalid, or out of place expression: %s.` fallback for an `S.Unparsed` statement -- see
2017
+ `checker/structure.py`'s own `S.Unparsed`-`else` branch, whose before-`BeginProg` follow-up/
2018
+ stop is separately carved out for CR300/CR350 there (`profile.undefined_call_message`, mirroring
2019
+ `_check_call_pass2`'s identical carve-out; `docs/loggers.md` "Round 8" Finding 2), so a file with
2020
+ several such statements gets every one of them reacted to here, not just the first.
2021
+
2022
+ Deliberately reacts only to diagnostics already sitting in `diags.items` (the same
2023
+ after-the-fact pattern `check_expr_cascade` above already uses) rather than re-deriving *why*
2024
+ `structure.py` chose to emit the fallback for a given statement, so it stays correct however
2025
+ that gating logic is refactored, and is restricted to lines that `program.statements` itself
2026
+ marks `S.Unparsed` -- deliberately **not** every statement shape that can reach the same
2027
+ generic message (`checker/structure.py`'s own docstrings: a bare unresolved call already gets
2028
+ its own, already-implemented, single-name CR300/CR350 line via `profile.
2029
+ undefined_call_message` above, in `_check_call_pass2`; an assignment with an unresolved target
2030
+ was not oracle-tested this round for this same leading-word behaviour, so is left alone here
2031
+ rather than guessed at).
2032
+ """
2033
+ if profile.name not in ("cr300", "cr350"):
2034
+ return
2035
+ if not diags.items:
2036
+ return
2037
+ unparsed_lines = {(s.line, s.file) for s in program.statements if isinstance(s, S.Unparsed)}
2038
+ # (a line with several `:`-separated statements echoes each one; only the Unparsed one counts)
2039
+ unparsed_text = {(s.line, s.file, _echo_key(s.text)): s for s in program.statements if isinstance(s, S.Unparsed)}
2040
+ if not unparsed_lines:
2041
+ return
2042
+ known: set[str] | None = None
2043
+ first: dict[str, tuple[str, int]] | None = None
2044
+ new_items: list[Diagnostic] = []
2045
+ changed = False
2046
+ for d in diags.items:
2047
+ if (
2048
+ d.severity == "error"
2049
+ and d.message.endswith(".")
2050
+ and d.message.startswith(_GENERIC_FALLBACK_PREFIX)
2051
+ and (d.line, d.file) in unparsed_lines
2052
+ and (d.line, d.file, _echo_key(d.message[len(_GENERIC_FALLBACK_PREFIX) : -1])) in unparsed_text
2053
+ ):
2054
+ if known is None:
2055
+ known = prose_known(program, profile)
2056
+ if first is None:
2057
+ first = first_declarations(program)
2058
+ here = known - declared_later(first, d.line, d.file)
2059
+ text = d.message[len(_GENERIC_FALLBACK_PREFIX) : -1]
2060
+ if re.match(r"(?i)\s*Alias\b", text) and any(
2061
+ (e.line, e.file) == (d.line, d.file) and e.message.startswith("Need '=' in alias definition")
2062
+ for e in diags.items
2063
+ ):
2064
+ # an `Alias` line without its `=` has been answered by the alias message alone
2065
+ # (oracle, CR300/CR350, 2026-10-11: `Alias rTime(1)` / `= Year` on the next line)
2066
+ changed = True
2067
+ continue
2068
+ table = _TABLE_LINE_RE.match(text)
2069
+ signed = None if table else _SIGNED_TABLE_RE.match(text)
2070
+ if signed and (
2071
+ signed.group(1).upper() in symbols._ALWAYS_KNOWN_SYSTEM_TABLES
2072
+ or signed.group(1).upper() in prose_known_tables(program, profile)
2073
+ ):
2074
+ signed = None
2075
+ table = table or signed
2076
+ if table:
2077
+ new_items.append(
2078
+ Diagnostic("error", d.line, f"Could not find DataTable {table.group(1)}.", d.file, cascade=False)
2079
+ )
2080
+ d = dataclasses.replace(d, cascade=False) # (no cascade: oracle, `r1` probe)
2081
+ changed = True
2082
+ if _loose_before_beginprog(program, d.line, d.file):
2083
+ # (oracle, CR300, 2026-10-11: `foo.` before `BeginProg` also gets the placement line)
2084
+ new_items.append(d)
2085
+ new_items.append(
2086
+ Diagnostic("error", d.line, "Expr illegally occurs before BeginProg.", d.file, cascade=False)
2087
+ )
2088
+ continue
2089
+ bare = text.rstrip("\n")
2090
+ cp = structure.condition_part(bare)
2091
+ if (
2092
+ cp is not None
2093
+ and not cp.strip()
2094
+ and not (
2095
+ re.match(r"(?i)\s*(?:DO|LOOP)\b", bare)
2096
+ and any(e.severity == "error" and e.line and e.line < d.line for e in diags.items)
2097
+ )
2098
+ and (empty := structure._pass1_scan(bare, structure._Everything(), profile)) is not None
2099
+ and empty in (("pass1", "missing_expr"), ("pass1", "fallback"))
2100
+ ):
2101
+ # a block keyword with no condition at all: the same pass-1 verdicts as CR6/CR1000X
2102
+ # (oracle, CR300/CR350, 2026-10-11: bare `If`/`ElseIf`/`Case`/`Loop While` are
2103
+ # `Invalid or missing expression.`, `If ` and `Select Case` an empty echo)
2104
+ new_items.append(
2105
+ dataclasses.replace(
2106
+ d,
2107
+ message=(
2108
+ "Invalid or missing expression."
2109
+ if empty[1] == "missing_expr"
2110
+ else f"{_GENERIC_FALLBACK_PREFIX}."
2111
+ ),
2112
+ )
2113
+ )
2114
+ changed = True
2115
+ continue
2116
+ zero = _ZERO_TAIL_RE.match(bare)
2117
+ if (
2118
+ zero
2119
+ and zero.group(1).upper() in _TAIL_REJECTING
2120
+ and (tok := instr_rules._next_simple_token(zero.group(2)))
2121
+ ):
2122
+ # an instruction with no parameters followed by text: the first token is the
2123
+ # surplus (oracle, CR300/CR350, 2026-10-11: `BeginProg, EndProg (Begin Program,
2124
+ # End Program)` -> `Too many parameters: EndProg.`)
2125
+ new_items.append(Diagnostic("error", d.line, f"Too many parameters: {tok}.", d.file))
2126
+ changed = True
2127
+ continue
2128
+ call = re.match(r"\s*([A-Za-z_]\w*)\s*\((.*)$", bare, re.S)
2129
+ cinfo = profile.instructions.get(call.group(1).upper()) if call else None
2130
+ if (
2131
+ call
2132
+ and cinfo
2133
+ and cinfo["params"]
2134
+ and call.group(1).upper() not in _CALL_ADJACENT_EXCLUDED
2135
+ and (
2136
+ found := _call_adjacent_arg_messages(
2137
+ call.group(2),
2138
+ cinfo,
2139
+ here,
2140
+ symbols.declared_variable_names(program),
2141
+ profile,
2142
+ _inside_table(program, d.line, d.file),
2143
+ )
2144
+ )
2145
+ is not None
2146
+ ):
2147
+ for message in found:
2148
+ new_items.append(Diagnostic("error", d.line, message, d.file))
2149
+ changed = True
2150
+ continue
2151
+ if (
2152
+ call
2153
+ and cinfo
2154
+ and cinfo["params"]
2155
+ and cinfo["params"][0] % 100 not in (6, 11)
2156
+ and call.group(1).upper()
2157
+ not in (
2158
+ "DATA",
2159
+ "DATALONG",
2160
+ "ALIAS",
2161
+ "UNITS",
2162
+ "SUB",
2163
+ "FUNCTION",
2164
+ "IF",
2165
+ "ELSEIF",
2166
+ "WHILE",
2167
+ "UNTIL",
2168
+ "SELECT",
2169
+ "CASE",
2170
+ "FOR",
2171
+ "DO",
2172
+ "LOOP",
2173
+ )
2174
+ and (carg := _split_top_commas(call.group(2))[0].rstrip())
2175
+ and re.search(r"[\w\"]\s+[\w\"]", re.sub(r'"[^"]*"', '"', carg))
2176
+ and not re.search(r"(?i)\b(?:and|or|xor|eqv|imp|mod|is|to|not)\b", carg)
2177
+ ):
2178
+ # `Instruction(a b, ...)`: the first argument is read as an expression with
2179
+ # adjacent operands (oracle, CR300/CR350, 2026-10-11: `MQTTPublishTable(MQTT QoS,
2180
+ # ...)` -> both words, `Incomplete expression: MQTT QoS.`, and for a constant-only
2181
+ # parameter `Variable illegal in Parameter: MQTT QoS.`); the rest is never read
2182
+ if carg.endswith(")") and carg.count("(") < carg.count(")"):
2183
+ carg = carg[:-1].rstrip() # (the call's own closing parenthesis)
2184
+ for word in _leading_unknown_words(carg, here):
2185
+ new_items.append(
2186
+ Diagnostic("error", d.line, profile.unresolved_symbol_message.format(name=word), d.file)
2187
+ )
2188
+ new_items.append(Diagnostic("error", d.line, f"Incomplete expression: {carg}.", d.file))
2189
+ if cinfo["params"][0] in _CONST_FIRST_PARAM:
2190
+ new_items.append(Diagnostic("error", d.line, f"Variable illegal in Parameter: {carg}.", d.file))
2191
+ changed = True
2192
+ continue
2193
+ lead = _INSTRUCTION_PROSE_RE.match(text)
2194
+ info = profile.instructions.get(lead.group(1).upper()) if lead else None
2195
+ if (
2196
+ lead
2197
+ and info
2198
+ and lead.group(1).upper() not in ("DATA", "DATALONG")
2199
+ and info["params"]
2200
+ and not (structure.condition_part(text) or "").strip()
2201
+ and not structure.inline_if_condition(text)
2202
+ ):
2203
+ # `Battery sensor using the`: the instruction reads the prose as its argument
2204
+ # (oracle, CR300, 2026-10-01): its words, `Incomplete expression: <prose>.`, and
2205
+ # for a constant-only first parameter `Variable illegal in Parameter: <prose>.`
2206
+ rest = lead.group(2).rstrip("\n")
2207
+ codes = list(info["params"])
2208
+ if codes[0] % 100 == 11 and "," not in rest and len(codes) > 1 and codes[1] < 100:
2209
+ # a deferred-text first parameter (`Histogram`, `FFT`, ...) takes the whole text
2210
+ # unread; the next required parameter is then missing (oracle, CR300/CR350,
2211
+ # 2026-10-11)
2212
+ new_items.append(Diagnostic("error", d.line, "Invalid or missing parameter.", d.file))
2213
+ changed = True
2214
+ continue
2215
+ if codes[0] % 100 == 6:
2216
+ # a name token (`CallTable`, `DataTable`, `FileMark`): the first word is taken
2217
+ # unchecked, the rest is the next parameter, or the surplus when there is none
2218
+ name_tok = instr_rules._next_simple_token(rest)
2219
+ rest = rest[rest.index(name_tok) + len(name_tok) :] if name_tok else rest
2220
+ codes = codes[1:]
2221
+ if not codes:
2222
+ if tok := instr_rules._next_simple_token(rest):
2223
+ new_items.append(Diagnostic("error", d.line, f"Too many parameters: {tok}.", d.file))
2224
+ changed = True
2225
+ continue
2226
+ rest = rest.lstrip(" \t")
2227
+ if codes and rest:
2228
+ op = _LEAD_OP_WORD_RE.match(rest)
2229
+ words = [] if op else _leading_unknown_words(rest, here)
2230
+ after = ""
2231
+ if op and op.group(1).upper() not in _LEAD_OP_NO_OPERAND:
2232
+ # a leading binary operator word: the next operand is read, then the
2233
+ # expression is out of place (oracle, CR300/CR350, 2026-10-11)
2234
+ after = _LEAD_OP_SKIP_RE.sub("", rest[op.end() :], count=1)
2235
+ words = _leading_unknown_words(after, here)[:1] if _LINE_WORD_RE.match(after) else []
2236
+ for word in words:
2237
+ new_items.append(
2238
+ Diagnostic("error", d.line, profile.unresolved_symbol_message.format(name=word), d.file)
2239
+ )
2240
+ new_items.append(
2241
+ Diagnostic(
2242
+ "error",
2243
+ d.line,
2244
+ (f"{_GENERIC_FALLBACK_PREFIX}{rest}." if op else f"Incomplete expression: {rest}."),
2245
+ d.file,
2246
+ )
2247
+ )
2248
+ if codes[0] in _CONST_FIRST_PARAM and not (op and not _LINE_WORD_RE.match(after)):
2249
+ new_items.append(Diagnostic("error", d.line, f"Variable illegal in Parameter: {rest}.", d.file))
2250
+ changed = True
2251
+ continue
2252
+ callables = frozenset(symbols.declared_variable_names(program) | set(profile.functions))
2253
+ fw = _LINE_WORD_RE.match(text, len(text) - len(text.lstrip(" \t")))
2254
+ needs_paren = bool(
2255
+ fw
2256
+ and fw.group(0).upper() not in profile.instructions
2257
+ and not text[fw.end() :].lstrip(" \t").startswith(("(", "."))
2258
+ and fw.group(0).upper() not in symbols.declared_variable_names(program)
2259
+ and structure._bare_function_needs_paren(fw.group(0).upper(), profile.functions)
2260
+ )
2261
+ cond_text = structure.condition_part(text)
2262
+ cond_text = cond_text if cond_text and cond_text.strip() else None
2263
+ if cond_text is None and re.match(r"(?i)\s*if\b", text):
2264
+ cond_text = structure.inline_if_condition(text) or None # (a one-line `If`'s condition)
2265
+ words = _leading_unknown_words(text if cond_text is None else cond_text, here, callables)
2266
+ if needs_paren and fw:
2267
+ # the operands after a parenthesis-less function are named (oracle, CR300,
2268
+ # 2026-10-10: `Timer is bar baz` -> `bar`, `baz`)
2269
+ rest = text[fw.end() :] # (a leading `is` counts as an operand, never named)
2270
+ if re.fullmatch(r"[\w\s]*", rest):
2271
+ # as many operands as the function has parameters are read (oracle, CR300,
2272
+ # 2026-10-10: `Timer foo bar baz` names three, `COS foo bar` one)
2273
+ count = profile.functions[fw.group(0).upper()].get("nargs") or 1
2274
+ words = [
2275
+ w
2276
+ for w in re.findall(r"[A-Za-z_]\w*|\d+", rest)[:count]
2277
+ if not w[0].isdigit() and w.upper() not in here and w.upper() != "IS"
2278
+ ]
2279
+ else:
2280
+ words = _leading_unknown_words(rest, here)
2281
+ elif re.match(r"(?i)\s*NOT\s", text):
2282
+ words = _leading_unknown_words(re.sub(r"(?i)^\s*NOT\b", "", text), here)
2283
+ for word in words:
2284
+ new_items.append(
2285
+ Diagnostic("error", d.line, profile.unresolved_symbol_message.format(name=word), d.file, True)
2286
+ )
2287
+ changed = True
2288
+ if needs_paren:
2289
+ # a built-in function used as the first word without its parenthesis (oracle,
2290
+ # CR300/CR350, 2026-10-08: `Sin foo`, `Timer 1`, `COS of angle`)
2291
+ d = dataclasses.replace(d, message=f"Expression expects ( : {text}.")
2292
+ changed = True
2293
+ if d.message.startswith(_GENERIC_FALLBACK_PREFIX) and (
2294
+ stmt := unparsed_text.get((d.line, d.file, _echo_key(text)))
2295
+ ):
2296
+ # an assignment's pass-1 syntax verdicts are the same words on CR300/CR350
2297
+ # (oracle, 2026-10-10: `X = 1 2` -> `Incomplete expression`, `X = (1 2)` -> `No Matching,
2298
+ # or out of place )`), whatever names precede them
2299
+ cond = structure.condition_part(stmt.text)
2300
+ cond = cond if cond and cond.strip() else None
2301
+ scan_text = stmt.text
2302
+ inline = structure.inline_if_condition(stmt.text) if cond is None else None
2303
+ if inline and ";" in inline:
2304
+ cond, inline = inline, None # `If X ; = 1 Then Y = 2`: the condition alone
2305
+ if inline:
2306
+ # `If X Y Then Y = 1`: an unreadable condition is reported alone, as for a block
2307
+ # `If` (oracle, CR300/CR350 and CR6, 2026-10-10)
2308
+ scan_text = "If " + inline + "Then"
2309
+ inline_scan = structure._pass1_scan(scan_text, structure._Everything(), profile)
2310
+ if (
2311
+ inline_scan
2312
+ and inline_scan[0] == "pass1"
2313
+ and inline_scan[1] in ("incomplete", "no_matching", "fallback")
2314
+ ):
2315
+ cond = inline
2316
+ verdict = (
2317
+ structure._pass1_scan(scan_text, structure._Everything(), profile)
2318
+ if cond is not None
2319
+ or re.match(r"\s*(?:[A-Za-z_]\w*\s*=|NOT\b)", stmt.text, re.I)
2320
+ or re.match(r"\s*(?:\d[\w.]*|-\s*\d[\w.]*|\"[^\"]*\")\s*=", stmt.text)
2321
+ or re.match(r"\s*[A-Za-z_]\w*\s+(?:AND|OR|XOR|IMP|EQV)\b[^=]*[\w)]\s*=", stmt.text, re.I)
2322
+ or re.search(r"\b(?:AND|OR|XOR|IMP|EQV)\b[^=]*=", stmt.text, re.I)
2323
+ else None
2324
+ ) # (prose stays generic)
2325
+ head = (
2326
+ {
2327
+ "invalid_assignment": "Invalid assignment in expression: ",
2328
+ "incomplete": "Incomplete expression: ",
2329
+ "no_matching": "No Matching, or out of place ) in expression: ",
2330
+ "expects_comma": "Expression expects , : ",
2331
+ }.get(verdict[1])
2332
+ if verdict and verdict[0] == "pass1"
2333
+ else None
2334
+ )
2335
+ call = re.match(r"\s*([A-Za-z_]\w*)\s*\(([^()]*)", stmt.text)
2336
+ juxt = (
2337
+ call
2338
+ and stmt.reason != "lex error: unterminated string"
2339
+ and re.search(r"[\w\"]\s+(?!(?:AND|OR|XOR|MOD|IMP|EQV)\b)[A-Za-z_\d\"]", call.group(2), re.I)
2340
+ )
2341
+ if (
2342
+ head is None
2343
+ and call
2344
+ and call.group(1).upper() in profile.functions
2345
+ and call.group(1).upper() not in symbols.declared_variable_names(program)
2346
+ and juxt
2347
+ ):
2348
+ # a built-in call whose argument list holds juxtaposed operands (oracle, CR300,
2349
+ # 2026-10-10: `Sin (foo bar)`, `ArrayIndex (Array Index)`): `Expression expects ,`
2350
+ # when the argument is not the function's last, else `No Matching` (oracle,
2351
+ # CR300, 2026-10-11: `Round (A B, 1)`, `UDPDataGram("a",UDP Port, ...) - tail`
2352
+ # expect a comma, `Sin (A B)`, `Round (A, 1 B)` do not); text after the `)` is not read
2353
+ k = call.group(2)[: juxt.start() + 1].count(",") + 1
2354
+ nargs = profile.functions[call.group(1).upper()].get("nargs") or 0
2355
+ head = "Expression expects , : " if k < nargs else "No Matching, or out of place ) in expression: "
2356
+ if call.group(1).upper() in structure._SELF_CLOSING_FUNCTIONS:
2357
+ head = "Incomplete expression: " # (`Abs(x y)`, oracle, CR300, 2026-10-12)
2358
+ rhs_call = re.match(r"\s*[A-Za-z_]\w*\s*=\s*([A-Za-z_]\w*)\s*\((.*)$", stmt.text, re.S)
2359
+ if (
2360
+ verdict
2361
+ and verdict[0] == "pass1"
2362
+ and rhs_call
2363
+ and rhs_call.group(1).upper() in profile.functions
2364
+ and not words
2365
+ and (
2366
+ verdict[1] in ("no_matching", "expects_comma")
2367
+ or (
2368
+ verdict[1] == "incomplete"
2369
+ and rhs_call.group(1).upper() in structure._SELF_CLOSING_FUNCTIONS
2370
+ )
2371
+ )
2372
+ ):
2373
+ # the bad argument's own undefined words come first (oracle, CR300, 2026-10-12:
2374
+ # `x = Sin(Zork Y)` -> `Zork is not defined.` then the message)
2375
+ parts = _split_args_quoted(rhs_call.group(2)) or []
2376
+ bad = next((a for a in parts if _adjacent_operands(a)), None)
2377
+ for word in _leading_unknown_words(bad.strip(), here) if bad else []:
2378
+ new_items.append(
2379
+ Diagnostic(
2380
+ "error", d.line, profile.unresolved_symbol_message.format(name=word), d.file, True
2381
+ )
2382
+ )
2383
+ if (
2384
+ head == "No Matching, or out of place ) in expression: "
2385
+ and cond is None
2386
+ and stmt.reason != "lex error: unterminated string"
2387
+ and (
2388
+ refined := structure._unclosed_call_message(
2389
+ profile, stmt.text, stmt.at_eol, symbols.declared_variable_names(program)
2390
+ )
2391
+ )
2392
+ ):
2393
+ # a call left open at the end of the line: its argument count decides the message
2394
+ # as on CR6/CR1000X (oracle, CR300/CR350, 2026-10-11: `x = Round(x` -> `Expression
2395
+ # expects ,`, `x = FileOpen (s, "r",` -> the generic fallback)
2396
+ d = dataclasses.replace(d, message=refined)
2397
+ changed = True
2398
+ elif cond is not None:
2399
+ if (
2400
+ head == "No Matching, or out of place ) in expression: "
2401
+ and (
2402
+ refined := structure._unclosed_call_message(
2403
+ profile, cond, False, symbols.declared_variable_names(program)
2404
+ )
2405
+ )
2406
+ and refined.startswith("Expression expects ,")
2407
+ ):
2408
+ # `If Round(X Then`: a call left open in a condition, same count rule as a
2409
+ # statement (oracle, CR300/CR350, 2026-10-12)
2410
+ head = "Expression expects , : "
2411
+ # a `While`/`Do While|Until`/`Loop While|Until` condition echoes alone, no
2412
+ # line-end newline (oracle, CR300/CR350, 2026-10-10)
2413
+ if head is None and ";" in cond: # (oracle, 2026-10-12: `Do While X ;`, `Case 1 ;`)
2414
+ head = "Invalid symbol in expression: "
2415
+ d = dataclasses.replace(d, message=(head or _GENERIC_FALLBACK_PREFIX) + cond + ".")
2416
+ changed = True
2417
+ elif head:
2418
+ d = dataclasses.replace(d, message=head + d.message[len(_GENERIC_FALLBACK_PREFIX) :])
2419
+ changed = True
2420
+ new_items.append(d)
2421
+ if changed:
2422
+ diags.items[:] = new_items
2423
+ return
2424
+
2425
+
2426
+ _ARITY_PREFIXES = {
2427
+ "many": "No Matching, or out of place ) in expression: ",
2428
+ "few": "Expression expects , : ",
2429
+ "empty": "Invalid, or out of place expression: ",
2430
+ }
2431
+
2432
+
2433
+ class _ArityState(TypedDict, total=False):
2434
+ names: list[str]
2435
+ kind: str
2436
+ bad: bool
2437
+ stop: bool
2438
+
2439
+
2440
+ def _arity_walk(expr: S.Expr | None, known: set[str], profile, state: _ArityState) -> None:
2441
+ """Left-to-right parse-order walk collecting `state["names"]` (undefined names met) until a
2442
+ built-in call with too many arguments stops it at its first surplus argument
2443
+ (`state["kind"] == "many"`); too few arguments only mark `state["kind"] == "few"`."""
2444
+ if expr is None or state.get("stop"):
2445
+ return
2446
+ if isinstance(expr, S.Name):
2447
+ if expr.name.upper() not in known or expr.name.upper() in getattr(known, "tables", ()):
2448
+ state["names"].append(expr.name)
2449
+ return
2450
+ if isinstance(expr, S.Index):
2451
+ if expr.name.upper() not in known:
2452
+ state["names"].append(expr.name)
2453
+ problem = expressions._call_arity_problem(expr.name, expr.args, profile)
2454
+ args, stop = expr.args, False
2455
+ if problem == "many":
2456
+ at = next(
2457
+ k
2458
+ for k in range(len(args))
2459
+ if expressions._call_arity_problem(expr.name, args[: k + 1], profile) == "many"
2460
+ )
2461
+ args, stop = args[:at], True
2462
+ state.setdefault("kind", "many")
2463
+ elif problem in ("few", "empty"):
2464
+ state.setdefault("kind", problem)
2465
+ elif problem:
2466
+ state["bad"] = True
2467
+ for a in args:
2468
+ _arity_walk(a, known, profile, state)
2469
+ state["stop"] = state.get("stop") or stop
2470
+ return
2471
+ if isinstance(expr, S.Unary):
2472
+ _arity_walk(expr.operand, known, profile, state)
2473
+ elif isinstance(expr, S.Binary):
2474
+ _arity_walk(expr.left, known, profile, state)
2475
+ _arity_walk(expr.right, known, profile, state)
2476
+ elif isinstance(expr, S.Member):
2477
+ if not (isinstance(expr.base, S.Name) and expr.base.name.upper() in known):
2478
+ _arity_walk(expr.base, known, profile, state) # (`Tb.x`: a table is fine as the base)
2479
+ for a in expr.args or []:
2480
+ _arity_walk(a, known, profile, state)
2481
+ elif isinstance(expr, S.ArrayLiteral):
2482
+ for item in expr.items:
2483
+ _arity_walk(item, known, profile, state)
2484
+
2485
+
2486
+ def order_names_around_arity(program: S.Program, profile, diags: Diagnostics) -> None:
2487
+ """CR300/CR350: the parser reports undefined names as it meets them and the arity error of a
2488
+ built-in call last (oracle, 2026-10-03): `Sc = Nope3+Round(Nope1)` -> `Nope3`, `Nope1`, then
2489
+ `Expression expects , :`. A call with too many arguments stops the parse at its first surplus
2490
+ argument, so later names are not reported (`Floor(Nope1,Nope)` -> `Nope1` only,
2491
+ `Floor(Sd,Nope)+Nope3` -> none); too few arguments let the rest of the expression be parsed."""
2492
+ if profile.name not in ("cr300", "cr350"):
2493
+ return
2494
+ per_line = collections.Counter((st.line, st.file) for st in program.statements)
2495
+ known = None
2496
+ for stmt in program.statements:
2497
+ if isinstance(stmt, S.Assignment):
2498
+ exprs = [stmt.target, stmt.value]
2499
+ elif isinstance(stmt, S.If):
2500
+ exprs = [stmt.cond]
2501
+ else:
2502
+ continue
2503
+ key = (stmt.line, stmt.file)
2504
+ if per_line[key] != 1:
2505
+ continue
2506
+ own = [(i, d) for i, d in enumerate(diags.items) if (d.line, d.file) == key and d.severity == "error"]
2507
+ if known is None:
2508
+ known = expressions._known_names(program, profile)
2509
+ state: _ArityState = {"names": []}
2510
+ for e in exprs:
2511
+ _arity_walk(e, known, profile, state)
2512
+ kind = state.get("kind")
2513
+ if state.get("bad") or kind is None:
2514
+ continue
2515
+ arity = [(i, d) for i, d in own if d.message.startswith(_ARITY_PREFIXES[kind])]
2516
+ if not arity and isinstance(stmt, S.Assignment) and own and expressions._undefined_names(stmt.target, known):
2517
+ # an unresolved target hides the pass-1 arity check, but the parse still reaches it
2518
+ arity = [
2519
+ (
2520
+ own[0][0],
2521
+ Diagnostic(
2522
+ "error",
2523
+ stmt.line,
2524
+ expressions._arity_message(kind, stmt.text, profile, stmt.at_eol, True),
2525
+ stmt.file,
2526
+ show_file=False,
2527
+ ),
2528
+ )
2529
+ ]
2530
+ if len(arity) != 1 or any(
2531
+ d.message != arity[0][1].message and not d.message.endswith(_UNRESOLVED_SUFFIX) for _, d in own
2532
+ ):
2533
+ continue
2534
+ new = [
2535
+ Diagnostic("error", stmt.line, profile.unresolved_symbol_message.format(name=n), stmt.file)
2536
+ for n in state["names"]
2537
+ ] + [arity[0][1]]
2538
+ first = own[0][0]
2539
+ drop = {id(d) for _, d in own}
2540
+ items = [d for d in diags.items if id(d) not in drop]
2541
+ at = first - sum(1 for i, d in enumerate(diags.items[:first]) if id(d) in drop)
2542
+ items[at:at] = new
2543
+ diags.items[:] = items
2544
+
2545
+
2546
+ def order_names_before_call_arity(program: S.Program, profile, diags: Diagnostics) -> None:
2547
+ """CR300/CR350: an assignment that calls a user `Function` with the wrong argument count and
2548
+ also names undefined variables reports the names first, then the call's messages (oracle,
2549
+ 2026-10-12: `A(Z) = F2(1,2,3)`, `Q = F2(1,2,3)`, `A(Z) = A(Y) + F2(1)`: `Z is not defined.`,
2550
+ `Y is not defined.`, then `Call argument required.` / the `No Matching` or fallback line)."""
2551
+ if profile.name not in ("cr300", "cr350"):
2552
+ return
2553
+ functions = {st.name.upper() for st in program.statements if isinstance(st, S.SubDef) and st.keyword == "FUNCTION"}
2554
+ if not functions:
2555
+ return
2556
+ per_line = collections.Counter((st.line, st.file) for st in program.statements)
2557
+ for stmt in program.statements:
2558
+ if not isinstance(stmt, S.Assignment) or per_line[(stmt.line, stmt.file)] != 1:
2559
+ continue
2560
+ if not any(re.search(rf"(?i)\b{re.escape(f)}\s*\(", stmt.text or "") for f in functions):
2561
+ continue
2562
+ key = (stmt.line, stmt.file)
2563
+ at = [i for i, d in enumerate(diags.items) if (d.line, d.file) == key]
2564
+ if not at or at != list(range(at[0], at[-1] + 1)):
2565
+ continue
2566
+ own = [diags.items[i] for i in at]
2567
+ names = [d for d in own if re.fullmatch(r"\w+ is not defined\.", d.message)]
2568
+ rest = [d for d in own if d not in names]
2569
+ if (
2570
+ not names
2571
+ or not rest
2572
+ or not all(
2573
+ d.message == "Call argument required."
2574
+ or d.message.startswith(
2575
+ ("No Matching, or out of place ) in expression: ", "Invalid, or out of place expression: ")
2576
+ )
2577
+ for d in rest
2578
+ )
2579
+ ):
2580
+ continue
2581
+ diags.items[at[0] : at[-1] + 1] = names + rest
2582
+
2583
+
2584
+ _CLOSER_ERRORS = frozenset(
2585
+ {
2586
+ "END IF without IF or END SELECT without SELECT.",
2587
+ "NEXT without FOR.",
2588
+ "LOOP without DO.",
2589
+ "IF or SELECT statement illegally has SCAN without NEXT SCAN.",
2590
+ "FOR NEXT statement illegally has SCAN without NEXT SCAN.",
2591
+ "LOOP statement illegally has SCAN without NEXT SCAN.",
2592
+ }
2593
+ )
2594
+
2595
+
2596
+ # Errors that do not hide the closer / `CallTable` errors below (oracle, CR300, 2026-10-04, the
2597
+ # fuzz reproducers `false_accept/ca5935f3a78b` and `a2f667cdbbc7`): another block-structure error
2598
+ # (`EndSub without Sub or nested subs.`) and a built-in call's arity error.
2599
+ def _hides_nothing(d) -> bool:
2600
+ if d.message.endswith(" must be used inside a table declaration."):
2601
+ return True # (oracle, 2026-10-13: `Sample` outside a table beside an unknown `CallTable`)
2602
+ if d.message.endswith("without Sub or nested subs."):
2603
+ return True
2604
+ if d.message == "If without EndIf inside of Sub.": # oracle, CR300/CR350, 2026-10-11 (`t5` probe)
2605
+ return True
2606
+ # a built-in call's arity error hides nothing only when a `Public.<name>` reference precedes
2607
+ # its first `)` on the line (oracle, 2026-10-04: `Round(Public.x,1,2)`, `Round(x,Public.T,2,3)`
2608
+ # hide nothing; `Round(x)+Public.T`, `FormatLong(x)`, `Floor(x,1)` hide the closers)
2609
+ if d.message.startswith(("No Matching, or out of place ) in expression", "Expression expects")):
2610
+ text = re.sub(r'"[^"]*"', lambda m: " " * len(m.group(0)), d.message.split(":", 1)[-1].lower())
2611
+ at = text.find("public.")
2612
+ return 0 <= at < (text.find(")") if ")" in text else len(text))
2613
+ return False
2614
+
2615
+
2616
+ def drop_block_closer_errors_after_errors(program: S.Program, profile, diags: Diagnostics) -> None:
2617
+ """CR300/CR350: the unmatched-closer errors (`END IF without IF ...`, `NEXT without FOR.`,
2618
+ `LOOP without DO.` and their `... illegally has SCAN without NEXT SCAN.` companions, with the
2619
+ `<closer> illegally occurs before BeginProg.` line) are only reported when no other error
2620
+ exists anywhere in the file, before or after them (oracle, CR300, 2026-10-03: `Sc = Nope1`
2621
+ then `EndIf`, `EndIf` then `Sc = Nope1`, a duplicate declaration, `Too many parameters`;
2622
+ several closer errors do not hide one another). CR6/CR1000X always report them."""
2623
+ if profile.name not in ("cr300", "cr350"):
2624
+ return
2625
+ closers = [d for d in diags.items if d.severity == "error" and d.message in _CLOSER_ERRORS]
2626
+ if not closers:
2627
+ return
2628
+ lines = {(d.line, d.file) for d in closers}
2629
+ placement = [
2630
+ d
2631
+ for d in diags.items
2632
+ if d.severity == "error"
2633
+ and (d.line, d.file) in lines
2634
+ and d.message.endswith(" illegally occurs before BeginProg.")
2635
+ ]
2636
+ ours = {id(d) for d in closers + placement}
2637
+ if not any(d.severity == "error" and id(d) not in ours and not _hides_nothing(d) for d in diags.items):
2638
+ return
2639
+ diags.items[:] = [d for d in diags.items if id(d) not in ours]
2640
+
2641
+
2642
+ def drop_calltable_error_with_other_errors(program: S.Program, profile, diags: Diagnostics) -> None:
2643
+ """CR300/CR350: the `CallTable` (or table field) `Could not find DataTable %s.` is hidden by any other error
2644
+ found by pass 1 or tier B, before or after it (oracle, CR300, 2026-10-03: `CallTable Hourly`
2645
+ with `Sc = Nope1` before or after it, or a `Too many parameters`). The late checks run after
2646
+ it and print beside it (`SetSetting("Baudrate(9)",1)`, a `Matrix` option error; tests of
2647
+ `tests/fixtures/file_drives*.json`, `option_args.json`, `settings_fields.json`)."""
2648
+ if profile.name not in ("cr300", "cr350"):
2649
+ return
2650
+ # (a table field in an expression is hidden the same way: oracle, CR300/CR350, 2026-10-11,
2651
+ # `x = Nope.Field(1,1)` with `Zork = 1` before or after it, `chm/tablenameeventend_1.CR6`)
2652
+ sites = {(st.line, st.file) for st in program.statements if not isinstance(st, S.Unparsed)}
2653
+ mine = [
2654
+ d
2655
+ for d in diags.items
2656
+ if d.severity == "error"
2657
+ and (d.line, d.file) in sites
2658
+ and d.message.startswith(
2659
+ ("Could not find DataTable ", "Could not find FileMark table ", "Cannot find DataTable ")
2660
+ )
2661
+ ]
2662
+ lines = {(d.line, d.file) for d in mine} # (an error on the same line does not hide it: oracle,
2663
+ # CR300/CR350, 2026-10-11, `X = Tmp.dat + Zed` prints both)
2664
+ if mine and any(
2665
+ d.severity == "error" and d not in mine and not _hides_nothing(d) and (d.line, d.file) not in lines
2666
+ for d in diags.items
2667
+ ):
2668
+ diags.items[:] = [d for d in diags.items if d not in mine]
2669
+
2670
+
2671
+ def drop_compound_target_repeat(program: S.Program, profile, diags: Diagnostics) -> None:
2672
+ """CR300/CR350: `Nope += 1` reports `Nope is not defined.` once, though it is stored as
2673
+ `Nope = Nope + 1` (oracle, 2026-10-01; also under the cascade, `i2copen_1.CR6`)."""
2674
+ if profile.name not in ("cr300", "cr350"):
2675
+ return
2676
+ for stmt in program.statements:
2677
+ if not (isinstance(stmt, S.Assignment) and stmt.compound and isinstance(stmt.target, (S.Name, S.Index))):
2678
+ continue
2679
+ message = profile.unresolved_symbol_message.format(name=stmt.target.name)
2680
+ same = [
2681
+ i for i, d in enumerate(diags.items) if (d.line, d.file) == (stmt.line, stmt.file) and d.message == message
2682
+ ]
2683
+ if len(same) >= 2:
2684
+ del diags.items[same[1]]
2685
+
2686
+
2687
+ _CALL_KEYWORD_RE = re.compile(r"\b(?:CASE|DO|FOR|FUNCTION|SCAN|SELECT|SUB|SUBSCAN|WHILE)\s*\(", re.I)
2688
+
2689
+
2690
+ def order_block_keyword_calls(program: S.Program, profile, diags: Diagnostics) -> None:
2691
+ """CR300/CR350: a block keyword used as a function (`Y = Scan(1)`) ends the statement's name
2692
+ resolution at that call. The names before it are reported (`Q = Scan(1)`, `Y = Q + Scan(1)`,
2693
+ `If Q = 1 Then Y = Scan(1)`: `Q is not defined.`) ahead of its `Incomplete expression`;
2694
+ names inside or after it are not (`Y = Scan(Q)`, `Y = Scan(1) + Q`, `Y = Scan(Zork(1))`, no
2695
+ `Dimensions not declared` either). An indexed target that is not a declared array is the
2696
+ generic `Invalid, or out of place expression:` instead, with no `Incomplete`. Oracle, CR300/CR350, 2026-10-10."""
2697
+ if profile.name not in ("cr300", "cr350"):
2698
+ return
2699
+ for stmt in program.statements:
2700
+ if not isinstance(stmt, (S.Assignment, S.If, S.ElseIf)):
2701
+ continue
2702
+ key = (stmt.line, stmt.file)
2703
+ # string literals are not code: `LEN("Scan(")` is no call, `LEN("Nope")` names nothing
2704
+ code = re.sub(r'"[^"]*"', lambda q: " " * len(q.group()), stmt.text or "")
2705
+ m = _CALL_KEYWORD_RE.search(code)
2706
+ marks = [d for d in diags.items if (d.line, d.file) == key and d.message.startswith("Incomplete expression:")]
2707
+ if not (m and marks):
2708
+ continue
2709
+ if any(
2710
+ (d.line, d.file) == key and d.message.startswith("Invalid, or out of place expression:")
2711
+ for d in diags.items
2712
+ ): # `Q(1) = Scan(1)`, an undeclared or scalar indexed target
2713
+ diags.items[:] = [d for d in diags.items if d not in marks]
2714
+ continue
2715
+ before = {w.upper() for w in re.findall(r"[A-Za-z_]\w*", code[: m.start()])}
2716
+ keep = []
2717
+ for d in diags.items:
2718
+ if (d.line, d.file) == key:
2719
+ name = re.fullmatch(r"(\w+) is not defined\.", d.message)
2720
+ if d.message.startswith("Dimensions not declared") or (name and name.group(1).upper() not in before):
2721
+ continue
2722
+ keep.append(d)
2723
+ group = [d for d in keep if (d.line, d.file) == key]
2724
+ group = [d for d in group if d not in marks] + [d for d in group if d in marks]
2725
+ first = next(i for i, d in enumerate(keep) if (d.line, d.file) == key)
2726
+ rest = [d for d in keep if (d.line, d.file) != key]
2727
+ rest[first:first] = group # (`first` counts only earlier other-line items: all kept)
2728
+ diags.items[:] = rest
2729
+
2730
+
2731
+ def fix_then_operand(program: S.Program, profile, diags: Diagnostics) -> None:
2732
+ """CR300/CR350: an `If` whose condition runs into its `Then` (`If X = Then`, `If X + Then`) is
2733
+ the condition's own syntax error, not `Then is not defined.` (oracle, 2026-10-10; the same
2734
+ message CR6/CR1000X give from `structure._check_if_pass1`)."""
2735
+ if profile.name not in ("cr300", "cr350"):
2736
+ return
2737
+ for stmt in program.statements:
2738
+ if not (isinstance(stmt, (S.If, S.ElseIf)) and structure._mentions_then(stmt.cond)):
2739
+ continue
2740
+ verdict = structure._pass1_scan(stmt.text, structure._Everything(), profile)
2741
+ if not (verdict and verdict[0] == "pass1"):
2742
+ continue
2743
+ key = (stmt.line, stmt.file)
2744
+ hits = [
2745
+ i
2746
+ for i, d in enumerate(diags.items)
2747
+ if (d.line, d.file) == key and d.message == profile.unresolved_symbol_message.format(name="Then")
2748
+ ]
2749
+ if not hits:
2750
+ continue
2751
+ message = structure._pass1_message(profile, dataclasses.replace(stmt, at_eol=False), verdict[1])
2752
+ diags.items[hits[0]] = dataclasses.replace(diags.items[hits[0]], message=message)
2753
+ for i in reversed(hits[1:]):
2754
+ del diags.items[i]
2755
+
2756
+
2757
+ def order_invalid_assignments(program: S.Program, profile, diags: Diagnostics) -> None:
2758
+ """CR300/CR350: the undefined names of an assignment come before its `Invalid assignment in
2759
+ expression:` line (oracle, 2026-10-10: `Sec = Nope`, `6 = estimated`), which pass 1 emitted first."""
2760
+ if profile.name not in ("cr300", "cr350"):
2761
+ return
2762
+ for key in {(d.line, d.file) for d in diags.items if d.message.startswith("Invalid assignment in expression:")}:
2763
+ own = [d for d in diags.items if (d.line, d.file) == key]
2764
+ marks = [d for d in own if d.message.startswith("Invalid assignment in expression:")]
2765
+ group = [d for d in own if d not in marks] + marks
2766
+ first = next(i for i, d in enumerate(diags.items) if (d.line, d.file) == key)
2767
+ rest = [d for d in diags.items if (d.line, d.file) != key]
2768
+ rest[first:first] = group
2769
+ diags.items[:] = rest
2770
+
2771
+
2772
+ _STRING_END_QUOTE = "String without end quote"
2773
+ _IF_STRING_RE = re.compile(r"(?i)\s*If\s+(.*?)\s+Then\s+([A-Za-z_]\w*)\s*(?:\([^()]*\))?\s*=")
2774
+
2775
+
2776
+ _BINARY_SYMBOLS = frozenset({"+", "-", "*", "/", "^", "&", "<", ">", "=", "<>", "<=", ">=", "\\"})
2777
+ _WORD_OPERATORS = frozenset({"AND", "OR", "XOR", "EQV", "IMP", "MOD", "IS", "TO"})
2778
+
2779
+
2780
+ def _string_reached(text: str) -> bool:
2781
+ """True when the tokens before an unterminated string are a well-formed expression prefix, so
2782
+ the lexer's `String without end quote` is met (oracle, CR300/CR350, 2026-10-11: `Foo" bar`,
2783
+ `Nope + "abc`, `Len("abc` reach it, `Datalogger</title>")` fails at the `/` first)."""
2784
+ try:
2785
+ lexer.tokenize(text, 0)
2786
+ return False
2787
+ except lexer.LexError as e:
2788
+ if e.message != "unterminated string" or text[e.col : e.col + 1] != '"':
2789
+ return False
2790
+ head = text[: e.col]
2791
+ toks = [t for t in lexer.tokenize(head, 0) if t.kind != "EOF"]
2792
+ expect, depth = True, 0
2793
+ for t in toks:
2794
+ word_op = t.kind == "IDENT" and t.upper in _WORD_OPERATORS
2795
+ if expect:
2796
+ if t.kind == "OP" and t.text in ("(", "-", "+"):
2797
+ depth += t.text == "("
2798
+ elif t.kind == "IDENT" and t.upper == "NOT":
2799
+ pass
2800
+ elif t.kind in ("IDENT", "NUMBER", "STRING") and not word_op:
2801
+ expect = False
2802
+ else:
2803
+ return False
2804
+ elif (t.kind == "OP" and t.text in _BINARY_SYMBOLS) or word_op:
2805
+ expect = True
2806
+ elif t.kind == "OP" and t.text == "(":
2807
+ depth += 1
2808
+ expect = True
2809
+ elif t.kind == "OP" and t.text == ")":
2810
+ depth -= 1
2811
+ elif t.kind == "OP" and t.text == "," and depth > 0:
2812
+ expect = True
2813
+ else:
2814
+ return False # a juxtaposed operand or anything else before the string: not modelled
2815
+ return True
2816
+
2817
+
2818
+ def order_unterminated_string(program: S.Program, profile, diags: Diagnostics) -> None:
2819
+ """CR300/CR350: a statement cut short by an unterminated string prints its undefined names,
2820
+ then `String without end quote`, then its final message (oracle, CR300 and CR350, 2026-10-11:
2821
+ `Foo" bar`, `x + "abc`, `Len("abc`, `Nope = "abc`, `If Nope Then Nope2 = "abc`; the final
2822
+ message was already right for most shapes). A call of a built-in function that the string leaves
2823
+ open is the generic fallback, not `No Matching` (`SprintF ( s, "The ...`). Left alone: a line
2824
+ that already has an instruction/call message (`Battery("abc`), and a declared variable with a
2825
+ subscript before the string (`x(1) "abc`, no string line)."""
2826
+ if profile.name not in ("cr300", "cr350"):
2827
+ return
2828
+ known = None
2829
+ declared = symbols.declared_variable_names(program)
2830
+ for stmt in program.statements:
2831
+ if not isinstance(stmt, S.Unparsed) or stmt.reason != "lex error: unterminated string":
2832
+ continue
2833
+ key = (stmt.line, stmt.file)
2834
+ own = [d for d in diags.items if (d.line, d.file) == key]
2835
+ final = next(
2836
+ (
2837
+ d
2838
+ for d in own
2839
+ if d.message.startswith(
2840
+ (_GENERIC_FALLBACK_PREFIX, "Incomplete expression:", "No Matching, or out of place )")
2841
+ )
2842
+ ),
2843
+ None,
2844
+ )
2845
+ if final is None or not all(
2846
+ d.message.endswith(_UNRESOLVED_SUFFIX) or d.message == _STRING_END_QUOTE or d is final for d in own
2847
+ ):
2848
+ continue
2849
+ sub = re.match(r"\s*([A-Za-z_]\w*)\s*\([^()\"]*\)\s+\"", stmt.text)
2850
+ if (sub and sub.group(1).upper() in declared) or re.match(r"(?i)\s*(?:Public|Dim)\b", stmt.text):
2851
+ continue
2852
+ if not _IF_STRING_RE.match(stmt.text) and not _string_reached(stmt.text):
2853
+ continue
2854
+ names = [d for d in own if d.message.endswith(_UNRESOLVED_SUFFIX)]
2855
+ if not names and (m := _IF_STRING_RE.match(stmt.text)):
2856
+ if known is None:
2857
+ known = prose_known(program, profile)
2858
+ here = known - declared_later(first_declarations(program), stmt.line, stmt.file) - diags.cascade_gone
2859
+ words = _leading_unknown_words(m.group(1), here) + ([m.group(2)] if m.group(2).upper() not in here else [])
2860
+ names = [
2861
+ Diagnostic("error", stmt.line, profile.unresolved_symbol_message.format(name=w), stmt.file, True)
2862
+ for w in words
2863
+ ]
2864
+ if (
2865
+ final.message.startswith("No Matching")
2866
+ and re.match(r"\s*([A-Za-z_]\w*)\s*\(", stmt.text)
2867
+ and stmt.text.lstrip().split("(")[0].strip().upper() in profile.functions
2868
+ ):
2869
+ final = dataclasses.replace(
2870
+ final,
2871
+ message=f"{_GENERIC_FALLBACK_PREFIX}{stmt.text}"
2872
+ f"{chr(10) if final.message.endswith(chr(10) + '.') else ''}.",
2873
+ )
2874
+ at = diags.items.index(own[0])
2875
+ diags.items[:] = [d for d in diags.items if (d.line, d.file) != key]
2876
+ diags.items[at:at] = [*names, Diagnostic("error", stmt.line, _STRING_END_QUOTE, stmt.file, True), final]
2877
+
2878
+
2879
+ def semicolon_headers(program: S.Program, profile, diags: Diagnostics) -> None:
2880
+ """CR300/CR350: a `;` in a `For` header or `Const` value is one `Invalid symbol in expression:
2881
+ <value> ;.` that replaces everything else reported for the line; the echo runs from the value
2882
+ that holds the first `;` (after `=`, `To` or `Step`) to the next `To`/`Step` word or the end of the
2883
+ statement, the blank before that word excluded (oracle, 2026-10-12:
2884
+ `For Y = 1 To 3 Step 1 ;` -> `1 ;`, `For Y = 1 ; To 3` -> ` 1 ;`, `Const K = ; 1` -> `; 1`)."""
2885
+ if profile.name not in ("cr300", "cr350"):
2886
+ return
2887
+ for stmt in program.statements:
2888
+ if not isinstance(stmt, S.Unparsed) or ";" not in stmt.text:
2889
+ continue
2890
+ m = re.match(r"\s*(FOR|CONST)\b", stmt.text, re.I)
2891
+ if not m:
2892
+ continue
2893
+ try:
2894
+ toks = [t for t in lexer.tokenize(stmt.text, 0) if t.kind != "EOF"]
2895
+ except lexer.LexError:
2896
+ continue
2897
+ semi = next((i for i, t in enumerate(toks) if t.kind == "OP" and t.text == ";"), None)
2898
+ if semi is None or semi < 2:
2899
+ continue
2900
+ if m.group(1).upper() == "CONST":
2901
+ eq = next((t for t in toks if t.kind == "OP" and t.text == "="), None)
2902
+ if eq is None:
2903
+ continue
2904
+ echo = stmt.text[(toks[semi].col if toks[semi].col < eq.col else eq.col + 1) :].lstrip()
2905
+ else:
2906
+ start = None
2907
+ for t in toks[:semi]:
2908
+ if t.kind == "OP" and t.text == "=":
2909
+ start = t.col + 1
2910
+ elif t.kind == "IDENT" and t.upper in ("TO", "STEP"):
2911
+ rest = stmt.text[t.col + len(t.text) :]
2912
+ start = t.col + len(t.text) + len(rest) - len(rest.lstrip())
2913
+ if start is None:
2914
+ continue
2915
+ stop = next((t for t in toks[semi:] if t.kind == "IDENT" and t.upper in ("TO", "STEP")), None)
2916
+ end = len(stmt.text) if stop is None else stop.col - (stop.col > 0 and stmt.text[stop.col - 1] in " \t")
2917
+ echo = stmt.text[start:end]
2918
+ key = (stmt.line, stmt.file)
2919
+ at = next((i for i, d in enumerate(diags.items) if (d.line, d.file) == key), None)
2920
+ if at is None:
2921
+ continue
2922
+ diags.items[:] = [d for d in diags.items if (d.line, d.file) != key]
2923
+ diags.items.insert(
2924
+ at, Diagnostic("error", stmt.line, f"Invalid symbol in expression: {echo}.", stmt.file, True)
2925
+ )
2926
+
2927
+
2928
+ def add_string_illegal(program: S.Program, profile, diags: Diagnostics) -> None:
2929
+ """CR300/CR350: an unparsable statement that starts with a well-formed expression holding a
2930
+ String operand of a numeric/logical operator prints `String is illegal here` before its final
2931
+ message, as on CR6/CR1000X (`structure._unparsed_string_messages`; oracle, CR300/CR350,
2932
+ 2026-10-11: `"BattV" and "Counter".`, `x = "BattV" and "Counter".`; a lone string or `x "a" and
2933
+ "b"` print none)."""
2934
+ if profile.name not in ("cr300", "cr350"):
2935
+ return
2936
+ for stmt in program.statements:
2937
+ if not isinstance(stmt, S.Unparsed) or stmt.reason == "lex error: unterminated string":
2938
+ continue
2939
+ key = (stmt.line, stmt.file)
2940
+ own = [d for d in diags.items if (d.line, d.file) == key]
2941
+ prefixes = (_GENERIC_FALLBACK_PREFIX, "Incomplete expression:") + (
2942
+ ("Invalid symbol in expression:",) if ";" in stmt.text else ()
2943
+ ) # `X = "a" and "b" ;`
2944
+ final = next((d for d in own if d.message.startswith(prefixes)), None)
2945
+ if final is None or any(d.message == "String is illegal here" for d in own):
2946
+ continue
2947
+ messages = structure._unparsed_string_messages(stmt, program, profile)
2948
+ if messages:
2949
+ at = diags.items.index(final)
2950
+ diags.items[at:at] = [Diagnostic("error", stmt.line, m, stmt.file, True) for m in messages]
2951
+ _order_arith_string_lines(diags)
2952
+
2953
+
2954
+ _ARITH_STRING_MESSAGES = ("Illegal String operand", "Illegal operand of type STRING")
2955
+
2956
+
2957
+ def _order_arith_string_lines(diags: Diagnostics) -> None:
2958
+ """CR300/CR350: the arithmetic String errors (`Illegal String operand`) come before the
2959
+ statement's `X is not defined.` lines, the logical `String is illegal here` after them (oracle,
2960
+ CR300, 2026-10-12: `"a" * 2 foo`, `S * 2 foo`, `"a" MOD 2 foo` vs `X AND "a" foo`)."""
2961
+ items = diags.items
2962
+ strings = (*_ARITH_STRING_MESSAGES, "String is illegal here")
2963
+ for key in {(d.line, d.file) for d in items if d.message in strings}:
2964
+ slots = [i for i, d in enumerate(items) if (d.line, d.file) == key]
2965
+ own = [items[i] for i in slots]
2966
+ arith = [d for d in own if d.message in _ARITH_STRING_MESSAGES]
2967
+ logical = [d for d in own if d.message == "String is illegal here"]
2968
+ names = [d for d in own if d.message.endswith(" is not defined.")]
2969
+ if not names or (arith and logical):
2970
+ continue
2971
+ rest = [d for d in own if d not in arith and d not in logical and d not in names]
2972
+ order = arith + names + logical + rest
2973
+ if order != own:
2974
+ for i, d in zip(slots, order):
2975
+ items[i] = d
2976
+
2977
+
2978
+ _TCPOPEN_HANDLE = "Parameter must be a variable of type long"
2979
+
2980
+
2981
+ def check_tcpopen_handle_early(program: S.Program, profile, diags: Diagnostics) -> None:
2982
+ """The handle error is a `Too many parameters` cascade trigger (oracle, CR300/CR350,
2983
+ 2026-10-12: a bad handle inside a `Sub` makes the following `Scan (1,Sec,3,0)` and `Delay(1,..)`
2984
+ print `Too many parameters: Sec.` / `1.`), so it is added before `check_too_many_cascade`;
2985
+ `check_tcpopen_handle` re-adds it afterwards on a line the cascade rewrote."""
2986
+ check_tcpopen_handle(program, profile, diags, early=True)
2987
+
2988
+
2989
+ def check_tcpopen_handle(program: S.Program, profile, diags: Diagnostics, early: bool = False) -> None:
2990
+ """CR300/CR350: `TCPOpen`'s fifth argument (the connection handle) must be a Long or Boolean
2991
+ variable or array (a `Boolean` is a 4-byte integer: oracle, CR300/CR350, 2026-10-12, `Public
2992
+ Sock As Boolean`, `Sock(3) As Boolean`, `H As Boolean` Sub parameter compile), including a
2993
+ `Sub` parameter (`H As Long` / `H As Boolean`; an untyped, `As Float`, `As String` or defaulted
2994
+ untyped `Optional H = 1` parameter is bad: same date): a Float or String variable, a constant, an expression or an undefined name (a
2995
+ declaration the cascade dropped too) is `Parameter must be a variable of type long` (oracle,
2996
+ CR300, CR350 and CR6, 2026-10-11, `chm/tcpopen_4.CR6`), printed after the statement's
2997
+ `X is not defined.` lines and before `Dimensions not declared`. CR6/CR1000X not done: their
2998
+ line comes first, with the connection count check."""
2999
+ if profile.name not in ("cr300", "cr350"):
3000
+ return
3001
+ types = symbols.declared_variable_types(program)
3002
+ known = expressions._known_names(program, profile)
3003
+ gone = diags.cascade_gone
3004
+ params: dict[str, str | None] = {} # the enclosing Sub/Function's plain parameters: name -> type
3005
+ for stmt in program.statements:
3006
+ if isinstance(stmt, S.SubDef):
3007
+ params = {
3008
+ p.name.upper(): (
3009
+ (p.type or "FLOAT").upper()
3010
+ if p.by_ref is None
3011
+ and not p.dims
3012
+ and (p.type or "FLOAT").upper() in ("LONG", "BOOLEAN", "FLOAT", "STRING")
3013
+ else None
3014
+ )
3015
+ for p in stmt.params
3016
+ }
3017
+ continue
3018
+ if bindings.closes_routine(stmt):
3019
+ params = {}
3020
+ continue
3021
+ if isinstance(stmt, S.Call) and stmt.name.upper() == "TCPOPEN":
3022
+ calls = [stmt.args]
3023
+ else:
3024
+ calls = [
3025
+ ix.args
3026
+ for e in expressions._stmt_exprs(stmt)
3027
+ for ix in expressions._index_nodes(e)
3028
+ if ix.name.upper() == "TCPOPEN"
3029
+ ]
3030
+ for args in calls:
3031
+ if len(args) < 5 or args[4] is None:
3032
+ continue
3033
+ arg = args[4]
3034
+ own_dims = any(
3035
+ (d.line, d.file) == (stmt.line, stmt.file) and d.message.startswith("Dimensions not declared")
3036
+ for d in diags.items
3037
+ )
3038
+ if own_dims and not (isinstance(args[4], S.Index) and args[4].name.upper() not in known):
3039
+ continue # (an earlier argument's dimension error ends the walk before the fifth)
3040
+ if isinstance(args[3], (S.Name, S.Index)) and args[3].name.upper() not in known:
3041
+ continue # (a non-constant fourth argument ends the list: `Parameter must be a constant integer`, not done)
3042
+ if isinstance(arg, (S.Name, S.Index)):
3043
+ up = arg.name.upper()
3044
+ if up in params:
3045
+ if params[up] is None:
3046
+ continue # (a ByVal/ByRef, array or unusual-type parameter: not probed)
3047
+ bad = params[up] not in ("LONG", "BOOLEAN")
3048
+ elif up in gone or up not in known:
3049
+ bad = True
3050
+ elif up in types:
3051
+ bad = types[up] not in ("LONG", "BOOLEAN")
3052
+ else:
3053
+ continue # (an alias or a constant: not probed)
3054
+ else:
3055
+ bad = isinstance(arg, (S.Number, S.String, S.Binary, S.Unary))
3056
+ if not bad:
3057
+ continue
3058
+ key = (stmt.line, stmt.file)
3059
+ own = [i for i, d in enumerate(diags.items) if (d.line, d.file) == key]
3060
+ if any(diags.items[i].message == _TCPOPEN_HANDLE for i in own) and not early:
3061
+ continue
3062
+ names = [i for i in own if diags.items[i].message.endswith(_UNRESOLVED_SUFFIX)]
3063
+ at = (names[-1] + 1) if names else (own[0] if own else len(diags.items))
3064
+ diags.items.insert(at, Diagnostic("error", stmt.line, _TCPOPEN_HANDLE, stmt.file, True))
3065
+
3066
+
3067
+ def order_target_name_first(program: S.Program, profile, diags: Diagnostics) -> None:
3068
+ """CR300/CR350: `Socket = TCPOpen` with `Socket` undeclared names the target before the
3069
+ `Expression expects (` of the bare function on the right (oracle, 2026-10-10: comportisactive_1,
3070
+ ethernetpower_1), though pass 1 emitted that one first."""
3071
+ if profile.name not in ("cr300", "cr350"):
3072
+ return
3073
+ for stmt in program.statements:
3074
+ if not (isinstance(stmt, S.Assignment) and isinstance(stmt.target, S.Name)):
3075
+ continue
3076
+ key = (stmt.line, stmt.file)
3077
+ message = profile.unresolved_symbol_message.format(name=stmt.target.name)
3078
+ own = [d for d in diags.items if (d.line, d.file) == key]
3079
+ mark = next((d for d in own if d.message == message), None)
3080
+ if mark is None or own[0] is mark or not any(d.message.startswith("Expression expects (") for d in own):
3081
+ continue
3082
+ first = next(i for i, d in enumerate(diags.items) if (d.line, d.file) == key)
3083
+ diags.items.remove(mark)
3084
+ diags.items.insert(first, mark)
3085
+
3086
+
3087
+ def drop_after_alias_collision(program: S.Program, profile, diags: Diagnostics) -> None:
3088
+ """CR300/CR350: after an `Alias` name reports `%s already declared as a variable.` (global or
3089
+ in a routine), no later line reports anything; the rest of that statement still does (oracle,
3090
+ 2026-10-23, `tests/fixtures/r31_alias_collisions.json`; a `Const` or predefined collision does
3091
+ not do this). "Later" is in expanded source order, across `Include` files (round 31 review)."""
3092
+ if not profile.single_pass:
3093
+ return
3094
+ aliases = {
3095
+ (st.line, st.file)
3096
+ for st in program.statements
3097
+ if isinstance(st, S.Instruction) and st.keyword.upper() == "ALIAS"
3098
+ }
3099
+ hit = next(
3100
+ (
3101
+ d
3102
+ for d in diags.items
3103
+ if d.severity == "error"
3104
+ and (d.line, d.file) in aliases
3105
+ and d.message.endswith(" already declared as a variable.")
3106
+ ),
3107
+ None,
3108
+ )
3109
+ if hit is not None:
3110
+ order: dict[tuple, int] = {}
3111
+ for i, st in enumerate(program.statements):
3112
+ order.setdefault((st.line, st.file), i)
3113
+ at = order[(hit.line, hit.file)]
3114
+ diags.items[:] = [
3115
+ d
3116
+ for d in diags.items
3117
+ if not (order.get((d.line, d.file), -1) > at or (d.file == hit.file and d.line > hit.line))
3118
+ ]