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