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.
- opencrbasic/__init__.py +3 -0
- opencrbasic/__main__.py +5 -0
- opencrbasic/analysis.py +184 -0
- opencrbasic/bindings.py +1034 -0
- opencrbasic/catalog.py +173 -0
- opencrbasic/checkconfig.py +144 -0
- opencrbasic/checker/__init__.py +977 -0
- opencrbasic/checker/cr3xx.py +3118 -0
- opencrbasic/checker/ess.py +81 -0
- opencrbasic/checker/expressions.py +3581 -0
- opencrbasic/checker/instr_data.py +1802 -0
- opencrbasic/checker/instr_measure.py +1547 -0
- opencrbasic/checker/instr_rules.py +3540 -0
- opencrbasic/checker/instructions.py +17 -0
- opencrbasic/checker/intervals.py +678 -0
- opencrbasic/checker/longconv.py +133 -0
- opencrbasic/checker/scan_time.py +580 -0
- opencrbasic/checker/storage.py +456 -0
- opencrbasic/checker/structs.py +240 -0
- opencrbasic/checker/structure.py +6213 -0
- opencrbasic/checker/symbols.py +609 -0
- opencrbasic/checker/terminals.py +249 -0
- opencrbasic/checker/unused.py +186 -0
- opencrbasic/checker/var_types.py +564 -0
- opencrbasic/checker/warnings.py +661 -0
- opencrbasic/checks.py +491 -0
- opencrbasic/cli.py +391 -0
- opencrbasic/commands.py +128 -0
- opencrbasic/compiler.py +320 -0
- opencrbasic/consteval.py +327 -0
- opencrbasic/data/background_calibration.json +4 -0
- opencrbasic/data/before_beginprog.json +99 -0
- opencrbasic/data/cr1000x/background_calibration.json +4 -0
- opencrbasic/data/cr1000x/before_beginprog.json +138 -0
- opencrbasic/data/cr1000x/ess_variables.json +1087 -0
- opencrbasic/data/cr1000x/fnotch.json +29 -0
- opencrbasic/data/cr1000x/functions.json +1337 -0
- opencrbasic/data/cr1000x/instructions.json +4836 -0
- opencrbasic/data/cr1000x/measure.json +121 -0
- opencrbasic/data/cr1000x/mode.json +111 -0
- opencrbasic/data/cr1000x/pipeline_tasks.json +88 -0
- opencrbasic/data/cr1000x/settings_fields.json +321 -0
- opencrbasic/data/cr1000x/status_members.json +318 -0
- opencrbasic/data/cr1000x/symbols.json +872 -0
- opencrbasic/data/cr1000x/terminals.json +225 -0
- opencrbasic/data/cr300/before_beginprog.json +42 -0
- opencrbasic/data/cr300/ess_variables.json +1087 -0
- opencrbasic/data/cr300/fnotch.json +22 -0
- opencrbasic/data/cr300/functions.json +1261 -0
- opencrbasic/data/cr300/instructions.json +2837 -0
- opencrbasic/data/cr300/measure.json +33 -0
- opencrbasic/data/cr300/mode.json +121 -0
- opencrbasic/data/cr300/settings_fields.json +259 -0
- opencrbasic/data/cr300/status_members.json +303 -0
- opencrbasic/data/cr300/symbols.json +584 -0
- opencrbasic/data/cr300/terminals.json +193 -0
- opencrbasic/data/cr350/before_beginprog.json +44 -0
- opencrbasic/data/cr350/ess_variables.json +1087 -0
- opencrbasic/data/cr350/fnotch.json +22 -0
- opencrbasic/data/cr350/functions.json +1262 -0
- opencrbasic/data/cr350/instructions.json +2848 -0
- opencrbasic/data/cr350/measure.json +35 -0
- opencrbasic/data/cr350/mode.json +121 -0
- opencrbasic/data/cr350/settings_fields.json +292 -0
- opencrbasic/data/cr350/status_members.json +303 -0
- opencrbasic/data/cr350/symbols.json +572 -0
- opencrbasic/data/cr350/terminals.json +190 -0
- opencrbasic/data/ess_variables.json +1087 -0
- opencrbasic/data/fnotch.json +34 -0
- opencrbasic/data/functions.json +1337 -0
- opencrbasic/data/instructions.json +4965 -0
- opencrbasic/data/measure.json +123 -0
- opencrbasic/data/mode.json +121 -0
- opencrbasic/data/notices_cr6.json +890 -0
- opencrbasic/data/pipeline_tasks.json +84 -0
- opencrbasic/data/settings_fields.json +322 -0
- opencrbasic/data/status_members.json +319 -0
- opencrbasic/data/status_table.json +684 -0
- opencrbasic/data/symbols.json +902 -0
- opencrbasic/data/terminals.json +195 -0
- opencrbasic/data/type_codes.json +249 -0
- opencrbasic/diagnostics.py +196 -0
- opencrbasic/editing.py +827 -0
- opencrbasic/formatting.py +310 -0
- opencrbasic/helpdocs.py +677 -0
- opencrbasic/hover.py +667 -0
- opencrbasic/inlay.py +169 -0
- opencrbasic/lexer.py +183 -0
- opencrbasic/lsp/__init__.py +5 -0
- opencrbasic/lsp/__main__.py +7 -0
- opencrbasic/lsp/config.py +202 -0
- opencrbasic/lsp/features.py +602 -0
- opencrbasic/lsp/server.py +741 -0
- opencrbasic/lsp/session.py +1189 -0
- opencrbasic/lsp/worker.py +128 -0
- opencrbasic/mode.py +143 -0
- opencrbasic/navigation.py +401 -0
- opencrbasic/parser.py +3264 -0
- opencrbasic/positions.py +274 -0
- opencrbasic/preprocess.py +269 -0
- opencrbasic/profiles.py +266 -0
- opencrbasic/provenance.py +17 -0
- opencrbasic/quickfix.py +331 -0
- opencrbasic/rename.py +662 -0
- opencrbasic/signature.py +33 -0
- opencrbasic/source.py +600 -0
- opencrbasic/sources.py +125 -0
- opencrbasic/syntax.py +337 -0
- opencrbasic/tables.py +2173 -0
- opencrbasic/tables_hook.py +14 -0
- opencrbasic/tdf.py +142 -0
- opencrbasic/timing.py +565 -0
- opencrbasic-1.0.0.dist-info/METADATA +223 -0
- opencrbasic-1.0.0.dist-info/RECORD +117 -0
- opencrbasic-1.0.0.dist-info/WHEEL +4 -0
- opencrbasic-1.0.0.dist-info/entry_points.txt +3 -0
- 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)
|