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,1802 @@
1
+ """Data-table structure and output-processing placement checks (`PLAN.md`'s "data tables and
2
+ output processing" workstream). Two pass-2 rules (gated the same way as `tables_hook.attach`/
3
+ `warnings.check`, i.e. only when pass 1 found no error anywhere in the file -- oracle-confirmed for
4
+ both below, the same "two-pass compilation" architecture `docs/checker.md` documents for
5
+ `instructions.py`'s channel check):
6
+
7
+ - `check_table_structure` -- `%s must be used inside a table declaration.` for every output-
8
+ processing instruction found (via the decompiled call sites of its shared validator function,
9
+ `thunk_FUN_004ea5d0` on CR6 / `thunk_FUN_004ea5f0` on CR1000X) to require `DataTable...EndTable`
10
+ placement, plus `EndTable without DataTable.` for an orphan/extra `EndTable`.
11
+ - `check_duplicate_fields` -- `Duplicate field name %s in DataTable %s.`, reusing the already-
12
+ computed field names in `result.tables` (`tables.py`, a different workstream's module) rather
13
+ than re-deriving each instruction's own output-field-naming rules (`_Avg`/`_Min`/... suffixes) a
14
+ second time.
15
+
16
+ Full rules and oracle evidence: `docs/instructions.md` (new "Data tables and output processing"
17
+ section).
18
+ """
19
+
20
+ import dataclasses
21
+ import math
22
+ import re
23
+
24
+ from .. import consteval
25
+ from .. import syntax as S
26
+ from ..diagnostics import Diagnostic, Diagnostics
27
+ from ..profiles import Profile
28
+ from . import symbols
29
+
30
+ # Instructions the real compiler's shared "must be inside a DataTable" validator checks --
31
+ # `%s must be used inside a table declaration.` -- keyed by instruction-table keyword (upper case),
32
+ # valued by the *displayed* name in that message. Derived from the decompiled call sites of the
33
+ # shared validator function on both CR6 (`thunk_FUN_004ea5d0`) and CR1000X (`thunk_FUN_004ea5f0`,
34
+ # same call-site set, `/opt/re/out/decomp.c` and `decomp_cr1000x.c`, `docs/instructions.md`): every
35
+ # one of these instructions passes its own name (read directly out of the compiled string table at
36
+ # its call site, `pefile.get_string_at_rva`) as the validator's argument, except `Maximum`/
37
+ # `Minimum`, whose call sites pass the unlabelled string constants `"Max"`/`"Min"` instead (byte-
38
+ # confirmed the same way) -- both independently oracle-confirmed (`docs/instructions.md`; the `Min`
39
+ # spelling is also what a saved fuzz reproducer, `tests/corpus/fuzz/cr6/false_accept/
40
+ # 5b5ae86fd087.json`, shows for a bare `Minimum(...)`). Every other entry below with a **checked with
41
+ # the oracle** note was independently confirmed with a from-scratch scratch probe against
42
+ # `CR6Comp.exe` (and, for one representative case, all three other loggers, which gave byte-
43
+ # identical text); the rest are the decompiled call sites alone (an "official repository reference",
44
+ # AGENTS.md's evidence tier 3) and are *not* independently oracle-confirmed this round -- see
45
+ # `docs/instructions.md`'s "not independently oracle-confirmed" list for exactly which those are.
46
+ # One more call site exists (the unlabelled string `"Et"`, near the `SampleFieldCal` call site)
47
+ # that could not be attributed to a specific instruction with confidence and is deliberately left
48
+ # out -- AGENTS.md: never guess into a false reject. The other originally-unlabelled string this
49
+ # round found nearby, `"FFT"`, *was* independently confirmed (a fresh scratch probe, `tfft.cr6`,
50
+ # and two CHM corpus files whose `DataTable` bodies contain only `FFT(...)` calls, `fft_1.CR6`/
51
+ # `covspa_1.CR6` -- both were a **false-reject regression** this round's own full difftest re-run
52
+ # caught, from `check_table_structure`'s sibling `Table %s declared with no output fields.` rule
53
+ # below not yet recognising `FFT` as a field producer, before `FFT` was added here too) and is
54
+ # included as `FFT`.
55
+ TABLE_ONLY_INSTRUCTIONS: dict[str, str] = {
56
+ "CARDOUT": "CardOut", # checked with the oracle
57
+ "MQTTPUBLISHTABLE": "MQTTPublishTable",
58
+ "GOESTABLE": "GoesTable",
59
+ "GOESFIELD": "GoesTable", # oracle, all four loggers, 2026-10-12 (r16_goes_field)
60
+ "TABLEFILE": "TableFile", # checked with the oracle
61
+ "TOTALIZE": "Totalize", # checked with the oracle
62
+ "AVERAGE": "Average", # checked with the oracle (also a saved fuzz reproducer)
63
+ "MAXIMUM": "Max", # checked with the oracle
64
+ "MINIMUM": "Min", # a saved fuzz reproducer (5b5ae86fd087.json); not
65
+ # independently re-confirmed with a fresh scratch probe this round
66
+ "SAMPLEMAXMIN": "SampleMaxMin", # checked with the oracle
67
+ "STDDEV": "StdDev", # checked with the oracle
68
+ "MOMENT": "Moment",
69
+ "SAMPLE": "Sample", # checked with the oracle (also a saved fuzz reproducer, on
70
+ # all four loggers)
71
+ "HISTOGRAM": "Histogram", # checked with the oracle
72
+ "HISTOGRAM4D": "Histogram4D",
73
+ "COVARIANCE": "Covariance",
74
+ "RAINFLOW": "RainFlow", # checked with the oracle
75
+ "LEVELCROSSING": "LevelCrossing", # checked with the oracle
76
+ "WINDVECTOR": "WindVector", # checked with the oracle
77
+ "MEDIAN": "Median", # checked with the oracle
78
+ "SAMPLEFIELDCAL": "SampleFieldCal",
79
+ "FILLSTOP": "FillStop", # checked with the oracle
80
+ "DATATIME": "DataTime", # checked with the oracle
81
+ "OPENINTERVAL": "OpenInterval", # checked with the oracle
82
+ "DATAINTERVAL": "DataInterval", # a saved fuzz reproducer (1ded68266aec.json/5b5ae86fd087.json)
83
+ "FFT": "FFT", # checked with the oracle (also two saved CHM corpus files,
84
+ # `fft_1.CR6`/`covspa_1.CR6`, whose `DataTable` bodies contain only `FFT(...)` calls)
85
+ "DATAEVENT": "DataEvent", # decompiled call site only -- a from-scratch probe placing
86
+ # a bare `DataEvent(...)` outside any table crashes the real compiler under Wine rather than
87
+ # printing this text (a real-compiler-side crash, the same class `docs/checker.md`'s
88
+ # `pointervariable_2.CR6` entry already documents as unmatchable; not chased further, since our
89
+ # own clean rejection here is strictly better behaviour than reproducing a crash), so this entry
90
+ # is *not* oracle-confirmed by its own message text, only by the decompiled call site plus the
91
+ # fact that every corpus/fuzz use of `DataEvent` this project has seen is always correctly
92
+ # placed inside a table.
93
+ "TABLEHIDE": "TableHide", # checked with the oracle
94
+ "ETSZ": "Et", # checked with the oracle, all four loggers (2026-10-12):
95
+ "ETCLEARSKY": "Et", # the unlabelled `"Et"` call site; docs/instructions.md
96
+ }
97
+
98
+ # `FieldNames` was checked directly (a bare `FieldNames("X")` with no enclosing `DataTable` at all)
99
+ # and found to compile clean -- the real compiler does *not* route it through this same validator,
100
+ # unlike every instruction above -- so it is deliberately left out despite appearing in the task's
101
+ # own illustrative instruction list; see `docs/instructions.md`.
102
+
103
+ # The subset of `TABLE_ONLY_INSTRUCTIONS` that actually *produces* an output field, for
104
+ # `Table %s declared with no output fields.` (oracle-confirmed at the `EndTable` line, `docs/
105
+ # instructions.md`: a `DataTable` body containing only a configuration instruction -- `DataInterval`
106
+ # alone, or `CardOut` alone, both from-scratch probes this round -- or nothing at all still gets
107
+ # this error). Deliberately excludes every purely-configuration instruction in
108
+ # `TABLE_ONLY_INSTRUCTIONS` (`DataInterval`, `CardOut`, `TableFile`, `DataEvent`, `FillStop`,
109
+ # `DataTime`, `OpenInterval`, `TableHide`) rather than reusing `result.tables`' already-computed
110
+ # field *count* the way `check_duplicate_fields` does: `tables.py`'s own `_emit_output_fields` does
111
+ # not yet derive fields for several instructions this project already knows are real field
112
+ # producers (`RainFlow`/`LevelCrossing`/`Histogram4D`/`SampleFieldCal`, `docs/tdf-rules.md`'s open
113
+ # gaps) -- reusing its field count here would misreport a table containing *only* one of those as
114
+ # "no output fields" too, a false reject this purely syntactic, tables.py-independent set avoids by
115
+ # construction. `GoesTable`/`MQTTPublishTable` are included leniently, not from oracle confirmation
116
+ # that either alone (with no `Sample`/`GOESField` alongside it, an unseen real-world shape) counts as
117
+ # a field producer by itself: if that lenient guess is ever wrong, the failure mode is under-
118
+ # reporting (a table this rule should flag but doesn't), never a false reject.
119
+ _FIELD_PRODUCING_INSTRUCTIONS = frozenset(
120
+ {
121
+ "SAMPLE",
122
+ "AVERAGE",
123
+ "TOTALIZE",
124
+ "STDDEV",
125
+ "MAXIMUM",
126
+ "MINIMUM",
127
+ "MEDIAN",
128
+ "MOMENT",
129
+ "SAMPLEMAXMIN",
130
+ "COVARIANCE",
131
+ "WINDVECTOR",
132
+ "HISTOGRAM",
133
+ "HISTOGRAM4D",
134
+ "RAINFLOW",
135
+ "LEVELCROSSING",
136
+ "SAMPLEFIELDCAL",
137
+ "MQTTPUBLISHTABLE",
138
+ "FFT",
139
+ "ETSZ",
140
+ "ETCLEARSKY",
141
+ }
142
+ )
143
+
144
+
145
+ def _table_name(stmt: S.Instruction) -> str | None:
146
+ if stmt.args and isinstance(stmt.args[0], S.Name):
147
+ return stmt.args[0].name
148
+ return None
149
+
150
+
151
+ def calltable_targets(program: S.Program) -> set[str]:
152
+ """Upper-case names every `CallTable <name>` statement in `program` refers to. Used by
153
+ `structure.py`'s `check_pass1` to decide whether an unclosed `DataTable` at end-of-file gets
154
+ the bare `DataTable ... EndTable not matched.` (nothing references it) or is left for
155
+ `check_unclosed_calltable` below to report as `Could not find DataTable %s.` instead (it *is*
156
+ referenced) -- see that function's own docstring for the oracle evidence distinguishing the
157
+ two (round 6, `docs/checker.md`).
158
+ """
159
+ names: set[str] = set()
160
+ for stmt in program.statements:
161
+ if isinstance(stmt, S.Instruction) and stmt.keyword.upper() == "CALLTABLE":
162
+ name = _table_name(stmt)
163
+ if name:
164
+ names.add(name.upper())
165
+ # a `ResetTable`/`GetRecord`/`FileMark` naming it does the same (oracle, CR6/CR1000X, round 58)
166
+ names.update(name.upper() for _stmt, name, spec in _unknown_table_references(program) if spec[2])
167
+ return names
168
+
169
+
170
+ def unknown_calltables(program: S.Program):
171
+ """`(CallTable statement, name)` pairs naming no closed table, for `check_unclosed_calltable`.
172
+
173
+ `Could not find DataTable %s.` (round 6, `docs/checker.md`) at a `CallTable <name>` site
174
+ whose own `DataTable(name,...)` was declared somewhere in the program but never closed with a
175
+ matching `EndTable` -- an unclosed table never gets "finalised" into the real compiler's table
176
+ registry, so a later reference to it fails exactly like a genuine typo would, oracle-confirmed
177
+ (`docs/checker.md`): `DataTable (Test,True,-1)` with one real field (`Sample`), no `EndTable`
178
+ anywhere in the file, `CallTable Test` later -> `Could not find DataTable Test.` at the
179
+ `CallTable`'s own line (not the `DataTable`'s).
180
+
181
+ **Tier B, not pass 1** (oracle-confirmed, a fresh sweep this round): unlike the sibling
182
+ `DataTable ... EndTable not matched.` bare message (`structure.py`'s `check_pass1`, pass 1,
183
+ suppressed by an earlier pass-1 error) this one accumulates freely with an unrelated
184
+ `expressions.check`/`structure.check_pass2` finding elsewhere in the file (`Randomize
185
+ (FooBar)` on an earlier line still gets its own `Invalid symbol in expression` *and* this
186
+ message on the `CallTable` line, in that order) -- called from `checker/__init__.py`'s own
187
+ tier-B block alongside those two, not from this module's existing (tier-C-gated)
188
+ `check_table_structure`.
189
+
190
+ Deliberately simple, matching `check_table_structure`'s own stated assumption that `DataTable`
191
+ blocks never nest: a table name counts as "closed" once *any* `EndTable` follows *any* of its
192
+ own `DataTable` declarations anywhere in the program (no attempt to pair a specific `EndTable`
193
+ with a specific same-named `DataTable`, since two same-named `DataTable`s in one program is not
194
+ a shape this project has evidence for either way).
195
+ """
196
+ closed: set[str] = set()
197
+ pending: str | None = None
198
+ unknown = []
199
+ names = {id(stmt): name for stmt, name in _calltable_names(program)}
200
+ for stmt in _with_inline(program.statements):
201
+ if not isinstance(stmt, S.Instruction):
202
+ continue
203
+ kw = stmt.keyword.upper()
204
+ if kw == "DATATABLE":
205
+ name = _table_name(stmt)
206
+ if name:
207
+ name = _const_table_name(name, program)
208
+ if not name and stmt.args and isinstance(stmt.args[0], S.String):
209
+ name = stmt.args[0].value # `DataTable ("HIST4D",1,100)`, histogram4d_1.CR6
210
+ if name:
211
+ pending = name.upper()
212
+ elif kw == "ENDTABLE":
213
+ if pending is not None:
214
+ closed.add(pending)
215
+ pending = None
216
+ elif id(stmt) in names and names[id(stmt)].upper() not in closed:
217
+ # Any target that is not a table closed *before this statement* fails the same way
218
+ # (oracle, all four loggers, 2026-09-27 and round 9): `CallTable Nope`, a variable
219
+ # `CallTable X`, `CallTable Status`, `CallTable ("fuzz")` -> `fuzz`, `CallTable((1+2))`
220
+ # -> `1+2`; also the table's own still-open body, a table declared later, and a `Sub`
221
+ # above the table (docs/checker.md "CallTable before its table is closed").
222
+ unknown.append((stmt, names[id(stmt)]))
223
+ return unknown
224
+
225
+
226
+ def _with_inline(stmts):
227
+ for stmt in stmts:
228
+ yield stmt
229
+ if isinstance(stmt, S.If):
230
+ yield from _with_inline(stmt.inline or [])
231
+ yield from _with_inline(stmt.inline_else or [])
232
+
233
+
234
+ def _const_table_name(name: str, program: S.Program) -> str:
235
+ """A table name that is a String `Const` names the table its value spells, both in `DataTable`
236
+ and in `CallTable` (`Const TN = "T1"`: `CallTable TN` calls `T1`, and an unknown one is `Could
237
+ not find DataTable T1.`; oracle, all four loggers, round 67)."""
238
+ value = program.consts.get(name.upper(), program.consts.get(name)) if program.consts else None
239
+ return value if isinstance(value, str) and value else name
240
+
241
+
242
+ def _calltable_names(program: S.Program):
243
+ for stmt in _with_inline(program.statements):
244
+ if not (isinstance(stmt, S.Instruction) and stmt.keyword.upper() == "CALLTABLE"):
245
+ continue
246
+ target = stmt.args[0] if stmt.args else None
247
+ if isinstance(target, S.Name):
248
+ name = _const_table_name(target.name, program)
249
+ elif isinstance(target, S.String):
250
+ name = target.value
251
+ elif isinstance(target, S.Binary) and stmt.arg_spans and stmt.arg_spans[0]:
252
+ start, end = stmt.arg_spans[0]
253
+ name = stmt.text[start:end].strip()
254
+ while name.startswith("(") and name.endswith(")"):
255
+ name = name[1:-1].strip()
256
+ else:
257
+ continue
258
+ yield stmt, name
259
+
260
+
261
+ # Round 14 (oracle, all four loggers, 2026-10-12, `docs/checker.md` "Round 14 table references"):
262
+ # instructions naming a table that does not exist. `(0-based argument, message, ends pass 2)`.
263
+ # The ending ones behave like `CallTable` (only the earliest of them is printed, later statements
264
+ # are not compiled); `SendData`/`SendTableDef` do not end pass 2 and every one is printed.
265
+ TABLE_REFERENCES = {
266
+ "GETRECORD": (1, "Could not find DataTable {}.", True),
267
+ "RESETTABLE": (0, "Could not find DataTable {}.", True),
268
+ "FILEMARK": (0, "Could not find FileMark table {}.", True),
269
+ "SENDDATA": (3, "Cannot find DataTable {}.", False),
270
+ "SENDTABLEDEF": (3, "Cannot find DataTable {}.", False),
271
+ }
272
+ _SPECIAL_TABLES = frozenset({"STATUS", "PUBLIC", "SETTINGS", "DATATABLEINFO", "ENDTABLE"})
273
+
274
+
275
+ def unknown_table_references(program: S.Program, profile: Profile | None = None):
276
+ """`(statement, message, ends pass 2)` for each `TABLE_REFERENCES` statement whose table is not
277
+ a `DataTable` closed before it (declared later, unclosed, or the table's own body, like
278
+ `CallTable`; oracle, CR6/CR1000X, round 58) nor a `ConstTable`."""
279
+ return [(stmt, spec[1].format(name), spec[2]) for stmt, name, spec in _unknown_table_references(program, profile)]
280
+
281
+
282
+ def _unknown_table_references(program: S.Program, profile: Profile | None = None):
283
+ const_tables = {
284
+ st.args[0].name.upper()
285
+ for st in _with_inline(program.statements)
286
+ if isinstance(st, S.Instruction)
287
+ and st.keyword.upper() == "CONSTTABLE"
288
+ and st.args
289
+ and isinstance(st.args[0], S.Name)
290
+ }
291
+ closed: set[str] = set()
292
+ pending: str | None = None
293
+ out = []
294
+ for stmt in _with_inline(program.statements):
295
+ if not isinstance(stmt, S.Instruction):
296
+ continue
297
+ kw = stmt.keyword.upper()
298
+ if kw == "DATATABLE":
299
+ pending = (_table_name(stmt) or "").upper() or None
300
+ continue
301
+ if kw == "ENDTABLE":
302
+ if pending is not None:
303
+ closed.add(pending)
304
+ pending = None
305
+ continue
306
+ spec = TABLE_REFERENCES.get(kw)
307
+ if spec is None or len(stmt.args) <= spec[0] or stmt.args[spec[0]] is None:
308
+ continue
309
+ arg = stmt.args[spec[0]]
310
+ if stmt.keyword.upper() in ("SENDDATA", "SENDTABLEDEF"):
311
+ from . import expressions
312
+
313
+ name = expressions._arg_text(stmt, spec[0]).strip()
314
+ elif isinstance(arg, S.Name):
315
+ name = arg.name
316
+ elif isinstance(arg, S.String):
317
+ name = arg.value
318
+ else:
319
+ continue
320
+ if (
321
+ kw == "GETRECORD"
322
+ and profile is not None
323
+ and name.upper() in closed
324
+ and (fields := _getrecord_short(stmt, name, program, profile)) is not None
325
+ ):
326
+ out.append(
327
+ (
328
+ stmt,
329
+ name,
330
+ (
331
+ spec[0],
332
+ f"Destination array not large enough for the {fields} fields in a record\n" + spec[1],
333
+ spec[2],
334
+ ),
335
+ )
336
+ )
337
+ continue
338
+ if not name or name.upper() in closed or name.upper() in const_tables or name.upper() in _SPECIAL_TABLES:
339
+ continue
340
+ out.append((stmt, name, spec))
341
+ return out
342
+
343
+
344
+ def _getrecord_short(stmt: S.Instruction, table: str, program: S.Program, profile: Profile) -> int | None:
345
+ """The record's value count when `GetRecord`'s numeric destination variable holds fewer values
346
+ (its whole declared size, whatever the subscript; each String element counts once), else None
347
+ (oracle, all four loggers, round 65, docs/instructions.md#getrecord-destination-size)."""
348
+ from .. import consteval, tables
349
+ from . import var_types
350
+
351
+ dest = stmt.args[0] if stmt.args else None
352
+ if not isinstance(dest, (S.Name, S.Index)):
353
+ return None
354
+ typer = var_types._Typer(program)
355
+ var = typer.variable(dest)
356
+ if var is None or typer.types[var] not in ("FLOAT", "LONG", "BOOLEAN", "DOUBLE"):
357
+ return None
358
+ try:
359
+ built, _, _ = tables.build_tables(program, profile, dict(program.consts))
360
+ except Exception:
361
+ return None # the tables cannot be built: nothing is checked (like `_data_table_fields`)
362
+ record = next((t for t in built if t.name.upper() == table.upper()), None)
363
+ if record is None:
364
+ return None
365
+ count = sum((math.prod(f.subdims[:-1]) if f.subdims else 1) if f.type == 11 else f.dimension for f in record.fields)
366
+ dims = symbols.declared_all_dims(program, consteval.Env(profile, dict(program.consts))).get(var)
367
+ if dims is None:
368
+ return None
369
+ return count if math.prod(dims) < count else None
370
+
371
+
372
+ def check_unclosed_calltable(program: S.Program, diags: Diagnostics, profile: Profile | None = None) -> None:
373
+ refs = unknown_table_references(program, profile)
374
+ first = next(iter(unknown_calltables(program)), None)
375
+ ending = [(stmt, message) for stmt, message, ends in refs if ends]
376
+ stopper = None # the earliest statement that ends the walk and prints its message
377
+ if ending and (first is None or (ending[0][0].file, ending[0][0].line) <= (first[0].file, first[0].line)):
378
+ stopper = ending[0]
379
+ elif first is not None:
380
+ stopper = (first[0], f"Could not find DataTable {first[1]}.")
381
+ for stmt, message, ends in refs:
382
+ if ends or (stopper is not None and stmt.line > stopper[0].line):
383
+ continue
384
+ at = next(
385
+ (
386
+ i
387
+ for i, d in enumerate(diags.items)
388
+ if d.line == stmt.line
389
+ and d.file == stmt.file
390
+ and d.message.endswith("illegally occurs before BeginProg.")
391
+ ),
392
+ len(diags.items),
393
+ )
394
+ diags.items.insert(at, Diagnostic("error", stmt.line, message, stmt.file))
395
+ if stopper is not None:
396
+ # only the first one is printed (oracle, all four loggers, 2026-10-03); a short `GetRecord`
397
+ # destination adds its own line first (round 65)
398
+ for message in stopper[1].split("\n"):
399
+ diags.error(stopper[0].line, message, stopper[0].file)
400
+
401
+
402
+ # `FieldNames`, `FieldClassify` and `FieldOrigin` records directly after a field-producing
403
+ # instruction are read by that instruction's builder (docs/checker.md "FieldNames owner line");
404
+ # `SampleFieldCal` was probed not to read them, the other three exclusions are unprobed.
405
+ _FIELD_METADATA = frozenset({"FIELDNAMES", "FIELDCLASSIFY", "FIELDORIGIN"})
406
+ _FIELDNAMES_OWNERS = _FIELD_PRODUCING_INSTRUCTIONS - {"SAMPLEFIELDCAL", "ETSZ", "ETCLEARSKY", "MQTTPUBLISHTABLE"}
407
+ _FIRST_WORD = re.compile(r"\s*([A-Za-z_]\w*)")
408
+
409
+
410
+ def fieldnames_owners(program: S.Program) -> dict[int, S.Stmt | None]:
411
+ """`id(statement)` of every `FieldNames` (parsed or not) -> the output instruction whose
412
+ builder reads it: the last owner before it with only `FieldNames`/`FieldClassify`/`FieldOrigin`
413
+ in between, inside a `DataTable`, else None (a `FieldNames` anywhere else is never resolved;
414
+ a `Sample` outside a table owns nothing, replay `921b96a020e3`)."""
415
+ owners: dict[int, S.Stmt | None] = {}
416
+ owner = None
417
+ in_table = False
418
+ for st in program.statements:
419
+ if isinstance(st, S.Instruction):
420
+ kw = st.keyword.upper()
421
+ elif isinstance(st, S.Unparsed) and (m := _FIRST_WORD.match(st.text)):
422
+ kw = m.group(1).upper()
423
+ else:
424
+ continue
425
+ if kw in ("DATATABLE", "ENDTABLE"):
426
+ in_table = kw == "DATATABLE"
427
+ if kw == "FIELDNAMES":
428
+ owners[id(st)] = owner
429
+ elif kw not in _FIELD_METADATA:
430
+ owner = st if kw in _FIELDNAMES_OWNERS and in_table else None
431
+ return owners
432
+
433
+
434
+ def move_fieldnames_symbol_errors(program: S.Program, items: list[Diagnostic]):
435
+ """CR6/CR1000X: the pass-2 `Invalid symbol in expression` findings of a `FieldNames` argument
436
+ belong to its owner (line, and before the owner's own argument errors), followed by the two
437
+ semantic errors; an unowned `FieldNames` is never resolved, so they vanish. Edits `items` in
438
+ place; returns ({id(old): replacement or None}, added) for the caller's bookkeeping."""
439
+ owners = fieldnames_owners(program)
440
+ replaced: dict[int, Diagnostic | None] = {}
441
+ added: list[Diagnostic] = []
442
+ next_at: dict[int, int] = {}
443
+ for st in program.statements:
444
+ if id(st) not in owners:
445
+ continue
446
+ mine = [
447
+ d
448
+ for d in items
449
+ if (d.file, d.line) == (st.file, st.line) and d.message.startswith("Invalid symbol in expression")
450
+ ]
451
+ if not mine:
452
+ continue
453
+ items[:] = [d for d in items if not any(d is m for m in mine)]
454
+ owner = owners[id(st)]
455
+ if owner is None:
456
+ replaced.update((id(d), None) for d in mine)
457
+ continue
458
+ moved = [dataclasses.replace(d, line=owner.line, file=owner.file) for d in mine]
459
+ replaced.update((id(d), n) for d, n in zip(mine, moved))
460
+ pair = [
461
+ Diagnostic("error", owner.line, message, owner.file, show_file=False)
462
+ for message in ("FieldNames cannot be a variable.", "FieldNames canot be NULL")
463
+ ]
464
+ added += pair
465
+ at = next_at.get(
466
+ id(owner),
467
+ next(
468
+ (
469
+ i
470
+ for i, d in enumerate(items)
471
+ if (d.file, d.line) == (owner.file, owner.line)
472
+ and d.message.startswith("Invalid symbol in expression")
473
+ ),
474
+ len(items),
475
+ ),
476
+ )
477
+ items[at:at] = moved + pair
478
+ next_at[id(owner)] = at + len(moved) + len(pair)
479
+ return replaced, added
480
+
481
+
482
+ def check_fieldnames(program: S.Program, profile: Profile, diags: Diagnostics, env) -> None:
483
+ """`FieldNames` takes a non-empty constant string (docs/checker.md "FieldNames argument"): a
484
+ numeric constant or a variable is `FieldNames cannot be a variable.`, an empty string `FieldNames
485
+ canot be NULL`, and a String variable both (its compile-time value is empty). Undefined names are
486
+ left to the symbol checks. The output instruction whose builder reads it (`fieldnames_owners`)
487
+ reports it, so the line is that instruction's; an unowned `FieldNames` is not checked."""
488
+ tables = symbols.scope_tables(program, env, scalars=True)
489
+ owners = fieldnames_owners(program)
490
+ for st in program.statements:
491
+ if not isinstance(st, S.Instruction) or st.keyword.upper() != "FIELDNAMES":
492
+ continue
493
+ owner = owners.get(id(st))
494
+ if len(st.args) != 1 or st.args[0] is None or owner is None:
495
+ continue
496
+ line, file = owner.line, owner.file
497
+ arg = st.args[0]
498
+ try:
499
+ value = consteval.value_of(arg, env)
500
+ except consteval.NotConstant:
501
+ kind = tables(st).types.get(arg.name.upper()) if isinstance(arg, (S.Name, S.Index)) else None
502
+ if kind is None:
503
+ continue
504
+ diags.error(line, "FieldNames cannot be a variable.", file)
505
+ value = "" if kind == "STRING" else None
506
+ else:
507
+ if not isinstance(value, str):
508
+ diags.error(line, "FieldNames cannot be a variable.", file)
509
+ if value == "":
510
+ diags.error(line, "FieldNames canot be NULL", file)
511
+
512
+
513
+ def check_table_structure(program: S.Program, profile: Profile, diags: Diagnostics) -> tuple[str | None, int] | None:
514
+ """`%s must be used inside a table declaration.` for an output-processing instruction (see
515
+ `TABLE_ONLY_INSTRUCTIONS`) found outside any `DataTable...EndTable` block,
516
+ `EndTable without DataTable.` for an orphan/extra `EndTable` (oracle-confirmed at the `EndTable`
517
+ statement's own line: a bare `EndTable` with no `DataTable` anywhere before it, and a second,
518
+ extra `EndTable` right after a properly-closed table, both give this message -- `docs/
519
+ instructions.md`), and `Table %s declared with no output fields.` for a properly-closed
520
+ `DataTable` block whose body has no instruction in `_FIELD_PRODUCING_INSTRUCTIONS` (oracle-
521
+ confirmed at the `EndTable` line: a body containing only `DataInterval`, only `CardOut`, or
522
+ nothing at all, three from-scratch probes this round, plus a saved fuzz reproducer,
523
+ `tests/corpus/fuzz/cr6/false_accept/5d5ffec92ac4.json`, for the "nothing at all" shape -- `docs/
524
+ instructions.md`).
525
+
526
+ Either `EndTable` message ends pass 2: the first one is returned as the stop location and
527
+ later findings are dropped (oracle, all four loggers, 2026-10-03: `EndTable` then `Sample`
528
+ outside a table reports only the `EndTable`; `Sample` then `EndTable` reports both).
529
+
530
+ Pass-2 (called from `checker/__init__.py` only when pass 1 found no error anywhere in the
531
+ file): oracle-confirmed for both messages this round by placing an unrelated pass-1 error (a
532
+ truncated instruction call) elsewhere in a file that also has a misplaced `Sample`/orphan
533
+ `EndTable` -- neither message appears, only the pass-1 error (`docs/instructions.md`).
534
+
535
+ Deliberately simple state tracking (a single "currently inside a `DataTable`" flag toggled by
536
+ `DataTable`/`EndTable`, no depth stack): CRBasic `DataTable` blocks do not nest (matching
537
+ `tables.py`'s own `_scan_program` docstring, "DataTable and ConstTable bodies do not nest"), so
538
+ this is exact for every well-formed program; it only degrades gracefully (never a false reject)
539
+ on an already-malformed one, e.g. an unclosed `DataTable` -- oracle-confirmed separately that
540
+ this shape gets a *different*, unimplemented message (`Could not find DataTable %s.`, at the
541
+ table's first use) rather than an "EndTable"/"not matched" pairing error, so no attempt is made
542
+ to detect it here (`docs/instructions.md`).
543
+ """
544
+ env = consteval.Env(profile, dict(program.consts))
545
+ in_table = False
546
+ table_name = ""
547
+ has_field = False
548
+ goes_seen = False # a `GOESTable` was declared (CR300/CR350 keep it across tables)
549
+ goes_orphans = 0 # `GOESField`s in this table without it
550
+ for stmt in program.statements:
551
+ if not isinstance(stmt, S.Instruction):
552
+ if isinstance(stmt, S.Unparsed):
553
+ has_field = has_field or in_table # a line we could not parse may be the field
554
+ continue
555
+ kw = stmt.keyword.upper()
556
+ if kw == "DATATABLE":
557
+ in_table = True
558
+ has_field = False
559
+ goes_orphans = 0
560
+ goes_seen = goes_seen and profile.single_pass
561
+ table_name = (_declared_table_name(stmt) or "")[:_TABLE_NAME_LIMIT]
562
+ continue
563
+ if kw == "ENDTABLE":
564
+ if not in_table:
565
+ diags.error(stmt.line, "EndTable without DataTable.", stmt.file)
566
+ return (stmt.file, stmt.line)
567
+ if not has_field and table_name:
568
+ diags.error(stmt.line, f"Table {table_name} declared with no output fields.", stmt.file)
569
+ return (stmt.file, stmt.line)
570
+ for _ in range(goes_orphans): # one per such `GOESField`
571
+ diags.error(stmt.line, "GoesField declared without a GoesTable declaration.", stmt.file)
572
+ in_table = False
573
+ continue
574
+ if in_table:
575
+ if kw in _FIELD_PRODUCING_INSTRUCTIONS and not _fieldless(stmt, profile, env):
576
+ has_field = True
577
+ elif kw == "GOESTABLE":
578
+ goes_seen = True
579
+ elif kw == "GOESFIELD" and kw in profile.instructions and not goes_seen:
580
+ diags.error(stmt.line, "GoesTable not active.", stmt.file)
581
+ goes_orphans += 1
582
+ continue
583
+ display = TABLE_ONLY_INSTRUCTIONS.get(kw)
584
+ if display is not None and kw in profile.instructions:
585
+ diags.error(stmt.line, f"{display} must be used inside a table declaration.", stmt.file)
586
+ return None
587
+
588
+
589
+ # `Sample(Reps, CH400Status, ...)` samples the predefined CH400Status table: `Reps` consecutive
590
+ # copies of these 32 field names (oracle-observed through the duplicate-name messages,
591
+ # `docs/checker.md` "Duplicate field names").
592
+ _CH400_FIELDS = (
593
+ "ChargeAVoltage",
594
+ "ChargeACurrent",
595
+ "ChargeBVoltage",
596
+ "ChargeBCurrent",
597
+ "InputChargeStatus",
598
+ "InputCurrentLimit",
599
+ "BattAVoltage",
600
+ "BattACurrent",
601
+ "BattAChargeStatus",
602
+ "BattASetVoltage",
603
+ "BattACurrentLimit",
604
+ "BattADepthofDischarge",
605
+ "BattBVoltage",
606
+ "BattBCurrent",
607
+ "BattBChargeStatus",
608
+ "BattBSetVoltage",
609
+ "BattBCurrentLimit",
610
+ "BattBDepthofDischarge",
611
+ "SystemVoltage",
612
+ "SystemCurrent",
613
+ "AlwaysOn12Voltage",
614
+ "AlwaysOn12Current",
615
+ "Switched12Voltage",
616
+ "Switched12Current",
617
+ "Switched24Voltage",
618
+ "Switched24Current",
619
+ "PowerSwitchState",
620
+ "MaxBoardTemp",
621
+ "MinBoardTemp",
622
+ "PanelTemp",
623
+ "BattAProbeTemp",
624
+ "BattBProbeTemp",
625
+ )
626
+
627
+ # Error-text buffer: messages are appended while they fit in 4095 bytes counting CRLF per line.
628
+ _ERROR_BUFFER_BYTES = 4095
629
+
630
+
631
+ def _dup_entries(table):
632
+ """(name, renamed, range) for each field of `table`, in order (`docs/checker.md` "Duplicate
633
+ field names"). Falls back to the TDF fields for a table without `tables.py` bookkeeping."""
634
+ fields = getattr(table, "_dup_fields", None)
635
+ if fields is None:
636
+ for f in table.fields:
637
+ yield f.name, False, (f.begin_index, f.begin_index + max(f.dimension, 1) - 1), False
638
+ return
639
+ for f in fields:
640
+ if f.ch400:
641
+ for _ in range(max(f.dim, 1)):
642
+ for n in _CH400_FIELDS:
643
+ yield n, False, (1, 1), False
644
+ else:
645
+ yield f.name, f.renamed, f.rng, f.placeholder
646
+
647
+
648
+ def check_duplicate_fields(program: S.Program, result, diags: Diagnostics) -> None:
649
+ """`Duplicate field name %s in DataTable %s.` (no line number), once for every pair of
650
+ conflicting fields, reported for the later field of the pair in table order. Rule, evidence and
651
+ the error-buffer cap: `docs/checker.md` "Duplicate field names".
652
+
653
+ Two fields conflict when their names match case-insensitively and either both were named by
654
+ `FieldNames` or their source-element ranges overlap. Field names are reused from `tables.py`
655
+ (`TdfTable._dup_fields`), which also supplies name-only FFT and CH400Status fields. Entries
656
+ explicitly marked as generated placeholders are skipped; a user field named `Anonymous` is
657
+ still checked.
658
+ """
659
+ tables = getattr(result, "tables", None)
660
+ if not tables:
661
+ return
662
+ user_tables: list[str] = []
663
+ for stmt in program.statements:
664
+ if isinstance(stmt, S.Instruction) and stmt.keyword.upper() == "DATATABLE":
665
+ name = _table_name(stmt)
666
+ if name:
667
+ name = _const_table_name(name, program) # (as `tables.py` names it, round 67)
668
+ if name and name.upper() not in user_tables:
669
+ user_tables.append(name.upper())
670
+ by_upper = {t.name.upper(): t for t in tables}
671
+ remaining = _ERROR_BUFFER_BYTES
672
+ for key in user_tables:
673
+ table = by_upper.get(key)
674
+ if table is None:
675
+ continue
676
+ seen: dict[str, list[tuple[bool, tuple[int, int] | None]]] = {}
677
+ shortest = len(f"Duplicate field name x in DataTable {table.name}.") + 2
678
+ for name, renamed, rng, placeholder in _dup_entries(table):
679
+ if remaining < shortest:
680
+ break
681
+ if placeholder:
682
+ continue
683
+ earlier = seen.setdefault(name.upper(), [])
684
+ text = f"Duplicate field name {name} in DataTable {table.name}."
685
+ cost = len(text) + 2
686
+ for e_renamed, e_rng in earlier:
687
+ if renamed and e_renamed:
688
+ conflict = True
689
+ elif rng is None or e_rng is None:
690
+ conflict = False
691
+ else:
692
+ conflict = rng[0] <= e_rng[1] and e_rng[0] <= rng[1]
693
+ if not conflict:
694
+ continue
695
+ if cost <= remaining:
696
+ diags.error(0, text)
697
+ remaining -= cost
698
+ earlier.append((renamed, rng))
699
+
700
+
701
+ def check_units_targets(program: S.Program, profile: Profile, diags: Diagnostics) -> None:
702
+ """`Can't assign units to %s, it must be declared Public or referenced as a source in a Data
703
+ Table.` (all four loggers, checked after pass 2 like the table finalisation): a `Units`
704
+ target whose base name is not a `Public`/`Dim` variable of a plain type or an `Alias` name,
705
+ wherever declared. Oracle, 2026-09-27 (`docs/checker.md` "Units targets"): `Units D = m` on a
706
+ `Dim D` that no table uses compiles; `Const`s, undeclared names, `Sub` parameters,
707
+ `StructureType`-typed variables and table field names (`P_Avg`) are all rejected; the name
708
+ is echoed as written, the base name for `Units Nope(1)`."""
709
+ struct_types = {
710
+ st.args[0].name.upper()
711
+ for st in program.statements
712
+ if isinstance(st, S.Instruction)
713
+ and st.keyword.upper() in ("STRUCTURETYPE", "OBJECTTYPE")
714
+ and st.args
715
+ and isinstance(st.args[0], S.Name)
716
+ }
717
+ allowed: set[str] = set()
718
+ for st in program.statements:
719
+ if isinstance(st, S.Declaration) and st.kind in ("PUBLIC", "DIM", "STATIC"):
720
+ allowed |= {v.name.upper() for v in st.vars if not (v.type and v.type in struct_types)}
721
+ elif isinstance(st, S.Instruction) and st.keyword.upper() == "ALIAS":
722
+ allowed |= {a.name.upper() for a in st.args[1:] if isinstance(a, S.Name)}
723
+ in_struct = False
724
+ assigned: dict[str, list[S.Instruction]] = {}
725
+ indexed: set[str] = set()
726
+ for st in program.statements:
727
+ kw = st.keyword.upper() if isinstance(st, S.Instruction) else ""
728
+ if kw in ("STRUCTURETYPE", "OBJECTTYPE", "ENDSTRUCTURETYPE", "ENDOBJECTTYPE"):
729
+ in_struct = not kw.startswith("END")
730
+ if in_struct or kw != "UNITS" or not isinstance(st, S.Instruction) or not st.args:
731
+ continue # inside a `StructureType`, `Units` names a member (`structuretype_1.CR6`)
732
+ for extra in getattr(st, "extra_targets", ()):
733
+ if extra.name.upper() not in allowed:
734
+ diags.error(
735
+ st.line,
736
+ f"Can't assign units to {extra.name}, it must be declared "
737
+ "Public or referenced as a source in a Data Table.",
738
+ st.file,
739
+ )
740
+ target = st.args[0]
741
+ if isinstance(target, (S.Name, S.Index)) and target.name.upper() not in allowed:
742
+ diags.error(
743
+ st.line,
744
+ f"Can't assign units to {target.name}, it must be declared Public "
745
+ "or referenced as a source in a Data Table.",
746
+ st.file,
747
+ )
748
+ elif isinstance(target, S.Name):
749
+ assigned.setdefault(target.name.upper(), []).append(st)
750
+ elif isinstance(target, S.Index):
751
+ indexed.add(target.name.upper())
752
+ # Each of a variable's n `Units` statements gets n-1 copies of `%s already assigned Units %s.`,
753
+ # echoing its own spelling (oracle, all four loggers, 2026-09-28: three `Units X` lines give two
754
+ # lines each). Indexed targets are not probed.
755
+ for name, statements in assigned.items():
756
+ if len(statements) < 2 or name in indexed:
757
+ continue
758
+ for st in statements:
759
+ units = st.args[1].value if len(st.args) > 1 and isinstance(st.args[1], S.String) else None
760
+ if units is None:
761
+ continue
762
+ target = st.args[0]
763
+ assert isinstance(target, S.Name) # (only bare-name targets are collected)
764
+ for _ in range(len(statements) - 1):
765
+ diags.error(st.line, f"{target.name} already assigned Units {units}.", st.file)
766
+
767
+
768
+ _SEQUENCE_OPENERS = frozenset({"DIALSEQUENCE", "MODEMHANGUP"})
769
+ _SEQUENCE_CLOSERS = {
770
+ "ENDDIALSEQUENCE": ("Missing DialSequence().", "DIALSEQUENCE"),
771
+ "ENDMODEMHANGUP": ("Missing ModemHangup().", "MODEMHANGUP"),
772
+ "ENDVOICE": ("EndVoice without Voice", None),
773
+ }
774
+
775
+
776
+ def check_table_and_menu_placement(
777
+ program: S.Program, profile: Profile, diags: Diagnostics, env=None
778
+ ) -> tuple[str, int] | None:
779
+ """Pass 2 (oracle, suppressed by a pass-1 error): a second `DataInterval` in one table ->
780
+ `Only one DataInterval allowed within DataTable..EndTable.` (all four loggers), and the display
781
+ menu structure (CR6/CR1000X; CR300/CR350 have no menus), following the decompiled menu builder
782
+ (`FUN_004309c0`, `FUN_00430aa0`, `FUN_00430b50`; oracle, 2026-09-28, `docs/checker.md`
783
+ "Display menus"). Returns the `(file, line)` pass 2 stops at (`EndSubMenu without SubMenu.`), if any."""
784
+ in_table = False
785
+ intervals = 0
786
+ has_maxmin = False
787
+ menus = submenus = 0
788
+ open_list = False # the builder's insertion point; an empty `SubMenu` clears it
789
+ levels: list[list[str]] = []
790
+ table_consts: set[str] = set()
791
+ in_const_table = False
792
+ for st in program.statements:
793
+ kw = st.keyword.upper() if isinstance(st, S.Instruction) else ""
794
+ if kw in ("CONSTTABLE", "ENDCONSTTABLE"):
795
+ in_const_table = kw == "CONSTTABLE"
796
+ elif in_const_table and isinstance(st, S.ConstDecl):
797
+ table_consts.add(st.name.upper())
798
+ item_const: str | None = None # the current `MenuItem`'s ConstTable constant, if it is one
799
+ item_plain = False # the current `MenuItem` names a `Const` outside the ConstTable
800
+ has_const_table = any(
801
+ isinstance(st, S.Instruction) and st.keyword.upper() == "CONSTTABLE" for st in program.statements
802
+ )
803
+ item_bool = False # the current `MenuItem`/`MenuRecompile` variable is Boolean
804
+ item_kind: str | None = None # "int" / "float" / "string": what its `MenuPick` values must be
805
+ first_is_const = False
806
+ sequences: dict[str, int] = {} # open DialSequence / ModemHangup blocks
807
+ variables = symbols.declared_variable_names(program)
808
+ types = symbols.declared_variable_types(program)
809
+ if env is None:
810
+ env = consteval.Env(profile, dict(program.consts))
811
+
812
+ def within(st: S.Instruction, kw: str) -> bool:
813
+ if menus and open_list:
814
+ return True
815
+ diags.error(st.line, f"{profile.instructions[kw]['name']} must be used within DisplayMenu..EndMenu.", st.file)
816
+ return False
817
+
818
+ for st in program.statements:
819
+ if not isinstance(st, S.Instruction):
820
+ continue
821
+ kw = st.keyword.upper()
822
+ if kw == "DATATABLE":
823
+ in_table, intervals, has_maxmin = True, 0, False
824
+ if not profile.single_pass: # CR300/CR350: `finalize_illegal_table_names`
825
+ diags.items += _illegal_table_name(st)
826
+ elif kw == "ENDTABLE":
827
+ in_table = False
828
+ elif in_table and kw in ("MAXIMUM", "MINIMUM"):
829
+ has_maxmin = True
830
+ elif in_table and kw == "SAMPLEMAXMIN" and not has_maxmin:
831
+ # oracle, all four loggers, 2026-10-08 (docs/fuzz-findings.md "Round 5"); CR300/CR350
832
+ # drop it when the same statement has an excess, missing or empty-last argument
833
+ # (2026-10-10/12, docs/checker.md "Round 13")
834
+ from . import instr_rules
835
+
836
+ need = len(instr_rules._real_params(profile.instructions[kw]["params"]))
837
+ if profile.single_pass and (
838
+ len(st.args) < need or st.args[need - 1] is None or any(a is not None for a in st.args[need:])
839
+ ):
840
+ continue
841
+ diags.error(st.line, "Maximum() or Minimum() must precede SampleMaxMin().", st.file)
842
+ elif kw == "DATAINTERVAL" and in_table:
843
+ intervals += 1
844
+ if intervals == 2:
845
+ diags.error(st.line, "Only one DataInterval allowed within DataTable..EndTable.", st.file)
846
+ elif kw not in profile.instructions:
847
+ continue
848
+ elif not profile.single_pass and kw in _SEQUENCE_CLOSERS:
849
+ # an `EndDialSequence`/`EndModemHangup`/`EndVoice` with no opener (CR6/CR1000X, oracle
850
+ # 2026-10-13, `docs/checker.md` "Round 16 ... second worker"): printed in line order,
851
+ # every one, and pass 2 goes on
852
+ message, opener = _SEQUENCE_CLOSERS[kw]
853
+ if opener and sequences.get(opener):
854
+ sequences[opener] -= 1
855
+ else:
856
+ diags.error(st.line, message, st.file)
857
+ elif not profile.single_pass and kw in _SEQUENCE_OPENERS:
858
+ sequences[kw] = sequences.get(kw, 0) + 1
859
+ elif kw == "DISPLAYMENU" and menus:
860
+ # a second `DisplayMenu` before `EndMenu` (the first stays open; fuzz-found, oracle)
861
+ diags.error(st.line, "DisplayMenu without EndMenu.", st.file)
862
+ elif kw == "DISPLAYMENU":
863
+ menus += 1
864
+ levels.append([])
865
+ open_list = True
866
+ elif kw in _MENU_ITEMS:
867
+ if within(st, kw):
868
+ levels[-1].append(kw)
869
+ target = st.args[1] if kw == "MENUITEM" and len(st.args) > 1 else None
870
+ item_const = (
871
+ target.name.upper() if isinstance(target, S.Name) and target.name.upper() in table_consts else None
872
+ )
873
+ item_plain = (
874
+ isinstance(target, S.Name)
875
+ and target.name.upper() not in table_consts
876
+ and target.name.upper() in {str(c).upper() for c in program.consts}
877
+ and target.name.upper() not in variables
878
+ )
879
+ target = st.args[1] if kw in ("MENUITEM", "MENURECOMPILE") and len(st.args) > 1 else None
880
+ item_bool = isinstance(target, (S.Name, S.Index)) and types.get(target.name.upper()) == "BOOLEAN"
881
+ item_kind = None
882
+ if kw == "MENUITEM" and isinstance(target, (S.Name, S.Index)):
883
+ if item_const:
884
+ value = env.consts.get(item_const)
885
+ # a non-finite float (`Const F=1e999`) is a float item (real: any numeric pick)
886
+ item_kind = (
887
+ "string"
888
+ if isinstance(value, str)
889
+ else "int"
890
+ if isinstance(value, int) and not isinstance(value, bool)
891
+ else "float"
892
+ if isinstance(value, float) and (not math.isfinite(value) or value != int(value))
893
+ else None
894
+ )
895
+ else:
896
+ item_kind = {"LONG": "int", "FLOAT": "float", "STRING": "string"}.get(
897
+ types.get(target.name.upper(), "")
898
+ )
899
+ first_is_const = False
900
+ if kw == "SUBMENU":
901
+ levels.append([])
902
+ submenus += 1
903
+ elif kw == "MENUPICK":
904
+ # After a `MenuItem` or `MenuRecompile` it is accepted (chm `menuitem_2.CR6`), and
905
+ # repeated for an ordinary variable; a ConstTable constant's item takes one pick list,
906
+ # naming the constant first (oracle, 2026-10-01, fuzz `62246db1b167`).
907
+ if within(st, kw):
908
+ after_item = bool(levels[-1]) and levels[-1][-1] == "MENUITEM"
909
+ if not levels[-1] or levels[-1][-1] not in ("MENUITEM", "MENURECOMPILE"):
910
+ diags.error(st.line, "MenuPick must follow a MenuItem instruction.", st.file)
911
+ elif levels[-1][-1] == "MENUITEM" and item_plain:
912
+ # the item names a `Const` outside the ConstTable (oracle, CR6/CR1000X,
913
+ # 2026-10-12): with a ConstTable anywhere in the program every pick list is
914
+ # the ConstTable message, even `MenuPick(D,1,2)`; without one, its own message
915
+ diags.error(
916
+ st.line,
917
+ "When using a ConstTable constant in MenuPick, the constant name must be the first parameter."
918
+ if has_const_table
919
+ else "Value in preceding MenuItem must be a variable or a Const within "
920
+ "ConstTable..EndConstTable.",
921
+ st.file,
922
+ )
923
+ continue
924
+ elif levels[-1][-1] == "MENUITEM" and item_const:
925
+ first = st.args[0] if st.args else None
926
+ first_is_const = isinstance(first, S.Name) and first.name.upper() in table_consts
927
+ if first_is_const:
928
+ levels[-1].append(kw)
929
+ else:
930
+ diags.error(
931
+ st.line,
932
+ "When using a ConstTable constant in MenuPick, the "
933
+ "constant name must be the first parameter.",
934
+ st.file,
935
+ )
936
+ bad = next((a for a in st.args if isinstance(a, S.Name) and a.name.upper() in variables), None)
937
+ if item_bool and levels[-1] and levels[-1][-1] in ("MENUITEM", "MENURECOMPILE"):
938
+ _check_boolean_picklist(st, env, diags)
939
+ elif after_item and (item_kind is not None or item_const):
940
+ # a ConstTable item whose first parameter is not a constant checks every
941
+ # value as a plain `Const` (oracle, 2026-10-10)
942
+ wrong_first = item_const and not first_is_const
943
+ _check_pick_consts(
944
+ st,
945
+ "float" if wrong_first else item_kind,
946
+ env,
947
+ program,
948
+ profile,
949
+ variables,
950
+ diags,
951
+ skip_first=bool(item_const) and first_is_const,
952
+ )
953
+ elif bad is not None:
954
+ # `MenuPick (Y)` for `Public Y` (oracle, 2026-10-01)
955
+ diags.error(st.line, f"{bad.name} must be declared as a Const.", st.file)
956
+ elif kw == "ENDSUBMENU":
957
+ if not submenus:
958
+ diags.error(st.line, "EndSubMenu without SubMenu.", st.file)
959
+ return st.file, st.line
960
+ submenus -= 1
961
+ if within(st, kw) and not levels.pop():
962
+ diags.error(st.line, "Empty SubMenu ... EndSubMenu.", st.file)
963
+ open_list = False
964
+ elif kw == "ENDMENU":
965
+ if within(st, kw):
966
+ menus -= 1
967
+ if not levels.pop():
968
+ diags.error(st.line, "Empty DisplayMenu .. EndMenu.", st.file)
969
+ if submenus:
970
+ diags.error(st.line, "SubMenu with no EndSubMenu.", st.file)
971
+ return None
972
+
973
+
974
+ _BOOL_NAME_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*\s*$")
975
+ _BOOL_INT_RE = re.compile(r"[+-]?\d+$")
976
+
977
+
978
+ def _boolean_pick_ok(token: str, env) -> bool:
979
+ """Whether one raw `MenuPick` token is acceptable in a Boolean picklist (oracle, 2026-10-02,
980
+ `docs/checker.md` "Boolean picklists"): a constant name whose value is 0 or -1 (`True`,
981
+ `False`, a `Const`), or a bare integer literal that is 0 or -1 as a 32-bit value. Leading
982
+ blanks are skipped; blanks after a literal reject it; anything else (expression, string,
983
+ float, parenthesis, variable, undefined name, empty token) is rejected."""
984
+ token = token.lstrip(" \t")
985
+ if _BOOL_NAME_RE.match(token):
986
+ try:
987
+ value = consteval._lookup(token.strip(), env)
988
+ except consteval.NotConstant:
989
+ return False
990
+ return not isinstance(value, str) and value in (0, -1)
991
+ if _BOOL_INT_RE.match(token):
992
+ return int(token) % (1 << 32) in (0, (1 << 32) - 1)
993
+ prefix, body = token[:2].upper(), token[2:]
994
+ if prefix == "&H" and re.fullmatch("[0-9A-Fa-f]+", body):
995
+ digits, ok = 8, (0, 0xFFFFFFFF)
996
+ elif prefix == "&B" and re.fullmatch("[01]+", body):
997
+ digits, ok = 32, (0, 0xFFFFFFFF)
998
+ else:
999
+ return False
1000
+ if len(body.lstrip("0")) > digits:
1001
+ return True # past the probed range: stay silent rather than guess
1002
+ return int(body, 16 if digits == 8 else 2) in ok
1003
+
1004
+
1005
+ def _pick_tokens(stmt: S.Instruction) -> list[str] | None:
1006
+ """The comma-split raw argument text of a `MenuPick` statement (quotes are not honoured, as
1007
+ in the real compiler), without its comment and enclosing parentheses."""
1008
+ m = re.match(r"\s*MenuPick\b(.*)$", stmt.text or "", re.IGNORECASE | re.DOTALL)
1009
+ if not m:
1010
+ return None
1011
+ rest, quoted = "", False
1012
+ for ch in m.group(1):
1013
+ if ch == '"':
1014
+ quoted = not quoted
1015
+ elif ch == "'" and not quoted:
1016
+ break
1017
+ rest += ch
1018
+ rest = rest.strip()
1019
+ if rest.startswith("(") and rest.endswith(")"):
1020
+ rest = rest[1:-1]
1021
+ return rest.split(",")
1022
+
1023
+
1024
+ def _check_boolean_picklist(stmt: S.Instruction, env, diags: Diagnostics) -> None:
1025
+ tokens = _pick_tokens(stmt)
1026
+ if tokens is None:
1027
+ return
1028
+ trailing = len(tokens) > 1 and tokens[-1] == ""
1029
+ for token in tokens[:-1] if trailing else tokens:
1030
+ if not _boolean_pick_ok(token, env):
1031
+ diags.error(stmt.line, "Boolean picklist must be True or False.", stmt.file)
1032
+ if trailing:
1033
+ diags.error(stmt.line, "Unable to parse MenuPick strings.", stmt.file)
1034
+
1035
+
1036
+ _PICK_INT_RE = re.compile(r"[+-]?\d+$")
1037
+ _PICK_FLOAT_RE = re.compile(r"[+-]?(\d+\.\d*|\.\d+)([eE][+-]?\d+)?$|[+-]?\d+[eE][+-]?\d+$")
1038
+ _PICK_RADIX_RE = re.compile(r"&[Hh][0-9A-Fa-f]+$|&[Bb][01]+$")
1039
+ _PICK_NAME_RE = re.compile(r"[A-Za-z_]\w*$")
1040
+
1041
+
1042
+ def _pick_value_bad(token: str, kind: str, env, program, profile, variables) -> str | None:
1043
+ """The echoed text if the real compiler rejects this raw `MenuPick` value, else None. Rule
1044
+ (oracle, CR6/CR1000X, 2026-10-10, `docs/fuzz-findings.md` "Round 9"): a Long item takes
1045
+ integer constants, a Float item (or ConstTable constant) any numeric constant; a String item
1046
+ anything. Silent for any token shape not probed."""
1047
+ t = token.lstrip(" \t")
1048
+ if t == "":
1049
+ return "" if kind == "int" else None
1050
+ if t != t.rstrip():
1051
+ return t
1052
+ inner = t
1053
+ if t[0] == "(" and t[-1] == ")":
1054
+ inner = t[1:-1]
1055
+ if "(" in inner or ")" in inner or "," in inner:
1056
+ return None
1057
+ if inner.startswith('"') and inner.endswith('"') and len(inner) >= 2 and inner.count('"') == 2:
1058
+ return inner
1059
+ if _PICK_INT_RE.match(inner) or _PICK_RADIX_RE.match(inner):
1060
+ return None
1061
+ if _PICK_FLOAT_RE.match(inner):
1062
+ return inner if kind == "int" else None
1063
+ if _PICK_NAME_RE.match(inner):
1064
+ try:
1065
+ value = consteval._lookup(inner, env)
1066
+ except consteval.NotConstant:
1067
+ from .expressions import _known_names
1068
+
1069
+ if (
1070
+ inner.upper() in variables
1071
+ or inner.upper() not in _known_names(program, profile)
1072
+ or (kind == "int" and inner.upper() == "NAN")
1073
+ ):
1074
+ return inner
1075
+ return None
1076
+ if kind == "int" and (isinstance(value, str) or value != value or value != int(value)):
1077
+ return inner
1078
+ return None
1079
+ if re.fullmatch(r"[A-Za-z0-9_.&+\-*/ ]+", inner) and re.search(r"[A-Za-z0-9_.)]\s*[-+*/]", inner):
1080
+ return inner # an operator expression, or `-A` / `-&H10`
1081
+ if re.fullmatch(r"-([A-Za-z_]\w*|&[Hh][0-9A-Fa-f]+)", inner):
1082
+ return inner
1083
+ return None
1084
+
1085
+
1086
+ def _check_pick_consts(stmt, kind, env, program, profile, variables, diags, skip_first) -> None:
1087
+ if kind is None or kind == "string":
1088
+ return
1089
+ tokens = _pick_tokens(stmt)
1090
+ if tokens is None:
1091
+ return
1092
+ word = "integer Const" if kind == "int" else "Const"
1093
+ for token in tokens[1:] if skip_first else tokens:
1094
+ bad = _pick_value_bad(token, kind, env, program, profile, variables)
1095
+ if bad is not None:
1096
+ diags.error(stmt.line, f"{bad} must be declared as a {word}.", stmt.file)
1097
+
1098
+
1099
+ _MENU_ITEMS = ("SUBMENU", "MENUITEM", "DISPLAYVALUE", "MENURECOMPILE", "DISPLAYLINE")
1100
+
1101
+
1102
+ _TABLE_OUTPUT = (
1103
+ "TOTALIZE",
1104
+ "AVERAGE",
1105
+ "MAXIMUM",
1106
+ "MINIMUM",
1107
+ "SAMPLEMAXMIN",
1108
+ "STDDEV",
1109
+ "MOMENT",
1110
+ "SAMPLE",
1111
+ "COVARIANCE",
1112
+ "MEDIAN",
1113
+ )
1114
+ _OWN_VARIABLE_CHECK = ("ADDPRECISE", "MOVEPRECISE") # `instr_rules.check_precision_variable`
1115
+ _INT32_MIN = -2147483648
1116
+
1117
+
1118
+ def _has_names(expr) -> bool:
1119
+ """Whether `expr` references any name (variable, constant, function or symbol)."""
1120
+ if isinstance(expr, (S.Name, S.Index, S.Member)):
1121
+ return True
1122
+ if isinstance(expr, S.Unary):
1123
+ return _has_names(expr.operand)
1124
+ if isinstance(expr, S.Binary):
1125
+ return _has_names(expr.left) or _has_names(expr.right)
1126
+ return False
1127
+
1128
+
1129
+ def constant_destination(expr, env, variables) -> bool:
1130
+ """Whether a destination argument is a compile-time constant, which CR6/CR1000X reject with
1131
+ `Invalid Variable or Array`: a literal, a `Const` or predefined name, or an expression of
1132
+ them (`Battery(K+1)`, `PanelTemp(ComRS232,15000)`; oracle, round 59)."""
1133
+ from .expressions import _variable_refs
1134
+
1135
+ if expr is None or isinstance(expr, S.String):
1136
+ return False
1137
+ return not _has_names(expr) or (consteval.is_constant(expr, env) and not _variable_refs(expr, variables))
1138
+
1139
+
1140
+ def _reps_value(expr, env) -> int | None:
1141
+ """A `Reps` argument's value as the real compiler reads it: a string or a value outside the
1142
+ 32-bit range becomes -2147483648 (oracle: `Sample("fuzz",...)`, `Maximum (999999999999,...)`)."""
1143
+ if isinstance(expr, S.String):
1144
+ return _INT32_MIN
1145
+ try:
1146
+ value = consteval.value_of(expr, env)
1147
+ except consteval.NotConstant:
1148
+ return None
1149
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
1150
+ return None
1151
+ if isinstance(value, float) and not math.isfinite(value):
1152
+ return _INT32_MIN # `Sample(1e309,...)`: outside the range like any huge count (round 69)
1153
+ value = int(value)
1154
+ return value if -(2**31) <= value < 2**31 else _INT32_MIN
1155
+
1156
+
1157
+ def check_stage2a(program: S.Program, profile: Profile, diags: Diagnostics, env) -> bool:
1158
+ """CR6/CR1000X instruction checks that run after pass 1 but before pass 2's expression checks
1159
+ (oracle, 2026-09-27: each is suppressed by a pass-1 error, and each suppresses a pass-2
1160
+ `Invalid symbol in expression` elsewhere, even on an earlier line). Returns whether anything
1161
+ was reported (then the caller skips pass 2's expression tier).
1162
+
1163
+ - A constant (no names at all) where a destination variable is required (type code 8):
1164
+ `Battery((1+2))` -> `Invalid Variable or Array: (1+2).`, `PanelTemp(3,15000)` -> `... 3.`.
1165
+ - A table-output `Reps` below 1: `Sample (0,X,FP2)` -> `Illegal Reps 0`; a source running past
1166
+ its array: `Sample (3,A(4),FP2)` with `A(5)` -> `Variable A out of bounds.` and `Variable A,
1167
+ index 4, reps 3, out of bounds.`. The first such table statement ends the stage (round 24
1168
+ forms `A()`, multi-dimensional and expression-subscripted sources: docs/checker.md "Table
1169
+ output source with `X()`").
1170
+ """
1171
+ if profile.name not in ("cr6", "cr1000x"):
1172
+ return False
1173
+ return _check_stage2a(program, profile, diags, env, destinations=True)
1174
+
1175
+
1176
+ def check_table_reps_late(program: S.Program, profile: Profile, diags: Diagnostics, env) -> None:
1177
+ """CR300/CR350: the same `Illegal Reps` / `Variable %s out of bounds.` table checks, reported
1178
+ only when nothing else failed and stopping at the first (oracle, 2026-09-27: `Sample (0,X,FP2)`
1179
+ plus an unrelated `X = Q` prints only `Q is not defined.`)."""
1180
+ if profile.name in ("cr300", "cr350"):
1181
+ _check_stage2a(program, profile, diags, env, destinations=False)
1182
+
1183
+
1184
+ def _report_undefined_source(
1185
+ st: S.Instruction, record: dict, known: set[str], profile: Profile, diags: Diagnostics
1186
+ ) -> bool:
1187
+ """CR300/CR350: an undefined name in a table-output source (`Sample (1,Nope,FP2)` -> `Nope is
1188
+ not defined.`, and `Dimensions not declared: Nope(2).` for `Nope(2)`), late and only the first
1189
+ (oracle, 2026-09-27; CR6/CR1000X: `structure._undefined_table_source`)."""
1190
+ from . import expressions
1191
+
1192
+ kinds = expressions._type_code_kinds()
1193
+ for i, code in enumerate(record["params"][: len(st.args)]):
1194
+ arg = st.args[i]
1195
+ if arg is None or kinds.get(code % 100 if code >= 100 else code) != "deferred_text":
1196
+ continue
1197
+ names = expressions._undefined_names(arg, known)
1198
+ if not names:
1199
+ continue
1200
+ for name in names:
1201
+ diags.error(st.line, profile.unresolved_symbol_message.format(name=name), st.file, show_file=False)
1202
+ if expressions._has_undefined_index(arg, known):
1203
+ diags.error(
1204
+ st.line, expressions.dimensions_message(expressions._arg_text(st, i).strip(), profile, False), st.file
1205
+ )
1206
+ return True
1207
+ return False
1208
+
1209
+
1210
+ def _is_struct_typed(program: S.Program, name: str) -> bool:
1211
+ types = {
1212
+ st.args[0].name.upper()
1213
+ for st in program.statements
1214
+ if isinstance(st, S.Instruction)
1215
+ and st.keyword.upper() == "STRUCTURETYPE"
1216
+ and st.args
1217
+ and isinstance(st.args[0], S.Name)
1218
+ }
1219
+ return any(
1220
+ (v.type or "").upper() in types
1221
+ for d in program.statements
1222
+ if isinstance(d, S.Declaration)
1223
+ for v in d.vars
1224
+ if v.name.upper() == name.upper()
1225
+ )
1226
+
1227
+
1228
+ def _short_array(arg, reps: int, dims: dict, env) -> bool:
1229
+ """`arg` is a declared variable (or a constant 1-D element) with fewer than `reps` elements left."""
1230
+ if not isinstance(arg, (S.Name, S.Index)) or (shape := dims.get(arg.name.upper())) is None:
1231
+ return False
1232
+ offset = 0
1233
+ if isinstance(arg, S.Index) and arg.args:
1234
+ if len(arg.args) != 1 or len(shape) != 1 or (sub := _reps_value(arg.args[0], env)) is None:
1235
+ return False
1236
+ offset = sub - 1
1237
+ return offset + reps > math.prod(shape)
1238
+
1239
+
1240
+ def _alias_sources(program: S.Program, tables, env) -> dict[str, tuple[str, str, int]]:
1241
+ """Top-level `Alias` names of a declared variable or of one element of a 1-D array:
1242
+ `(name of the first bounds message, declared alias name, elements from the alias on)`; the
1243
+ first message names the
1244
+ backing variable when the alias starts at its first element (oracle, CR6/CR1000X, round 57,
1245
+ docs/checker.md#alias-sources-of-table-output-round-57)."""
1246
+ out: dict[str, tuple[str, str, int]] = {}
1247
+ declared = {v.name.upper(): v.name for d in program.statements if isinstance(d, S.Declaration) for v in d.vars}
1248
+ depth = 0
1249
+ for st in program.statements:
1250
+ if isinstance(st, S.SubDef):
1251
+ depth += 1
1252
+ elif S.ends_routine(st):
1253
+ depth -= 1
1254
+ if (
1255
+ depth
1256
+ or not isinstance(st, S.Instruction)
1257
+ or st.keyword.upper() != "ALIAS"
1258
+ or len(st.args) < 2
1259
+ or not isinstance(target := st.args[0], (S.Name, S.Index))
1260
+ ):
1261
+ continue
1262
+ shape = tables(st).dims.get(target.name.upper())
1263
+ if shape is None:
1264
+ continue
1265
+ offset = 0
1266
+ if isinstance(target, S.Index):
1267
+ if len(target.args) != 1 or len(shape) != 1 or (k := _reps_value(target.args[0], env)) is None:
1268
+ continue
1269
+ offset = k - 1
1270
+ total = math.prod(shape)
1271
+ for alias in st.args[1:]:
1272
+ if not isinstance(alias, S.Name) or not 0 <= offset < total:
1273
+ break
1274
+ out[alias.name.upper()] = (
1275
+ declared.get(target.name.upper(), target.name) if offset == 0 else alias.name,
1276
+ alias.name,
1277
+ total - offset,
1278
+ )
1279
+ offset += 1
1280
+ return out
1281
+
1282
+
1283
+ # `WindVector` SubInterval/OutputOpt (oracle, CR6/CR1000X, round 58,
1284
+ # docs/checker.md#windvector-subinterval-and-output-option-round-58); the SubInterval line gates
1285
+ # nothing (`checker/__init__.py` moves it out of the way)
1286
+ WIND_SUBINTERVAL = "WindVector: non-zero SubInterval only valid for output modes 0, 2, and 4."
1287
+
1288
+
1289
+ def _wind_options(st: S.Instruction, reps: int | None, env, diags: Diagnostics) -> bool:
1290
+ """Report the SubInterval and output-option errors; whether the walk ends here."""
1291
+
1292
+ def long_arg(i: int) -> int | None:
1293
+ try:
1294
+ return consteval.to_long(consteval.value_of(st.args[i], env))
1295
+ except consteval.NotConstant, consteval.DivideByZero, TypeError, ValueError:
1296
+ return None
1297
+
1298
+ sub, option = long_arg(5), long_arg(7)
1299
+ mode = option & 0xFF if option is not None else None
1300
+ if mode in (1, 3) and sub not in (None, 0):
1301
+ diags.error(st.line, WIND_SUBINTERVAL, st.file)
1302
+ if mode is not None and mode > 4:
1303
+ diags.error(st.line, "Invalid Output Option in WindVector.", st.file)
1304
+ if reps is not None and reps >= 1:
1305
+ diags.error(st.line, "Out of memory.", st.file)
1306
+ return True
1307
+ if reps == 0:
1308
+ diags.error(st.line, "Illegal Reps 0", st.file)
1309
+ return True
1310
+ return False
1311
+
1312
+
1313
+ def check_constant_destinations(program: S.Program, profile: Profile, diags: Diagnostics, env) -> None:
1314
+ """CR6/CR1000X pass 1: a constant where a destination variable is required (type codes 8, 27,
1315
+ 29) -> `Invalid Variable or Array: %s.`, a one-line If's statement too. Reported beside every
1316
+ other pass-1 error in line order, and so hiding the stage-2a `Reps`/bounds checks and pass 2
1317
+ (oracle, round 59, docs/checker.md#constant-destinations-are-pass-1-round-59)."""
1318
+ from . import expressions
1319
+
1320
+ if profile.name not in ("cr6", "cr1000x"):
1321
+ return
1322
+ variables = symbols.declared_variable_names(program)
1323
+ in_const_table = False # its constants are settable at run time (`MenuRecompile("x",C)`)
1324
+ for st in program.statements:
1325
+ if isinstance(st, S.Instruction) and st.keyword.upper() in ("CONSTTABLE", "ENDCONSTTABLE"):
1326
+ in_const_table = st.keyword.upper() == "CONSTTABLE"
1327
+ elif in_const_table and isinstance(st, S.ConstDecl):
1328
+ variables.add(st.name.upper())
1329
+ for st in _with_inline(program.statements):
1330
+ record = profile.instructions.get(st.keyword.upper()) if isinstance(st, S.Instruction) else None
1331
+ if not record or st.keyword.upper() in _OWN_VARIABLE_CHECK:
1332
+ continue
1333
+ for i, code in enumerate(record["params"][: len(st.args)]):
1334
+ if code in (8, 27, 29) and constant_destination(st.args[i], env, variables):
1335
+ # (27/29: `Erase(5)`, `RealTime(5)`, 2026-10-01)
1336
+ diags.error(st.line, f"Invalid Variable or Array: {expressions._arg_text(st, i).strip()}.", st.file)
1337
+
1338
+
1339
+ def drop_unreached_destinations(program: S.Program, profile: Profile, diags: Diagnostics, env) -> None:
1340
+ """A `Variable illegal in Parameter` on an earlier argument ends the statement, so a constant
1341
+ destination after it is not reported (oracle, CR6/CR1000X, round 59: `CDM_PanelTemp(CDM_A108,
1342
+ PTemp,1,1,15000)`, `Matrix(A, 0, R)`); one before it still is (`PanelTemp(15000,Nope)`)."""
1343
+ from . import expressions
1344
+
1345
+ if profile.name not in ("cr6", "cr1000x"):
1346
+ return
1347
+ variables = symbols.declared_variable_names(program)
1348
+ drop = []
1349
+ for st in _with_inline(program.statements):
1350
+ record = profile.instructions.get(st.keyword.upper()) if isinstance(st, S.Instruction) else None
1351
+ if not record:
1352
+ continue
1353
+ here = [d for d in diags.items if (d.line, d.file) == (st.line, st.file)]
1354
+ texts = [expressions._arg_text(st, i).strip() if a is not None else None for i, a in enumerate(st.args)]
1355
+ first_bad = next(
1356
+ (
1357
+ j
1358
+ for j, text in enumerate(texts)
1359
+ if text is not None and any(d.message == f"Variable illegal in Parameter: {text}." for d in here)
1360
+ ),
1361
+ None,
1362
+ )
1363
+ if first_bad is None:
1364
+ continue
1365
+ for i, code in enumerate(record["params"][: len(st.args)]):
1366
+ if i > first_bad and code in (8, 27, 29) and constant_destination(st.args[i], env, variables):
1367
+ drop += [d for d in here if d.message == f"Invalid Variable or Array: {texts[i]}."]
1368
+ diags.items[:] = [d for d in diags.items if not any(d is x for x in drop)]
1369
+
1370
+
1371
+ def _check_stage2a(program: S.Program, profile: Profile, diags: Diagnostics, env, destinations: bool) -> bool:
1372
+ from . import expressions
1373
+
1374
+ reported = False
1375
+ tables = symbols.scope_tables(program, env)
1376
+ aliases = _alias_sources(program, tables, env) if destinations and not profile.single_pass else {}
1377
+ known = expressions._known_names(program, profile)
1378
+ source_vars = None
1379
+ in_table = False
1380
+ sources_ok = True # CR6/CR1000X: an undefined table source ends the table checks (round 38)
1381
+ top_level = {id(st) for st in program.statements}
1382
+ for st in _with_inline(program.statements):
1383
+ if not isinstance(st, S.Instruction):
1384
+ continue
1385
+ dims = tables(st).dims
1386
+ kw = st.keyword.upper()
1387
+ inline = id(st) not in top_level # a one-line If's statement: only the destination check (round 52)
1388
+ if destinations and not inline and kw == "ENDTABLE" and not in_table:
1389
+ # an orphan `EndTable` ends this walk; tier C reports it (oracle, CR6/CR1000X, round 58,
1390
+ # docs/checker.md#orphan-endtable-in-the-table-walk-round-58)
1391
+ return reported
1392
+ if not inline:
1393
+ in_table = kw == "DATATABLE" or (in_table and kw != "ENDTABLE")
1394
+ record = profile.instructions.get(kw)
1395
+ if not record:
1396
+ continue
1397
+ if inline and not destinations:
1398
+ continue
1399
+ if kw == "WINDVECTOR" and st.args and not inline:
1400
+ reps = _reps_value(st.args[0], env)
1401
+ if reps is not None and _INT32_MIN < reps < 0:
1402
+ diags.error(st.line, "Out of memory.", st.file)
1403
+ return True
1404
+ if destinations and in_table and len(st.args) >= 8 and _wind_options(st, reps, env, diags):
1405
+ return True
1406
+ if kw == "DEBUG" and not inline:
1407
+ from .instr_rules import debug_history_oom
1408
+
1409
+ if debug_history_oom(st, env, profile, program):
1410
+ diags.error(st.line, "Out of memory.", st.file)
1411
+ return True
1412
+ if inline:
1413
+ continue
1414
+ if destinations and in_table and kw in _TABLE_OUTPUT and sources_ok:
1415
+ from .structure import _undefined_table_source
1416
+
1417
+ sources_ok = _undefined_table_source(st, profile, known) is None
1418
+ wind_reps = _reps_value(st.args[0], env) if kw == "WINDVECTOR" and st.args else None
1419
+ if wind_reps == _INT32_MIN:
1420
+ # an overflowing count: the direction bounds, then `Illegal Reps -2147483648` (oracle,
1421
+ # CR6/CR1000X, round 64, docs/checker.md#overflowing-windvector-reps-round-64)
1422
+ wind_reps = 2**31
1423
+ wind = (
1424
+ kw == "WINDVECTOR"
1425
+ and destinations
1426
+ and in_table
1427
+ and len(st.args) >= 3
1428
+ and isinstance(st.args[1], (S.Name, S.Index))
1429
+ and (wind_reps or 0) >= 1
1430
+ )
1431
+ if wind and _short_array(st.args[2], wind_reps or 0, dims, env):
1432
+ # the direction first, one line (oracle, round 56, docs/checker.md#round-56-fuzz-rules)
1433
+ diags.error(st.line, f"Variable {getattr(st.args[2], 'name', '')} out of bounds.", st.file)
1434
+ if (kw in _TABLE_OUTPUT or wind) and len(st.args) >= 2 and sources_ok and (in_table or not destinations):
1435
+ # (CR6/CR1000X: outside a table only `must be used inside a table declaration`, round 39)
1436
+ reps = _reps_value(st.args[0], env)
1437
+ if reps is not None and reps < 1:
1438
+ diags.error(st.line, f"Illegal Reps {reps}", st.file)
1439
+ return True
1440
+ if not destinations and in_table and _report_undefined_source(st, record, known, profile, diags):
1441
+ return True
1442
+ source = st.args[1]
1443
+ if kw == "SAMPLE" and len(st.args) > 2:
1444
+ from .. import tables as output_tables
1445
+
1446
+ if output_tables._dtype_code(st.args[2], env) == 11:
1447
+ if source_vars is None:
1448
+ source_vars = {
1449
+ v.name.upper(): output_tables._make_var_info(v, env)
1450
+ for v in output_tables._scan_program(program)["all_vars"]
1451
+ }
1452
+ if (
1453
+ output_tables._is_anonymous_source(source, source_vars, env)
1454
+ and output_tables._string_stored_len(
1455
+ output_tables._anonymous_string_len(source, source_vars, env)
1456
+ )
1457
+ <= 0
1458
+ ):
1459
+ diags.error(st.line, "Out of memory.", st.file)
1460
+ return True
1461
+ if reps is not None and reps > 1 and not isinstance(source, (S.Name, S.Index)):
1462
+ diags.error(st.line, "Reps must be 1 when the source is an expression.", st.file)
1463
+ diags.error(st.line, f"Variable Anonymous1, index 1, reps {reps}, out of bounds.", st.file)
1464
+ return True
1465
+ if reps is None or not isinstance(source, (S.Name, S.Index)):
1466
+ continue
1467
+ shape = dims.get(source.name.upper())
1468
+ if shape is None and isinstance(source, S.Name) and source.name.upper() in aliases:
1469
+ first, alias_name, left = aliases[source.name.upper()]
1470
+ if reps > left:
1471
+ if in_table and not wind and (message := _odd_pair_reps(st, env)):
1472
+ diags.error(st.line, message, st.file)
1473
+ diags.error(st.line, f"Variable {first} out of bounds.", st.file)
1474
+ diags.error(st.line, f"Variable {alias_name}, index 1, reps {reps}, out of bounds.", st.file)
1475
+ return True
1476
+ continue
1477
+ if shape is None:
1478
+ continue
1479
+ if _is_struct_typed(program, source.name):
1480
+ # never bounds-checked: the overrun shows as duplicate field names (oracle, CR300/
1481
+ # CR350 2026-10-12, CR6/CR1000X round 59)
1482
+ continue
1483
+ start, offset = 1, 0
1484
+ empty = isinstance(source, S.Index) and not source.args
1485
+ if isinstance(source, S.Index) and not empty:
1486
+ if len(source.args) != len(shape) or any(a is None for a in source.args):
1487
+ continue
1488
+ subs = [k for a in source.args if (k := _reps_value(a, env)) is not None]
1489
+ if len(subs) != len(source.args):
1490
+ continue
1491
+ start = subs[0] if len(shape) == 1 else 1
1492
+ for sub, n in zip(subs, shape):
1493
+ offset = offset * n + sub - 1
1494
+ total = math.prod(shape)
1495
+ if offset + reps > total:
1496
+ if destinations and in_table and (message := _odd_pair_reps(st, env)):
1497
+ diags.error(st.line, message, st.file) # (first on the line, round 38)
1498
+ name = next(
1499
+ (
1500
+ v.name
1501
+ for d in program.statements
1502
+ if isinstance(d, S.Declaration)
1503
+ for v in d.vars
1504
+ if v.name.upper() == source.name.upper()
1505
+ ),
1506
+ source.name,
1507
+ )
1508
+ if not isinstance(source, S.Index) or all(isinstance(a, S.Number) for a in source.args):
1509
+ diags.error(st.line, f"Variable {name} out of bounds.", st.file)
1510
+ if empty:
1511
+ diags.error(st.line, "Invalid, or out of place expression: ().", st.file)
1512
+ if start + reps - 1 > total:
1513
+ diags.error(st.line, f"Variable {name}, index {start}, reps {reps}, out of bounds.", st.file)
1514
+ return True
1515
+ return reported
1516
+
1517
+
1518
+ def check_eventcount_tables(program: S.Program, profile: Profile, diags: Diagnostics) -> None:
1519
+ """`Tb.EventCount` for a table without `DataEvent` -> `DataTable Tb must have DataEvent() to
1520
+ request EventCount.` (oracle, 2026-10-01: pass 2 on CR6/CR1000X, alongside other pass-2
1521
+ errors; CR300/CR350 only in an otherwise clean file)."""
1522
+ tables: dict[str, bool] = {}
1523
+ current: str | None = None
1524
+ for st in program.statements:
1525
+ kw = st.keyword.upper() if isinstance(st, S.Instruction) else ""
1526
+ if isinstance(st, S.Instruction) and (kw == "DATATABLE" and st.args and isinstance(st.args[0], S.Name)):
1527
+ current = st.args[0].name.upper()
1528
+ tables.setdefault(current, False)
1529
+ elif kw == "ENDTABLE":
1530
+ current = None
1531
+ elif kw == "DATAEVENT" and current:
1532
+ tables[current] = True
1533
+ if not tables:
1534
+ return
1535
+ for st in program.statements:
1536
+ for expr in _statement_exprs(st):
1537
+ for node in _members(expr):
1538
+ base = node.base
1539
+ if (
1540
+ isinstance(base, S.Name)
1541
+ and node.member.upper() == "EVENTCOUNT"
1542
+ and tables.get(base.name.upper()) is False
1543
+ ):
1544
+ diags.error(st.line, f"DataTable {base.name} must have DataEvent() to request EventCount.", st.file)
1545
+ break
1546
+ else:
1547
+ continue
1548
+ break
1549
+
1550
+
1551
+ def _statement_exprs(st: S.Stmt) -> list[S.Expr]:
1552
+ out: list[S.Expr | None] = []
1553
+ if isinstance(st, S.Assignment):
1554
+ out += [st.target, st.value]
1555
+ elif isinstance(st, (S.If, S.ElseIf, S.DoLoop)):
1556
+ out.append(st.cond)
1557
+ elif isinstance(st, (S.Instruction, S.Call)):
1558
+ out += list(st.args)
1559
+ elif isinstance(st, S.For):
1560
+ out += [st.start, st.end, st.step]
1561
+ return [e for e in out if e is not None]
1562
+
1563
+
1564
+ def _members(expr: S.Expr | None):
1565
+ if isinstance(expr, S.Member):
1566
+ yield expr
1567
+ yield from _members(expr.base)
1568
+ for a in expr.args or []:
1569
+ yield from _members(a)
1570
+ elif isinstance(expr, S.Index):
1571
+ for a in expr.args:
1572
+ yield from _members(a)
1573
+ elif isinstance(expr, S.Binary):
1574
+ yield from _members(expr.left)
1575
+ yield from _members(expr.right)
1576
+ elif isinstance(expr, S.Unary):
1577
+ yield from _members(expr.operand)
1578
+
1579
+
1580
+ _TABLE_NAME_LIMIT = 20
1581
+ _EXPR_NAME = re.compile(r"[0-9][0-9A-Za-z_]*(?:[-+*/^][0-9A-Za-z_]+)+\Z")
1582
+
1583
+
1584
+ def _declared_table_name(st: S.Instruction) -> str | None:
1585
+ """The text the real compiler takes as a `DataTable` name: a bare name, a string without
1586
+ spaces, or (r16) a digit-led operator expression read as raw text, `(1+2)` / `5-2` -> `1+2`
1587
+ (no spaces, no inner parentheses; oracle, cr6 and cr300, 2026-10-12)."""
1588
+ if not st.args:
1589
+ return None
1590
+ first = st.args[0]
1591
+ if isinstance(first, S.Name):
1592
+ return first.name
1593
+ if isinstance(first, S.String) and re.fullmatch(r"[0-9A-Za-z_.]+", first.value):
1594
+ return first.value
1595
+ if isinstance(first, S.Binary) and st.arg_spans and (span := st.arg_spans[0]):
1596
+ text = st.text[span[0] : span[1]].strip()
1597
+ if text.startswith("(") and text.endswith(")"):
1598
+ text = text[1:-1]
1599
+ if _EXPR_NAME.match(text):
1600
+ return text
1601
+ return None
1602
+
1603
+
1604
+ def _illegal_table_name(st: S.Instruction) -> list[Diagnostic]:
1605
+ """oracle, all four loggers, 2026-10-11 (docs/checker.md "Table names starting with a digit");
1606
+ a name over 20 characters is `DataTable name X is N chars too long.` and wins over the digit
1607
+ messages (2026-10-12, docs/checker.md "Round 16 message-diff families")"""
1608
+ name = _declared_table_name(st)
1609
+ if name is None:
1610
+ return []
1611
+ if len(name) > _TABLE_NAME_LIMIT:
1612
+ text = f"DataTable name {name} is {len(name) - _TABLE_NAME_LIMIT} chars too long."
1613
+ elif name[:1] in "0123456789.":
1614
+ text = (
1615
+ f"DataTable name {name} is illegal." if "." in name else f"Table name {name} illegal, starts with a number."
1616
+ )
1617
+ else:
1618
+ return []
1619
+ return [Diagnostic("error", st.line, text, st.file, cascade=False)]
1620
+
1621
+
1622
+ def is_table_name_diag(d: Diagnostic) -> bool:
1623
+ """CR6/CR1000X: a table-name error does not keep the tier-C checks (instruction channel/range
1624
+ validation, `Sample must be used inside a table declaration.`, `Table T declared with no
1625
+ output fields.`) from running (oracle, cr6, 2026-10-12)."""
1626
+ return d.message.startswith(("Table name ", "DataTable name ")) and d.message.endswith(
1627
+ ("starts with a number.", "is illegal.", "chars too long.")
1628
+ )
1629
+
1630
+
1631
+ def finalize_illegal_table_names(program: S.Program, profile: Profile, diags: Diagnostics) -> None:
1632
+ """CR300/CR350 only: the first `Table name X illegal, starts with a number.` is the only one
1633
+ reported, and only when no other error was found earlier in the compile (oracle, 2026-10-11,
1634
+ docs/checker.md "Table names starting with a digit"). Errors outside table bodies and
1635
+ unknown-statement errors inside them come first and hide it; an undefined name inside a table
1636
+ body (`Zed is not defined.`) races it in file order, the first one wins and ends the rest."""
1637
+ marks = [
1638
+ d
1639
+ for st in program.statements
1640
+ if isinstance(st, S.Instruction) and st.keyword.upper() == "DATATABLE"
1641
+ for d in _illegal_table_name(st)
1642
+ ][:1]
1643
+ if not marks:
1644
+ return
1645
+ bodies: list[tuple[str, int, int]] = []
1646
+ start = None
1647
+ for st in program.statements:
1648
+ kw = st.keyword.upper() if isinstance(st, S.Instruction) else ""
1649
+ if kw == "DATATABLE":
1650
+ start = st
1651
+ elif kw == "ENDTABLE" and start is not None:
1652
+ bodies.append((start.file, start.line, st.line))
1653
+ start = None
1654
+ if start is not None:
1655
+ bodies.append((start.file, start.line, 10**9))
1656
+ unparsed = {(st.file, st.line) for st in program.statements if isinstance(st, S.Unparsed)}
1657
+
1658
+ def late(d) -> bool: # resolved after the table names: `CallTable T9`, an unclosed table
1659
+ return (
1660
+ d.message.startswith("Could not find DataTable ")
1661
+ or d.message in ("DataTable ... EndTable not matched.", "No BeginProg in Program.")
1662
+ or d.message.endswith(("must be used inside a table declaration.", "declared with no output fields."))
1663
+ or d.message.startswith("Invalid port specified ")
1664
+ )
1665
+
1666
+ def stage_b(d) -> bool:
1667
+ return (
1668
+ late(d)
1669
+ or d.message.endswith(" is not defined.")
1670
+ and (d.file, d.line) not in unparsed
1671
+ and any(f == d.file and a < d.line <= b for f, a, b in bodies)
1672
+ )
1673
+
1674
+ others = [d for d in diags.items if d.severity == "error"]
1675
+ if any(not stage_b(d) for d in others):
1676
+ return
1677
+ first = sorted([*marks, *others], key=lambda d: (late(d), d.file or "", d.line))[0]
1678
+ diags.items[:] = [d for d in diags.items if d.severity != "error"] + [first]
1679
+
1680
+
1681
+ # CR300/CR350: codes in 1..30 that the real compiler rejects with `Illegal data type parameter %d
1682
+ # in %s instruction.` alone (oracle sweep 0..30 of `Sample(1,X,n)`, 2026-10-12; 0, 16, 29 and 30
1683
+ # also print a `declared with no output fields` line and 1, 17, 23 other messages, so they are
1684
+ # left out there).
1685
+ # CR6/CR1000X, round 36 (sweep 0..255 of `Sample`, alone and beside a second field): every code in
1686
+ # 0..127 but the legal ones and 1, 14, 17 is illegal; the codes in `_FIELDLESS_DATA_TYPES` also
1687
+ # give no field (with `_SILENT_FIELDLESS`, which print nothing), so a table of only those is
1688
+ # `declared with no output fields` (docs/checker.md#output-data-type-codes-round-36).
1689
+ _CR6_ILLEGAL = frozenset(range(128)) - {1, 2, 3, 6, 7, 9, 11, 14, 17, 18, 20, 24, 25, 28}
1690
+ _FIELDLESS_DATA_TYPES = frozenset({0, 16, *range(29, 44), *range(48, 57), *range(60, 64), 94})
1691
+ _SILENT_FIELDLESS = frozenset(
1692
+ {*range(156, 160), *range(180, 184), *range(204, 212), *range(220, 224), *range(236, 240), *range(252, 256)}
1693
+ )
1694
+ _ILLEGAL_DATA_TYPES = {
1695
+ "cr6": _CR6_ILLEGAL,
1696
+ "cr1000x": _CR6_ILLEGAL,
1697
+ "cr300": frozenset({2, 3, 4, 5, 8, 10, 12, 13, 14, 15, 19, 26, 27}),
1698
+ "cr350": frozenset({2, 3, 4, 5, 8, 10, 12, 13, 14, 15, 19, 26, 27}),
1699
+ }
1700
+ _DATA_TYPE_ARG = {
1701
+ "SAMPLE": 2,
1702
+ "AVERAGE": 2,
1703
+ "MAXIMUM": 2,
1704
+ "MINIMUM": 2,
1705
+ "WINDVECTOR": 3,
1706
+ } # WindVector: round 36, Minimum: 56
1707
+
1708
+
1709
+ def _constant_data_type(stmt: S.Instruction, pos: int, env: consteval.Env) -> int | None:
1710
+ """The truncated constant `DataType` at `pos`, or None (not constant, negative, past 255)."""
1711
+ try:
1712
+ value = consteval.value_of(stmt.args[pos], env)
1713
+ except consteval.NotConstant:
1714
+ return None
1715
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or not 0 <= value < 256:
1716
+ return None
1717
+ return int(value)
1718
+
1719
+
1720
+ def _fieldless(stmt: S.Instruction, profile: Profile, env: consteval.Env) -> bool:
1721
+ pos = _DATA_TYPE_ARG.get(stmt.keyword.upper())
1722
+ if profile.name not in ("cr6", "cr1000x") or pos is None or pos >= len(stmt.args) or stmt.args[pos] is None:
1723
+ return False
1724
+ return _constant_data_type(stmt, pos, env) in _FIELDLESS_DATA_TYPES | _SILENT_FIELDLESS
1725
+
1726
+
1727
+ def check_output_datatype(program: S.Program, profile: Profile, diags: Diagnostics, env: consteval.Env) -> None:
1728
+ """A constant `DataType` that is no data type: `Sample(1,X,(1+2))` on CR300/CR350,
1729
+ `Sample(1,X,4)` everywhere -> `Illegal data type parameter 3 in Sample instruction.` (the value
1730
+ truncated: `2.5` is 2). Reported like `instr_rules.check_avgrun_options` (with pass 2 on
1731
+ CR6/CR1000X, alone in an otherwise clean file on CR300/CR350); oracle, 2026-10-12."""
1732
+ bad = _ILLEGAL_DATA_TYPES.get(profile.name)
1733
+ if not bad:
1734
+ return
1735
+ in_table = False # outside a table, `must be used inside a table declaration` wins
1736
+ for stmt in program.statements:
1737
+ if not isinstance(stmt, S.Instruction):
1738
+ continue
1739
+ kw = stmt.keyword.upper()
1740
+ in_table = kw == "DATATABLE" or (in_table and kw != "ENDTABLE")
1741
+ pos = _DATA_TYPE_ARG.get(kw)
1742
+ if pos is None or not in_table or pos >= len(stmt.args) or stmt.args[pos] is None:
1743
+ continue
1744
+ value = _constant_data_type(stmt, pos, env)
1745
+ if value is not None and value in bad:
1746
+ name = profile.instructions[stmt.keyword.upper()]["name"]
1747
+ diags.error(stmt.line, f"Illegal data type parameter {int(value)} in {name} instruction.", stmt.file)
1748
+
1749
+
1750
+ def _odd_pair_reps(stmt: S.Instruction, env: consteval.Env) -> str | None:
1751
+ """CR6/CR1000X: UINT1 / BOOL8 pack bits or bytes in pairs, so odd constant Reps is illegal
1752
+ after integer conversion (rounds 36/60); the message, or None."""
1753
+ pos = _DATA_TYPE_ARG.get(stmt.keyword.upper())
1754
+ if (
1755
+ pos is None
1756
+ or pos >= len(stmt.args)
1757
+ or stmt.args[pos] is None
1758
+ or stmt.args[0] is None
1759
+ or _constant_data_type(stmt, pos, env) not in (1, 17)
1760
+ ):
1761
+ return None
1762
+ reps = _reps_value(stmt.args[0], env)
1763
+ if reps is not None and reps > 0 and reps % 2:
1764
+ return f"Reps {reps} illegal; with BOOL8 and UINT1 data types, reps must be evenly divisible by 2"
1765
+ return None
1766
+
1767
+
1768
+ def check_odd_pair_reps(program: S.Program, profile: Profile, diags: Diagnostics, env: consteval.Env) -> None:
1769
+ """`_odd_pair_reps` inside a table, with pass 2 but printed first on its line (oracle, round 38:
1770
+ ahead of `Invalid symbol in expression: Nope.` in `Sample(9,Nope,UINT1)`)."""
1771
+ if profile.name not in ("cr6", "cr1000x"):
1772
+ return
1773
+ in_table = False
1774
+ types = {
1775
+ v.name.upper(): v.type or "FLOAT" for st in program.statements if isinstance(st, S.Declaration) for v in st.vars
1776
+ }
1777
+ for st in program.statements: # an `Alias` has its target's type
1778
+ if (
1779
+ isinstance(st, S.Instruction)
1780
+ and st.keyword.upper() == "ALIAS"
1781
+ and len(st.args) > 1
1782
+ and isinstance(st.args[0], (S.Name, S.Index))
1783
+ and isinstance(st.args[1], S.Name)
1784
+ ):
1785
+ types.setdefault(st.args[1].name.upper(), types.get(st.args[0].name.upper(), "UINT1"))
1786
+ for stmt in program.statements:
1787
+ if isinstance(stmt, S.Instruction):
1788
+ kw = stmt.keyword.upper()
1789
+ in_table = kw == "DATATABLE" or (in_table and kw != "ENDTABLE")
1790
+ if in_table and (message := _odd_pair_reps(stmt, env)):
1791
+ diags.error(stmt.line, message, stmt.file)
1792
+ # (oracle, round 56: `Sample(1,X,UINT1)` with a Float or Long X)
1793
+ if (
1794
+ in_table
1795
+ and kw == "SAMPLE"
1796
+ and len(stmt.args) > 2
1797
+ and stmt.args[2] is not None
1798
+ and _constant_data_type(stmt, 2, env) == 1
1799
+ and isinstance(stmt.args[1], (S.Name, S.Index))
1800
+ and types.get(stmt.args[1].name.upper(), "UINT1") != "UINT1"
1801
+ ):
1802
+ diags.error(stmt.line, "Sample type UINT1 can only sample a variable of type UINT1.", stmt.file)