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,3540 @@
1
+ """Instruction-specific checks that are not channel/port related (that logic lives in
2
+ `checker/instructions.py`, a different workstream, `PLAN.md`). A required argument position with
3
+ no corresponding value at all (`check`, pass-1, gated normally through `checker/__init__.py`), plus
4
+ a small set of *ungated* `Scan`/`DataInterval` numeric-value checks (`check_scan_params`,
5
+ `check_data_interval`, called from `checker/warnings.py`'s `check_early` -- oracle-confirmed to
6
+ fire even on an otherwise-failed compile, same as that module's own ungated warnings; see its
7
+ module docstring). Rules and oracle evidence: `docs/checker.md` (the required-parameter check) and
8
+ `docs/instructions.md` (the numeric-value checks).
9
+ """
10
+
11
+ import math
12
+ import re
13
+ import struct
14
+ from typing import cast
15
+
16
+ from .. import consteval
17
+ from .. import syntax as S
18
+ from ..diagnostics import Diagnostic, Diagnostics
19
+ from ..tables import _unit_value
20
+ from . import longconv, symbols
21
+ from .expressions import _arg_text, _known_names, _type_code_kinds, _undefined_names, _variable_refs
22
+
23
+ # `type_codes.json` codes exempt from the "missing required parameter" error: when the
24
+ # statement's own physical line runs out of tokens right at one of these, `FUN_004baf00` silently
25
+ # fills it (and everything declared after it) with a default/sentinel value and ends that
26
+ # statement's own parameter parsing with no diagnostic at all, rather than reporting anything --
27
+ # `docs/type-codes.md` code 4's row ("this code (and 16, 17, 36, 37, 45, 46) is exempt from the
28
+ # loop-level `Too few parameters in instruction.` check"); this is what lets a bare `Do`/`Loop`/
29
+ # `Else` (code 4, no trailing `While`/`Until` condition), bare `End`/`Exit` (16), and bare `Next`
30
+ # (17) all compile with zero declared arguments even though their own table row lists the code as
31
+ # non-optional (code < 100). 36/45/46 are listed here too for documentation fidelity to that
32
+ # passage, but never reach this check in practice: `_real_params` below already drops them (they
33
+ # are `none`-kind, not real argument slots at all).
34
+ _TOLERANT_CODES = frozenset({4, 16, 17, 36, 37, 45, 46})
35
+
36
+ # --- Required parameter cut off by an unterminated string: also `Invalid or missing parameter.` -
37
+
38
+ # A required argument that *starts* (a `"` was seen) but never closes before the physical line
39
+ # ends never reaches `check` above at all: the frontend's own lexer raises `LexError("unterminated
40
+ # string")` on that whole line before a statement can be built, so `parser.py` records it as
41
+ # `S.Unparsed(reason="lex error: unterminated string")`, not an `S.Instruction` -- `check` above
42
+ # only walks `S.Instruction` nodes and silently skips it. Oracle-confirmed (`httpout_1.CR6`/
43
+ # `webpagebeginwebpageend_1.CR6`, both CHM corpus, and a standalone scratch probe reproducing the
44
+ # same shape byte-for-byte): `HTTPOut("<title>CR1000X` (opening quote, no closing quote before the
45
+ # newline) reports `Invalid or missing parameter.` at that statement's own line, nothing else --
46
+ # not the continuation line the unterminated string spills onto (`Datalogger</title>")`, which the
47
+ # real compiler reports nothing for at all (consistent with the already-implemented pass-1/pass-2
48
+ # gate in `checker/__init__.py`: this is itself a pass-1 error, so pass 2's generic `Unparsed`-
49
+ # >`Expr` fallback -- the source of this project's own previous, wrong `Invalid, or out of place
50
+ # expression: HTTPOut(...` -- never runs for the rest of the file, silencing the continuation line
51
+ # for free, the same way the count-shortfall check above already relies on that gate).
52
+ #
53
+ # **Deliberately narrow, keyed to the argument's type-code `kind`, not "any known instruction"**:
54
+ # a same-shaped probe against `If <cond> Then Test = "TEMP` (`call_1.CR6`, also in the CHM corpus)
55
+ # gave a *different* real message (`String without end quote`) for its own unterminated string,
56
+ # even though `IF` is a `profile.instructions` entry too (`params=[7]`, kind `bool_expr`) -- so
57
+ # "known instruction" alone is not the trigger. `docs/type-codes.md` documents `bool_expr` (7) and
58
+ # `expr_or_name` (26, `HTTPOut`'s own single param) as reaching the shared per-code parameter
59
+ # resolver through two *different* switch cases (`_caseD_7` vs `_caseD_19`); this check fires only
60
+ # when the statement's first declared parameter is `expr_or_name` (the confirmed case), not
61
+ # generalised to every `kind` the shared resolver accepts -- no oracle evidence covers the others,
62
+ # and AGENTS.md rules out guessing at the rest.
63
+
64
+ _LEADING_IDENT_RE = re.compile(r"\s*([A-Za-z_][A-Za-z0-9_]*)")
65
+
66
+ # --- Required parameter missing, `identifier`/`deferred_text` kind: `Too few parameters in
67
+ # instruction.` instead of `Invalid or missing parameter.` -------------------------------------
68
+
69
+ # `docs/type-codes.md` section 4 left the trigger for this, the loop-level "b4" guard in
70
+ # `FUN_004baf00`, as an open question ("every multi-parameter instruction tried instead hit a more
71
+ # specific check inside the parameter's own handler first"); pinned down this round by the same
72
+ # oracle method, four minimal probes plus corpus cross-checks (`docs/checker.md` has the full
73
+ # evidence table):
74
+ #
75
+ # - `Battery` bare (code 8, `variable_or_array`) -> `Invalid or missing parameter.`
76
+ # - `Average` bare (code 1, `const_expr`) -> `Invalid or missing parameter.` (also the CHM
77
+ # corpus's own `average_1.CR6`, cross-line continuation, oracle-confirmed same session)
78
+ # - `CallTable` bare (code 6, `identifier`) -> `Too few parameters in instruction.`
79
+ # - `Histogram` bare (code 11, `deferred_text`) -> `Too few parameters in instruction.`
80
+ #
81
+ # and confirmed against the CHM corpus's own `openinterval_1.CR6` (`DataTable` bare, code 6) and
82
+ # `etsz_1.CR6` (`ETsz` bare, code 11), both `Too few parameters in instruction.`, and
83
+ # `writeio_1.CR6` (`CallTable` bare, code 6) the same. The generic per-code expression/constant
84
+ # resolver (`FUN_004b5c50`/`FUN_004b8700`, every other kind) has its own "nothing here" message and
85
+ # never falls through to the loop-level guard; `identifier` and `deferred_text` are instead
86
+ # captured via a raw `strtok` copy (`docs/type-codes.md` codes 6/11's own "Accepted forms" column)
87
+ # with no such per-code "missing" check of its own, so an empty token there reaches the top-of-loop
88
+ # guard uncaught by anything more specific -- the two kinds share this one property (a bare
89
+ # `strtok`, no dedicated handler) that every other kind lacks, which is why exactly these two, and
90
+ # not e.g. `raw_line_text` (18, `MenuPick`) or `type_name` (45, `StructureType`/`ObjectType`, also
91
+ # raw-`strtok`-captured per the same column) are included here: no oracle evidence covers those
92
+ # two, and AGENTS.md rules out extending the guess to them.
93
+ #
94
+ # Does **not** explain every CHM corpus row with this message (`tablenamerecord_1.CR6`'s
95
+ # `Main.record(1,1)`, `realtime_1.CR6`'s later lines): those sit downstream of an earlier,
96
+ # already-diverging recovery this project's own parser takes differently from the real compiler
97
+ # for unrelated reasons (confirmed by isolating `RecData = Main.record(1,1)` alone, which compiles
98
+ # clean here -- the corpus file's own divergence starts earlier), not a second, distinct trigger
99
+ # for this message; those files stay `verdict`, not claimed fixed.
100
+ _TOO_FEW_PARAMS_KINDS = frozenset({"identifier", "deferred_text"})
101
+
102
+
103
+ _DECLARATION_WORDS = frozenset({"PUBLIC", "DIM", "STATIC", "CONST", "ALIAS", "UNITS", "READONLY", "SUB", "FUNCTION"})
104
+
105
+
106
+ def unterminated_string_shape(
107
+ stmt: S.Stmt,
108
+ profile,
109
+ subs: set[str],
110
+ operands: set[str] | None = None,
111
+ functions: frozenset[str] | set[str] = frozenset(),
112
+ ) -> str | None:
113
+ """How a line cut short by an unterminated string is reported (oracle, all four loggers,
114
+ 2026-09-28, `docs/checker.md` "Unterminated strings"): `"if"` (a one-line `If`), `"call"` (a
115
+ `Sub` call), `"instruction"`, `"assignment"`, or `None` (left to the generic fallback)."""
116
+ if not isinstance(stmt, S.Unparsed) or stmt.reason != "lex error: unterminated string":
117
+ return None
118
+ m = _LEADING_IDENT_RE.match(stmt.text)
119
+ word = m.group(1).upper() if m else ""
120
+ if word == "IF":
121
+ return "if" if re.search(r"(?i)\bThen\b\s*\S", stmt.text) else None
122
+ if word != "CALL" and not profile.single_pass and (shape := _function_call_string(stmt.text, functions)):
123
+ return shape
124
+ if word == "CALL" or word in subs:
125
+ return "call"
126
+ record = profile.instructions.get(word)
127
+ if word in _DECLARATION_WORDS:
128
+ return None # `Public</a></li></ul>")` in `httpout_1.CR6` is not reported
129
+ if record:
130
+ params = _real_params(record["params"])
131
+ kind = _type_code_kinds().get(params[0] % 100) if params else None
132
+ if not params or kind == "bool_expr":
133
+ return None
134
+ if not profile.single_pass and (extra := _past_last_param(stmt.text, len(params))):
135
+ return "too_many:" + extra
136
+ return "instruction"
137
+ if re.match(r"\s*[A-Za-z_][A-Za-z0-9_]*\s*(?:\([^\"]*\))?\s*=\s*\"", stmt.text):
138
+ return "assignment"
139
+ if operands is not None:
140
+ from . import structure
141
+
142
+ reached = structure.string_reached(stmt.text, operands, profile)
143
+ if reached:
144
+ return "assignment" if reached == "reached" else "many"
145
+ return None
146
+
147
+
148
+ _FUNCTION_CALL_RE = re.compile(r"\s*(?:[A-Za-z_]\w*\s*=\s*)?([A-Za-z_]\w*)\s*\(")
149
+
150
+
151
+ def _function_call_string(text: str, functions: frozenset[str] | set[str]) -> str | None:
152
+ """CR6/CR1000X: a user Function call (`F("abc)`, `N = G("a", "b)`) whose unterminated string
153
+ opens inside its parentheses: `"function"`, or `"function_arg"` when the string follows a
154
+ top-level comma (`Call argument required.` before the fallback; oracle, round 64,
155
+ docs/checker.md#unterminated-strings-in-user-function-calls-round-64); `"function_tail"` when
156
+ it directly follows the call (round 67)."""
157
+ m = _FUNCTION_CALL_RE.match(text)
158
+ if not m or m.group(1).upper() not in functions:
159
+ return None
160
+ depth, comma, i = 0, False, m.end() - 1
161
+ while i < len(text):
162
+ c = text[i]
163
+ if c == '"':
164
+ close = text.find('"', i + 1)
165
+ if close < 0:
166
+ return "function_arg" if comma else "function"
167
+ i = close + 1
168
+ continue
169
+ depth += c == "("
170
+ depth -= c == ")"
171
+ if depth == 0:
172
+ # the unterminated quote follows the call: `F("abc")"` reads it as an adjacent operand
173
+ # (`Incomplete expression`), `F("abc") + "x` as the fallback (oracle, round 67)
174
+ rest = text[i + 1 :]
175
+ if rest.count('"') != 1:
176
+ return None
177
+ return "function" if rest[: rest.index('"')].strip() else "function_tail"
178
+ comma |= c == "," and depth == 1
179
+ i += 1
180
+ return None
181
+
182
+
183
+ def user_functions(program: S.Program) -> set[str]:
184
+ """The upper-cased names of the program's `Function` routines."""
185
+ return {
186
+ st.name.upper() for st in program.statements if isinstance(st, S.SubDef) and st.keyword.upper() == "FUNCTION"
187
+ }
188
+
189
+
190
+ def _past_last_param(text: str, count: int) -> str | None:
191
+ """CR6/CR1000X: when the unterminated string opens after the last declared parameter, the
192
+ excess argument is reported first (`FieldNames(S1,S2,S3")` -> `Too many parameters: S2.`;
193
+ oracle, round 59). Its first simple token, else None."""
194
+ open_at, quote = text.find("("), text.find('"')
195
+ if open_at < 0 or quote < open_at:
196
+ return None
197
+ depth, commas = 0, []
198
+ for i in range(open_at + 1, quote):
199
+ depth += text[i] in "({"
200
+ depth -= text[i] in ")}"
201
+ if text[i] == "," and depth == 0:
202
+ commas.append(i)
203
+ if len(commas) < count:
204
+ return None
205
+ return _next_simple_token(text[commas[count - 1] + 1 :]) or None
206
+
207
+
208
+ def _check_unterminated_string_param(program: S.Program, profile, diags: Diagnostics) -> None:
209
+ """Pass 1: `HTTPOut("<title>CR1000X` / `Battery("abc` -> `Invalid or missing parameter.`;
210
+ `Foo("abc` -> `Invalid or malformed function argument.`; `S = "abc` -> `String without end
211
+ quote` then the fallback echoing the raw line; `If X Then S = "abc` -> `String without end
212
+ quote` then the fallback for `S = "abc:endif` (the compiler's block-form rewrite)."""
213
+ from . import structure, symbols
214
+
215
+ subs = symbols.declared_subs(program)
216
+ operands = structure.pass1_operands(program, profile)
217
+ functions = user_functions(program)
218
+ for stmt in program.statements:
219
+ shape = unterminated_string_shape(stmt, profile, subs, operands, functions)
220
+ if shape == "instruction":
221
+ diags.error(stmt.line, "Invalid or missing parameter.", stmt.file)
222
+ elif shape and shape.startswith("too_many:"):
223
+ diags.error(stmt.line, f"Too many parameters: {shape[len('too_many:') :]}.", stmt.file)
224
+ elif shape == "call":
225
+ diags.error(stmt.line, "Invalid or malformed function argument.", stmt.file)
226
+ elif shape in ("function", "function_arg", "function_tail"):
227
+ diags.error(stmt.line, "String without end quote", stmt.file)
228
+ if shape == "function_arg":
229
+ diags.error(stmt.line, "Call argument required.", stmt.file)
230
+ diags.error(
231
+ stmt.line,
232
+ f"Incomplete expression: {stmt.text}."
233
+ if shape == "function_tail"
234
+ else structure._out_of_place(profile, stmt.text, stmt.at_eol),
235
+ stmt.file,
236
+ )
237
+ elif shape == "many":
238
+ diags.error(stmt.line, structure._no_matching(profile, stmt.text, stmt.at_eol), stmt.file)
239
+ elif shape == "assignment":
240
+ diags.error(stmt.line, "String without end quote", stmt.file)
241
+ diags.error(stmt.line, structure._out_of_place(profile, stmt.text, stmt.at_eol), stmt.file)
242
+ elif shape == "if":
243
+ then = re.search(r"(?i)\bThen\b\s*(.*)$", stmt.text)
244
+ assert then is not None # (`unterminated_string_shape` found it)
245
+ rest = then.group(1)
246
+ diags.error(stmt.line, "String without end quote", stmt.file)
247
+ diags.error(stmt.line, f"Invalid, or out of place expression: {rest}:endif.", stmt.file)
248
+
249
+
250
+ def _real_params(params: list[int]) -> list[int]:
251
+ """`params` with every `none`-kind code removed (14 `EndSub`/`EndFunction`, 32
252
+ `RainFlowSample`'s first slot, 36 `EndSelect`, 46 `EndStructureType`/`EndObjectType`,
253
+ `docs/type-codes.md`) -- these are structural markers in the raw per-instruction table that
254
+ consume no user-written token at all, so counting them as "required" would flag a legitimately
255
+ argument-less statement (`EndSub` alone), and leaving one in the list at all would shift every
256
+ later position out of alignment with the parsed `stmt.args` (found as a regression while
257
+ developing this check: bare `EndSub`, `params=[14]`, was flagged as one required argument
258
+ short; `RainFlowSample`'s real first user argument is its own table position 1, code 11, after
259
+ the position-0 no-op marker, code 32)."""
260
+ kinds = _type_code_kinds()
261
+ return [c for c in params if kinds.get(c - 100 if c >= 100 else c) != "none"]
262
+
263
+
264
+ def _required_count(params: list[int]) -> int:
265
+ """How many leading `params` entries are non-optional (code < 100). `FUN_004baf00`'s own
266
+ rule -- "All arguments after the first optional parameter must be optional." -- means the
267
+ first optional (code >= 100) entry marks where the required prefix ends; the CHM param tables
268
+ never observed one out of order, and `data/instructions.json` is table-driven so this holds by
269
+ construction."""
270
+ for i, code in enumerate(params):
271
+ if code >= 100:
272
+ return i
273
+ return len(params)
274
+
275
+
276
+ def misplaced_returns(program: S.Program) -> dict[int, bool]:
277
+ """`id(Return statement) -> inside a Sub` for each `Return` not inside a `Function`, one-line
278
+ `If` bodies included."""
279
+ found: dict[int, bool] = {}
280
+ routine = None
281
+ flat: list[S.Stmt] = []
282
+ for stmt in program.statements:
283
+ flat.append(stmt)
284
+ if isinstance(stmt, S.If):
285
+ flat.extend((stmt.inline or []) + (stmt.inline_else or []))
286
+ for stmt in flat:
287
+ if isinstance(stmt, S.SubDef):
288
+ routine = stmt.keyword.upper()
289
+ elif isinstance(stmt, S.Instruction):
290
+ kw = stmt.keyword.upper()
291
+ if kw in ("ENDSUB", "ENDFUNCTION") or (
292
+ kw == "END"
293
+ and stmt.args
294
+ and isinstance(stmt.args[0], S.Name)
295
+ and stmt.args[0].name.upper() in ("SUB", "FUNCTION")
296
+ ):
297
+ routine = None
298
+ elif kw == "RETURN" and routine != "FUNCTION":
299
+ found[id(stmt)] = routine == "SUB"
300
+ return found
301
+
302
+
303
+ def check_return_placement(program: S.Program, profile, diags: Diagnostics) -> None:
304
+ """`Return` outside a `Function`, pass 1, all four loggers (oracle, 2026-10-03): `Return
305
+ outside of Function.` (any context but a `Sub`, `DataTable` and `BeginProg` bodies included),
306
+ then, for any argument, `Too many parameters: <first argument token>.` (the instruction
307
+ takes none there). In a `Sub` only `RETURN illegal inside Sub` (no period) and only with an
308
+ argument; a bare `Return` there is still `Invalid or missing parameter.` (`check`)."""
309
+ misplaced = misplaced_returns(program)
310
+ for stmt in _return_statements(program):
311
+ in_sub = misplaced.get(id(stmt))
312
+ if in_sub is None:
313
+ continue
314
+ if in_sub:
315
+ if stmt.args:
316
+ diags.error(stmt.line, "RETURN illegal inside Sub", stmt.file)
317
+ continue
318
+ diags.error(stmt.line, "Return outside of Function.", stmt.file)
319
+ spans = getattr(stmt, "arg_spans", None)
320
+ if stmt.args and spans and spans[0] is not None:
321
+ token = _next_simple_token(stmt.text[spans[0][0] :])
322
+ if token:
323
+ diags.error(stmt.line, f"Too many parameters: {token}.", stmt.file)
324
+
325
+
326
+ def _return_statements(program: S.Program):
327
+ for stmt in program.statements:
328
+ for st in [stmt] + ((stmt.inline or []) + (stmt.inline_else or []) if isinstance(stmt, S.If) else []):
329
+ if isinstance(st, S.Instruction) and st.keyword.upper() == "RETURN":
330
+ yield st
331
+
332
+
333
+ def check(program: S.Program, profile, diags: Diagnostics) -> None:
334
+ """Flags an `Instruction` statement whose declared required-parameter prefix runs past the end
335
+ of the parsed argument list -- almost always a CHM corpus example whose instruction call was
336
+ wrapped across two physical lines with no `_` continuation (`docs/checker.md`: `Average` alone
337
+ on one line, `(1,TCTemp,FP2,False)` on the next -- our own tolerant "auto-close an unclosed `(`
338
+ at end of line" parsing, `docs/frontend.md`, silently produces a *short* argument list rather
339
+ than a syntax error, unlike the real compiler, which reports the gap and abandons the rest of
340
+ that statement's own parsing there).
341
+
342
+ Deliberately checks only a *shortfall in count* (`len(stmt.args) < required`), never a `None`
343
+ gap from a skipped comma in the middle of the list -- no oracle evidence this session found
344
+ supports flagging that shape, and AGENTS.md's priority (never reject an accepted program) rules
345
+ out guessing.
346
+
347
+ The message itself depends on the missing parameter's own `kind` -- see
348
+ `_TOO_FEW_PARAMS_KINDS`'s docstring above for the oracle evidence: `identifier` (6) and
349
+ `deferred_text` (11) report `Too few parameters in instruction.`, every other kind the
350
+ original `Invalid or missing parameter.`.
351
+
352
+ This is pass-1 work (`checker/__init__.py`'s docstring): it fires regardless of what other
353
+ errors the file already has. It does *not* need to separately suppress
354
+ `checker/instructions.py`'s own per-instruction checks (channel ranges etc.) for the statement
355
+ it flags -- `checker/__init__.py` already gates all of pass 2 behind "no pass-1 error anywhere
356
+ in the file" (oracle evidence there), which covers this case for free.
357
+ """
358
+ outside = {k for k, in_sub in misplaced_returns(program).items() if not in_sub}
359
+ known = _known_names(program, profile)
360
+ for stmt in program.statements:
361
+ if not isinstance(stmt, S.Instruction) or id(stmt) in outside:
362
+ continue
363
+ record = profile.instructions.get(stmt.keyword.upper())
364
+ if not record:
365
+ continue
366
+ params = _real_params(record["params"])
367
+ required = _required_count(params)
368
+ given = given_count(stmt, profile)
369
+ if given >= required:
370
+ continue
371
+ missing_code = params[given]
372
+ if missing_code in _TOLERANT_CODES:
373
+ continue
374
+ if profile.name in ("cr6", "cr1000x"):
375
+ # an unresolved name in an argument other than a destination (8/27/29) or a code 28
376
+ # is reported as `Invalid symbol`, and the shortfall is not (oracle, 2026-10-10)
377
+ kinds = _type_code_kinds()
378
+ if any(
379
+ a is not None
380
+ and params[i] % 100 not in (8, 27, 28, 29)
381
+ and kinds.get(params[i] % 100) in _TOO_MANY_SAFE_KINDS
382
+ and _undefined_names(a, known)
383
+ for i, a in enumerate(stmt.args[: len(params)])
384
+ ):
385
+ continue
386
+ _report_constant_destinations(stmt, record, params, program, profile, diags)
387
+ # CR6/CR1000X: an earlier parameter-kind error on the statement hides the shortfall, as it
388
+ # hides `Too many parameters` below (`Average Strain`, `Sample (Strain)`, `PortSet (X,1)`)
389
+ if profile.name in ("cr6", "cr1000x") and _earlier_error_in_stmt(
390
+ program, stmt, diags, ("Illegal parameter ", "Variable illegal in Parameter")
391
+ ):
392
+ continue
393
+ kind = _type_code_kinds().get(missing_code - 100 if missing_code >= 100 else missing_code)
394
+ if kind in _TOO_FEW_PARAMS_KINDS:
395
+ diags.error(stmt.line, "Too few parameters in instruction.", stmt.file)
396
+ else:
397
+ diags.error(stmt.line, "Invalid or missing parameter.", stmt.file)
398
+ _check_unterminated_string_param(program, profile, diags)
399
+
400
+
401
+ def given_count(stmt: S.Instruction, profile) -> int:
402
+ """The arguments actually given: a trailing comma leaves an empty last slot that is the
403
+ missing parameter, not a given one. Every further trailing empty is dropped too, on all four
404
+ loggers (oracle, 2026-10-12, `Sample(1,x,,)`, `Sample(1,,,)`, `PanelTemp(x,,,)`)."""
405
+ given = len(stmt.args)
406
+ while given and stmt.args[given - 1] is None:
407
+ given -= 1
408
+ return given
409
+
410
+
411
+ def given_count_of(args: list[str]) -> int:
412
+ """`given_count` for a call's raw argument texts (empty slots at the end are not given)."""
413
+ given = len(args)
414
+ while given and not args[given - 1].strip().rstrip(")").strip():
415
+ given -= 1
416
+ return given
417
+
418
+
419
+ def shortfall_message(record: dict, given: int) -> str | None:
420
+ """The message for a statement of `given` arguments that is short of `record`'s required
421
+ parameters (the rule `check` applies to a parsed `S.Instruction`), else None."""
422
+ params = _real_params(record["params"])
423
+ if given >= _required_count(params) or params[given] in _TOLERANT_CODES:
424
+ return None
425
+ kind = _type_code_kinds().get(params[given] - 100 if params[given] >= 100 else params[given])
426
+ return "Too few parameters in instruction." if kind in _TOO_FEW_PARAMS_KINDS else "Invalid or missing parameter."
427
+
428
+
429
+ def _report_constant_destinations(
430
+ stmt: S.Instruction, record: dict, params: list, program, profile, diags: Diagnostics
431
+ ) -> None:
432
+ """CR6/CR1000X, a statement short of required parameters: the arguments given are still
433
+ examined first, so a constant `PrecisionVariable` gives `Invalid Variable or Array: <arg>.`
434
+ ahead of `Invalid or missing parameter.` (oracle, 2026-10-03,
435
+ `tests/fixtures/short_constant_dest.json`), as `check_precision_variable` reports it for a
436
+ complete statement. Other destinations: `instr_data.check_constant_destinations`."""
437
+ if stmt.keyword.upper() in _PRECISION_VARIABLE_POSITIONS:
438
+ known = _known_names(program, profile)
439
+ variables = symbols.declared_variable_names(program)
440
+ if (
441
+ stmt.args
442
+ and stmt.args[0] is not None
443
+ and not (_undefined_names(stmt.args[0], known) or _variable_refs(stmt.args[0], variables))
444
+ ):
445
+ diags.error(stmt.line, f"Invalid Variable or Array: {_arg_text(stmt, 0)}.", stmt.file)
446
+
447
+
448
+ # --- Too many parameters: `Too many parameters: %s.` (round 5, new this round) -----------------
449
+
450
+ # `docs/type-codes.md` `kind`s eligible for `check_too_many` below: every `kind` whose own argument
451
+ # is always exactly one ordinary, comma-delimited token compiled through the generic per-code
452
+ # expression/constant/keyword resolver -- the same shared assumption `expressions.py`'s
453
+ # `_RESOLVED_KINDS` makes for a *different* purpose (name resolution), deliberately narrower here.
454
+ # **Not** included, each for its own oracle/corpus-evidenced reason a corpus safety scan (`docs/
455
+ # checker.md` "Round 5") found necessary:
456
+ #
457
+ # - `identifier` (6), `deferred_text` (11), `raw_line_text` (18), `expr_marker` (37), `type_name`
458
+ # (45): raw-text-capturing codes whose own instruction (`Units`, `ConstTable`, `MenuPick`, ...)
459
+ # may swallow embedded commas as part of *one* declared slot's own text (`Units X = Volts` is one
460
+ # code-6 argument to the real compiler, never comma-split) -- comparing this checker's own,
461
+ # always-comma-split `stmt.args` length against the declared code count for these would flag
462
+ # real, oracle-accepted programs as having "too many" parameters purely from this checker's own
463
+ # parsing convention, not a real excess (found as 631 `Alias`/534 `Units`/35 `MenuPick`/17
464
+ # `ConstTable` false hits on this project's own *accepted* `crbasic/`+`chm` corpus before this
465
+ # exclusion was added).
466
+ # - `sub_params` (12/35), `for_header` (3), `expr_list` (13): declaration/loop-header/call-argument
467
+ # grammars with their own, already-implemented parsing (`Sub`/`Function`/`FOR`/`Call`), never
468
+ # reached via a plain `S.Instruction`'s `args` list at all (`Call` parses to `S.Call`, `Sub`/
469
+ # `Function` to `S.SubDef`, `FOR` to `S.For` -- `checker/structure.py`'s own AST node types), so
470
+ # excluding them here is defensive rather than corpus-evidenced.
471
+ # Instructions checked although a parameter kind above excludes them, each oracle-probed on all
472
+ # four loggers (2026-09-27/28): `DataTable (T,1,-1,"fuzz")`, `CallTable(T,X,Y)`, and table output
473
+ # with its `deferred_text` source, `Minimum (1,X,FP2,False,False,"fuzz")` -> `Too many parameters:
474
+ # "fuzz".`, `SendTableDef(ComRS232,0,4094,T,(1+2))` -> `... 1+2.`.
475
+ _TOO_MANY_EXTRA = frozenset(
476
+ {
477
+ "DATATABLE",
478
+ "CALLTABLE",
479
+ "SAMPLE",
480
+ "AVERAGE",
481
+ "MAXIMUM",
482
+ "MINIMUM",
483
+ "TOTALIZE",
484
+ "STDDEV",
485
+ "MEDIAN",
486
+ "SENDTABLEDEF",
487
+ "ESSINITIALIZE",
488
+ "WINDVECTOR",
489
+ }
490
+ )
491
+
492
+ # Parameterless instructions (`params == []`): any argument is `Too many parameters: <first token>.`
493
+ # (oracle, CR6/CR1000X, 2026-10-10, each probed as `Name junk` and `Name(1)`). Not `EndIf`/
494
+ # `EndSelect` (block-closer messages come with it), nor the custom-parsed `Const`/`Data`/`Alias`/...
495
+ ZERO_PARAM_INSTRUCTIONS = frozenset(
496
+ {
497
+ "BEGINPROG",
498
+ "ENDPROG",
499
+ "NEXTSCAN",
500
+ "NEXTSUBSCAN",
501
+ "EXITSCAN",
502
+ "CONTINUESCAN",
503
+ "EXITDO",
504
+ "EXITFOR",
505
+ "EXITSUB",
506
+ "ABORTSCAN",
507
+ "PRESERVEVARIABLES",
508
+ "PROCHIPRI",
509
+ "ENDPROCHIPRI",
510
+ "LEDOFF",
511
+ "CARDFLUSH",
512
+ "OPENINTERVAL",
513
+ "TABLEHIDE",
514
+ "RESTORE",
515
+ "PIPELINEMODE",
516
+ "ANGLEDEGREES",
517
+ "WEND",
518
+ "RESTART",
519
+ "DEBUGBREAK",
520
+ "ALLOWSPECIALCOMMS",
521
+ "PULSECOUNTRESET",
522
+ }
523
+ )
524
+
525
+ _TOO_MANY_SAFE_KINDS = frozenset(
526
+ {
527
+ "const_expr",
528
+ "expr",
529
+ "const_expr_only",
530
+ "bool_expr",
531
+ "variable_or_array",
532
+ "expr_or_name",
533
+ "expr_typed",
534
+ "expr_special",
535
+ "expr_pair",
536
+ "datatype_kw",
537
+ "const_int_kw",
538
+ "next_kw",
539
+ "end_exit_kw",
540
+ }
541
+ )
542
+ # `deferred_text` (the source of `SampleMaxMin`, `Moment`, `Histogram`, `FFT`, `Covariance`,
543
+ # `RainFlow`, `LevelCrossing`, `ETsz`, `SendData`, ...) ends at the next top-level comma like the
544
+ # kinds above, so an excess argument is left over and reported (oracle, all four loggers,
545
+ # 2026-10-10: `SampleMaxMin(1,X,FP2,False,"fuzz")`, `FFT(...,5)`, `Histogram4D(...,Nope)`,
546
+ # `SendData(...,5)`, `AcceptDataRecords(...,5)`); it stays out of `_TOO_MANY_SAFE_KINDS`, which the
547
+ # shortfall check shares for another purpose.
548
+ _TOO_MANY_KINDS = _TOO_MANY_SAFE_KINDS | {"deferred_text"}
549
+
550
+ # `FUN_004baf00`'s own "end of a simple token" delimiter set (`docs/type-codes.md` section 1,
551
+ # `FUN_004ba440`): comma, colon, both parens, space, `=`, newline, tab, `>`. The "too many
552
+ # parameters" message's own echo uses *this* flat tokenizer, not the depth-aware one a normal
553
+ # argument boundary is parsed with (`FUN_004b9700`) -- oracle-confirmed, `docs/checker.md` "Round
554
+ # 5": an extra, fully-parenthesised argument (`(1+2)`) echoes as `1+2`, its own leading `(`
555
+ # consumed as a delimiter (the *skip-leading-delimiters* step) and its first inner `)` ending the
556
+ # token, the nested nesting being coincidental rather than honoured; `Battery(X,X,X)` (two bare
557
+ # extra `X`s) echoes only the *first* one, `X` -- confirming the echo is a single next-token
558
+ # lookup, not the whole remaining comma list, matching `docs/type-codes.md`'s own worked example.
559
+ _TOO_MANY_DELIMS = frozenset(",:() =\n\t>")
560
+
561
+
562
+ def _next_simple_token(text: str) -> str:
563
+ i, n = 0, len(text)
564
+ while i < n and text[i] in _TOO_MANY_DELIMS:
565
+ i += 1
566
+ start = i
567
+ while i < n and text[i] not in _TOO_MANY_DELIMS:
568
+ i += 1
569
+ return text[start:i]
570
+
571
+
572
+ def _too_many_eligible(params: list[int]) -> bool:
573
+ """`params` (the raw, un-filtered declared code list) is eligible for `check_too_many` only
574
+ when it is non-empty and every code's `kind` is either `_TOO_MANY_SAFE_KINDS` or `none`
575
+ (`_real_params` already strips `none`-kind codes for the actual count, but a `none` code does
576
+ not itself disqualify the surrounding instruction). An **empty** `params` list (`Alias`,
577
+ `Units`, `Data`, `EssVariables`, oracle/corpus-evidenced above) is never eligible: it means this
578
+ project's own extracted table has no per-code model for the instruction's arguments at all --
579
+ real, custom parsing the generic per-code loop this check models does not apply to.
580
+ """
581
+ if not params:
582
+ return False
583
+ kinds = _type_code_kinds()
584
+ for code in params:
585
+ base = code - 100 if code >= 100 else code
586
+ kind = kinds.get(base)
587
+ if kind != "none" and kind not in _TOO_MANY_KINDS:
588
+ return False
589
+ return True
590
+
591
+
592
+ _RAW_TEXT_KINDS = frozenset(
593
+ {
594
+ "identifier",
595
+ "deferred_text",
596
+ "raw_line_text",
597
+ "expr_marker",
598
+ "type_name",
599
+ "sub_params",
600
+ "for_header",
601
+ "expr_list",
602
+ }
603
+ )
604
+
605
+
606
+ def plain_expression_params(params: list[int]) -> bool:
607
+ """No parameter reads raw text (so every comma-separated chunk is an ordinary expression)."""
608
+ kinds = _type_code_kinds()
609
+ return bool(params) and not any(kinds.get(c % 100) in _RAW_TEXT_KINDS for c in params)
610
+
611
+
612
+ def check_too_many(program: S.Program, profile, diags: Diagnostics) -> None:
613
+ """`Too many parameters: %s.` (note the two spaces before `%s`, oracle-confirmed verbatim,
614
+ `docs/type-codes.md`) when an `Instruction` statement's parsed argument list runs *past* its
615
+ declared parameter count -- the mirror image of `check` above (a shortfall), and, like it, pass
616
+ 1 (unconditional -- oracle-confirmed this round, `docs/checker.md` "Round 5": fires alongside an
617
+ earlier, unrelated `Variable not named.` in the same file, the same "ungated" shape `check`
618
+ already has). Deliberately restricted to instructions whose declared codes are *all*
619
+ `_too_many_eligible` -- see that function and `_TOO_MANY_SAFE_KINDS` for the corpus-evidenced
620
+ reasoning; this is the check's actual safety margin, not the echo logic below.
621
+
622
+ The echoed text is the *next* token by the real compiler's own flat, non-depth-aware tokenizer
623
+ (`_next_simple_token`) starting at the first excess argument's own raw source position
624
+ (`Instruction.arg_spans`) -- not a re-derived or whole-remaining-text form. A missing span
625
+ (an empty/skipped argument slot with no captured position) is left unflagged rather than
626
+ guessed at.
627
+ """
628
+ outside = {k for k, in_sub in misplaced_returns(program).items() if not in_sub}
629
+ for stmt in program.statements:
630
+ if not isinstance(stmt, S.Instruction) or id(stmt) in outside:
631
+ continue
632
+ record = profile.instructions.get(stmt.keyword.upper())
633
+ # `DataTable (T,1,-1,"fuzz")` -> `Too many parameters: "fuzz".`, pass 1, all four
634
+ # loggers (oracle, 2026-09-27), although its name parameter is not an eligible kind; so is
635
+ # `CallTable` (`CallTable(T,X,Y)` -> `Too many parameters: Y.`, 2026-09-28).
636
+ if not record or not (
637
+ _too_many_eligible(record["params"])
638
+ or stmt.keyword.upper() in _TOO_MANY_EXTRA
639
+ or stmt.keyword.upper() in ("GOESDATA", "ARGOSDATA")
640
+ or (profile.name in ("cr6", "cr1000x") and stmt.keyword.upper() in ZERO_PARAM_INSTRUCTIONS)
641
+ ):
642
+ # (`GOESData(X,T,0,0,1,7)` all four loggers, `ArgosData(R,1,T,2,"FP2",7)` CR6/CR1000X:
643
+ # oracle, 2026-10-12)
644
+ continue
645
+ max_count = len(_real_params(record["params"]))
646
+ if len(stmt.args) <= max_count:
647
+ continue
648
+ spans = getattr(stmt, "arg_spans", None)
649
+ if not spans or max_count >= len(spans) or spans[max_count] is None:
650
+ continue
651
+ start = spans[max_count][0]
652
+ token = _next_simple_token(stmt.text[start:])
653
+ if not token:
654
+ continue
655
+ # CR6/CR1000X: an earlier parameter-kind error on the statement hides it (oracle,
656
+ # 2026-10-04: `PortSet(C1,1,x,y)`, `PortSet(x,1,y,z)`, `Delay(1,x,y,z)`)
657
+ if not profile.single_pass and _earlier_error_in_stmt(
658
+ program, stmt, diags, ("Illegal parameter ", "Variable illegal in Parameter")
659
+ ):
660
+ continue
661
+ diags.error(stmt.line, f"Too many parameters: {token}.", stmt.file)
662
+
663
+
664
+ def _earlier_error_in_stmt(program: S.Program, stmt, diags: Diagnostics, prefixes: tuple) -> bool:
665
+ """A `prefixes` error on `stmt`'s line that belongs to `stmt` itself. On a `:`-joined line
666
+ (oracle, 2026-10-08, CR6/CR1000X: `PortSet(C1,1,x):PortSet(C1,1,1,2)` still reports `Too many
667
+ parameters: 2.`) a diagnostic belongs to the statement whose text contains its echoed
668
+ argument; a single-statement line keeps the plain line match."""
669
+ here = [d for d in diags.items if d.line == stmt.line and d.file == stmt.file and d.message.startswith(prefixes)]
670
+ if not here:
671
+ return False
672
+ if sum(1 for o in program.statements if (o.file, o.line) == (stmt.file, stmt.line)) < 2:
673
+ return True
674
+ for d in here:
675
+ if d.stmt: # raised by a tagged emitter: exact statement identity, not echo text
676
+ if d.stmt == id(stmt):
677
+ return True
678
+ continue
679
+ echo = re.sub(r"^(?:Illegal parameter |[^:]*: )|(?:, expecting constant integer)?\.$", "", d.message).strip()
680
+ if echo in stmt.text:
681
+ return True
682
+ return False
683
+
684
+
685
+ def check_essinitialize_too_many(program: S.Program, profile, diags: Diagnostics) -> None:
686
+ """An unterminated second `ESSInitialize` argument still reaches its excess-argument
687
+ diagnostic. The lexer leaves this exact malformed line as `Unparsed`, so the ordinary
688
+ `check_too_many` walk cannot see its second argument."""
689
+ for stmt in program.statements:
690
+ if not isinstance(stmt, S.Unparsed):
691
+ continue
692
+ m = re.match(r"(?is)^\s*ESSInitialize\s*\((.*)\)\s*$", stmt.text)
693
+ if not m:
694
+ continue
695
+ body = m.group(1)
696
+ depth = 0
697
+ quoted = False
698
+ split = None
699
+ for i, char in enumerate(body):
700
+ if char == '"':
701
+ quoted = not quoted
702
+ elif not quoted:
703
+ if char == "(":
704
+ depth += 1
705
+ elif char == ")":
706
+ depth -= 1
707
+ elif char == "," and depth == 0:
708
+ split = i
709
+ break
710
+ if split is None:
711
+ continue
712
+ token = _next_simple_token(body[split + 1 :])
713
+ if token:
714
+ diags.error(stmt.line, f"Too many parameters: {token}.", stmt.file)
715
+
716
+
717
+ _SNMP_SPACED_OID = re.compile(
718
+ r"^\s*SNMPVARIABLE\s*\(\s*[^,]+\s*,\s*(?:\d+\.)+\s+\d+(?:\.\d+)*\s*,\s*([A-Za-z_]\w*)\s*,",
719
+ re.I,
720
+ )
721
+ _SNMP_OID_DEST = re.compile(r"^\s*SNMPVARIABLE\s*\(\s*(\d+(?:\.\d+)*)\s*,", re.I)
722
+ _SNMP_TAIL = re.compile(r"^\s*SNMPVARIABLE\s*\(\s*[^,]+,\s*\d+(?:\.\d+)*\s*,", re.I)
723
+ _SNMP_TYPES = frozenset({"INTEGER", "COUNTER", "STRING", "TIMETICKS", "OPAQUE", "GAUGE", "FLOAT"})
724
+
725
+
726
+ _SNMP_OID_LITERAL = re.compile(r"^\s*SNMPVARIABLE\s*\(\s*[^,]+,\s*([\d.]+)\s*[,)]", re.I)
727
+ _SNMP_FLOOR = (1, 3, 6, 1, 2, 1, 1, 7) # the highest OID the system group already owns
728
+
729
+
730
+ def _snmp_components(text: str) -> tuple | None:
731
+ """`1.3.6.1` as integers; an empty component (`..`, a leading or trailing dot) is 0 for the
732
+ ordering and kept as '' for identity. None when a component does not fit 16 bits (not probed)."""
733
+ parts = text.split(".")
734
+ if any(len(p) > 5 or (p and int(p) > 65535) for p in parts):
735
+ return None
736
+ return tuple(int(p) if p else "" for p in parts)
737
+
738
+
739
+ def check_snmp_oid(program: S.Program, profile, diags: Diagnostics, env=None) -> None:
740
+ """`SNMPVariable(Name,OID,...)` object identifier rules (oracle, all four loggers, 2026-10-12,
741
+ docs/instructions.md "SNMPVariable OID rules")."""
742
+ if "SNMPVARIABLE" not in profile.instructions:
743
+ return
744
+ cr3xx = profile.name in ("cr300", "cr350")
745
+ seen: list[tuple] = []
746
+ typer = None
747
+ for stmt in program.statements:
748
+ both = greater = False
749
+ key: tuple | None = None
750
+ before = len(diags.items)
751
+ if isinstance(stmt, S.Unparsed):
752
+ match = _SNMP_OID_LITERAL.match(stmt.text)
753
+ comps = _snmp_components(match.group(1)) if match else None
754
+ if comps is None or len(comps) < 3:
755
+ continue
756
+ key = comps
757
+ elif isinstance(stmt, S.Instruction) and stmt.keyword.upper() == "SNMPVARIABLE" and len(stmt.args) >= 2:
758
+ arg = stmt.args[1]
759
+ if isinstance(arg, S.Number):
760
+ if arg.text.isdigit() and arg.value in (0, 1):
761
+ both, key = True, (int(arg.value),)
762
+ elif re.fullmatch(r"\d+\.\d+", arg.text):
763
+ key = _snmp_components(arg.text)
764
+ else:
765
+ continue
766
+ elif (
767
+ isinstance(arg, S.Unary)
768
+ and arg.op == "-"
769
+ and isinstance(arg.operand, S.Number)
770
+ and arg.operand.text == "1"
771
+ ):
772
+ both = True
773
+ elif isinstance(arg, S.String):
774
+ if re.fullmatch(r"\d+(?:\.\d+)+", arg.value):
775
+ greater = True
776
+ elif not re.fullmatch(r"[\d.]*\d[\d.]*", arg.value):
777
+ both = True
778
+ elif (
779
+ isinstance(arg, S.Name)
780
+ and env is not None
781
+ and arg.name.upper() not in env.consts
782
+ and arg.name.upper() not in ("TRUE", "FALSE", "NAN")
783
+ ):
784
+ both = True
785
+ else:
786
+ continue
787
+ else:
788
+ continue
789
+ low = [0 if c == "" else c for c in key] if key else None
790
+ if both:
791
+ diags.error(stmt.line, "Illegal object id.", stmt.file)
792
+ if both or greater or (low is not None and (tuple(low) < _SNMP_FLOOR or (cr3xx and tuple(low) == _SNMP_FLOOR))):
793
+ diags.error(stmt.line, f"SNMP OID must be greater than {'.'.join(map(str, _SNMP_FLOOR))}", stmt.file)
794
+ if key is not None:
795
+ declared = sum(1 for earlier in seen if earlier == key)
796
+ if cr3xx:
797
+ declared += key in ((*_SNMP_FLOOR, 0), (*_SNMP_FLOOR, ""))
798
+ else:
799
+ declared += (
800
+ len(key) == 8 and tuple(key[:7]) == _SNMP_FLOOR[:7] and isinstance(key[7], int) and 1 <= key[7] <= 7
801
+ )
802
+ for _ in range(int(declared)):
803
+ diags.error(stmt.line, "Illegal OID since already declared.", stmt.file)
804
+ seen.append(key)
805
+ # (the type check is a later pass that these errors would hide; the real compiler prints it)
806
+ if len(diags.items) > before and isinstance(stmt, S.Unparsed):
807
+ from . import var_types
808
+
809
+ typer = typer or var_types._Typer(program)
810
+ m = var_types._SNMP_RE.match(stmt.text)
811
+ clash = bool(m) and var_types._snmp_mismatch(typer, S.Name(stmt.line, name=m.group(1)), m.group(2))
812
+ if clash:
813
+ diags.error(stmt.line, "Miss match of types", stmt.file)
814
+
815
+
816
+ def check_modemcallback_baud(program: S.Program, profile, diags: Diagnostics) -> None:
817
+ """`ModemCallBack(R,COMSDC7,<baud>,...)` -> `<baud> is illegal baud rate.` unless the baud,
818
+ truncated toward zero (outside 32 bits it becomes -2147483648), is 0 or 600..460800 (oracle, CR6/CR1000X,
819
+ 2026-10-10: 599, 300, 460801 and 2^31-1 fail; 1234, 100000 and 0.5 pass; 2^31 and wider
820
+ give -2147483648). Only the COMSDC7 port was probed (`ComC1,300` passes)."""
821
+ for stmt in program.statements:
822
+ if not (
823
+ isinstance(stmt, S.Instruction)
824
+ and stmt.keyword.upper() == "MODEMCALLBACK"
825
+ and "MODEMCALLBACK" in profile.instructions
826
+ and len(stmt.args) == 9
827
+ ): # (a wrong count reports only the count, oracle 2026-10-10)
828
+ continue
829
+ port, arg = stmt.args[1], stmt.args[2]
830
+ if not (isinstance(port, S.Name) and port.name.upper() == "COMSDC7"):
831
+ continue
832
+ sign = 1
833
+ if isinstance(arg, S.Unary) and arg.op == "-":
834
+ sign, arg = -1, arg.operand
835
+ if not (isinstance(arg, S.Number) and isinstance(arg.value, (int, float))):
836
+ # a constant expression or string (oracle, CR6/CR1000X, 2026-10-10: `Hr` -> 4, `Hr+1`
837
+ # -> 5, `True` -> -1 are illegal, `uSec` -> 0 passes; a string becomes -2147483648)
838
+ try:
839
+ value = consteval.value_of(stmt.args[2], consteval.Env(profile, dict(program.consts)))
840
+ except consteval.NotConstant, TypeError, ValueError, ZeroDivisionError:
841
+ continue
842
+ if isinstance(arg, S.Number):
843
+ continue
844
+ if isinstance(value, str):
845
+ diags.error(stmt.line, f"{-(2**31)} is illegal baud rate.", stmt.file)
846
+ continue
847
+ arg, sign = S.Number(stmt.line, stmt.file, abs(int(value)), str(abs(int(value)))), (-1 if value < 0 else 1)
848
+ baud = int(sign * arg.value)
849
+ if not -(2**31) <= baud < 2**31:
850
+ baud = -(2**31)
851
+ if baud != 0 and not 600 <= baud <= 460800:
852
+ diags.error(stmt.line, f"{baud} is illegal baud rate.", stmt.file)
853
+
854
+
855
+ def check_snmpvariable_too_many(program: S.Program, profile, diags: Diagnostics) -> None:
856
+ """Whitespace within a dotted SNMP OID makes the real parser resume at its type token.
857
+
858
+ The normal dotted OID is an intentional parser gap that compiles; this is only its observed
859
+ whitespace-split recovery shape, which emits the ordinary excess-parameter message.
860
+ """
861
+ for stmt in program.statements:
862
+ if not isinstance(stmt, S.Unparsed) or not (
863
+ stmt.reason == "dotted SNMP OID literal" or stmt.reason.startswith("expected ')', found")
864
+ ):
865
+ continue
866
+ match = _SNMP_SPACED_OID.match(stmt.text)
867
+ if match:
868
+ diags.error(stmt.line, f"Too many parameters: {match.group(1)}.", stmt.file)
869
+ continue
870
+ match = _SNMP_TAIL.match(stmt.text)
871
+ if match:
872
+ # `SNMPVariable(V,1.3.6.1.4,Float,RONLY,1)` -> `Too many parameters: 1.`; a Type that
873
+ # is not an SNMP type ends the list one argument earlier (`Nope,RONLY`), all four
874
+ # loggers (oracle, 2026-10-10); an `RWRITE` access also takes the Valid argument
875
+ starts, depth, quoted, begin = [], 0, False, match.end()
876
+ for i in range(match.end(), len(stmt.text)):
877
+ c = stmt.text[i]
878
+ if c == '"':
879
+ quoted = not quoted
880
+ elif not quoted:
881
+ depth += (c == "(") - (c == ")")
882
+ if depth < 0:
883
+ break
884
+ if c == "," and depth == 0:
885
+ starts.append(begin)
886
+ begin = i + 1
887
+ starts.append(begin)
888
+ ends = [b - 1 for b in starts[1:]] + [None]
889
+ words = [stmt.text[b:e].strip().upper() for b, e in zip(starts, ends)]
890
+ allowed = 1 if words[0] not in _SNMP_TYPES else 2
891
+ if allowed == 2 and len(words) > 1 and words[1] == "RWRITE":
892
+ allowed = 3 # RWRITE takes the Valid argument
893
+ if len(words) > 2 and re.match(r"(?:RANGES|VALUES)\s", words[2]):
894
+ allowed = len(words) # `RANGES 0,65535` / `VALUES 1,2,3`: commas belong to it
895
+ if len(starts) > allowed and (token := _next_simple_token(stmt.text[starts[allowed] :])):
896
+ diags.error(stmt.line, f"Too many parameters: {token}.", stmt.file)
897
+ continue
898
+ match = _SNMP_OID_DEST.match(stmt.text)
899
+ if match:
900
+ oid = match.group(1)
901
+ diags.error(stmt.line, "Illegal object id.", stmt.file)
902
+ diags.error(stmt.line, "SNMP OID must be greater than 1.3.6.1.2.1.1.7", stmt.file)
903
+ diags.error(stmt.line, f"Variable name {oid} illegal, starts with a number.", stmt.file)
904
+
905
+
906
+ # --- Ungated `Scan`/`DataInterval` numeric-value checks (called from `checker/warnings.py`'s
907
+ # `check_early`; see this module's docstring and `docs/instructions.md` for the oracle evidence
908
+ # that both fire even when the file has other errors elsewhere) ------------------------------
909
+
910
+ # CR300/CR350 do not perform the `Scan`/`DataInterval` non-negative-value validation at all --
911
+ # oracle-confirmed a genuine per-logger difference, not a shared check with a different message
912
+ # (`docs/instructions.md`): `Scan("fuzz",Sec,3,0)`, `Scan(-1,Sec,3,0)` and `DataInterval(0,-3,
913
+ # Sec,0)` all compile clean on `cr300comp.exe` (found as a **regression** during development --
914
+ # the corpus's own `hex_2.CR6`, `Scan(1,Sec,3,-1)`, is real CR300/CR350-accepted code, a false
915
+ # reject once this went live logger-unconditionally). The `DataTable`-interval-vs-`Scan`-interval
916
+ # divisibility check (`check_data_interval`'s other half) is *not* gated this way -- oracle-
917
+ # confirmed identical, message and all, on `cr300comp.exe` too.
918
+ _SCAN_VALUE_CHECK_LOGGERS = frozenset({"cr6", "cr1000x"})
919
+
920
+
921
+ def _is_negative_or_invalid(value: object) -> bool:
922
+ """`True` when a folded constant `value` is what the real compiler's shared `Scan`/
923
+ `DataInterval` parameter validator (`FUN_00468900`/`FUN_004f11d0`) rejects: a non-numeric
924
+ constant (a string, oracle: `Scan("fuzz",Sec,1,0)` -> `Scan Interval parameter must be a
925
+ non-negative integer`), a number whose value truncated toward zero is negative, or one that
926
+ overflows a signed 32-bit int. Python's `int()` on a float already truncates toward zero,
927
+ matching the oracle's own boundary exactly: `-0.5` (`int(-0.5) == 0`) passes this check and is
928
+ accepted (`docs/instructions.md`), `-1` fails it. **Round 5 addition, oracle-confirmed**
929
+ (`docs/checker.md` "Round 5", a fuzz-found false accept): the same message also covers a
930
+ positive value too large to fit `INT32_MAX` (`2147483647`) -- `Scan(1,Sec,3,2147483647)`
931
+ compiles, `Scan(1,Sec,3,2147483648)` fails with this exact text, boundary-swept on both `Count`
932
+ and `Interval`; `DataInterval`'s own sibling check (`check_data_interval` below, same shared
933
+ predicate) shares the identical boundary (`DataInterval(2147483648,10,Sec,10)` ->
934
+ `DataInterval TintoInt and Interval parameters must be non-negative`). An unrecognised constant
935
+ type (never observed; `consteval.value_of` only ever returns `int`/`float`/`str`) is
936
+ deliberately treated as valid -- AGENTS.md: never guess into a false rejection.
937
+ """
938
+ if isinstance(value, str):
939
+ return True
940
+ if consteval.is_finite_number(value):
941
+ return int(value) < 0 or int(value) > 2147483647
942
+ return False
943
+
944
+
945
+ def check_scan_params(program: S.Program, profile, diags: Diagnostics, env: consteval.Env) -> None:
946
+ """`Scan Interval/BufferOption/Count parameter must be a non-negative integer` (no trailing
947
+ period -- verbatim oracle/`docs/messages.tsv` text, function `FUN_00468900`) for `Scan`'s 1st,
948
+ 3rd and 4th arguments respectively (`Scan(Interval,Units,BufferOption,Count)`, `docs/
949
+ instructions.md`). Only checked when the argument is already a compile-time constant (a
950
+ variable there is a different check's concern, matching `checker/terminals.py`'s own
951
+ convention); this is deliberately narrower than the real compiler's full `Scan` validation
952
+ (minimum-interval, maximum-interval and multiple-of-a-logger-granularity checks all exist too,
953
+ oracle-confirmed present but with boundaries this project could not pin down precisely enough
954
+ to implement without risking a false reject on a borderline-valid interval -- `docs/
955
+ instructions.md` "Known gaps"). This closes the fuzz-found crash in `tables.py`'s
956
+ `_scan_weights` (a non-numeric `Scan` interval, `docs/fuzz-findings.md` "2. Crash") with a real
957
+ diagnostic instead of an unhandled exception, and the corpus's own `hex_2.CR6`
958
+ (`Scan Count parameter must be a non-negative integer`, `docs/checker.md` "Known remaining
959
+ gaps"). CR6/CR1000X only -- see `_SCAN_VALUE_CHECK_LOGGERS`.
960
+ """
961
+ if profile.name not in _SCAN_VALUE_CHECK_LOGGERS:
962
+ return
963
+ slow = False
964
+ for stmt in program.statements:
965
+ if isinstance(stmt, S.Instruction) and stmt.keyword.upper() == "SLOWSEQUENCE":
966
+ slow = True
967
+ if not isinstance(stmt, S.Instruction) or stmt.keyword.upper() != "SCAN":
968
+ continue
969
+ split = None
970
+ for idx, label in ((0, "Interval"), (2, "BufferOption"), (3, "Count")):
971
+ if idx >= len(stmt.args) or stmt.args[idx] is None:
972
+ continue
973
+ arg = stmt.args[idx]
974
+ if not consteval.is_constant(arg, env):
975
+ continue
976
+ try:
977
+ value = consteval.value_of(arg, env)
978
+ except consteval.NotConstant:
979
+ continue
980
+ if _is_negative_or_invalid(value):
981
+ diags.error(stmt.line, f"Scan {label} parameter must be a non-negative integer", stmt.file)
982
+ if idx == 0:
983
+ split = _scan_min_interval(stmt, diags, env)
984
+ if (
985
+ split
986
+ and split[0] > 86400
987
+ and not (split[0] == 0 and split[1] < 1000)
988
+ and _crem(split[1], 1000) == 0
989
+ ):
990
+ diags.error(stmt.line, "Maximum Scan interval is 1 Day.", stmt.file)
991
+ if slow and split is not None:
992
+ raw_us = split[0] * 1_000_000 + split[1]
993
+ # a Scan after `SlowSequence` (oracle, CR6/CR1000X, round 9, docs/instructions.md
994
+ # "Slow scan intervals"), after the BufferOption/Count messages
995
+ if -1_000_000 < raw_us < 100_000:
996
+ diags.error(stmt.line, "Slow Scan interval must be >= 100mSec.", stmt.file)
997
+ if raw_us % 10_000:
998
+ diags.error(stmt.line, "Slow Scan interval must be a multiple of 100mSec.", stmt.file)
999
+ if split[0] > 86400:
1000
+ diags.error(stmt.line, "Maximum Slow Scan interval is 1 Day.", stmt.file)
1001
+
1002
+
1003
+ # `Scan`/`DataInterval`'s `Units` argument folds (via `consteval`, `profile.symbols`) to this 0..5
1004
+ # code (`data/symbols.json`: `usec`=0 .. `day`=5); microseconds per unit.
1005
+ _US_PER_UNIT = {0: 1, 1: 1_000, 2: 1_000_000, 3: 60_000_000, 4: 3_600_000_000, 5: 86_400_000_000}
1006
+
1007
+
1008
+ def _scan_min_interval(stmt: S.Instruction, diags: Diagnostics, env: consteval.Env) -> tuple[int, int] | None:
1009
+ """`Scan %duSec invalid must be at least %dmSec.` / `Scans must be a multiple of %dmSec.`
1010
+ (round 6, CR6/CR1000X only, same `FUN_00468900` family as `check_scan_params` above -- oracle-
1011
+ confirmed ungated the same way, fires alongside unrelated errors both before and after it in
1012
+ the file). Previously investigated and left unimplemented (`docs/instructions.md`
1013
+ "Investigated, not implemented": "each has a boundary this session could not pin down cleanly
1014
+ ... smells like float-precision artifacts") -- pinned down this round with a dozen fresh
1015
+ `Interval` x `Units` probes: the declared `Interval` is truncated toward zero **in its own
1016
+ declared unit** (`int(value)`, matching `_is_negative_or_invalid`'s own truncation convention)
1017
+ *before* being multiplied by that unit's microsecond factor (`_US_PER_UNIT`) -- not the real,
1018
+ fractional microsecond value. This single rule, applied uniformly regardless of which unit was
1019
+ declared, reproduces every probe in the sweep:
1020
+
1021
+ - `Scan(0.5,mSec,...)` -> `int(0.5) * 1_000 = 0` uSec (not `500`) -> `Scan 0uSec invalid...`
1022
+ - `Scan(0.0015,Sec,...)` -> `int(0.0015) * 1_000_000 = 0` uSec (not `1500`) -> same message
1023
+ - `Scan(1.9999,mSec,...)` -> `int(1.9999) * 1_000 = 1000` uSec -> compiles clean
1024
+ - `Scan(1500,uSec,...)` -> `int(1500) * 1 = 1500` uSec -> `Scans must be a multiple of 1mSec.`
1025
+ (not a multiple of 1000), while the numerically-equal `Scan(1.5,mSec,...)` truncates to
1026
+ `int(1.5) * 1_000 = 1000` uSec first and compiles clean -- this reproduces the previous
1027
+ session's own unresolved `uSec`-vs-`mSec` asymmetry (`7999uSec` fails, `7.9mSec` compiles)
1028
+ exactly, with no per-unit branch: after truncate-then-multiply, an `mSec`/`Sec`-declared
1029
+ interval is *always* already an exact multiple of 1000, so the "must be a multiple" branch
1030
+ can structurally never fire for those two units.
1031
+
1032
+ Both comparisons run on this one folded `raw_us` value, **independently of whether
1033
+ `check_scan_params`'s own non-negative check also already fired for the same `Interval`
1034
+ argument** -- oracle-confirmed not mutually exclusive: `Scan(-1,mSec,...)` fires *both* `Scan
1035
+ Interval parameter must be a non-negative integer` *and* `Scan -1000uSec invalid must be at
1036
+ least 1mSec.`, on the same line, in that order, both before the `BufferOption`/`Count`
1037
+ messages (`check_scan_params` calls this right after the `Interval` check). A negative value
1038
+ at or below -1000000 uSec gets no "at least" message (oracle, CR6/CR1000X, 2026-10-03:
1039
+ `Scan(-999999,uSec,..)` does, `Scan(-1000000,uSec,..)`, `Scan(-1,Sec,..)`, `Scan(-2147,mSec,..)`
1040
+ do not; `Scan(-1000001,uSec,..)` gets the multiple-of message). Only `Interval` (position 0) -- no oracle evidence either message
1041
+ applies to `BufferOption`/`Count`.
1042
+ """
1043
+ split = scan_split(stmt, env)
1044
+ if split is None:
1045
+ return None
1046
+ sec, us = split
1047
+ if sec == 0 and us < 1000:
1048
+ diags.error(stmt.line, f"Scan {us}uSec invalid must be at least 1mSec.", stmt.file)
1049
+ elif _crem(us, 1000) != 0:
1050
+ diags.error(stmt.line, "Scans must be a multiple of 1mSec.", stmt.file)
1051
+ return split
1052
+
1053
+
1054
+ def _crem(a: int, b: int) -> int:
1055
+ """C `%`: the remainder takes the sign of the dividend."""
1056
+ return a - b * (abs(a) // abs(b) * (1 if (a >= 0) == (b > 0) else -1))
1057
+
1058
+
1059
+ def scan_split(stmt: S.Instruction, env: consteval.Env) -> tuple[int, int] | None:
1060
+ """Whole seconds and microseconds the compiler makes of a constant `Scan` interval
1061
+ (`FUN_005082c0`, all four loggers identical): the value converted as a `long` (a string, NaN
1062
+ or overflow is -2^31), then in 32-bit C arithmetic `uSec` -> `/1000000`, `%1000000`; `mSec` ->
1063
+ `/1000`, `%1000*1000`; `Sec` itself; `Min`, `Hr`, `Day` times 60, 3600, 86400 (wrapping); any
1064
+ other unit 0 (a float32 of exactly +2^31 is `INT_MAX` on CR300/CR350, `-2^31` elsewhere:
1065
+ `Scan(2147483648,Sec)` is `Maximum Scan interval is 1 Day.` there). No validity check: a negative, string or wrapped interval is used as it is
1066
+ (oracle, 2026-10-10; `docs/instructions.md` "Scan interval arithmetic")."""
1067
+ args = stmt.args
1068
+ if (
1069
+ len(args) < 2
1070
+ or args[0] is None
1071
+ or args[1] is None
1072
+ or not (consteval.is_constant(args[0], env) and consteval.is_constant(args[1], env))
1073
+ ):
1074
+ return None
1075
+ try:
1076
+ value, unit = consteval.value_of(args[0], env), consteval.value_of(args[1], env)
1077
+ except consteval.NotConstant:
1078
+ return None
1079
+ n = consteval.to_long(-1 if value is True else 0 if value is False else value)
1080
+ if (
1081
+ n == -2147483648
1082
+ and env.profile.name in ("cr300", "cr350")
1083
+ and isinstance(value, (int, float))
1084
+ and not isinstance(value, bool)
1085
+ and _f32_is_2_31(value)
1086
+ ):
1087
+ n = 2147483647 # CR300/CR350 saturate a float32 of exactly +2^31 (CR6/CR1000X: -2^31)
1088
+ unit = unit if isinstance(unit, (int, float)) and not isinstance(unit, bool) else None
1089
+ if not consteval.is_finite_number(unit) or int(unit) not in _US_PER_UNIT:
1090
+ return 0, 0
1091
+ unit = int(unit)
1092
+ if unit == 0:
1093
+ sec = _cdiv(n, 1_000_000)
1094
+ return sec, n - sec * 1_000_000
1095
+ if unit == 1:
1096
+ sec = _cdiv(n, 1000)
1097
+ return sec, (n - sec * 1000) * 1000
1098
+ if unit == 2:
1099
+ return n, 0
1100
+ wrapped = (n * {3: 60, 4: 3600, 5: 86400}[unit]) & 0xFFFFFFFF
1101
+ return (wrapped - 2**32 if wrapped >= 2**31 else wrapped), 0
1102
+
1103
+
1104
+ def _f32_is_2_31(value) -> bool:
1105
+ import struct
1106
+
1107
+ try:
1108
+ return struct.unpack("f", struct.pack("f", float(value)))[0] == 2147483648.0
1109
+ except OverflowError, ValueError:
1110
+ return False
1111
+
1112
+
1113
+ def _cdiv(a: int, b: int) -> int:
1114
+ q = abs(a) // abs(b)
1115
+ return q if (a >= 0) == (b > 0) else -q
1116
+
1117
+
1118
+ def check_scan_max_interval(program: S.Program, profile, diags: Diagnostics, env: consteval.Env) -> None:
1119
+ """`Maximum Scan interval is 1 Day.` on a `Scan` longer than 86400 s, all four loggers (oracle,
1120
+ 2026-09-27: `Scan(86401,Sec,...)`, `Scan(1441,Min,...)`, `Scan(2,Day,...)`; `Scan(1,Day,...)`
1121
+ is accepted), from the seconds/microseconds of `scan_split` (32-bit: `Scan(2147483647,Min,..)`
1122
+ wraps negative and passes). CR6/CR1000X say it only when the interval is a whole number of
1123
+ mSec and not already under 1 mSec; CR300/CR350 add `Scans > 10mSec must be a multiple of
1124
+ 10mSec.` for an interval of one second or more, or 10 mSec or more, that is not a multiple
1125
+ of 10 mSec, instead (decompiled `Scan` code, oracle 2026-10-10: `Scan(15,mSec)` fails,
1126
+ `Scan(10,mSec)`, `Scan(9500,uSec)`, `Scan(1500,mSec)` pass)."""
1127
+ if profile.name in _SCAN_VALUE_CHECK_LOGGERS:
1128
+ return # folded into `check_scan_params` (message order)
1129
+ for stmt in program.statements:
1130
+ if isinstance(stmt, S.Instruction) and stmt.keyword.upper() == "SCAN":
1131
+ split = scan_split(stmt, env)
1132
+ if split is None:
1133
+ continue
1134
+ sec, us = split
1135
+ if (sec != 0 or us >= 10000) and _crem(us, 10000) != 0:
1136
+ diags.error(stmt.line, "Scans > 10mSec must be a multiple of 10mSec.", stmt.file)
1137
+ elif (sec != 0 or us >= 10000) and sec > 86400:
1138
+ diags.error(stmt.line, "Maximum Scan interval is 1 Day.", stmt.file)
1139
+
1140
+
1141
+ def _scan_raw(stmt: S.Instruction, env: consteval.Env) -> tuple[int, int] | None:
1142
+ """`(int(interval), units)` of a constant `Scan` interval that fits `INT32_MAX`, else `None`."""
1143
+ args = stmt.args
1144
+ if (
1145
+ len(args) < 2
1146
+ or args[0] is None
1147
+ or args[1] is None
1148
+ or not (consteval.is_constant(args[0], env) and consteval.is_constant(args[1], env))
1149
+ ):
1150
+ return None
1151
+ try:
1152
+ value = consteval.value_of(args[0], env)
1153
+ unit = consteval.value_of(args[1], env)
1154
+ except consteval.NotConstant:
1155
+ return None
1156
+ if (
1157
+ isinstance(value, bool)
1158
+ or not consteval.is_finite_number(value)
1159
+ or unit not in _US_PER_UNIT
1160
+ or not 0 <= int(value) <= 2147483647
1161
+ ):
1162
+ return None
1163
+ return int(value), cast(int, unit)
1164
+
1165
+
1166
+ def _units_key(units_arg: S.Expr, env: consteval.Env, truncate: bool):
1167
+ """Units of a `DataInterval` (`truncate`: `tables._unit_value`, a fraction truncated toward zero,
1168
+ `1.9` is mSec) or of a `Scan` (the folded value, unchanged). `None` when unusable."""
1169
+ if truncate:
1170
+ return _unit_value(units_arg, env)
1171
+ return consteval.value_of(units_arg, env)
1172
+
1173
+
1174
+ def _interval_us(
1175
+ interval_arg: S.Expr | None, units_arg: S.Expr | None, env: consteval.Env, truncate_units: bool = False
1176
+ ) -> float | None:
1177
+ if interval_arg is None or units_arg is None:
1178
+ return None
1179
+ if not (consteval.is_constant(interval_arg, env) and consteval.is_constant(units_arg, env)):
1180
+ return None
1181
+ try:
1182
+ value = consteval.value_of(interval_arg, env)
1183
+ unit = _units_key(units_arg, env, truncate_units)
1184
+ except consteval.NotConstant:
1185
+ return None
1186
+ us_per_unit = _US_PER_UNIT.get(unit)
1187
+ if us_per_unit is None or not consteval.is_finite_number(value) or value < 0:
1188
+ return None
1189
+ return value * us_per_unit
1190
+
1191
+
1192
+ def _divisible_interval_us(
1193
+ interval_arg: S.Expr | None, units_arg: S.Expr | None, env: consteval.Env, truncate_units: bool = False
1194
+ ) -> float | None:
1195
+ """`_interval_us` with the interval's value truncated toward zero to a whole number of its own
1196
+ units first. Oracle-confirmed (`docs/checker.md` "Interval truncation", all four loggers
1197
+ identical): `DataInterval(0,1.5,Sec,10)` against `Scan(1,Min,0,0)` reports `interval 1000000
1198
+ uSec`, `1.5,Msec` -> `1000 uSec`, `1000.5,Msec` -> `1000000 uSec`, `90.5,Sec` -> `90000000 uSec`;
1199
+ the same truncation applies to the `Scan` interval (`Scan(3.5,Sec)` -> `scan interval 3000000
1200
+ uSec`, `Scan(3000.7,Msec)` -> `3000000`). A fractional interval therefore still fires whenever
1201
+ its truncated value is not a divisor (`2.5` Sec in a `1` Min table fires; `1.5` Sec in a
1202
+ `1` Sec scan is clean because it truncates to `1`).
1203
+ """
1204
+ us = _interval_us(interval_arg, units_arg, env, truncate_units)
1205
+ if us is None or units_arg is None:
1206
+ return None
1207
+ try:
1208
+ value = consteval.value_of(interval_arg, env)
1209
+ except consteval.NotConstant:
1210
+ return None
1211
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
1212
+ return None
1213
+ return int(value) * _US_PER_UNIT[_units_key(units_arg, env, truncate_units)]
1214
+
1215
+
1216
+ def check_data_interval(program: S.Program, profile, diags: Diagnostics, env: consteval.Env) -> None:
1217
+ """Two `DataInterval` checks, both oracle-confirmed ungated (`docs/instructions.md`):
1218
+
1219
+ - `DataInterval TintoInt and Interval parameters must be non-negative` (function
1220
+ `FUN_004f11d0`, verbatim, no trailing period) when either of `DataInterval`'s first two
1221
+ arguments (`TintoInt`, `Interval`) is a negative compile-time-constant number, reported at
1222
+ the `DataInterval` statement's own line. CR6/CR1000X only -- oracle-confirmed a genuine
1223
+ per-logger difference, not shared with CR300/CR350 (`_SCAN_VALUE_CHECK_LOGGERS`; found the
1224
+ same way as `check_scan_params`'s own CR300/CR350 gate, same session).
1225
+ - `DataTable interval %d uSec not integrally divisible by scan interval %d uSec.` (function
1226
+ `FUN_004f6540`) -- project knowledge (`knowledge/crbasic/lessons.md` "`DataInterval` versus
1227
+ `Scan`": "a table interval must be at least the scan interval and an exact integer multiple
1228
+ of it", human-verified against `CR6Comp.exe`), reported at each `CallTable <name>` site whose
1229
+ table has a `DataInterval` and which sits directly inside a `Scan...NextScan` block (main
1230
+ sequence or `SlowSequence`, both accepted per the CHM) with its own resolvable constant
1231
+ interval, when the two aren't an exact multiple. `Interval = 0` (CHM: "the data storage
1232
+ interval is the scan interval") trivially always divides evenly, so it needs no special case.
1233
+
1234
+ **Deliberately conservative** (AGENTS.md: never falsely reject a program the real compiler
1235
+ accepts) -- this only tracks the *textually innermost* `Scan`/`SlowSequence` `Scan` a
1236
+ `CallTable` sits inside (no depth stack beyond "currently open or not": `Scan` never nests
1237
+ with itself, only `SubScan`, which uses distinct `SUBSCAN`/`NEXTSCAN` -- wait, distinct
1238
+ `SUBSCAN`/`NEXTSUBSCAN` keywords, so it never resets this tracker). It does **not** attempt:
1239
+ a table called indirectly through a `Sub` (the call site's enclosing `Scan` is not visible at
1240
+ the `Sub`'s own definition), a table called from more than one differently-interval'd `Scan`
1241
+ (each call site is still checked independently against whatever `Scan` textually contains
1242
+ it, which is correct for the common case but under-covers a table called from *outside* any
1243
+ `Scan` at all, e.g. via `AcceptDataRecords` -- silently skipped), or the CHM's documented
1244
+ `GetDataRecord`-destination-table exception (not implemented; a false reject there is
1245
+ possible but not found in this project's corpus/fuzz evidence, `docs/instructions.md`).
1246
+ """
1247
+ declared_interval_us: dict[str, tuple[float, int]] = {} # table name -> (interval_us, line)
1248
+ current_table: str | None = None
1249
+ for stmt in program.statements:
1250
+ if not isinstance(stmt, S.Instruction):
1251
+ continue
1252
+ kw = stmt.keyword.upper()
1253
+ if kw == "DATATABLE":
1254
+ current_table = stmt.args[0].name.upper() if stmt.args and isinstance(stmt.args[0], S.Name) else None
1255
+ continue
1256
+ if kw == "ENDTABLE":
1257
+ current_table = None
1258
+ continue
1259
+ if kw != "DATAINTERVAL" or not current_table:
1260
+ continue
1261
+ args = stmt.args
1262
+ tinto = args[0] if len(args) > 0 else None
1263
+ interval = args[1] if len(args) > 1 else None
1264
+ units = args[2] if len(args) > 2 else None
1265
+ for arg in (tinto, interval) if profile.name in _SCAN_VALUE_CHECK_LOGGERS else ():
1266
+ if arg is None or not consteval.is_constant(arg, env):
1267
+ continue
1268
+ try:
1269
+ value = consteval.value_of(arg, env)
1270
+ except consteval.NotConstant:
1271
+ continue
1272
+ if _is_negative_or_invalid(value):
1273
+ diags.error(stmt.line, "DataInterval TintoInt and Interval parameters must be non-negative", stmt.file)
1274
+ break
1275
+ interval_us = _divisible_interval_us(interval, units, env, truncate_units=True)
1276
+ if interval_us is not None:
1277
+ declared_interval_us[current_table] = (interval_us, stmt.line)
1278
+
1279
+ scan_interval_us: float | None = None
1280
+ for stmt in program.statements:
1281
+ if not isinstance(stmt, S.Instruction):
1282
+ continue
1283
+ kw = stmt.keyword.upper()
1284
+ if kw == "SCAN":
1285
+ args = stmt.args
1286
+ scan_interval_us = (
1287
+ _divisible_interval_us(args[0] if args else None, args[1] if len(args) > 1 else None, env)
1288
+ if _scan_raw(stmt, env)
1289
+ else None
1290
+ )
1291
+ if scan_split(stmt, env) == (0, 0):
1292
+ scan_interval_us = None # wraps to a zero interval: no divisibility check (r15)
1293
+ continue
1294
+ if kw == "NEXTSCAN":
1295
+ scan_interval_us = None
1296
+ continue
1297
+ if kw != "CALLTABLE" or not scan_interval_us or not stmt.args:
1298
+ continue
1299
+ name_arg = stmt.args[0]
1300
+ if not isinstance(name_arg, S.Name):
1301
+ continue
1302
+ entry = declared_interval_us.get(name_arg.name.upper())
1303
+ if entry is None:
1304
+ continue
1305
+ message = _divisibility_message(int(entry[0]), int(scan_interval_us))
1306
+ if message:
1307
+ diags.error(stmt.line, message, stmt.file)
1308
+
1309
+
1310
+ def _divisibility_message(dt_us: int, scan_us: int) -> str | None:
1311
+ """Decompiled `FUN_004f6540`: a table interval under 3601 s is compared in 100 uSec units
1312
+ (printed `%d00 uSec`), a longer one in whole seconds (oracle, 2026-09-27: `Scan(2,Hr)` against
1313
+ a 3-hour table -> `DataTable interval 10800 Sec not integrally divisible by scan interval 7200
1314
+ Sec.`; `Scan(1,Day)` against a 1-hour table -> `3600000000 uSec ... 86400000000 uSec.`)."""
1315
+ dt_sec, dt_sub = divmod(dt_us, 1_000_000)
1316
+ scan_sec, scan_sub = divmod(scan_us, 1_000_000)
1317
+ scan_units = scan_sec * 10000 + scan_sub // 100
1318
+ if scan_units == 0:
1319
+ return None
1320
+ if dt_sec < 3601:
1321
+ dt_units = dt_sec * 10000 + dt_sub // 100
1322
+ if dt_units > 0 and dt_units % scan_units:
1323
+ return (
1324
+ f"DataTable interval {dt_units}00 uSec not integrally divisible by scan interval {scan_units}00 uSec."
1325
+ )
1326
+ elif scan_sub == 0:
1327
+ if dt_sec % scan_sec:
1328
+ return f"DataTable interval {dt_sec} Sec not integrally divisible by scan interval {scan_sec} Sec."
1329
+ elif scan_sec or 10000 % scan_units:
1330
+ return (
1331
+ "DataTable with interval > 1 hour cannot be called by scan whose interval is not "
1332
+ "an integral second is not divisible into a second."
1333
+ )
1334
+ return None
1335
+
1336
+
1337
+ # `AddPrecise`/`MovePrecise`'s own first argument (`PrecisionVariable`, `params=[8, 2]`, a
1338
+ # `variable_or_array` kind-8 position on every logger that has these two instructions at all --
1339
+ # `data/instructions.json`/`data/cr1000x/instructions.json`; **absent from `data/cr300/
1340
+ # instructions.json` and `data/cr350/instructions.json` entirely**, so this never applies there):
1341
+ # oracle-confirmed (scratch probes this round, `CR6Comp.exe` and `cr1000xcomp.exe`, byte-identical
1342
+ # on both) to reject with `Invalid Variable or Array: <echo>.` whenever the argument has no genuine
1343
+ # variable reference anywhere in it -- a bare literal (`AddPrecise(1, .1)` -> `... 1.`), a
1344
+ # parenthesised or unary-negated constant expression (`(1+2)` -> `... (1+2).`, `-1` -> `... -1.`),
1345
+ # a declared `Const` name (`AddPrecise(K, .1)`, `K` a `Const` -> `... K.`), and the special float
1346
+ # literal `NAN` (`AddPrecise(NAN, .1)` -> `... NAN.`) all reject this way. A declared `Public`/
1347
+ # `Dim` variable compiles clean regardless of how it appears -- bare, negated, parenthesised,
1348
+ # indexed, or mixed into a larger expression with literals (`Precise`, `-Precise`, `(Precise)`,
1349
+ # `d(1)`, `Precise+1`, `1+2+Precise` all oracle-confirmed accepted) -- and so does an *undeclared*
1350
+ # name (`AddPrecise(UndeclaredFuzzName123, .1)`, the existing saved fuzz false-reject reproducer,
1351
+ # `tests/corpus/fuzz/cr6/false_reject/fac2798b6778.CR6`, and `expressions.py`'s own
1352
+ # `_UNDEFINED_CHECK_EXEMPT_POSITIONS` docstring, which documents that this position never runs the
1353
+ # separate undefined-symbol check either). Reusing that same "any genuine variable reference,
1354
+ # declared or undeclared, anywhere in the expression" test here (`_undefined_names` OR
1355
+ # `_variable_refs`, both already written for the undefined-symbol/const-only-family checks in
1356
+ # `expressions.py`) rather than a fresh `consteval.is_constant` check: `NAN` is *not*
1357
+ # `consteval`-constant (it fails `value_of`, `_EXTRA_PREDEFINED_SYMBOLS` is a separate, untraced
1358
+ # resolution path -- `expressions.py`'s own docstring) yet still rejects here, so plain constant-
1359
+ # foldability is not what the real compiler is testing; "does the expression's own name(s), if any,
1360
+ # fail to resolve as a fixed compile-time symbol" is the trigger the oracle evidence above actually
1361
+ # supports, and this is exactly `not (_undefined_names(...) or _variable_refs(...))`.
1362
+ #
1363
+ # `X` (the second argument, `params[1]` code 2, a plain `expr` position) has no such restriction --
1364
+ # oracle-confirmed separately (`AddPrecise(Precise, (1+2))` compiles clean) -- so only position 0
1365
+ # is listed.
1366
+ #
1367
+ # Deliberately scoped to just these two instructions' own position 0, not generalised to every
1368
+ # `variable_or_array` position: `expressions.py`'s `_VARIABLE_REQUIRED_KINDS` docstring documents a
1369
+ # real regression at the same type code (`Move`'s third argument, `ModBusClient`'s last argument,
1370
+ # both oracle-confirmed to accept a literal there on CR1000X) that this session has no evidence
1371
+ # either way for, so this stays narrow rather than guessing.
1372
+ #
1373
+ # Pass 1, unconditional (`checker/__init__.py`, alongside this module's own `check`/
1374
+ # `check_too_many`): oracle-confirmed (a scratch probe combining a genuine, earlier pass-1
1375
+ # declaration error -- `Public x / foo bar baz` -- with `AddPrecise(1, .1)` on a later line)
1376
+ # to accumulate with that earlier pass-1 error rather than being suppressed by it, and, run alone
1377
+ # with no other error in the file, to itself suppress a later, otherwise-live pass-2 finding
1378
+ # elsewhere in the file (an undefined name inside a following `Randomize(...)`) -- the same "pass 1
1379
+ # found something anywhere in the file, pass 2 does not run at all" architecture `docs/checker.md`
1380
+ # ("Pass model") already documents for this module's other unconditional checks.
1381
+ _PRECISION_VARIABLE_POSITIONS: dict[str, frozenset[int]] = {
1382
+ "ADDPRECISE": frozenset({0}),
1383
+ "MOVEPRECISE": frozenset({0}),
1384
+ }
1385
+
1386
+
1387
+ def _has_call(expr: S.Expr | None, variable_names: set[str]) -> bool:
1388
+ """A function call (an `Index` that is not an array element) or a `Status.X`/`Public.X` member
1389
+ reference anywhere in `expr`."""
1390
+ if isinstance(expr, S.Member):
1391
+ return True
1392
+ if isinstance(expr, S.Index):
1393
+ return expr.name.upper() not in variable_names or any(_has_call(a, variable_names) for a in expr.args)
1394
+ if isinstance(expr, S.Unary):
1395
+ return _has_call(expr.operand, variable_names)
1396
+ if isinstance(expr, S.Binary):
1397
+ return _has_call(expr.left, variable_names) or _has_call(expr.right, variable_names)
1398
+ return False
1399
+
1400
+
1401
+ def _stringish(expr: S.Expr | None, str_consts: set[str]) -> bool:
1402
+ """A string literal or string `Const`, alone or joined with `&`/`+` (not under unary minus or a
1403
+ comparison, which are numeric)."""
1404
+ if isinstance(expr, S.String):
1405
+ return True
1406
+ if isinstance(expr, S.Name):
1407
+ return expr.name.upper() in str_consts
1408
+ if isinstance(expr, S.Binary) and expr.op in ("&", "+"):
1409
+ return _stringish(expr.left, str_consts) or _stringish(expr.right, str_consts)
1410
+ return False
1411
+
1412
+
1413
+ def _string_consts(program: S.Program) -> set[str]:
1414
+ out: set[str] = set()
1415
+ for st in program.statements:
1416
+ if isinstance(st, S.ConstDecl) and _stringish(st.value, out):
1417
+ out.add(st.name.upper())
1418
+ return out
1419
+
1420
+
1421
+ def _string_typed(expr: S.Expr | None, typer, str_consts: set[str], profile) -> bool | None:
1422
+ """Whether `expr` is String-typed for `IIF`: a string literal or `Const`, a String variable, a
1423
+ String-returning function (`kind` 312) or a `+`/`&` with a String side; `None` when unknown."""
1424
+ if isinstance(expr, S.String):
1425
+ return True
1426
+ if isinstance(expr, S.Number):
1427
+ return False
1428
+ if isinstance(expr, (S.Name, S.Index)):
1429
+ name = expr.name.upper()
1430
+ if isinstance(expr, S.Name) and name in str_consts:
1431
+ return True
1432
+ if typer.variable(expr) is not None:
1433
+ return typer.of(expr) == "STRING"
1434
+ record = profile.functions.get(name)
1435
+ if name == "IIF" and isinstance(expr, S.Index) and len(expr.args) == 3:
1436
+ arms = {_string_typed(a, typer, str_consts, profile) for a in expr.args[1:]}
1437
+ return arms.pop() if len(arms) == 1 else None
1438
+ if record is not None and isinstance(expr, S.Index):
1439
+ return record.get("kind") == 312
1440
+ return False if isinstance(expr, S.Name) and (name in ("TRUE", "FALSE") or name in profile.symbols) else None
1441
+ if isinstance(expr, S.Unary):
1442
+ return False
1443
+ if isinstance(expr, S.Binary):
1444
+ if expr.op not in ("+", "&"):
1445
+ return False
1446
+ sides = {
1447
+ _string_typed(expr.left, typer, str_consts, profile),
1448
+ _string_typed(expr.right, typer, str_consts, profile),
1449
+ }
1450
+ return True if True in sides else None if None in sides else False
1451
+ return None
1452
+
1453
+
1454
+ def check_iif_types(program: S.Program, profile, diags: Diagnostics) -> None:
1455
+ """`type mismatch` for an `IIF` whose second and third arguments are one String-typed and one
1456
+ numeric, including an omitted final arm (rounds 36/63). Evidence:
1457
+ docs/checker.md#iif-arm-types-round-36 and #iif-omitted-final-arm-round-63.
1458
+ One report per line, only on a line with no earlier error."""
1459
+ from .expressions import _index_nodes, _stmt_exprs
1460
+ from .var_types import _Typer
1461
+
1462
+ if "IIF" not in profile.functions:
1463
+ return
1464
+ typer = _Typer(program)
1465
+ str_consts = _string_consts(program)
1466
+ busy = {(d.line, d.file) for d in diags.items if d.severity == "error"}
1467
+ for stmt in program.statements:
1468
+ if (stmt.line, stmt.file) in busy:
1469
+ continue
1470
+ for ix in (ix for e in _stmt_exprs(stmt) for ix in _index_nodes(e)):
1471
+ if ix.name.upper() != "IIF" or len(ix.args) not in (2, 3) or typer.variable(ix) is not None:
1472
+ continue
1473
+ arms = [_string_typed(a, typer, str_consts, profile) for a in ix.args[1:]]
1474
+ if len(ix.args) == 2:
1475
+ arms.append(False) # the omitted final arm retains its numeric type (round 63)
1476
+ if None not in arms and arms[0] != arms[1]:
1477
+ diags.error(stmt.line, "type mismatch", stmt.file)
1478
+ busy.add((stmt.line, stmt.file))
1479
+ break
1480
+
1481
+
1482
+ def check_precision_variable(program: S.Program, profile, diags: Diagnostics) -> None:
1483
+ """`Invalid Variable or Array: %s.` for `AddPrecise`/`MovePrecise`'s own `PrecisionVariable`
1484
+ argument when it has no genuine variable reference (declared or undeclared) anywhere in it.
1485
+ See `_PRECISION_VARIABLE_POSITIONS`'s own docstring above for the oracle evidence and the exact
1486
+ rule. Gated per-`profile.instructions` (not just the keyword spelling) so this is a no-op on
1487
+ CR300/CR350, which do not have either instruction at all.
1488
+ """
1489
+ from . import instr_data # (a one-line If's statement too, round 52)
1490
+
1491
+ known: set[str] | None = None
1492
+ variable_names: set[str] | None = None
1493
+ str_consts: set[str] | None = None
1494
+ for stmt in instr_data._with_inline(program.statements):
1495
+ if not isinstance(stmt, S.Instruction):
1496
+ continue
1497
+ kw = stmt.keyword.upper()
1498
+ positions = _PRECISION_VARIABLE_POSITIONS.get(kw)
1499
+ if not positions or kw not in profile.instructions:
1500
+ continue
1501
+ if known is None or variable_names is None:
1502
+ known = _known_names(program, profile)
1503
+ variable_names = symbols.declared_variable_names(program)
1504
+ record = profile.instructions[kw]
1505
+ if given_count(stmt, profile) < _required_count(_real_params(record["params"])) and profile.name in (
1506
+ "cr6",
1507
+ "cr1000x",
1508
+ ):
1509
+ continue # reported with the missing-parameter line, `_report_constant_destinations`
1510
+ for i in sorted(positions):
1511
+ if i >= len(stmt.args) or stmt.args[i] is None:
1512
+ continue
1513
+ arg = stmt.args[i]
1514
+ if _undefined_names(arg, known) or _variable_refs(arg, variable_names):
1515
+ continue
1516
+ if str_consts is None:
1517
+ str_consts = _string_consts(program)
1518
+ if _has_call(arg, variable_names) or _stringish(arg, str_consts):
1519
+ continue # (`AddPrecise(Sqr(4),.1)`, `("fuzz")`, `KS+1` compile: R15, fuzz false reject)
1520
+ diags.error(stmt.line, f"Invalid Variable or Array: {_arg_text(stmt, i)}.", stmt.file)
1521
+
1522
+
1523
+ def check_vibrating_wire_diagnostics(program: S.Program, profile, diags: Diagnostics) -> None:
1524
+ """CR6 `VibratingWire`: the 8th argument (diagnostics) names a String variable; a numeric
1525
+ variable gives `Invalid diagnostic parameter.`, a number the variable-name message (oracle, round 55,
1526
+ docs/checker.md#task-instructions-in-pipeline-mode)."""
1527
+ if profile.name != "cr6":
1528
+ return
1529
+ from . import instr_data
1530
+
1531
+ strings = {
1532
+ v.name.upper()
1533
+ for st in program.statements
1534
+ if isinstance(st, S.Declaration)
1535
+ for v in st.vars
1536
+ if v.type == "STRING"
1537
+ }
1538
+ variables = symbols.declared_variable_names(program) # (an undefined name: pass 2 reports it)
1539
+ for st in instr_data._with_inline(program.statements):
1540
+ if isinstance(st, S.Instruction) and st.keyword.upper() == "VIBRATINGWIRE" and len(st.args) > 7:
1541
+ arg = st.args[7]
1542
+ if isinstance(arg, (S.Name, S.Index)) and arg.name.upper() in variables - strings:
1543
+ diags.error(st.line, "Invalid diagnostic parameter.", st.file)
1544
+ elif isinstance(arg, S.Number):
1545
+ diags.error(st.line, f"Variable name {arg.text} illegal, starts with a number.", st.file)
1546
+
1547
+
1548
+ # SDM address argument position, measured by setting each numeric argument of a chm example to 99
1549
+ # (oracle, CR6, 2026-09-28; `docs/instructions.md` "SDM addresses").
1550
+ _SDM_ADDRESS_ARG = {
1551
+ "CS7500": 2,
1552
+ "CSAT3": 2,
1553
+ "CSAT3B": 2,
1554
+ "CSAT3BMONITOR": 2,
1555
+ "EC100": 1,
1556
+ "EC100CONFIGURE": 1,
1557
+ "LI7200": 2,
1558
+ "LI7700": 2,
1559
+ "SDMCD16AC": 2,
1560
+ "SDMCD16MASK": 2,
1561
+ "SDMGENERIC": 1,
1562
+ "SDMIO16": 2,
1563
+ "SDMSIO4": 2,
1564
+ "SDMSW8A": 2,
1565
+ "SDMX50": 0,
1566
+ "TDR100": 1,
1567
+ "TDR200": 1,
1568
+ }
1569
+
1570
+
1571
+ def check_sdm_address(program: S.Program, profile, diags: Diagnostics, env: consteval.Env) -> None:
1572
+ """`Invalid SDM Address: %d, must be 0 - 14.` (decompiled `FUN_0043c680`) for a constant
1573
+ address whose low byte is 15 or more (`-1` -> `255`; `256` is accepted), pass 2, accumulating
1574
+ with other pass-2 errors (oracle, CR6/CR1000X, 2026-09-28)."""
1575
+ for stmt in program.statements:
1576
+ if not isinstance(stmt, S.Instruction):
1577
+ continue
1578
+ kw = stmt.keyword.upper()
1579
+ pos = _SDM_ADDRESS_ARG.get(kw)
1580
+ if pos is None or kw not in profile.instructions or pos >= len(stmt.args):
1581
+ continue
1582
+ arg = stmt.args[pos]
1583
+ if arg is None or not consteval.is_constant(arg, env):
1584
+ continue
1585
+ try:
1586
+ value = consteval.value_of(arg, env)
1587
+ except consteval.NotConstant:
1588
+ continue
1589
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
1590
+ continue
1591
+ # A decimal constant, or an integer beyond Long, is a float32 first (`16777233.0` -> 16);
1592
+ # a float32 outside Long range is accepted unchecked (oracle, CR6/CR1000X, 2026-10-08;
1593
+ # docs/fuzz-findings.md "Round 5, SDM address").
1594
+ # consteval values are float-valued throughout, so `consteval.is_float` tracks the type;
1595
+ # where it is not established an integer-valued result in Long range reads as integer.
1596
+ is_float = consteval.is_float(arg, env)
1597
+ if is_float is None:
1598
+ is_float = value != int(value) if abs(value) < 2**63 else True
1599
+ if is_float or not -(2**31) <= value < 2**31:
1600
+ try:
1601
+ rounded = struct.unpack("f", struct.pack("f", value))[0]
1602
+ except OverflowError: # some CPython builds raise on float32 overflow, others return +-inf
1603
+ rounded = math.copysign(math.inf, value)
1604
+ # float32 +inf saturates to Long max (255); -inf is 0
1605
+ value = (2**31 - 1 if rounded > 0 else 0) if math.isinf(rounded) else rounded
1606
+ if not -(2**31) <= value < 2**31:
1607
+ continue
1608
+ address = int(value) & 0xFF
1609
+ if address >= 15:
1610
+ diags.error(stmt.line, f"Invalid SDM Address: {address}, must be 0 - 14.", stmt.file)
1611
+
1612
+
1613
+ _AVGRUN_TYPE_ERROR = frozenset({"FLOAT", "STRING"})
1614
+ _RUNNING_AVG = frozenset({"AVGRUN", "TOTALRUN", "MINRUN", "MAXRUN", "STDDEVRUN"})
1615
+
1616
+
1617
+ # The number-of-instances position of the running statistics; the instance number follows it
1618
+ # (oracle, CR6/CR1000X, 2026-10-24: `MinRun(D,1,S,10,R,3)` -> `Missing instance number`).
1619
+ _INSTANCES_AT = {"AVGRUN": 6, "TOTALRUN": 6, "STDDEVRUN": 6, "MINRUN": 5, "MAXRUN": 5}
1620
+
1621
+
1622
+ def _operator_tree(expr: S.Expr | None) -> bool:
1623
+ """Whether `expr` holds only literals and names under operators (no call, element or field)."""
1624
+ if isinstance(expr, S.Unary):
1625
+ return _operator_tree(expr.operand)
1626
+ if isinstance(expr, S.Binary):
1627
+ return _operator_tree(expr.left) and _operator_tree(expr.right)
1628
+ return isinstance(expr, (S.Number, S.String, S.Name))
1629
+
1630
+
1631
+ def check_avgrun_options(program: S.Program, profile, diags: Diagnostics, env: consteval.Env) -> None:
1632
+ """`AvgRun`'s optional arguments after `ResetVar` (decompiled AvgRun compile function; oracle,
1633
+ 2026-10-01): a Float or String variable as `Count` -> `Optional Count parameter must be type
1634
+ long.` (all four loggers; a Long/Boolean variable, a literal or an expression compile). On
1635
+ CR6/CR1000X a variable or `0` as the number of distinct instances -> `Optional number of
1636
+ distinct instances must be a constant of type long.`, and any instances argument without the
1637
+ instance number after it -> `Missing instance number`. A constant `Reps` or `Number` below 1
1638
+ -> `number of averages %d and reps %d in RunningAvg must be > 0.` (all four). Reported with
1639
+ pass 2 without hiding the tier-C checks on CR6/CR1000X, only in an otherwise clean file on
1640
+ CR300/CR350. A negative instance count gives `Out of memory.`, which ends the compile (not
1641
+ modelled).
1642
+
1643
+ `Reps` and `Number` of `TotalRun`/`MinRun`/`MaxRun` and, on CR6/CR1000X, `StdDevRun` go through
1644
+ the same message (oracle, 2026-10-02, all four loggers). Values convert as C does: truncated
1645
+ toward zero, a string or anything outside Long range gives `-2147483648`."""
1646
+ if not _RUNNING_AVG & profile.instructions.keys():
1647
+ return
1648
+ scopes = symbols.statement_scopes(program, profile) # its own routine's parameters, also named like a later `Const`
1649
+ from .expressions import _alias_names, _has_dotted_field
1650
+
1651
+ def variable_type(arg: S.Expr | None, view: S.Program) -> str | None:
1652
+ if not isinstance(arg, (S.Name, S.Index)):
1653
+ return None
1654
+ return symbols.declared_variable_types(view).get(arg.name.upper())
1655
+
1656
+ from . import longconv
1657
+
1658
+ names = longconv.float_names(program.statements)
1659
+ literals = longconv.literal_names(program.statements)
1660
+
1661
+ def number(arg: S.Expr | None) -> int | None:
1662
+ converted = longconv.long_value(arg, env, profile, names, literals)
1663
+ if converted is not None:
1664
+ return converted
1665
+ try:
1666
+ value = consteval.value_of(arg, env) if arg is not None else None
1667
+ except consteval.NotConstant:
1668
+ return None
1669
+ if isinstance(value, bool) or not isinstance(value, (int, float, str)):
1670
+ return None
1671
+ return consteval.to_long(value)
1672
+
1673
+ for stmt in program.statements:
1674
+ if not isinstance(stmt, S.Instruction) or stmt.keyword.upper() not in _RUNNING_AVG:
1675
+ continue
1676
+ kw = stmt.keyword.upper()
1677
+ args = list(stmt.args) + [None] * (8 - len(stmt.args))
1678
+ view = symbols.scoped_view(program, stmt, publics=True) # the storage visible here (round 71)
1679
+ if (
1680
+ kw in ("TOTALRUN", "STDDEVRUN")
1681
+ and args[4] is not None
1682
+ and variable_type(args[5], view) in _AVGRUN_TYPE_ERROR
1683
+ ):
1684
+ # same Count message as `AvgRun` (oracle, CR6 and CR300, 2026-10-08)
1685
+ diags.error(stmt.line, "Optional Count parameter must be type long.", stmt.file)
1686
+ if kw == "AVGRUN" and args[4] is not None and variable_type(args[5], view) in _AVGRUN_TYPE_ERROR:
1687
+ diags.error(stmt.line, "Optional Count parameter must be type long.", stmt.file)
1688
+ at = _INSTANCES_AT.get(kw)
1689
+ if (
1690
+ at is not None
1691
+ and args[4] is not None
1692
+ and args[at] is not None
1693
+ and len(profile.instructions[kw]["params"]) > at + 1
1694
+ ):
1695
+ # a variable, alias or field anywhere in it: its own routine's parameters (also named
1696
+ # like a later `Const`) and locals, but no other routine's (`N+0`, round 71)
1697
+ visible = symbols.declared_variable_names(view) | _alias_names(view) | scopes[id(stmt)][0]
1698
+ variable = bool(_variable_refs(args[at], visible)) or _has_dotted_field(args[at])
1699
+ # only literals and numeric `Const`s under operators: a function call or a String
1700
+ # `Const` is the instances error too; `True` is -1 (round 71)
1701
+ try:
1702
+ value = consteval.value_of(args[at], env)
1703
+ except consteval.NotConstant:
1704
+ value = None
1705
+ constant = not variable and not isinstance(value, str) and _operator_tree(args[at])
1706
+ instances = -int(value) if isinstance(value, bool) else number(args[at])
1707
+ if constant and instances is not None and instances < 0:
1708
+ diags.error(stmt.line, "Out of memory.", stmt.file) # (ends the compile, not modelled)
1709
+ continue
1710
+ if not constant or variable_type(args[at], view) is not None or instances == 0:
1711
+ diags.error(
1712
+ stmt.line, "Optional number of distinct instances must be a constant of type long.", stmt.file
1713
+ )
1714
+ if args[at + 1] is None:
1715
+ diags.error(stmt.line, "Missing instance number", stmt.file)
1716
+ reps, count = number(args[1]), number(args[3])
1717
+ if reps is not None and count is not None and (reps < 1 or count < 1):
1718
+ diags.error(stmt.line, f"number of averages {count} and reps {reps} in RunningAvg must be > 0.", stmt.file)
1719
+
1720
+
1721
+ # CDM measurements' `SettlingTime` argument (0-based position; oracle, CR6/CR1000X, 2026-10-12).
1722
+ _CDM_SETTLING_ARG = {"CDM_TCSE": 9, "CDM_VOLTSE": 7}
1723
+
1724
+
1725
+ def check_option_values(program: S.Program, profile, diags: Diagnostics, env: consteval.Env) -> None:
1726
+ """Constant option arguments (oracle, 2026-10-02, `docs/checker.md` "Option arguments"),
1727
+ converted to a 32-bit `long` as in `checker/longconv.py`. Reported with pass 2 without hiding
1728
+ the tier-C checks on CR6/CR1000X, only in an otherwise clean file on CR300/CR350 (like
1729
+ `check_avgrun_options`).
1730
+
1731
+ - `SplitStr` `Options` (fifth argument), all four loggers: below 0 -> `Invalid option %d`.
1732
+ - `IPRoute` `Network` (second argument): outside 0..5 (CR300/CR350: 0..4) -> `%d is illegal
1733
+ network`; otherwise the optional third argument outside 0..1 -> `%d is illegal option`
1734
+ (the first line hides the second); a variable in any argument silences both. A string option crashes the real compiler: not modelled.
1735
+ - `CDM_Delay` `Option` (third argument, CR6/CR1000X): the value modulo 256 other than 0 or 1
1736
+ -> `Illegal option %d` (0 draws a different message about the module, not modelled).
1737
+ - `Matrix` `Option` (first argument): `(value & 0x7FFFFFFF)` outside 1..5 -> `Illegal option.`.
1738
+ - `MoveBytes` `Option` (sixth argument), all four loggers: `(value & 0x7FFF)` outside 0..4 ->
1739
+ `Invalid transfer option %d, must be 0..4.` with the whole value (oracle, 2026-10-12 and
1740
+ round 15: `-65536`, `65540`, `32772`, `-2^31`, a string and `NAN` (INT_MIN) are accepted;
1741
+ `65541`, `-65531` and `INF` (2147483647) are not).
1742
+ - `CDM_TCSE`/`CDM_VoltSe` settling time (CR6/CR1000X): not 0 and outside 100..100000 ->
1743
+ `Settling time: %d, out of range (100 - 100000, or 0 for default).` (oracle, 2026-10-12).
1744
+ """
1745
+ from . import longconv
1746
+
1747
+ names = longconv.float_names(program.statements)
1748
+ literals = longconv.literal_names(program.statements)
1749
+ cr3 = profile.name in ("cr300", "cr350")
1750
+ matrix_shapes = None
1751
+ for stmt in program.statements:
1752
+ if not isinstance(stmt, (S.Instruction, S.Call)):
1753
+ continue
1754
+ kw = stmt.keyword.upper() if isinstance(stmt, S.Instruction) else stmt.name.upper()
1755
+ if (
1756
+ kw not in ("SPLITSTR", "IPROUTE", "MATRIX", "CDM_DELAY", "MOVEBYTES") + tuple(_CDM_SETTLING_ARG)
1757
+ or kw not in profile.instructions
1758
+ ):
1759
+ continue
1760
+ args = list(stmt.args)
1761
+
1762
+ def value(i: int) -> int | None:
1763
+ return longconv.long_value(args[i], env, profile, names, literals) if i < len(args) else None
1764
+
1765
+ if kw == "SPLITSTR":
1766
+ v = value(4)
1767
+ if v is not None and v < 0:
1768
+ diags.error(stmt.line, f"Invalid option {v}", stmt.file)
1769
+ elif kw == "MOVEBYTES":
1770
+ v = value(5)
1771
+ if len(args) > 5 and isinstance(args[5], S.Name) and args[5].name.upper() == "INF":
1772
+ v = 2147483647 # (INF saturates here; NAN and strings give INT_MIN, see below)
1773
+ if v is not None and v & 0x7FFF > 4: # (only the low 15 bits are tested)
1774
+ diags.error(stmt.line, f"Invalid transfer option {v}, must be 0..4.", stmt.file)
1775
+ elif kw in _CDM_SETTLING_ARG:
1776
+ v = value(_CDM_SETTLING_ARG[kw])
1777
+ if v is not None and v != 0 and not 100 <= v <= 100000:
1778
+ diags.error(stmt.line, f"Settling time: {v}, out of range (100 - 100000, or 0 for default).", stmt.file)
1779
+ elif kw == "CDM_DELAY":
1780
+ v = value(2)
1781
+ if v is not None and v % 256 not in (0, 1):
1782
+ diags.error(stmt.line, f"Illegal option {v % 256}", stmt.file)
1783
+ elif v is not None and v % 256 == 0:
1784
+ for _ in range(_cdm_delay_overlong(args, env, profile, names, literals)):
1785
+ diags.error(stmt.line, "Measurement Delay Exceeds 60 min.", stmt.file)
1786
+ elif kw == "MATRIX":
1787
+ v = value(0)
1788
+ if v is not None and not 1 <= v & 0x7FFFFFFF <= 5:
1789
+ diags.error(stmt.line, "Illegal option.", stmt.file)
1790
+ elif v is not None and len(args) == 4:
1791
+ if matrix_shapes is None:
1792
+ matrix_shapes = _matrix_shapes(program, env)
1793
+ for message in _matrix_dimension_errors(v & 0x7FFFFFFF, args, matrix_shapes):
1794
+ diags.error(stmt.line, message, stmt.file)
1795
+ elif all(consteval.is_constant(a, env) for a in args if a is not None):
1796
+ net = value(1)
1797
+ if net is not None and not 0 <= net <= (4 if cr3 else 5):
1798
+ diags.error(stmt.line, f"{net} is illegal network", stmt.file)
1799
+ continue
1800
+ if len(args) > 2 and isinstance(args[2], S.String):
1801
+ continue
1802
+ opt = value(2)
1803
+ if opt is not None and opt not in (0, 1):
1804
+ diags.error(stmt.line, f"{opt} is illegal option", stmt.file)
1805
+
1806
+
1807
+ def _cdm_delay_overlong(args: list, env: consteval.Env, profile, names, literals) -> int:
1808
+ """How many `Measurement Delay Exceeds 60 min.` lines a `CDM_Delay` with `Option` 0 prints
1809
+ (CR6/CR1000X; decompiled `FUN_004160c0` / `FUN_0040f1c0`, oracle 2026-10-12). The integer
1810
+ interval becomes seconds and microseconds as in `scan_split` (32-bit C arithmetic). The
1811
+ instruction's own check prints once when either is negative, seconds exceed 3600, or they are
1812
+ exactly 3600 s plus any microseconds; the device module then prints it again when the total
1813
+ `us + sec * 1000000` (unsigned 32-bit) exceeds 3,600,000,000. So 61 Min, 3600001 mSec and every
1814
+ small negative interval print twice, -1 Hr, 1 Day and 86400000 mSec once, 60 Min none. Only an
1815
+ integral constant is modelled (a fraction goes through float arithmetic, not reproduced)."""
1816
+ from . import longconv
1817
+
1818
+ if len(args) < 5 or args[3] is None or args[4] is None:
1819
+ return 0
1820
+ try:
1821
+ raw = consteval.value_of(args[3], env)
1822
+ unit = consteval.value_of(args[4], env)
1823
+ except consteval.NotConstant:
1824
+ return 0
1825
+ if (
1826
+ isinstance(raw, bool)
1827
+ or not isinstance(raw, (int, float))
1828
+ or raw != int(raw)
1829
+ or not -(2**31) <= raw < 2**31
1830
+ or isinstance(unit, bool)
1831
+ or unit not in _US_PER_UNIT
1832
+ ):
1833
+ return 0
1834
+ n = longconv.long_value(args[3], env, profile, names, literals)
1835
+ if n is None:
1836
+ return 0
1837
+ if unit == 0:
1838
+ sec, us = _cdiv(n, 1_000_000), _crem(n, 1_000_000)
1839
+ elif unit == 1:
1840
+ sec, us = _cdiv(n, 1000), _crem(n, 1000) * 1000
1841
+ elif unit == 2:
1842
+ sec, us = n, 0
1843
+ else:
1844
+ wrapped = (n * {3: 60, 4: 3600, 5: 86400}[unit]) & 0xFFFFFFFF
1845
+ sec, us = (wrapped - 2**32 if wrapped >= 2**31 else wrapped), 0
1846
+ count = 0
1847
+ if sec < 0 or us < 0 or sec > 3600 or (sec == 3600 and us > 0):
1848
+ count += 1
1849
+ if ((us + sec * 1_000_000) & 0xFFFFFFFF) > 3_600_000_000:
1850
+ count += 1
1851
+ return count
1852
+
1853
+
1854
+ def _matrix_shapes(program: S.Program, env: consteval.Env) -> dict[str, tuple[int, int]]:
1855
+ """Name -> (rows, columns) of every variable declared exactly once as a Float of at most two
1856
+ constant dimensions (a 1-D array is `(n, 1)`, a scalar `(1, 1)`)."""
1857
+ dims = symbols.declared_all_dims(program, env)
1858
+ types = symbols.declared_variable_types(program)
1859
+ seen: set[str] = set()
1860
+ repeated: set[str] = set()
1861
+ for st in program.statements:
1862
+ if isinstance(st, S.Declaration) and st.kind in ("PUBLIC", "DIM", "STATIC"):
1863
+ for v in st.vars:
1864
+ (repeated if v.name.upper() in seen else seen).add(v.name.upper())
1865
+ return {
1866
+ n: _ILLEGAL_MATRIX if len(d) > 2 else (d[0], d[1] if len(d) == 2 else 1)
1867
+ for n, d in dims.items()
1868
+ if len(d) <= 3 and types.get(n) == "FLOAT" and n not in repeated
1869
+ }
1870
+
1871
+
1872
+ # Shape given to an operand the compiler calls `Illegal Matrix` (a String literal, an array of
1873
+ # three dimensions); it differs from every real shape, which reproduces the messages that follow.
1874
+ _ILLEGAL_MATRIX = (-1, -1)
1875
+
1876
+
1877
+ # An operand name that resolves to nothing has a shape of uninitialised memory in the real compiler
1878
+ # (oracle, CR6/CR1000X, 2026-10-12: `multiply result illegal dimensioned, must be -842150451,3`).
1879
+ _UNDEFINED_MATRIX = (-842150451, -842150451)
1880
+
1881
+
1882
+ def matrix_undefined_operands(stmt, program: S.Program, env, known, profile) -> list[str] | None:
1883
+ """CR6/CR1000X `Matrix (Option, A, B, Dest)` with undefined bare names as operands: for each
1884
+ one `Invalid symbol in expression: <name>.` and `Illegal Matrix`, then the dimension messages
1885
+ its uninitialised shape causes (oracle, 2026-10-12, `docs/instructions.md`). `None`: not this
1886
+ shape (an option that is not a constant 1-5, an operand that is neither a bare name nor of a
1887
+ known shape, a name inside an indexed operand)."""
1888
+ from . import longconv
1889
+
1890
+ args = list(stmt.args)
1891
+ if len(args) != 4 or any(a is None for a in args):
1892
+ return None
1893
+ names = longconv.float_names(program.statements)
1894
+ option = longconv.long_value(args[0], env, profile, names, longconv.literal_names(program.statements))
1895
+ if option is None or not 1 <= option & 0x7FFFFFFF <= 5:
1896
+ return None
1897
+ option &= 0x7FFFFFFF
1898
+ ops = [args[1], args[2], args[3]] if option <= 3 else [args[1], args[3]]
1899
+ if option > 3 and not isinstance(args[2], (S.Number, S.Name)):
1900
+ return None
1901
+ shapes = _matrix_shapes(program, env)
1902
+ out, shape = [], []
1903
+ for a in ops:
1904
+ if not isinstance(a, S.Name):
1905
+ return None
1906
+ up = a.name.upper()
1907
+ if up in known:
1908
+ if up not in shapes:
1909
+ return None
1910
+ shape.append(shapes[up])
1911
+ else:
1912
+ out += [f"Invalid symbol in expression: {a.name}.", "Illegal Matrix"]
1913
+ shape.append(_UNDEFINED_MATRIX)
1914
+ if not out:
1915
+ return None
1916
+ result = shape[-1]
1917
+ if option == 3:
1918
+ (m, n), (p, q) = shape[0], shape[1]
1919
+ if n != p:
1920
+ out.append("multiply A columns and B rows must be the same")
1921
+ if result == _UNDEFINED_MATRIX or (result[0] != m and result[1] != q):
1922
+ out.append(f"multiply result illegal dimensioned, must be {m},{q}")
1923
+ else:
1924
+ out.append("Illegal Matrix dimensioned")
1925
+ return out
1926
+
1927
+
1928
+ def _matrix_dimension_errors(option: int, args, shapes) -> list[str]:
1929
+ """`Matrix` operand dimension messages (oracle 2026-10-03, all four loggers,
1930
+ `docs/instructions.md` "Matrix dimensions"); empty unless every checked operand is a bare
1931
+ Float variable of known shape (`B` is unused by options 4 and 5). A String literal operand or an
1932
+ array of three dimensions is `Illegal Matrix` (oracle 2026-10-08, `docs/instructions.md`)."""
1933
+ ops = [args[1], args[2], args[3]] if option <= 3 else [args[1], args[3]]
1934
+ shape = []
1935
+ for i, a in enumerate(ops):
1936
+ if (isinstance(a, S.String) and i < len(ops) - 1) or (isinstance(a, S.Number) and option <= 3 and i == 1):
1937
+ # (a String destination has its own message; a number is only probed as `B`)
1938
+ shape.append(_ILLEGAL_MATRIX)
1939
+ elif isinstance(a, S.Name) and a.name.upper() in shapes:
1940
+ shape.append(shapes[a.name.upper()])
1941
+ else:
1942
+ return []
1943
+ out = ["Illegal Matrix"] if _ILLEGAL_MATRIX in shape else []
1944
+ out += ["Matrix cannot be > 4X4" for sh in shape if max(sh) > 4]
1945
+ result = shape[-1]
1946
+ if option == 3:
1947
+ (m, n), (p, q) = shape[0], shape[1]
1948
+ if n != p:
1949
+ out.append("multiply A columns and B rows must be the same")
1950
+ if result[0] != m and result[1] != q:
1951
+ out.append(f"multiply result illegal dimensioned, must be {m},{q}")
1952
+ elif any(sh != shape[0] for sh in shape) or (option > 3 and shape[0][0] != shape[0][1]):
1953
+ out.append("Illegal Matrix dimensioned")
1954
+ return out
1955
+
1956
+
1957
+ def check_const_int_args(program: S.Program, profile, diags: Diagnostics, env: consteval.Env) -> None:
1958
+ """`Illegal parameter %s, expecting constant integer.` at a type-code-4 position (`SW12`'s third
1959
+ argument, `PortSet`, `PortGet`, `Calibrate`, `CDM_SW12`, CR300 `SwVx`) holding anything but an
1960
+ integer constant: a variable, an undefined name, `Nope+1`, `0.5`, `"a"` (echoed verbatim).
1961
+ Pass 1, all four loggers (oracle, 2026-09-28; `docs/type-codes.md`). `Do`/`Loop`/`Else` read
1962
+ `While`/`Until` there and are left to the parser."""
1963
+ for stmt in program.statements:
1964
+ if not isinstance(stmt, S.Instruction):
1965
+ continue
1966
+ kw = stmt.keyword.upper()
1967
+ record = profile.instructions.get(kw)
1968
+ if not record or kw in ("DO", "LOOP", "ELSE"):
1969
+ continue
1970
+ if not profile.single_pass and _earlier_error_in_stmt(
1971
+ program, stmt, diags, ("Invalid symbol in expression", "Variable illegal in Parameter")
1972
+ ):
1973
+ continue # CR6/CR1000X: an earlier const-family error on the line ends the statement
1974
+ for i, code in enumerate(record["params"][: len(stmt.args)]):
1975
+ arg = stmt.args[i]
1976
+ if code not in (4, 104) or arg is None:
1977
+ continue
1978
+ from . import expressions
1979
+
1980
+ text = _strip_parens(expressions._arg_text(stmt, i).strip())
1981
+ if not text or _is_integer_token(text, arg, env):
1982
+ continue
1983
+ echo = "-" if re.match(r"-\s", text) else text
1984
+ diags.error(stmt.line, f"Illegal parameter {echo}, expecting constant integer.", stmt.file, stmt=stmt)
1985
+
1986
+
1987
+ def _strip_parens(text: str) -> str:
1988
+ """`text` without enclosing parentheses (`(1+2)` is echoed as `1+2`, oracle 2026-09-28)."""
1989
+ while text.startswith("(") and text.endswith(")") and _paren_depth_ok(text[1:-1]):
1990
+ text = text[1:-1].strip()
1991
+ return text
1992
+
1993
+
1994
+ def _paren_depth_ok(text: str) -> bool:
1995
+ depth = 0
1996
+ for ch in text:
1997
+ depth += (ch == "(") - (ch == ")")
1998
+ if depth < 0:
1999
+ return False
2000
+ return depth == 0
2001
+
2002
+
2003
+ def _is_integer_token(text: str, arg: S.Expr, env: consteval.Env) -> bool:
2004
+ """One integer token (after `_strip_parens`): `2`, `-1`, `&H1`, `(1)`, or a name whose constant
2005
+ value is an integer (`Const K = 1`, `True`). `1+2`, `K+1`, `1.0`, `1E0` and `- 1` are not
2006
+ (oracle, 2026-09-28)."""
2007
+ if re.fullmatch(r"(?:\+?\d+|-?(?:\d+|&[Hh][0-9A-Fa-f]+|&[Bb][01]+))", text):
2008
+ return True
2009
+ if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", text) or not consteval.is_constant(arg, env):
2010
+ return False
2011
+ try:
2012
+ value = consteval.value_of(arg, env)
2013
+ except consteval.NotConstant:
2014
+ return False
2015
+ return not isinstance(value, str) and isinstance(value, (int, float)) and value == int(value)
2016
+
2017
+
2018
+ # `SerialIn` constant ComPort codes, compared by absolute value (oracle, 2026-10-25,
2019
+ # docs/checker.md#serialin-comport-codes-round-36); CR300/CR350 not probed
2020
+ _SERIALIN_PORTS = {
2021
+ "cr6": frozenset(range(1, 19)) | frozenset(range(32, 48)),
2022
+ "cr1000x": frozenset(range(1, 15)) | frozenset(range(32, 48)),
2023
+ }
2024
+
2025
+
2026
+ def check_serialbrk_comport(program: S.Program, profile, diags: Diagnostics, env: consteval.Env) -> None:
2027
+ """Numeric constant ports of the statement builder, not expression calls.
2028
+
2029
+ CR6 FUN_004731f0 and four-profile observations: docs/checker.md#serialbrk-ports-round-42.
2030
+ Strings and runtime variables bypass this builder's constant numeric check.
2031
+ """
2032
+ bounds = {"cr6": (16, "COMC1, COMC3, or COMU1..COMU7"), "cr1000x": (12, "COMC1, COMC3..COMC7")}.get(profile.name)
2033
+ if bounds is None:
2034
+ return
2035
+ names = longconv.float_names(program.statements)
2036
+ literals = longconv.literal_names(program.statements)
2037
+ for stmt in program.statements:
2038
+ if (
2039
+ not isinstance(stmt, S.Instruction)
2040
+ or stmt.keyword.upper() != "SERIALBRK"
2041
+ or len(stmt.args) != 2
2042
+ or any(a is None for a in stmt.args)
2043
+ ):
2044
+ continue
2045
+ try:
2046
+ value = consteval.value_of(stmt.args[0], env)
2047
+ except consteval.NotConstant, TypeError, ValueError, ZeroDivisionError:
2048
+ value = None
2049
+ if isinstance(value, str):
2050
+ continue
2051
+ port = longconv.long_value(stmt.args[0], env, profile, names, literals)
2052
+ if port is not None and not 9 <= port <= bounds[0]:
2053
+ diags.error(stmt.line, f"Invalid COM port, must use {bounds[1]}", stmt.file)
2054
+
2055
+
2056
+ def check_serialin_comport(program: S.Program, profile, diags: Diagnostics, env: consteval.Env) -> None:
2057
+ """`ComPort N illegal.` for a constant `SerialIn` ComPort whose 32-bit value, sign dropped, is
2058
+ not a port code of the logger (`N` is that absolute value)."""
2059
+ from . import longconv
2060
+
2061
+ ports = _SERIALIN_PORTS.get(profile.name)
2062
+ if ports is None:
2063
+ return
2064
+ names = longconv.float_names(program.statements)
2065
+ literals = longconv.literal_names(program.statements)
2066
+ for stmt in program.statements:
2067
+ # a short statement stops at its missing parameter first (oracle, CR6/CR1000X, round 59)
2068
+ if (
2069
+ isinstance(stmt, S.Instruction)
2070
+ and stmt.keyword.upper() == "SERIALIN"
2071
+ and len(stmt.args) > 1
2072
+ and shortfall_message(profile.instructions["SERIALIN"], given_count(stmt, profile)) is None
2073
+ ):
2074
+ v = longconv.long_value(stmt.args[1], env, profile, names, literals)
2075
+ if v is not None and (abs(v) if v != -(2**31) else v) not in ports:
2076
+ diags.error(stmt.line, f"ComPort {abs(v) if v != -(2**31) else v} illegal.", stmt.file)
2077
+
2078
+
2079
+ _SERIALIN_DEST_MESSAGE = "A string variable destination is required"
2080
+
2081
+
2082
+ def _serialin_types(program: S.Program, profile) -> dict[str, str]:
2083
+ types = symbols.declared_variable_types(program)
2084
+ for stmt in program.statements:
2085
+ if isinstance(stmt, S.ConstDecl):
2086
+ types[stmt.name.upper()] = "CONST"
2087
+ elif (
2088
+ isinstance(stmt, S.Instruction)
2089
+ and stmt.keyword.upper() == "ALIAS"
2090
+ and stmt.args
2091
+ and isinstance(stmt.args[0], (S.Name, S.Index))
2092
+ ):
2093
+ kind = types.get(stmt.args[0].name.upper())
2094
+ if kind is not None:
2095
+ for alias in stmt.args[1:]:
2096
+ if isinstance(alias, S.Name):
2097
+ types[alias.name.upper()] = kind
2098
+ for name in profile.functions:
2099
+ types.setdefault(name, "FUNCTION")
2100
+ for name in profile.symbols: # `SerialIn(ComRS232,...)` (oracle, CR6/CR1000X, round 59)
2101
+ types.setdefault(name, "CONST")
2102
+ return types
2103
+
2104
+
2105
+ def _serialin_bad_destination(arg: S.Expr, types: dict[str, str]) -> bool | None:
2106
+ if isinstance(arg, (S.Name, S.Index)):
2107
+ kind = types.get(arg.name.upper())
2108
+ if kind == "FUNCTION" and isinstance(arg, S.Name):
2109
+ return None
2110
+ return None if kind is None else kind != "STRING"
2111
+ if isinstance(arg, (S.Number, S.String, S.Binary, S.Unary)):
2112
+ return True
2113
+ return None
2114
+
2115
+
2116
+ def check_serialin_destination(program: S.Program, profile, diags: Diagnostics) -> None:
2117
+ """Pass-1 SerialIn destination check; docs/instructions.md#serialin-destination.
2118
+
2119
+ Unknown names and structure members remain with the existing name/type checks. This rule
2120
+ deliberately does not generalize code 33 to other receiving instructions.
2121
+ """
2122
+ types = _serialin_types(program, profile)
2123
+ for stmt in program.statements:
2124
+ if (
2125
+ not isinstance(stmt, S.Instruction)
2126
+ or stmt.keyword.upper() != "SERIALIN"
2127
+ or "SERIALIN" not in profile.instructions
2128
+ or not stmt.args
2129
+ or stmt.args[0] is None
2130
+ ):
2131
+ continue
2132
+ if not _serialin_bad_destination(stmt.args[0], types):
2133
+ continue
2134
+ key = (stmt.line, stmt.file)
2135
+ at = next((i for i, d in enumerate(diags.items) if (d.line, d.file) == key), len(diags.items))
2136
+ new = [Diagnostic("error", stmt.line, _SERIALIN_DEST_MESSAGE, stmt.file)]
2137
+ if profile.single_pass:
2138
+ # A failed destination ends this argument list before count checks.
2139
+ diags.items[:] = [d for d in diags.items if (d.line, d.file) != key]
2140
+ at = min(at, len(diags.items))
2141
+ if len(stmt.args) > 1 and stmt.args[1] is not None:
2142
+ text = _next_simple_token(_arg_text(stmt, 1)) or _arg_text(stmt, 1)
2143
+ new.append(Diagnostic("error", stmt.line, f"Too many parameters: {text}.", stmt.file))
2144
+ diags.items[at:at] = new
2145
+
2146
+
2147
+ def check_serialinrecord(program: S.Program, profile, diags: Diagnostics, env: consteval.Env) -> None:
2148
+ """CR6/CR1000X `SerialInRecord(Port,Dest,StartWord,NumberBytes,EndWord,...)` (oracle,
2149
+ 2026-10-24, round 34, `docs/checker.md` "SerialInRecord, SerialOpen and running-statistic
2150
+ instances"):
2151
+ - StartWord, NumberBytes and EndWord all nonzero and one of them negative: `StartWord,
2152
+ NumberBytes and EndWord cannot all be not used` (a rule fitted to 24 probes; StartWord and EndWord
2153
+ read as signed 16-bit);
2154
+ - NumberBytes, compared unsigned, not below the port's input buffer: `Number of bytes %d must be
2155
+ less than input buffer %d`, after the line above. The buffer is 0 for a port no `SerialOpen`
2156
+ opens (a `Sub` or a later line counts) and otherwise max(256, BufferSize); with several
2157
+ `SerialOpen`s the largest is taken (not probed)."""
2158
+ if profile.single_pass:
2159
+ return
2160
+
2161
+ def value(arg):
2162
+ try:
2163
+ v = consteval.value_of(arg, env) if arg is not None else None
2164
+ except consteval.NotConstant, consteval.DivideByZero:
2165
+ return None
2166
+ return int(v) if isinstance(v, (int, float)) and not isinstance(v, bool) else None
2167
+
2168
+ buffers: dict[int, int | None] = {}
2169
+ unknown_port = False
2170
+ for st in program.statements:
2171
+ name = (
2172
+ st.keyword if isinstance(st, S.Instruction) else getattr(st, "name", "") if isinstance(st, S.Call) else ""
2173
+ )
2174
+ if not isinstance(st, (S.Call, S.Instruction)) or name.upper() != "SERIALOPEN" or not st.args:
2175
+ continue
2176
+ port = value(st.args[0])
2177
+ if port is None:
2178
+ unknown_port = True
2179
+ continue
2180
+ size = value(st.args[4]) if len(st.args) > 4 else 0
2181
+ old = buffers.get(port, 0)
2182
+ buffers[port] = None if size is None or old is None else max(old, 256, size)
2183
+ for st in program.statements:
2184
+ if not isinstance(st, S.Instruction) or st.keyword.upper() != "SERIALINRECORD" or len(st.args) < 5:
2185
+ continue
2186
+ port, start, count, end = (value(st.args[i]) for i in (0, 2, 3, 4))
2187
+ word = lambda v: (v + 2**15) % 2**16 - 2**15 # StartWord/EndWord are 16-bit (65535 is -1)
2188
+ if (
2189
+ (start is not None and count is not None and end is not None)
2190
+ and start
2191
+ and count
2192
+ and end
2193
+ and min(word(start), count, word(end)) < 0
2194
+ ):
2195
+ diags.error(st.line, "StartWord, NumberBytes and EndWord cannot all be not used", st.file)
2196
+ if port is None or count is None or unknown_port:
2197
+ continue
2198
+ size = buffers.get(port, 0)
2199
+ if size is not None and count % 2**32 >= size:
2200
+ diags.error(st.line, f"Number of bytes {count} must be less than input buffer {size}", st.file)
2201
+
2202
+
2203
+ _DEBUG_HISTORY_LIMIT = 26843544 # bytes: HistorySize*4 above this gives `Out of memory.`
2204
+ _DEBUG_ARG_TYPES = {2: frozenset({"LONG", "BOOLEAN"}), 3: frozenset({"LONG", "BOOLEAN"}), 4: frozenset({"STRING"})}
2205
+
2206
+
2207
+ def debug_history_oom(stmt: S.Instruction, env, profile, program: S.Program) -> bool:
2208
+ """`Debug`'s `HistorySize` (second argument) takes `(long)n * 4` as an unsigned 32-bit byte
2209
+ count and fails with `Out of memory.` above 26,843,544 (n = 6,710,886 passes, 6,710,887 does
2210
+ not; CR6 and CR1000X, oracle 2026-10-12, docs/instructions.md "Debug"). `n` is the 32-bit
2211
+ `long` of `checker/longconv.py` (`NAN`, strings and floats beyond 2^31 are -2^31, which wraps to
2212
+ 0 bytes and passes; `-1`, `INF` and `&HFFFFFFFF` wrap to 4294967292 and fail). A decimal
2213
+ literal is first rounded to a float32 (`6710886.6` passes, `6710886.9` fails; `1073741823.6`
2214
+ becomes 2^30 and passes) and `INF` saturates to 2147483647 (fails). The error ends
2215
+ the compile: nothing else on the line or after it is reported."""
2216
+ if len(stmt.args) != 5 or stmt.args[1] is None:
2217
+ return False
2218
+ arg = stmt.args[1]
2219
+ v = longconv.long_value(
2220
+ arg, env, profile, longconv.float_names(program.statements), longconv.literal_names(program.statements)
2221
+ )
2222
+ if v is None:
2223
+ return False
2224
+ if isinstance(arg, S.Name) and arg.name.upper() == "INF" and "INF" not in env.consts:
2225
+ v = 2**31 - 1 # (saturates, unlike NAN and -INF)
2226
+ elif isinstance(arg, S.Number) and not arg.text.startswith("&") and ("." in arg.text or "E" in arg.text.upper()):
2227
+ try:
2228
+ f = struct.unpack("<f", struct.pack("<f", arg.value))[0] # (a decimal literal is a float32)
2229
+ except OverflowError: # beyond float32 range: +-inf, as in check_stage2a above
2230
+ f = math.copysign(math.inf, arg.value)
2231
+ # +inf saturates to Long max (Debug 1e39 and 3.5e38: Out of memory); -inf and other out-of-range values to Long min
2232
+ v = int(f) if -(2**31) <= f < 2**31 else (2**31 - 1 if math.isinf(f) and f > 0 else -(2**31))
2233
+ return (v * 4) & 0xFFFFFFFF > _DEBUG_HISTORY_LIMIT
2234
+
2235
+
2236
+ def check_debug_args(program: S.Program, profile, diags: Diagnostics, env: consteval.Env) -> None:
2237
+ """`Debug (DebugSequence, HistorySize, Control, LineBreak, TraceHistory)`: `Control` and
2238
+ `LineBreak` must be `Long` or `Boolean` variables (scalar, array or element), `TraceHistory`
2239
+ a `String` variable. Any other argument -- a number, string, `Const`, expression, function
2240
+ call, `Status` member, a `Float`/`Double`/`String` (resp. non-`String`) variable, or an
2241
+ undeclared name -- ends the statement's findings with one `Illegal parameter(s)` (after the
2242
+ `Invalid symbol`/`Could not find` lines the arguments themselves draw; oracle CR6 and
2243
+ CR1000X, 2026-10-12, docs/instructions.md "Debug"). Absent on CR300/CR350."""
2244
+ if "DEBUG" not in profile.instructions:
2245
+ return
2246
+ types = _serialin_types(program, profile)
2247
+ for stmt in program.statements:
2248
+ if (
2249
+ not isinstance(stmt, S.Instruction)
2250
+ or stmt.keyword.upper() != "DEBUG"
2251
+ or len(stmt.args) != 5
2252
+ or any(a is None for a in stmt.args)
2253
+ ):
2254
+ continue
2255
+ key = (stmt.line, stmt.file)
2256
+ same = [d for d in diags.items if (d.line, d.file) == key]
2257
+ bad = False
2258
+ for i, allowed in _DEBUG_ARG_TYPES.items():
2259
+ arg = stmt.args[i]
2260
+ if isinstance(arg, (S.Name, S.Index)):
2261
+ kind = types.get(arg.name.upper())
2262
+ if kind is None:
2263
+ text = _arg_text(stmt, i).strip()
2264
+ bad |= any(d.message == f"Invalid symbol in expression: {text}." for d in same) or (
2265
+ isinstance(arg, S.Name)
2266
+ and (arg.name.upper() in ("NAN", "INF") or consteval.is_constant(arg, env))
2267
+ )
2268
+ else:
2269
+ bad |= kind not in allowed
2270
+ elif isinstance(arg, S.Member):
2271
+ bad |= isinstance(arg.base, S.Name) and arg.base.name.upper() == "STATUS"
2272
+ else:
2273
+ bad |= isinstance(arg, (S.Number, S.String, S.Binary, S.Unary))
2274
+ if bad:
2275
+ at = (
2276
+ max((i for i, d in enumerate(diags.items) if (d.line, d.file) == key), default=len(diags.items) - 1) + 1
2277
+ )
2278
+ diags.items.insert(at, Diagnostic("error", stmt.line, "Illegal parameter(s)", stmt.file))
2279
+
2280
+
2281
+ _EMAIL_FUNCTIONS = ("EMAILRECV", "EMAILSEND")
2282
+
2283
+
2284
+ def check_emailrecv_destination(program: S.Program, profile, diags: Diagnostics) -> None:
2285
+ """CR6/CR1000X `EmailRecv`/`EmailSend` (same behaviour, oracle 2026-10-04): argument 9 must
2286
+ exist and every argument from 9 on must be a String variable/element, else `A string variable destination is required` ahead of any arity message
2287
+ (docs/instructions.md#emailrecv-destination). Only literals, constants and non-String
2288
+ variables count as bad; expressions are left unchecked."""
2289
+ if "EMAILRECV" not in profile.functions:
2290
+ return
2291
+ types = _serialin_types(program, profile)
2292
+ from .expressions import _index_nodes, _known_names, _undefined_names
2293
+
2294
+ known = _known_names(program, profile)
2295
+
2296
+ def bad(a):
2297
+ # CR300/CR350 name the undefined variable first and still report the destination
2298
+ # (oracle, 2026-10-08)
2299
+ return _serialin_bad_destination(a, types) or (
2300
+ profile.single_pass and isinstance(a, (S.Name, S.Index)) and a.name.upper() not in known
2301
+ )
2302
+
2303
+ for stmt in program.statements:
2304
+ if isinstance(stmt, S.Assignment) and _undefined_names(stmt.target, known):
2305
+ continue # an unresolved target is the generic `Invalid, or out of place expression`
2306
+ call = stmt.value if isinstance(stmt, S.Assignment) else stmt
2307
+ fname = getattr(call, "name", "").upper()
2308
+ if isinstance(call, (S.Index, S.Call)) and call.name.upper() in _EMAIL_FUNCTIONS:
2309
+ args = call.args
2310
+ elif isinstance(call, S.Name) and call.name.upper() in _EMAIL_FUNCTIONS and isinstance(stmt, S.Assignment):
2311
+ args = [] # `X = EmailSend` with its arguments wrapped onto the next line
2312
+ elif isinstance(stmt, S.Assignment):
2313
+ nested = [
2314
+ n for n in _index_nodes(stmt.value) if n.name.upper() in _EMAIL_FUNCTIONS and len(n.args) >= 9
2315
+ ] # inside a larger expression (oracle: `...+1`)
2316
+ if not nested:
2317
+ continue
2318
+ args = nested[0].args
2319
+ fname = nested[0].name.upper()
2320
+ else:
2321
+ continue
2322
+ # destinations: `EmailRecv` arguments 9-12, `EmailSend` argument 9 only; the rest are
2323
+ # numbers (host sweep, oracle 2026-10-11, `tests/fixtures/r13_function_arity.json`)
2324
+ tail = args[8:12] if fname == "EMAILRECV" else args[8:9]
2325
+ if None in tail or (tail and not any(not isinstance(a, (S.Binary, S.Unary)) and bad(a) for a in tail)):
2326
+ continue
2327
+ key = (stmt.line, stmt.file)
2328
+ at = next((i for i, d in enumerate(diags.items) if (d.line, d.file) == key), len(diags.items))
2329
+ while (
2330
+ profile.single_pass
2331
+ and at < len(diags.items)
2332
+ and (diags.items[at].line, diags.items[at].file) == key
2333
+ and diags.items[at].message.endswith(" is not defined.")
2334
+ ):
2335
+ at += 1
2336
+ new = [Diagnostic("error", stmt.line, _SERIALIN_DEST_MESSAGE, stmt.file)]
2337
+ diags.items[at:at] = new
2338
+
2339
+
2340
+ def check_tcpopen_handle_type(program: S.Program, profile, diags: Diagnostics) -> None:
2341
+ """CR6/CR1000X: `TCPOpen`'s fifth argument (the handle) that is a Float or String variable, a
2342
+ literal or an expression is `Parameter must be a variable of type long`, ahead of the
2343
+ statement's other lines (oracle, 2026-10-12: `R = TCPOpen("",3002,1000,6000,F,1)`, the
2344
+ statement form, `...,S,1)`, `...,5,1)`; a Long or Boolean variable compiles). CR300/CR350:
2345
+ `cr3xx.check_tcpopen_handle`."""
2346
+ if profile.name not in ("cr6", "cr1000x"):
2347
+ return
2348
+ from .expressions import _index_nodes, _stmt_exprs
2349
+
2350
+ types = symbols.declared_variable_types(program)
2351
+ for stmt in program.statements:
2352
+ if isinstance(stmt, S.Call) and stmt.name.upper() == "TCPOPEN":
2353
+ calls = [stmt.args]
2354
+ else:
2355
+ calls = [ix.args for e in _stmt_exprs(stmt) for ix in _index_nodes(e) if ix.name.upper() == "TCPOPEN"]
2356
+ for args in calls[:1]:
2357
+ if len(args) < 5 or args[4] is None:
2358
+ continue
2359
+ arg = args[4]
2360
+ if isinstance(arg, (S.Name, S.Index)):
2361
+ kind = types.get(arg.name.upper())
2362
+ bad = kind is not None and kind not in ("LONG", "BOOLEAN")
2363
+ else:
2364
+ bad = isinstance(arg, (S.Number, S.String, S.Binary, S.Unary))
2365
+ if bad:
2366
+ at = next(
2367
+ (i for i, d in enumerate(diags.items) if (d.line, d.file) == (stmt.line, stmt.file)),
2368
+ len(diags.items),
2369
+ )
2370
+ diags.items.insert(
2371
+ at, Diagnostic("error", stmt.line, "Parameter must be a variable of type long", stmt.file)
2372
+ )
2373
+
2374
+
2375
+ def check_tcpopen_connections(program: S.Program, profile, diags: Diagnostics, env: consteval.Env) -> None:
2376
+ """CR6/CR1000X `TCPOpen(..., Handle, Count)` with a constant `Count` outside `1..N`, `N` the
2377
+ element count of the Long handle variable (1 for a scalar): `Max number of concurrent
2378
+ connections must be 1..N` on the statement's line (oracle, CR6 and CR1000X, 2026-10-12,
2379
+ `tests/fixtures/r13_tcpopen_connections.json`). Only calls of 5-7 arguments with a constant integer count (a missing
2380
+ count is 0) and a once-declared `Long` variable handle are judged; CR300/CR350 print nothing.
2381
+ A count literal not written as an integer (`3.0`, `2.0`, `2.5`, `1E0`) is the range message
2382
+ whatever its value, then `Parameter must be a constant integer: <statement>.`; CR300/CR350 print
2383
+ only the second line (oracle, all four loggers, 2026-10-12; review R13C-6)."""
2384
+ from .expressions import _index_nodes, _stmt_exprs
2385
+
2386
+ types = symbols.declared_variable_types(program)
2387
+ dims = symbols.declared_all_dims(program, env)
2388
+ declared: dict[str, int] = {}
2389
+ for st in program.statements:
2390
+ if isinstance(st, S.Declaration):
2391
+ for v in st.vars:
2392
+ declared[v.name.upper()] = declared.get(v.name.upper(), 0) + 1
2393
+ busy = {(d.line, d.file) for d in diags.items}
2394
+ stmts = [
2395
+ st
2396
+ for top in program.statements
2397
+ for st in (top, *(getattr(top, "inline", None) or []), *(getattr(top, "inline_else", None) or []))
2398
+ ]
2399
+ for stmt in stmts:
2400
+ if isinstance(stmt, S.Call) and stmt.name.upper() == "TCPOPEN":
2401
+ calls = [stmt.args]
2402
+ else:
2403
+ calls = [ix.args for e in _stmt_exprs(stmt) for ix in _index_nodes(e) if ix.name.upper() == "TCPOPEN"]
2404
+ for args in calls[:1]:
2405
+ if len(args) not in (5, 6, 7) or args[4] is None or (len(args) > 5 and args[5] is None):
2406
+ continue
2407
+ handle = args[4]
2408
+ name = handle.name.upper() if isinstance(handle, (S.Name, S.Index)) else None
2409
+ if name is None or declared.get(name) != 1 or types.get(name) != "LONG" or name not in dims:
2410
+ continue
2411
+ try:
2412
+ count = consteval.value_of(args[5], env) if len(args) > 5 else 0
2413
+ except consteval.NotConstant:
2414
+ continue
2415
+ lit = (
2416
+ args[5].operand
2417
+ if len(args) > 5 and isinstance(args[5], S.Unary) and args[5].op in ("-", "+")
2418
+ else (args[5] if len(args) > 5 else None)
2419
+ )
2420
+ size = 1
2421
+ for d in dims[name]:
2422
+ size *= d
2423
+ if (
2424
+ isinstance(lit, S.Number)
2425
+ and not re.fullmatch(r"\d+|&[Hh][0-9A-Fa-f]+", lit.text)
2426
+ and lit is args[5]
2427
+ and (stmt.line, stmt.file) not in busy
2428
+ ):
2429
+ if not profile.single_pass:
2430
+ diags.error(
2431
+ stmt.line, f"Max number of concurrent connections must be 1..{size}", stmt.file, stmt=stmt
2432
+ )
2433
+ sep = "\n" if profile.fallback_echo_newline and stmt.at_eol else ""
2434
+ diags.error(stmt.line, f"Parameter must be a constant integer: {stmt.text}{sep}.", stmt.file, stmt=stmt)
2435
+ continue
2436
+ if (
2437
+ profile.single_pass
2438
+ or isinstance(count, bool)
2439
+ or not isinstance(count, (int, float))
2440
+ or count != int(count)
2441
+ ):
2442
+ continue
2443
+ count = int(count)
2444
+ if 1 <= count <= size or (stmt.line, stmt.file) in busy:
2445
+ continue
2446
+ diags.error(stmt.line, f"Max number of concurrent connections must be 1..{size}", stmt.file, stmt=stmt)
2447
+
2448
+
2449
+ _PULSE_CHANNELS = {"cr6": re.compile(r"C[1-4]|U(?:[1-9]|1[0-2])"), "cr1000x": re.compile(r"C[1-8]|P[12]")}
2450
+
2451
+
2452
+ def check_pulsecount_channels(program: S.Program, profile, diags: Diagnostics) -> None:
2453
+ """CR6/CR1000X: a second `PulseCount` naming a terminal an earlier one in the same
2454
+ `Scan..NextScan` already took is `<T> already used as PulseCount in this scan.` (CR1000X first
2455
+ adds `Pulsecount: channel <T> already in use or unavailable.`); docs/instructions.md. Static
2456
+ textual order: branches, `SubScan`s and the scan's other measurements do not matter."""
2457
+ channel = _PULSE_CHANNELS.get(profile.name)
2458
+ if channel is None:
2459
+ return
2460
+ used: set[str] | None = None
2461
+ for stmt in program.statements:
2462
+ if not isinstance(stmt, S.Instruction):
2463
+ continue
2464
+ kw = stmt.keyword.upper()
2465
+ if kw == "SCAN":
2466
+ used = set()
2467
+ elif kw == "NEXTSCAN" or (kw == "NEXT" and re.match(r"(?i)\s*Next\s+Scan\b", stmt.text or "")):
2468
+ used = None
2469
+ elif (
2470
+ kw == "PULSECOUNT"
2471
+ and used is not None
2472
+ and len(stmt.args) > 2
2473
+ and isinstance(stmt.args[2], S.Name)
2474
+ and channel.fullmatch(stmt.args[2].name.upper())
2475
+ ):
2476
+ name = stmt.args[2].name.upper()
2477
+ if name in used:
2478
+ if profile.name == "cr1000x" and name[0] == "C": # (not `P1`/`P2`, 2026-10-24)
2479
+ diags.error(stmt.line, f"Pulsecount: channel {name} already in use or unavailable.", stmt.file)
2480
+ diags.error(stmt.line, f"{name} already used as PulseCount in this scan.", stmt.file)
2481
+ used.add(name)
2482
+
2483
+
2484
+ def check_sdi12_nan_fill(program: S.Program, profile, diags: Diagnostics, env) -> None:
2485
+ """CR6/CR1000X: an integer constant `SDI12Recorder` NAN-fill count (seventh argument) outside -1,
2486
+ 0 and 1..room, room being the numeric destination's elements from its first one to the end, is
2487
+ `Illegal NAN fill locations, legal is -1 or 1..<room>(room in the destination array)`
2488
+ (docs/checker.md#sdi12recorder-nan-fill-round-38); an alias of a Float array counts from its
2489
+ own element. String destinations, parameters and a partly indexed multidimensional destination
2490
+ are not checked."""
2491
+ if profile.name not in ("cr6", "cr1000x"):
2492
+ return
2493
+ from .instr_measure import _flat_offset, _index_value, _measurement_aliases
2494
+
2495
+ tables = symbols.scope_tables(program, env, scalars=True)
2496
+ aliases = _measurement_aliases(program, tables, env)
2497
+ for st in program.statements:
2498
+ if not (
2499
+ isinstance(st, S.Instruction)
2500
+ and st.keyword.upper() == "SDI12RECORDER"
2501
+ and len(st.args) > 6
2502
+ and isinstance(st.args[0], (S.Name, S.Index))
2503
+ ):
2504
+ continue
2505
+ dest, scope = st.args[0], tables(st)
2506
+ kind = scope.types.get(dest.name.upper())
2507
+ if kind is None and (alias := aliases(st, dest)):
2508
+ total, offset, _, size = alias
2509
+ if isinstance(dest, S.Index) and dest.args:
2510
+ k = _index_value(dest.args[0], env) if len(dest.args) == 1 else None
2511
+ if k is None or size is None or not 1 <= k <= size:
2512
+ continue
2513
+ offset += k - 1
2514
+ elif kind is None or kind.startswith("STRING"):
2515
+ continue
2516
+ else:
2517
+ shape = scope.dims.get(dest.name.upper()) or (1,)
2518
+ indexed = isinstance(dest, S.Index) and any(a is not None for a in dest.args)
2519
+ if indexed and len(dest.args) != len(shape) or (shape == (1,) and isinstance(dest, S.Index)):
2520
+ continue
2521
+ offset = _flat_offset(dest, shape, env)
2522
+ total = math.prod(shape)
2523
+ try:
2524
+ fill = consteval.value_of(st.args[6], env)
2525
+ except consteval.NotConstant:
2526
+ continue
2527
+ if (
2528
+ offset is None
2529
+ or offset >= total
2530
+ or isinstance(fill, (bool, str))
2531
+ or fill != int(fill)
2532
+ or not -(2**31) <= fill < 2**31
2533
+ ):
2534
+ continue
2535
+ room = total - offset
2536
+ if not (-1 <= fill <= room):
2537
+ diags.error(
2538
+ st.line, f"Illegal NAN fill locations, legal is -1 or 1..{room}(room in the destination array)", st.file
2539
+ )
2540
+
2541
+
2542
+ _SERIAL_TERMINALS = {
2543
+ "cr6": {9: 101, 10: 103, 11: 201, 12: 203, 13: 205, 14: 207, 15: 209, 16: 211},
2544
+ "cr1000x": {9: 101, 10: 103, 11: 105, 12: 107},
2545
+ }
2546
+ _IO_TERMINALS = {"cr6": {*range(101, 105), *range(201, 213)}, "cr1000x": set(range(101, 109))}
2547
+ _SDI12_TERMINALS = {"cr6": {101, 103, 201, 203, 205, 207, 209, 211}, "cr1000x": {101, 103, 105, 107}}
2548
+ _TERMINAL_ROLES = {
2549
+ "SDI12RECORDER": (1, "sdi12", "SDI12Recorder"), # (position, role, message label)
2550
+ "SDI12SENSORSETUP": (1, "sdi12", "SDI12SensorSetup"),
2551
+ "PORTSET": (0, "output", "PortSet"),
2552
+ "PULSECOUNT": (2, "pulse", "Pulsecount"),
2553
+ }
2554
+
2555
+
2556
+ def check_terminal_conflicts(program: S.Program, profile, diags: Diagnostics, env) -> None:
2557
+ """CR6/CR1000X: a terminal claimed in two roles is `<Instr>: channel <T> already in use or
2558
+ unavailable.` on the later claim (docs/checker.md#terminal-ownership-round-37). A constant
2559
+ `SerialOpen` claims both terminals of its pair for the whole program first; `SDI12Recorder`/
2560
+ `SDI12SensorSetup`, `PortSet`/`WriteIO` and `PulseCount` then claim in textual order, `WriteIO`
2561
+ one terminal per mask bit. Same-role reuse is fine here (`check_pulsecount_channels` owns
2562
+ `PulseCount`'s). Which earlier errors hide an SDI-12 conflict is decided by the caller. A recorder
2563
+ and a sensor on one terminal is `SDI Recorder and Sensor cannot use the same port.`"""
2564
+ serial = _SERIAL_TERMINALS.get(profile.name)
2565
+ if serial is None:
2566
+ return
2567
+ io, sdi12 = _IO_TERMINALS[profile.name], _SDI12_TERMINALS[profile.name]
2568
+
2569
+ def value(arg):
2570
+ try:
2571
+ v = consteval.value_of(arg, env)
2572
+ except consteval.NotConstant:
2573
+ return None
2574
+ return int(v) if consteval.is_finite_number(v) and not isinstance(v, bool) and v == int(v) else None
2575
+
2576
+ def name(t: int) -> str:
2577
+ return f"{'C' if t < 200 else 'U'}{t % 100}"
2578
+
2579
+ owner: dict[int, str] = {}
2580
+ sdi_kind: dict[int, str] = {}
2581
+ for st in program.statements: # (a `SerialOpen` statement parses as a `Call`)
2582
+ if (
2583
+ isinstance(st, S.Call)
2584
+ and st.name.upper() == "SERIALOPEN"
2585
+ and not st.explicit
2586
+ and st.args
2587
+ and (port := value(st.args[0])) is not None
2588
+ and (t := serial.get(port))
2589
+ ):
2590
+ owner[t] = owner[t + 1] = "serial"
2591
+ for st in program.statements:
2592
+ if not isinstance(st, S.Instruction):
2593
+ continue
2594
+ kw = st.keyword.upper()
2595
+ if kw == "WRITEIO" and st.args and (mask := value(st.args[0])) is not None:
2596
+ claims = [(101 + b, "output") for b in range(sum(t < 200 for t in io)) if mask >> b & 1]
2597
+ label = "WriteIO"
2598
+ elif kw in _TERMINAL_ROLES and len(st.args) > _TERMINAL_ROLES[kw][0]:
2599
+ pos, role, label = _TERMINAL_ROLES[kw]
2600
+ t = value(st.args[pos])
2601
+ if t not in (sdi12 if role == "sdi12" else io):
2602
+ continue
2603
+ claims = [(t, role)]
2604
+ else:
2605
+ continue
2606
+ for t, role in claims:
2607
+ if owner.setdefault(t, role) != role:
2608
+ diags.error(st.line, f"{label}: channel {name(t)} already in use or unavailable.", st.file)
2609
+ if role == "sdi12" and sdi_kind.setdefault(t, kw) != kw:
2610
+ diags.error(st.line, "SDI Recorder and Sensor cannot use the same port.", st.file)
2611
+
2612
+
2613
+ _SPI_PORTS = {"cr6": {101, 201, 205, 209}, "cr1000x": {101, 105}}
2614
+ _PORT_NAME = re.compile(r"([CU])(\d+)")
2615
+
2616
+
2617
+ def _port_number(arg: S.Expr | None) -> int | None:
2618
+ """`C<n>` -> 100+n, `U<n>` -> 200+n, an integer literal as is; `None` when not recognisable."""
2619
+ if isinstance(arg, S.Name):
2620
+ m = _PORT_NAME.fullmatch(arg.name.upper())
2621
+ return (100 if m.group(1) == "C" else 200) + int(m.group(2)) if m else None
2622
+ if isinstance(arg, S.Number) and float(arg.value).is_integer():
2623
+ return int(arg.value)
2624
+ return None
2625
+
2626
+
2627
+ def check_spi_open_order(program: S.Program, profile, diags: Diagnostics) -> None:
2628
+ """CR6/CR1000X: `SPIRead`/`SPIWrite` on a port no earlier (textual order, `Sub` bodies and
2629
+ branches included) `SPIOpen` opened -> `SPIOpen must precede SPIWrite.` (`...SPIRead.`);
2630
+ docs/instructions.md. Ports compare by terminal (`C1` = 101, `U1` = 201). An `SPIOpen` on a
2631
+ numeric port outside the valid set opens nothing but later reads/writes are still checked; one whose
2632
+ port is not a plain terminal/number (or, on CR6, a bare number below 100) makes the rest of the file
2633
+ unchecked."""
2634
+ valid = _SPI_PORTS.get(profile.name)
2635
+ if valid is None:
2636
+ return
2637
+ opened: set[int] = set()
2638
+ for stmt in program.statements:
2639
+ if not isinstance(stmt, S.Instruction):
2640
+ continue
2641
+ kw = stmt.keyword.upper()
2642
+ if kw == "SPIOPEN":
2643
+ port = _port_number(stmt.args[0]) if stmt.args else None
2644
+ if port is None or (port < 100 and profile.name == "cr6"):
2645
+ return # CR6 reports `Invalid port N` for a non-terminal number instead
2646
+ if port in valid:
2647
+ opened.add(port)
2648
+ elif kw in ("SPIREAD", "SPIWRITE") and len(stmt.args) == 3:
2649
+ port = _port_number(stmt.args[0])
2650
+ if port in valid and port not in opened:
2651
+ name = "SPIRead" if kw == "SPIREAD" else "SPIWrite"
2652
+ diags.error(stmt.line, f"SPIOpen must precede {name}.", stmt.file)
2653
+
2654
+
2655
+ _DNP_BOOL = frozenset({"BOOLEAN"})
2656
+ _DNP_LONG = frozenset({"LONG"})
2657
+ _DNP_ANALOG = frozenset({"LONG", "FLOAT"})
2658
+ # `DNPVariable` object group -> (static group it needs assigned first, is an event group, source
2659
+ # types it accepts, name of that type in the message, highest variation). Oracle, all four loggers,
2660
+ # 2026-10-08 / 2026-10-12 (docs/instructions.md "DNPVariable groups"); any other group is
2661
+ # `Illegal DNP3 group N.`
2662
+ _DNP_GROUPS = {
2663
+ 1: (None, False, _DNP_BOOL, "Boolean", 2),
2664
+ 2: (1, True, _DNP_BOOL, "Boolean", 3),
2665
+ 10: (None, False, _DNP_BOOL, "Boolean", 2),
2666
+ 12: (10, False, _DNP_BOOL, "Boolean", 1),
2667
+ 20: (None, False, _DNP_LONG, "Long", 8),
2668
+ 21: (20, False, _DNP_LONG, "Long", 12),
2669
+ 22: (20, True, _DNP_LONG, "Long", 8),
2670
+ 23: (21, True, _DNP_LONG, "Long", 8),
2671
+ 30: (None, False, _DNP_ANALOG, "Long", 5),
2672
+ 31: (30, False, _DNP_ANALOG, "Long", 7),
2673
+ 32: (30, True, _DNP_ANALOG, "Long", 7),
2674
+ 33: (31, True, _DNP_ANALOG, "Long", 7),
2675
+ 40: (None, False, _DNP_ANALOG, "Long", 3),
2676
+ 41: (40, False, _DNP_ANALOG, "Long", 3),
2677
+ 110: (None, False, frozenset({"STRING"}), "String", None),
2678
+ }
2679
+ _DNP_KNOWN_TYPES = frozenset({"BOOLEAN", "LONG", "FLOAT", "STRING"})
2680
+
2681
+
2682
+ def _dnp_long(arg: S.Expr | None, conv, wrap16: bool = False) -> int | None:
2683
+ """A constant `DNPVariable` parameter as the compiler converts it (`longconv`; `True` is -1);
2684
+ group and variation then keep their low 16 bits as a signed value (`65537` is 1, `65536` is
2685
+ 0; oracle 2026-10-12)."""
2686
+ env, profile, names, literals = conv
2687
+ if env is None or arg is None:
2688
+ return None
2689
+ try:
2690
+ value = consteval.value_of(arg, env)
2691
+ except consteval.NotConstant:
2692
+ value = None
2693
+ if isinstance(value, bool):
2694
+ number = -1 if value else 0
2695
+ else:
2696
+ number = longconv.long_value(arg, env, profile, names, literals)
2697
+ if number is None:
2698
+ return None
2699
+ return ((number + 32768) & 0xFFFF) - 32768 if wrap16 else number
2700
+
2701
+
2702
+ def _dnp_source(stmt: S.Instruction, conv, dims: dict) -> tuple[list[str], int | None]:
2703
+ """The messages of the `Source` subscripts and how many elements it leaves for `Swath` (None
2704
+ where not probed). Oracle, all four loggers, 2026-10-12: only a literal, a negated literal or
2705
+ a `Const` name is a usable subscript (`B(M)`, `B(1+1)`, `B("x")`, `B(F(1))` ->
2706
+ `Cannot use a variable index reference.`); a truncated constant outside `1..size` adds
2707
+ `Variable B out of bounds.` before it; a negative last subscript counts by magnitude
2708
+ (`B(-2)` of `B(5)` leaves 4) but a negative earlier one is a variable reference. The
2709
+ elements left are counted row-major from the subscripts (`D(2,1)` of `D(3,2)` leaves 4)."""
2710
+ src = stmt.args[0]
2711
+ if not isinstance(src, (S.Name, S.Index)):
2712
+ return [], None
2713
+ shape = dims.get(src.name.upper())
2714
+ if shape is None:
2715
+ return [], None
2716
+ total = 1
2717
+ for n in shape:
2718
+ total *= n
2719
+ if isinstance(src, S.Name):
2720
+ return [], (1 if shape == (1,) else None)
2721
+ if not src.args:
2722
+ return [], total
2723
+ if len(src.args) > len(shape):
2724
+ return [], None
2725
+ values: list[int] = []
2726
+ for a in src.args:
2727
+ number = a.operand if isinstance(a, S.Unary) and a.op in ("-", "+") else a
2728
+ simple = isinstance(number, S.Number) or (isinstance(number, S.Name) and number.name.upper() in conv[0].consts)
2729
+ value = None
2730
+ if isinstance(number, S.Name) and number.name.upper() in ("TRUE", "FALSE"):
2731
+ return [], None # (`B(TRUE)` is out of bounds plus the variable-reference line; not modelled)
2732
+ if simple:
2733
+ try:
2734
+ value = consteval.value_of(a, conv[0])
2735
+ except consteval.NotConstant:
2736
+ pass
2737
+ if not isinstance(value, (int, float)) or isinstance(value, bool):
2738
+ return ["Cannot use a variable index reference."], None
2739
+ values.append(int(value))
2740
+ last = len(values) - 1
2741
+ if any(v < 0 for v in values[:last]):
2742
+ return ["Cannot use a variable index reference."], None
2743
+ if values[last] < 0:
2744
+ values[last] = -values[last]
2745
+ if any(not 1 <= v <= shape[i] for i, v in enumerate(values)):
2746
+ return [f"Variable {src.name} out of bounds.", "Cannot use a variable index reference."], None
2747
+ if len(values) != len(shape):
2748
+ return [], None
2749
+ offset = 0
2750
+ for v, n in zip(values, shape):
2751
+ offset = offset * n + (v - 1)
2752
+ return [], total - offset
2753
+
2754
+
2755
+ def _dnp_statement(stmt: S.Instruction, conv, dims, kind: str | None) -> tuple:
2756
+ """`(group, before, after, stops_early, fails_late)`: the group, the message that ends the
2757
+ statement before the `Must assign` rule (swath, variation <= 0, illegal group, in that order),
2758
+ the messages after it (type, variation range, class, event count) and whether a class on a
2759
+ static group makes the statement fail after them. A failing statement is not assigned and not
2760
+ counted as defined (oracle, all four loggers, 2026-10-12)."""
2761
+ group = _dnp_long(stmt.args[2], conv, wrap16=True) if len(stmt.args) >= 3 else None
2762
+ if group is None:
2763
+ return None, [], [], False, False
2764
+ full = len(stmt.args) == 8
2765
+ source_messages, left = _dnp_source(stmt, conv, dims) if conv[0] is not None else ([], None)
2766
+ if source_messages:
2767
+ return group, source_messages, [], True, False
2768
+ before: list[str] = []
2769
+ after: list[str] = []
2770
+ swath = _dnp_long(stmt.args[1], conv) if full else None
2771
+ # (a String source is only measured for group 110: `S,5,1,...` gets just the type message)
2772
+ limit = left if full and (kind != "STRING" or group == 110) else None
2773
+ if swath is not None and limit is not None and not 1 <= swath <= limit:
2774
+ return group, [f"Swath parameter illegal, must be 1..{limit}"], [], True, False
2775
+ variation = _dnp_long(stmt.args[3], conv, wrap16=True) if full else None
2776
+ if variation is not None and variation < 1:
2777
+ return group, ["Variation must be greater than 0."], [], True, False
2778
+ spec = _DNP_GROUPS.get(group)
2779
+ if spec is None:
2780
+ return group, [f"Illegal DNP3 group {group}."], [], True, False
2781
+ _base, is_event, allowed, type_name, top = spec
2782
+ if kind in _DNP_KNOWN_TYPES and kind not in allowed:
2783
+ after.append(f"For DNP3 group {group} variable must be of type {type_name}.")
2784
+ if variation is not None and top is not None and variation > top:
2785
+ after.append(f"Illegal Variation {variation}, max for group {group} is {top}.")
2786
+ if not full:
2787
+ return group, before, after, False, False
2788
+ klass = _dnp_long(stmt.args[4], conv)
2789
+ if klass is not None:
2790
+ if 1 <= klass <= 3 and not is_event:
2791
+ after.append("Static Data Class: must be 0")
2792
+ return group, before, after, False, True
2793
+ if not 0 <= klass <= 3:
2794
+ after.append("Illegal Class: must be 0..3")
2795
+ events = _dnp_long(stmt.args[7], conv)
2796
+ if events is not None:
2797
+ if is_event and events == 0:
2798
+ after.append("Event size must be non-zero to hold events")
2799
+ elif not is_event and events != 0:
2800
+ after.append(f"{events} Events allocated for a non-Event object")
2801
+ return group, before, after, False, False
2802
+
2803
+
2804
+ def check_dnp_update(program: S.Program, profile, diags: Diagnostics, env=None) -> None:
2805
+ """`DNPUpdate` with no `DNPVariable` textually before it -> `DNP3Statics and/or DNP3Events are
2806
+ not defined` (no period; docs/instructions.md). A `DNPVariable` of any kind counts, so a
2807
+ malformed one under-reports -- except a group whose static group is not assigned yet
2808
+ (`Must assign group 30 before using group 32.`), after which every `DNPUpdate` gets the line
2809
+ too unless the static group is assigned later in the program, and a statement that fails hard
2810
+ (bad swath, variation 0, illegal group, class on a static group) or uses group 110, which do
2811
+ not count. Also the `DNPVariable` argument rules (docs/instructions.md "DNPVariable groups")."""
2812
+ defined = False
2813
+ failed_bases: list[int] = [] # static groups an event group was used before
2814
+ assigned: set[int] = set()
2815
+ types = symbols.declared_variable_types(program)
2816
+ tables = symbols.scope_tables(program, env) if env is not None else None
2817
+ conv = (env, profile, longconv.float_names(program.statements), longconv.literal_names(program.statements))
2818
+ info: dict[int, tuple] = {}
2819
+ for st in program.statements:
2820
+ if isinstance(st, S.Instruction) and st.keyword.upper() == "DNPVARIABLE":
2821
+ src = st.args[0] if st.args else None
2822
+ kind = types.get(src.name.upper()) if isinstance(src, (S.Name, S.Index)) else None
2823
+ dims = tables(st).dims if tables is not None else {}
2824
+ info[id(st)] = _dnp_statement(st, conv, dims, kind)
2825
+ every_group = {i[0] for i in info.values() if not (i[3] or i[4]) and i[0] != 110}
2826
+ for stmt in program.statements:
2827
+ if isinstance(stmt, S.Instruction):
2828
+ kw = stmt.keyword.upper()
2829
+ if kw == "DNPVARIABLE":
2830
+ group, before, after, stops, fails = info[id(stmt)]
2831
+ if group is None:
2832
+ defined = True
2833
+ continue
2834
+ for text in before:
2835
+ diags.error(stmt.line, text, stmt.file)
2836
+ if stops:
2837
+ continue
2838
+ base = _DNP_GROUPS[group][0]
2839
+ if base is not None and base not in assigned:
2840
+ diags.error(stmt.line, f"Must assign group {base} before using group {group}.", stmt.file)
2841
+ failed_bases.append(base)
2842
+ defined = True
2843
+ continue
2844
+ for text in after:
2845
+ diags.error(stmt.line, text, stmt.file)
2846
+ if fails:
2847
+ continue
2848
+ assigned.add(group)
2849
+ if group != 110:
2850
+ defined = True
2851
+ elif kw == "DNPUPDATE" and (not defined or any(b not in every_group for b in failed_bases)):
2852
+ diags.error(stmt.line, "DNP3Statics and/or DNP3Events are not defined", stmt.file)
2853
+
2854
+
2855
+ def check_portbridge_array(program: S.Program, profile, diags: Diagnostics, env) -> None:
2856
+ """`PortBridge`'s second argument must be a variable array whose first two dimensions are
2857
+ (2,3) and whose type is Long, else `Bridge parameter must be an Array(2,3) of type long.`;
2858
+ docs/instructions.md. A literal or a plainly declared variable of another shape/type is flagged;
2859
+ anything not resolvable (undeclared, `B()`, `B(1,1)`, `Const`, `Alias`) is left alone."""
2860
+ types = symbols.declared_variable_types(program)
2861
+ tables = symbols.scope_tables(program, env)
2862
+ for stmt in program.statements:
2863
+ if not isinstance(stmt, S.Instruction) or stmt.keyword.upper() != "PORTBRIDGE" or len(stmt.args) != 2:
2864
+ continue
2865
+ dims = tables(stmt).dims
2866
+ arg = stmt.args[1]
2867
+ if isinstance(arg, S.Number):
2868
+ bad = True
2869
+ elif isinstance(arg, S.Name) and arg.name.upper() in types and arg.name.upper() in dims:
2870
+ shape = dims[arg.name.upper()]
2871
+ bad = types[arg.name.upper()] != "LONG" or len(shape) < 2 or shape[:2] != (2, 3)
2872
+ else:
2873
+ continue
2874
+ if bad:
2875
+ diags.error(stmt.line, "Bridge parameter must be an Array(2,3) of type long.", stmt.file)
2876
+
2877
+
2878
+ # CDM instructions whose first two arguments are `CDMType, CPIAddress` and that were each observed to
2879
+ # register their address (oracle, CR6; docs/instructions.md "CDM address and module type").
2880
+ _CDM_ADDRESS_INSTRUCTIONS = frozenset(
2881
+ {
2882
+ "CDM_BATTERY",
2883
+ "CDM_VOLTSE",
2884
+ "CDM_VOLTDIFF",
2885
+ "CDM_TCDIFF",
2886
+ "CDM_TCSE",
2887
+ "CDM_PANELTEMP",
2888
+ "CDM_BRHALF",
2889
+ "CDM_EXCITEV",
2890
+ "CDM_EXCITEI",
2891
+ "CDM_SW12",
2892
+ "CDM_SW5",
2893
+ "CDM_SWPOWER",
2894
+ "CDM_DELAY",
2895
+ "CDM_PULSEPORT",
2896
+ "CDM_MUXSELECT",
2897
+ "CDM_THERM107",
2898
+ "CDM_RESISTANCE",
2899
+ "CDM_ACPOWER",
2900
+ "CDM_CURRENTDIFF",
2901
+ "CDM_BRFULL6W",
2902
+ "CDM_BRHALF3W",
2903
+ "CDM_TCCOMP",
2904
+ "CDM_PERIODAVG",
2905
+ }
2906
+ )
2907
+ # `CDMType` values that name a module (the other values 0..65536 are the unnamed `NULL` module;
2908
+ # oracle sweep 0..299 on CR6 and CR1000X, spot values above).
2909
+ _CDM_NAMED_TYPES = frozenset(range(100, 106)) | {200, 201, 202} | frozenset(range(220, 232)) | {260, 261}
2910
+ _CDM_ANALOG = "Analog measurement"
2911
+ # Text before ` instruction not supported by module NULL.` for a NULL-module type (one or two lines).
2912
+ _CDM_NULL_MODULE = {
2913
+ "CDM_VOLTSE": [_CDM_ANALOG],
2914
+ "CDM_VOLTDIFF": [_CDM_ANALOG],
2915
+ "CDM_TCSE": [_CDM_ANALOG],
2916
+ "CDM_PANELTEMP": [_CDM_ANALOG],
2917
+ "CDM_BRFULL6W": [_CDM_ANALOG],
2918
+ "CDM_BRHALF3W": [_CDM_ANALOG],
2919
+ "CDM_THERM107": [_CDM_ANALOG],
2920
+ "CDM_RESISTANCE": [_CDM_ANALOG],
2921
+ "CDM_BATTERY": ["CDM_Battery"],
2922
+ "CDM_DELAY": ["CDM_Delay"],
2923
+ "CDM_PULSEPORT": ["CDM_PulsePort"],
2924
+ "CDM_TCCOMP": ["CDM_TCComp"],
2925
+ "CDM_ACPOWER": ["CDM_ACPower"],
2926
+ "CDM_PERIODAVG": ["CDM_PeriodAvg"],
2927
+ "CDM_EXCITEV": ["CDM_Excite"],
2928
+ "CDM_EXCITEI": ["CDM_Excite"],
2929
+ "CDM_MUXSELECT": ["CDM_MuxSel"],
2930
+ "CDM_SW12": ["SW"],
2931
+ "CDM_SW5": ["SW"],
2932
+ "CDM_SWPOWER": ["CDM_SWPower", "SW"],
2933
+ "CDM_CURRENTDIFF": ["CDM_CurrentDiff", _CDM_ANALOG],
2934
+ }
2935
+
2936
+
2937
+ # Names the compiler prints for the named `CDMType` values (oracle, CR6, 2026-10-12, `CDM_SW12(<id>,...)`).
2938
+ _CDM_MODULE_NAMES = {
2939
+ 100: "EC100",
2940
+ 101: "CSAT3B",
2941
+ 102: "TGA300",
2942
+ 103: "IRGASON2",
2943
+ 104: "EC155UNH",
2944
+ 105: "IRGASONHP",
2945
+ 200: "CDM-VW300",
2946
+ 201: "CDM-VW305",
2947
+ 202: "VWIRE305",
2948
+ 224: "TEMP120",
2949
+ 225: "VOLT408V1",
2950
+ 226: "VOLT408V10",
2951
+ 227: "VOLT408V60",
2952
+ 228: "TEMP408J",
2953
+ 229: "TEMP408K",
2954
+ 230: "TEMP408T",
2955
+ 231: "CURRENT408",
2956
+ 260: "SPECTRUM103",
2957
+ 261: "SPECTRUM109",
2958
+ }
2959
+ # Instructions only the CDM-A108/A116/VOLT108/VOLT116 (220..223) carry; any other named module gives
2960
+ # `<SW|CDM_PulsePort> instruction not supported by module <name>.` and nothing else on the line.
2961
+ _CDM_PORT_INSTRUCTIONS = frozenset({"CDM_SW12", "CDM_SW5", "CDM_PULSEPORT"})
2962
+ _CDM_PORT_MODULES = frozenset({220, 221, 222, 223})
2963
+
2964
+
2965
+ def cdm_unsupported_module(stmt: S.Instruction, env) -> str | None:
2966
+ """The name of a constant named `CDMType` that does not carry this port instruction."""
2967
+ kw = stmt.keyword.upper()
2968
+ if kw not in _CDM_PORT_INSTRUCTIONS or not stmt.args:
2969
+ return None
2970
+ try:
2971
+ kind = _cdm_type_value(consteval.value_of(stmt.args[0], env))
2972
+ except consteval.NotConstant:
2973
+ return None
2974
+ return None if kind is None or kind in _CDM_PORT_MODULES else _CDM_MODULE_NAMES.get(kind)
2975
+
2976
+
2977
+ def _delay_option(stmt: S.Instruction, env):
2978
+ """`CDM_Delay`'s constant `Option` (only option 0, the measurement delay, needs the module)."""
2979
+ try:
2980
+ return consteval.value_of(stmt.args[2], env) if len(stmt.args) > 2 else None
2981
+ except consteval.NotConstant:
2982
+ return None
2983
+
2984
+
2985
+ def check_fieldcal_variable_args(program: S.Program, profile, diags: Diagnostics, env=None) -> None:
2986
+ """`FieldCal` argument checks (oracle, all four loggers, 2026-10-11 and 2026-10-12;
2987
+ docs/instructions.md "FieldCal Mult and Offset arguments", "FieldCal argument rules").
2988
+
2989
+ `Mult` (argument 4) and `Offset` (argument 5) must name a variable the compiler can
2990
+ resolve: `FieldCal Mult must be a declared variable that can be resolved during compilation.` for
2991
+ a string, a non-negative number (`1`, `1.5`, `0`) or an operator expression (`CMult(1)+1`), on
2992
+ all four loggers, but only for some `Option` values: `Mult` for 2 and 3, `Offset` for 0, 1, 2 and
2993
+ 4. A declared variable, an array element (`CMult(1)`) and a negative literal (`CMult(-1)`)
2994
+ compile. `Option` outside 0..4 is `Invalid function option.` alone; `Reps` (truncated) outside
2995
+ 1..2 is `Illegal Reps: must be 1 or 2.`; a `MeasVar` or `Mode` element with a string subscript
2996
+ gives `FieldCal MeasVar|Mode must be a declared public variable that can be resolved during
2997
+ compilation.`, and `KnownVar` (options 1, 2 and 3 only) gives the same text for a string, a number, an
2998
+ expression, a String variable or an element with a string subscript. The real compiler also
2999
+ prints `Table <name> declared with no output fields.` at the `EndTable` of a table whose only
3000
+ field is `SampleFieldCal` when a `FieldCal` argument is bad; not modelled here."""
3001
+ types = symbols.declared_variable_types(program)
3002
+ dims = symbols.declared_all_dims(program, env) if env is not None else None
3003
+ conv = None
3004
+ if env is not None:
3005
+ conv = (env, profile, longconv.float_names(program.statements), longconv.literal_names(program.statements))
3006
+
3007
+ def number(arg):
3008
+ return _dnp_long(arg, conv) if conv is not None else None
3009
+
3010
+ size_reported: set[str] = set()
3011
+ for stmt in program.statements:
3012
+ if not isinstance(stmt, S.Instruction) or stmt.keyword.upper() != "FIELDCAL" or not stmt.args:
3013
+ continue
3014
+ args = stmt.args
3015
+ before = len(diags.items)
3016
+ option = number(args[0])
3017
+ if option is None:
3018
+ option = args[0].value if isinstance(args[0], S.Number) and not isinstance(args[0].value, float) else None
3019
+ if option is None:
3020
+ continue
3021
+ if not 0 <= option <= 4:
3022
+ diags.error(stmt.line, "Invalid function option.", stmt.file)
3023
+ continue
3024
+ earlier = False # a MeasVar or Reps error (CR300/CR350 then print no Mult/Offset line)
3025
+ meas = len(args) > 1 and _fieldcal_string_subscript(args[1])
3026
+ if meas:
3027
+ earlier = True
3028
+ diags.error(
3029
+ stmt.line,
3030
+ "FieldCal MeasVar must be a declared public variable that can be resolved during compilation.",
3031
+ stmt.file,
3032
+ )
3033
+ reps = number(args[2]) if len(args) > 2 else None
3034
+ if reps is not None and reps not in (1, 2) and not meas:
3035
+ earlier = True
3036
+ diags.error(stmt.line, "Illegal Reps: must be 1 or 2.", stmt.file)
3037
+ for index, label, options in ((3, "Mult", (2, 3)), (4, "Offset", (0, 1, 2, 4))):
3038
+ # which `Option` values check which argument (oracle, all four loggers, 2026-10-11)
3039
+ if (
3040
+ option in options
3041
+ and index < len(args)
3042
+ and _fieldcal_bad_variable(args[index])
3043
+ and not (earlier and profile.single_pass)
3044
+ ):
3045
+ diags.error(
3046
+ stmt.line,
3047
+ f"FieldCal {label} must be a declared variable that can be resolved during compilation.",
3048
+ stmt.file,
3049
+ )
3050
+ earlier = True
3051
+ if len(args) > 5 and _fieldcal_string_subscript(args[5]):
3052
+ diags.error(
3053
+ stmt.line,
3054
+ "FieldCal Mode must be a declared public variable that can be resolved during compilation.",
3055
+ stmt.file,
3056
+ )
3057
+ if option in (1, 2, 3) and len(args) > 6:
3058
+ known = args[6]
3059
+ shape = dims.get(known.name.upper()) if dims is not None and isinstance(known, S.Index) else None
3060
+ outside = (
3061
+ bool(shape)
3062
+ and isinstance(known, S.Index)
3063
+ and len(shape) == 1
3064
+ and len(known.args) == 1
3065
+ and isinstance(known.args[0], S.Number)
3066
+ and isinstance(known.args[0].value, int)
3067
+ and not 1 <= known.args[0].value <= shape[0]
3068
+ ) # (printed after `Variable K out of bounds.`)
3069
+ bad = (
3070
+ outside
3071
+ or _fieldcal_bad_variable(known)
3072
+ or isinstance(known, S.Unary)
3073
+ or (isinstance(known, (S.Name, S.Index)) and types.get(known.name.upper()) == "STRING")
3074
+ )
3075
+ if bad:
3076
+ diags.error(
3077
+ stmt.line,
3078
+ "FieldCal KnownVar must be a declared public variable that can be resolved during compilation.",
3079
+ stmt.file,
3080
+ )
3081
+ if option in (1, 2, 3) and len(args) > 6 and dims is not None and len(diags.items) == before:
3082
+ known, measure = args[6], args[1]
3083
+ sizes = []
3084
+ for var in (known, measure):
3085
+ shape = dims.get(var.name.upper()) if isinstance(var, (S.Name, S.Index)) else None
3086
+ sizes.append(
3087
+ None
3088
+ if shape is None
3089
+ or len(shape) > 1
3090
+ or not isinstance(var, (S.Name, S.Index))
3091
+ or types.get(var.name.upper()) == "STRING"
3092
+ else shape[0]
3093
+ )
3094
+ if None in sizes or sizes[0] == sizes[1]:
3095
+ continue
3096
+ assert isinstance(known, (S.Name, S.Index)) and isinstance(measure, (S.Name, S.Index))
3097
+ text = f"{known.name} and {measure.name} must be declared the same size."
3098
+ if text not in size_reported:
3099
+ size_reported.add(text) # (printed once per program, oracle 2026-10-12)
3100
+ diags.error(stmt.line, text, stmt.file)
3101
+
3102
+
3103
+ def _fieldcal_string_subscript(arg) -> bool:
3104
+ return isinstance(arg, S.Index) and any(isinstance(a, S.String) for a in arg.args)
3105
+
3106
+
3107
+ def check_matrix_destination(program: S.Program, profile, diags: Diagnostics) -> None:
3108
+ """CR300/CR350: `Matrix`'s fourth argument (the destination) must be an array or variable; a
3109
+ constant (`Matrix(1,X,X,3)`, `Matrix(1,X,X,"fuzz")`, `Matrix(1,X,X,-1)`, `Matrix(1,X,X,2+3)`)
3110
+ gives `Illegal destination variable type, expecting Float.` (oracle, CR300 and CR350, 2026-10-11).
3111
+ A subscripted element (`FA(1,1)`) and a variable (`Mv`, which gives `Illegal Matrix dimensioned`
3112
+ instead, not modelled) are not this rule. The real compiler then also prints `Too many
3113
+ parameters:` on the following `Matrix` lines (the argument after the second); not modelled."""
3114
+ if not profile.single_pass:
3115
+ return
3116
+ for stmt in program.statements:
3117
+ if not isinstance(stmt, S.Instruction) or stmt.keyword.upper() != "MATRIX" or len(stmt.args) < 4:
3118
+ continue
3119
+ dest = stmt.args[3]
3120
+ constant = isinstance(dest, (S.Number, S.String, S.Binary)) or (
3121
+ isinstance(dest, S.Unary) and isinstance(dest.operand, S.Number)
3122
+ )
3123
+ if constant:
3124
+ diags.error(stmt.line, "Illegal destination variable type, expecting Float.", stmt.file)
3125
+
3126
+
3127
+ def _fieldcal_bad_variable(arg) -> bool:
3128
+ if isinstance(arg, (S.String, S.Binary)):
3129
+ return True
3130
+ if isinstance(arg, S.Index): # `CMult("fuzz")`: a string subscript (oracle, 2026-10-11)
3131
+ return any(isinstance(a, S.String) for a in arg.args)
3132
+ return isinstance(arg, S.Number) and arg.value >= 0
3133
+
3134
+
3135
+ def check_samplefieldcal(program: S.Program, profile, diags: Diagnostics) -> None:
3136
+ """`SampleFieldCal with no FieldCal in program.` on the first `SampleFieldCal` when the program
3137
+ holds no `FieldCal` or `FieldCalStrain` call (docs/instructions.md "SampleFieldCal"); only the
3138
+ first is reported."""
3139
+ stmts = [st for st in program.statements if isinstance(st, S.Instruction)]
3140
+ if any(st.keyword.upper() in ("FIELDCAL", "FIELDCALSTRAIN") for st in stmts):
3141
+ return
3142
+ for st in stmts:
3143
+ if st.keyword.upper() == "SAMPLEFIELDCAL" and "SAMPLEFIELDCAL" in profile.instructions:
3144
+ diags.error(st.line, "SampleFieldCal with no FieldCal in program.", st.file)
3145
+ return
3146
+
3147
+
3148
+ def _cdm_type_value(kind) -> int | None:
3149
+ """A `CDMType` constant as the integer the compiler reads: a number truncates, a string is 0
3150
+ (oracle: `"fuzz"` and `1.5` are the `NULL` module); `TRUE` and other values are not modelled."""
3151
+ if isinstance(kind, bool):
3152
+ return None
3153
+ if isinstance(kind, str):
3154
+ return 0
3155
+ return int(kind) if isinstance(kind, (int, float)) and kind == kind and abs(kind) < 2**31 else None
3156
+
3157
+
3158
+ def cdm_null_module(stmt: S.Instruction, env) -> bool:
3159
+ """A constant `CDMType` that names no module on a CDM instruction that reports the `NULL`
3160
+ module; such a statement gets no `const_ranges` diagnostic (oracle, `CDM_TCComp` and
3161
+ `CDM_PeriodAvg`: only the module line)."""
3162
+ kw = stmt.keyword.upper()
3163
+ if kw not in _CDM_NULL_MODULE or not stmt.args:
3164
+ return False
3165
+ try:
3166
+ kind = _cdm_type_value(consteval.value_of(stmt.args[0], env))
3167
+ except consteval.NotConstant:
3168
+ return False
3169
+ return (
3170
+ kind is not None
3171
+ and 0 <= kind <= 65536
3172
+ and kind not in _CDM_NAMED_TYPES
3173
+ and (kw != "CDM_DELAY" or _delay_option(stmt, env) == 0)
3174
+ )
3175
+
3176
+
3177
+ def check_cdm_address_types(program: S.Program, profile, diags: Diagnostics, env) -> None:
3178
+ """CR6/CR1000X CDM instructions with a constant `CDMType` and a `CPIAddress` (1..120);
3179
+ docs/instructions.md "CDM address and module type".
3180
+
3181
+ - A type different from the first one used for that address -> `Address <n> already used with
3182
+ a different type CDM.` (every such statement; types compare by value, `VOLT116` is 223).
3183
+ - Otherwise a type that names no module (the `NULL` module) -> `<name> instruction not supported
3184
+ by module NULL.` (`SWPower` and `CurrentDiff` add a second line; `CDM_Delay` only for Option 0).
3185
+ The clash replaces it.
3186
+ A constant `CPIAddress` outside 1..120 -> `CPI Address must be 1..120.`
3187
+ first; the statement then counts as address 1 (docs/instructions.md "CPI address range").
3188
+ Non-constant arguments are skipped."""
3189
+ if profile.name not in ("cr6", "cr1000x"):
3190
+ return
3191
+ first: dict[int, object] = {}
3192
+ for stmt in program.statements:
3193
+ kw = stmt.keyword.upper() if isinstance(stmt, S.Instruction) else ""
3194
+ if not isinstance(stmt, S.Instruction) or kw not in _CDM_ADDRESS_INSTRUCTIONS or len(stmt.args) < 2:
3195
+ continue
3196
+ try:
3197
+ address = consteval.value_of(stmt.args[1], env)
3198
+ except consteval.NotConstant:
3199
+ continue
3200
+ # The address converts as C's `(long)`; outside 1..120 (a string, `TRUE`, overflow) is
3201
+ # `CPI Address must be 1..120.` and the statement then registers as address 1 (oracle).
3202
+ address = consteval.to_long(address)
3203
+ if not 1 <= address <= 120:
3204
+ if kw != "CDM_PERIODAVG": # its message is a `const_ranges` rule (printed last)
3205
+ diags.error(stmt.line, "CPI Address must be 1..120.", stmt.file)
3206
+ address = 1
3207
+ try:
3208
+ kind = consteval.value_of(stmt.args[0], env)
3209
+ except consteval.NotConstant:
3210
+ continue
3211
+ if isinstance(kind, (bool, str)) or not isinstance(kind, (int, float)) or kind != int(kind):
3212
+ # a string or fraction only gets the module line (its clash behaviour is not probed)
3213
+ if cdm_null_module(stmt, env):
3214
+ for name in _CDM_NULL_MODULE.get(kw, ()):
3215
+ diags.error(stmt.line, f"{name} instruction not supported by module NULL.", stmt.file)
3216
+ continue
3217
+ if first.setdefault(address, kind) != kind:
3218
+ diags.error(stmt.line, f"Address {address} already used with a different type CDM.", stmt.file)
3219
+ elif cdm_null_module(stmt, env):
3220
+ for name in _CDM_NULL_MODULE.get(kw, ()):
3221
+ diags.error(stmt.line, f"{name} instruction not supported by module NULL.", stmt.file)
3222
+ elif (module := cdm_unsupported_module(stmt, env)) is not None:
3223
+ for name in _CDM_NULL_MODULE.get(kw, ()):
3224
+ diags.error(stmt.line, f"{name} instruction not supported by module {module}.", stmt.file)
3225
+
3226
+
3227
+ def check_epi_scan_buffers(program: S.Program, profile, diags: Diagnostics, env) -> None:
3228
+ """Pipeline mode, CR6/CR1000X: `CDM_BridgeFilt`/`CDM_FFTFilt` in a main `Scan` with a constant
3229
+ interval of 1 mSec or more, per call (also inside conditionals): `Insufficient Scan buffers to
3230
+ read EPI data, minimum is N.` when the constant buffer count is below `min(100, 2 s / interval)`
3231
+ (integer division; intervals of 1 mSec..500 mSec only), and for `CDM_BridgeFilt` only (`CDM_FFTFilt` accepts 2 Sec) `Invalid Output Rate.` when the interval
3232
+ is not one of 1/2/5/10/20/50/100/200/500/1000 mSec, and `Maximum Scan interval for <name> is 1
3233
+ Sec.` past 1 Sec; docs/instructions.md. Not modelled inside `SubScan` (only the buffer message
3234
+ is), nor below 1 mSec."""
3235
+ from .. import mode
3236
+
3237
+ if profile.name not in ("cr6", "cr1000x"):
3238
+ return
3239
+ stmts = [st for st in program.statements if isinstance(st, S.Instruction)]
3240
+ if not any(st.keyword.upper() in _EPI_FILTERS for st in stmts):
3241
+ return
3242
+ try:
3243
+ if (
3244
+ mode.decide(
3245
+ program.statements,
3246
+ profile,
3247
+ lambda e: consteval.is_constant(e, env),
3248
+ lambda e: consteval.value_of(e, env),
3249
+ )
3250
+ != "Pipeline"
3251
+ ):
3252
+ return
3253
+ except Exception:
3254
+ return
3255
+ slow = False
3256
+ us = minimum = None
3257
+ subscan = 0
3258
+ for st in stmts:
3259
+ kw = st.keyword.upper()
3260
+ if kw == "SLOWSEQUENCE":
3261
+ slow = True
3262
+ elif kw == "SCAN":
3263
+ us = minimum = None
3264
+ subscan = 0
3265
+ raw = None if slow else _scan_raw(st, env)
3266
+ if raw is not None and raw[0] * _US_PER_UNIT[raw[1]] >= 1000:
3267
+ us = raw[0] * _US_PER_UNIT[raw[1]]
3268
+ buffers = _const_int(st.args[2], env) if len(st.args) > 2 else None
3269
+ if buffers is not None and us <= 500_000 and buffers < min(100, 2_000_000 // us):
3270
+ minimum = min(100, 2_000_000 // us)
3271
+ elif kw == "SUBSCAN":
3272
+ subscan += 1
3273
+ elif kw == "NEXT" and re.match(r"(?i)\s*Next\s+SubScan\b", st.text or "") or kw == "NEXTSUBSCAN":
3274
+ subscan = max(0, subscan - 1)
3275
+ elif kw == "NEXT" and re.match(r"(?i)\s*Next\s+Scan\b", st.text or "") or kw == "NEXTSCAN":
3276
+ us = minimum = None
3277
+ elif kw in _EPI_FILTERS and us is not None:
3278
+ if minimum is not None:
3279
+ diags.error(st.line, f"Insufficient Scan buffers to read EPI data, minimum is {minimum}.", st.file)
3280
+ if not subscan and kw == "CDM_BRIDGEFILT":
3281
+ if us not in _EPI_RATES_US:
3282
+ diags.error(st.line, "Invalid Output Rate.", st.file)
3283
+ if us > 1_000_000:
3284
+ diags.error(st.line, "Maximum Scan interval for CDM_BridgeFilt is 1 Sec.", st.file)
3285
+
3286
+
3287
+ _EPI_RATES_US = frozenset(ms * 1000 for ms in (1, 2, 5, 10, 20, 50, 100, 200, 500, 1000))
3288
+ _EPI_FILTERS = frozenset({"CDM_BRIDGEFILT", "CDM_FFTFILT"})
3289
+
3290
+
3291
+ def _const_int(arg, env) -> int | None:
3292
+ if arg is None or not consteval.is_constant(arg, env):
3293
+ return None
3294
+ try:
3295
+ value = consteval.value_of(arg, env)
3296
+ except consteval.NotConstant:
3297
+ return None
3298
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
3299
+ return None
3300
+ return int(value)
3301
+
3302
+
3303
+ def check_avw200_scan_time(program: S.Program, profile, diags: Diagnostics, env) -> None:
3304
+ """Pipeline mode, CR6/CR1000X: in the one main `Scan` that holds every `AVW200`, each `AVW200`
3305
+ after which twice the running sum of `Reps` (2 s per sensor) exceeds the scan interval gets
3306
+ `Scan interval <I> secs is too small; AVW200 measurement time is <T> secs (2 secs per sensor).`;
3307
+ docs/instructions.md. Only whole-second intervals, constant `Reps` 1..16 and channels 1..32, and no `SubScan` or
3308
+ conditional block in the scan."""
3309
+ from .. import mode
3310
+
3311
+ if profile.name not in ("cr6", "cr1000x") or "AVW200" not in profile.instructions:
3312
+ return
3313
+ stmts = [st for st in program.statements if isinstance(st, (S.Instruction, S.If, S.For, S.DoLoop, S.Case))]
3314
+ if not any(isinstance(st, S.Instruction) and st.keyword.upper() == "AVW200" for st in stmts):
3315
+ return
3316
+ try:
3317
+ decided = mode.decide(
3318
+ program.statements, profile, lambda e: consteval.is_constant(e, env), lambda e: consteval.value_of(e, env)
3319
+ )
3320
+ except Exception:
3321
+ return
3322
+ if decided != "Pipeline":
3323
+ return
3324
+ scan = None # the open main-sequence Scan statement
3325
+ slow = False
3326
+ sums: list[tuple[S.Instruction, int | None, list]] = [] # (scan, interval seconds, [(stmt, reps)])
3327
+ for st in stmts:
3328
+ if not isinstance(st, S.Instruction):
3329
+ if scan is not None:
3330
+ return # AVW200 in a conditional/loop has its own, unmodelled message
3331
+ continue
3332
+ kw = st.keyword.upper()
3333
+ if kw in ("SELECT", "WHILE") and scan is not None:
3334
+ return
3335
+ if kw == "SLOWSEQUENCE":
3336
+ slow = True
3337
+ elif kw == "SCAN":
3338
+ scan = None if slow else st
3339
+ if scan is not None:
3340
+ sums.append((st, None, []))
3341
+ elif kw == "NEXTSCAN" or (kw == "NEXT" and re.match(r"(?i)\s*Next\s+Scan\b", st.text or "")):
3342
+ scan = None
3343
+ elif kw == "SUBSCAN" and scan is not None:
3344
+ return
3345
+ elif kw == "AVW200":
3346
+ if scan is None:
3347
+ return
3348
+ sums[-1][2].append(st)
3349
+ holders = [entry for entry in sums if entry[2]]
3350
+ if len(holders) != 1 or sum(len(e[2]) for e in sums) != sum(
3351
+ 1 for st in stmts if isinstance(st, S.Instruction) and st.keyword.upper() == "AVW200"
3352
+ ):
3353
+ return
3354
+ scan_stmt, _, calls = holders[0]
3355
+ interval_us = _interval_us(
3356
+ scan_stmt.args[0] if scan_stmt.args else None, scan_stmt.args[1] if len(scan_stmt.args) > 1 else None, env
3357
+ )
3358
+ if interval_us is None or interval_us % 1_000_000 or interval_us <= 0:
3359
+ return
3360
+ interval = int(interval_us // 1_000_000)
3361
+ total = 0
3362
+ for st in calls:
3363
+ try:
3364
+ reps = consteval.value_of(st.args[7], env) if len(st.args) > 7 else None
3365
+ except consteval.NotConstant:
3366
+ return
3367
+ if isinstance(reps, bool) or not isinstance(reps, (int, float)) or reps != int(reps) or not 1 <= reps <= 16:
3368
+ return
3369
+ for position in (5, 6): # AVWChan / MuxChannel: outside 1..32 the compiler adds `Bad parameter(s)`
3370
+ try:
3371
+ channel = consteval.value_of(st.args[position], env)
3372
+ except consteval.NotConstant, IndexError:
3373
+ return
3374
+ if isinstance(channel, bool) or not isinstance(channel, (int, float)) or not 1 <= channel <= 32:
3375
+ return
3376
+ total += int(reps)
3377
+ if 2 * total > interval:
3378
+ diags.error(
3379
+ st.line,
3380
+ f"Scan interval {interval} secs is too small; AVW200 measurement "
3381
+ f"time is {2 * total} secs (2 secs per sensor).",
3382
+ st.file,
3383
+ )
3384
+
3385
+
3386
+ def check_mqtt_publish_payload(program: S.Program, profile, diags: Diagnostics) -> None:
3387
+ """CR300/CR350 require MQTTPublish's second argument to be string-typed.
3388
+
3389
+ CR6/CR1000X accept the same numeric literals and variables. This only covers a direct
3390
+ assignment from the function, the observed form; composite expressions are left unchecked.
3391
+ """
3392
+ if profile.name not in {"cr300", "cr350"}:
3393
+ return
3394
+ types = _serialin_types(program, profile)
3395
+ string_consts = {
3396
+ stmt.name.upper()
3397
+ for stmt in program.statements
3398
+ if isinstance(stmt, S.ConstDecl) and isinstance(stmt.value, S.String)
3399
+ }
3400
+ for stmt in program.statements:
3401
+ if not isinstance(stmt, S.Assignment) or not isinstance(stmt.value, S.Index):
3402
+ continue
3403
+ call = stmt.value
3404
+ if call.name.upper() != "MQTTPUBLISH" or len(call.args) < 2:
3405
+ continue
3406
+ payload = call.args[1]
3407
+ if isinstance(payload, S.String):
3408
+ continue
3409
+ if isinstance(payload, S.Name) and (
3410
+ payload.name.upper() in string_consts or types.get(payload.name.upper()) == "STRING"
3411
+ ):
3412
+ continue
3413
+ if isinstance(payload, (S.Number, S.Name)) or _numeric_expression(payload, types):
3414
+ diags.error(stmt.line, "A string or string variable is required", stmt.file)
3415
+
3416
+
3417
+ def _numeric_expression(expr: S.Expr | None, types: dict) -> bool:
3418
+ """A negated number or an arithmetic `+ - * / ^` of numbers and declared non-String variables
3419
+ (oracle 2026-10-08, CR300/CR350: `1+2`, `F+1` and `-1` are `A string or string variable is
3420
+ required`, `"a"+"b"` and `Payload+"x"` compile). Anything else, a function call included, is
3421
+ left unchecked."""
3422
+ if isinstance(expr, S.Number):
3423
+ return True
3424
+ if isinstance(expr, S.Name):
3425
+ return types.get(expr.name.upper()) not in (None, "STRING")
3426
+ if isinstance(expr, S.Unary):
3427
+ return expr.op == "-" and _numeric_expression(expr.operand, types)
3428
+ if isinstance(expr, S.Binary):
3429
+ return (
3430
+ expr.op in ("+", "-", "*", "/", "^")
3431
+ and _numeric_expression(expr.left, types)
3432
+ and _numeric_expression(expr.right, types)
3433
+ )
3434
+ return False
3435
+
3436
+
3437
+ _SETTINGS_FIELD_KEYWORDS = {"SETSETTING": "Settings", "SETSTATUS": "Status or Settings"}
3438
+
3439
+
3440
+ def _constant_name_text(arg: S.Expr | None, env: consteval.Env, depth: int = 0) -> str | None:
3441
+ """The text of a `SetSetting`/`SetStatus` name argument the compiler treats as constant: a
3442
+ string literal, a String `Const`, or `+` of those. A call such as `CHR(9)` is not constant
3443
+ there (oracle), so `consteval`'s own folding is not used. `None` for anything else."""
3444
+ if arg is None or depth > 16:
3445
+ return None
3446
+ if isinstance(arg, S.String):
3447
+ return arg.value
3448
+ if isinstance(arg, S.Name):
3449
+ value = env.consts.get(arg.name.upper())
3450
+ return value if isinstance(value, str) else None
3451
+ if isinstance(arg, S.Binary) and arg.op == "+":
3452
+ left = _constant_name_text(arg.left, env, depth + 1)
3453
+ right = _constant_name_text(arg.right, env, depth + 1)
3454
+ return None if left is None or right is None else left + right
3455
+ return None
3456
+
3457
+
3458
+ def _check_port_index(
3459
+ stmt: S.Instruction, key: str, index: str | None, cfg: dict | None, env: consteval.Env, profile, found: Diagnostics
3460
+ ) -> None:
3461
+ """`Illegal dimension N` for `Baudrate(index)`/`Security(index)`; docs/instructions.md#field-index-dimension."""
3462
+ if not cfg or index is None or key not in cfg or not index.endswith(")"):
3463
+ return
3464
+ # only the first word counts: `COMC1 x` is COMC1, `C OMC1` and `COMC1+1` are unknown, so 0
3465
+ # (oracle, round 55)
3466
+ index = (index[:-1].split() or [""])[0]
3467
+ if re.fullmatch(r"0|-?[1-9]\d*", index):
3468
+ value = int(index)
3469
+ elif re.match(r"[A-Za-z]", index) and index.upper() not in env.consts:
3470
+ symbol = profile.symbols.get(index.upper()) if re.fullmatch(r"[A-Za-z]\w*", index) else None
3471
+ value = symbol["value"] if symbol else 0
3472
+ else:
3473
+ return
3474
+ if not 1 <= value <= cfg[key]:
3475
+ found.error(stmt.line, f"Illegal dimension {value}", stmt.file)
3476
+
3477
+
3478
+ def check_settings_fields(
3479
+ program: S.Program, profile, diags: Diagnostics, found: Diagnostics, env: consteval.Env
3480
+ ) -> None:
3481
+ """`%s is not a Settings Field Name.` / `%s is not a Status or Settings Field Name.` /
3482
+ `Cannot find %s` for a constant first argument of `SetSetting`/`SetStatus`;
3483
+ docs/instructions.md#setsetting-and-setstatus-field-names. Findings go to `found` (the
3484
+ caller's aside list); `diags` is only read, for the `CallTable` stop line."""
3485
+ fields = profile.settings_fields
3486
+ trim = fields["trim_trailing_blanks"]
3487
+ settings = {n.upper() for n in fields["settings"]}
3488
+ both = settings | {n.upper() for n in fields["status"]}
3489
+ stop: tuple[str, int] | None = None
3490
+ for d in diags.items:
3491
+ if (
3492
+ d.severity == "error"
3493
+ and d.message.startswith("Could not find DataTable ")
3494
+ and d.line
3495
+ and (stop is None or d.line < stop[1])
3496
+ ):
3497
+ stop = (d.file, d.line)
3498
+ const_tables = symbols.const_table_fields(program)
3499
+ for stmt in program.statements:
3500
+ if isinstance(stmt, S.Instruction) and stmt.keyword.upper() == "ENDPROG":
3501
+ break
3502
+ if not isinstance(stmt, S.Instruction) or not stmt.args:
3503
+ continue
3504
+ kw = stmt.keyword.upper()
3505
+ label = _SETTINGS_FIELD_KEYWORDS.get(kw)
3506
+ if label is None or kw not in profile.instructions:
3507
+ continue
3508
+ if stop is not None and stmt.file == stop[0] and stmt.line > stop[1]:
3509
+ continue
3510
+ name = _constant_name_text(stmt.args[0], env)
3511
+ if name is None:
3512
+ # a number, variable or other non-string-constant name
3513
+ # (docs/checker.md#settings-name-must-be-constant-round-36)
3514
+ if stmt.args[0] is not None:
3515
+ found.error(stmt.line, "Status Field name must be constant.", stmt.file)
3516
+ continue
3517
+ if "." in name:
3518
+ table, field = name.split(".", 1)
3519
+ fields_for_table = const_tables.get(table.strip().upper())
3520
+ if fields_for_table is None:
3521
+ found.error(stmt.line, f"Cannot find {table}", stmt.file)
3522
+ elif field.split("(", 1)[0].strip().upper() not in fields_for_table and not (
3523
+ field.split("(", 1)[0].strip().upper() == "APPLYANDRESTART" and fields_for_table
3524
+ ):
3525
+ found.error(stmt.line, f"Cannot find {field.split('(', 1)[0].strip()}", stmt.file)
3526
+ continue
3527
+ if "(" in name:
3528
+ # `Name(index)`: only the field name before the first `(` is looked up here; the
3529
+ # index is validated separately (`Illegal dimension N`, not implemented).
3530
+ name, _, index = name.partition("(")
3531
+ if not name:
3532
+ continue
3533
+ else:
3534
+ index = None
3535
+ key = (name.rstrip(" \t") if trim else name).upper()
3536
+ if key in (both if kw == "SETSTATUS" else settings):
3537
+ _check_port_index(stmt, key, index, fields.get("index_size"), env, profile, found)
3538
+ continue
3539
+ shown = name if name else fields["empty_name"]
3540
+ found.error(stmt.line, f"{shown} is not a {label} Field Name.", stmt.file)