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,3581 @@
|
|
|
1
|
+
"""Name resolution and type-code-driven value checks for instruction arguments. Rules and oracle
|
|
2
|
+
evidence: `docs/checker.md`.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from functools import lru_cache
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import cast
|
|
10
|
+
|
|
11
|
+
from .. import consteval
|
|
12
|
+
from .. import syntax as S
|
|
13
|
+
from ..diagnostics import Diagnostic, Diagnostics
|
|
14
|
+
from ..profiles import DottedName
|
|
15
|
+
from . import instr_data, symbols
|
|
16
|
+
|
|
17
|
+
_TYPE_CODES_PATH = Path(__file__).resolve().parent.parent / "data" / "type_codes.json"
|
|
18
|
+
|
|
19
|
+
# `type_codes.json` `kind`s that route an argument through the real expression/named-constant
|
|
20
|
+
# resolver (`FUN_004b8700`/`FUN_004b5c50` and their `const`-only wrappers, `docs/type-codes.md`):
|
|
21
|
+
# an unresolved name anywhere inside one of these positions is what produces `Invalid symbol in
|
|
22
|
+
# expression: %s.`. Every other kind either never runs general symbol resolution at all (`6`
|
|
23
|
+
# identifier/raw text such as `DataTable`'s or `ConstTable`'s own name, captured verbatim and
|
|
24
|
+
# validated -- or not -- by a later, different pass) or resolves a fixed keyword list through a
|
|
25
|
+
# separate function (`4`/`16`/`17`/`19` `FUN_004b5db0`, a different failure message). Restricting
|
|
26
|
+
# to this explicit include-list (rather than excluding the handful of kinds known *not* to
|
|
27
|
+
# resolve) is deliberate: the default for an unrecognised kind is "don't check", so a gap in this
|
|
28
|
+
# list only under-reports, never falsely rejects (AGENTS.md's priority order); the `ConstTable`
|
|
29
|
+
# regression this session found during development (`crbasic/Main.CR6`'s
|
|
30
|
+
# `ConstTable(GlobalConfigTable)`, kind `expr_marker`) is the concrete case that shape of bug
|
|
31
|
+
# looks like.
|
|
32
|
+
_RESOLVED_KINDS = frozenset(
|
|
33
|
+
{
|
|
34
|
+
"const_expr",
|
|
35
|
+
"expr",
|
|
36
|
+
"const_expr_only",
|
|
37
|
+
"bool_expr",
|
|
38
|
+
"variable_or_array",
|
|
39
|
+
"expr_or_name",
|
|
40
|
+
"expr_typed",
|
|
41
|
+
"expr_special",
|
|
42
|
+
"expr_pair",
|
|
43
|
+
"expr_list",
|
|
44
|
+
"datatype_kw",
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# The compile-time-constant-only family (`docs/type-codes.md` codes 1/39, 5/38, 20 --
|
|
49
|
+
# `FUN_004b8700`/its `const`-only wrappers) is checked in its own pass, ahead of every other
|
|
50
|
+
# resolved kind, on CR6/CR1000X -- oracle-confirmed (round 4, this session): `WriteIO(1,BazQux,
|
|
51
|
+
# FooBar)` (`params=[1,25,101]`: position 0 code 1 = valid literal, position 1 code 25 = `BazQux`,
|
|
52
|
+
# position 2 code 101 = optional code 1 = `FooBar`) reports only `FooBar` (position 2, the later
|
|
53
|
+
# *const-family* position), never `BazQux` (position 1, the textually-earlier *general* position)
|
|
54
|
+
# -- and the reverse instruction shape, `CardTest(GenBad,ConstBad)` (`params=[8,1]`: position 0
|
|
55
|
+
# general, position 1 const), reports only `ConstBad` (the const-family position) even though it
|
|
56
|
+
# is the *later* argument. Within one family, the ordering is the ordinary ascending textual
|
|
57
|
+
# position (`WriteIO(FooBar,BazQux,1)` -> `FooBar`, the first *const*-family position; `WriteIO(1,
|
|
58
|
+
# BazQux,FooBar)` with only position 1 bad -> `BazQux`). Read together, this is one const-family
|
|
59
|
+
# pre-pass over every const-coded position (ascending) before the general-family pass runs at all,
|
|
60
|
+
# not a per-position textual scan -- plausibly the same constant-folding pre-pass CRBasic needs to
|
|
61
|
+
# size buffers/arrays from other arguments before it can even parse the rest of the call. Not
|
|
62
|
+
# traced to the decompilation; `docs/checker.md` records the oracle transcript.
|
|
63
|
+
_CONST_FAMILY_KINDS = frozenset({"const_expr", "const_expr_only", "datatype_kw"})
|
|
64
|
+
|
|
65
|
+
# CR300/CR350 do not share this pre-pass at all: their own position order is the plain ascending
|
|
66
|
+
# textual order, oracle-confirmed with the same `InternalTemp(GenBad,ConstBad)` shape
|
|
67
|
+
# (`params=[8,20]`: position 0 general, position 1 const-family `datatype_kw`) reporting the
|
|
68
|
+
# textually-*first* position (`GenBad is not defined.`) and then, unlike CR6/CR1000X, abandoning
|
|
69
|
+
# the rest of the statement's own parameter list entirely rather than moving on to check position 1
|
|
70
|
+
# at all (`Too many parameters: ConstBad.` -- position 1's own token was never consumed). They also
|
|
71
|
+
# echo every undefined name found *inside* one compound expression as its own separate diagnostic
|
|
72
|
+
# line, not one whole-expression echo (`docs/checker.md`, "Round 4"): `Randomize(X + FooBar +
|
|
73
|
+
# BazQux)` is one `Invalid symbol in expression: X + FooBar + BazQux.` line on CR6/CR1000X but two
|
|
74
|
+
# separate `FooBar is not defined.` / `BazQux is not defined.` lines on CR300/CR350, in the source
|
|
75
|
+
# (left-to-right) order `_undefined_names` already walks in. Modelled below as a plain per-logger
|
|
76
|
+
# branch on `profile.name` (both loggers in each pair share identical behaviour, oracle-confirmed
|
|
77
|
+
# side by side) rather than a new `Profile` field, to keep this session's change inside
|
|
78
|
+
# `checker/expressions.py`/`checker/symbols.py` (`PLAN.md`'s ownership split).
|
|
79
|
+
_WHOLE_EXPR_ECHO_LOGGERS = frozenset({"cr6", "cr1000x"})
|
|
80
|
+
|
|
81
|
+
# `NAN`/`INF` are accepted as predefined float constants on all four loggers (oracle-confirmed,
|
|
82
|
+
# this session, `docs/checker.md`) despite being absent from every `data/<logger>/symbols.json` --
|
|
83
|
+
# that table is the compiler's *named-constant* lookup (`FUN_004ee4f0`, `docs/type-codes.md`), and
|
|
84
|
+
# `NAN`/`INF` evidently resolve through a separate, special-cased path in the expression compiler
|
|
85
|
+
# itself (a floating-point literal spelling, not a named constant) that this session did not trace
|
|
86
|
+
# to the decompilation. Added here rather than to the extracted `symbols.json` files so as not to
|
|
87
|
+
# misrepresent a value as extracted-from-the-binary-table when it demonstrably is not one of that
|
|
88
|
+
# table's rows (AGENTS.md: label inference and unverified behaviour explicitly; here the *acceptance*
|
|
89
|
+
# is oracle-verified even though the *mechanism* is not).
|
|
90
|
+
_EXTRA_PREDEFINED_SYMBOLS = frozenset({"NAN", "INF"})
|
|
91
|
+
|
|
92
|
+
# Constant-only-and-rejects-a-variable, the narrower half of `_CONST_FAMILY_KINDS` (`docs/
|
|
93
|
+
# type-codes.md` codes 1/39 `const_expr`, 5/38 `const_expr_only` -- **not** 20 `datatype_kw`,
|
|
94
|
+
# excluded below): oracle-confirmed uniform across all four loggers, identical message text and no
|
|
95
|
+
# per-logger branch needed (unlike the undefined-symbol family) -- `WriteIO(X,1)` ->
|
|
96
|
+
# `Variable illegal in Parameter: X.` and `Scan(X+1,Sec,0,0)` -> `Variable illegal in Parameter:
|
|
97
|
+
# X+1.` both reproduce byte-for-byte on CR6/CR1000X/CR300/CR350, whole-expression echo included
|
|
98
|
+
# (round 4, this session, `docs/checker.md`).
|
|
99
|
+
#
|
|
100
|
+
# Deliberately narrower than the type code alone would suggest, on two axes, both to route around
|
|
101
|
+
# round 3's own found regression (`Sample`'s `DataType` argument, also coded `1`, legitimately
|
|
102
|
+
# accepting a `StructureType` name, `structuretype_1.CR6`):
|
|
103
|
+
#
|
|
104
|
+
# 1. **`datatype_kw` (code 20) is excluded from this set entirely** -- it is a keyword-validated
|
|
105
|
+
# slot (`thunk_FUN_00523010`, not `FUN_004b8700`) with its own per-instruction legal-value list
|
|
106
|
+
# this project has not traced (`docs/type-codes.md` "inferred" confidence only), so no oracle
|
|
107
|
+
# evidence here supports even the ordinary "rejects a variable" half of the rule for it.
|
|
108
|
+
# 2. **The trigger is "references a declared runtime variable" (`symbols.declared_variable_names`
|
|
109
|
+
# -- `Public`/`Dim`/`Static`/a `Sub`/`Function` parameter), not "fails to fold to a constant."**
|
|
110
|
+
# A `StructureType` name is neither: it is not a live variable (so this check leaves it alone,
|
|
111
|
+
# unlike a generic `not consteval.is_constant(...)` test would) and it is not expected to fold
|
|
112
|
+
# (so `Sample(1,S,VWSensor)` is simply never flagged by this narrower trigger, without needing
|
|
113
|
+
# a `Sample`-specific carve-out at all). A corpus-wide scan this session ran (every `chm`/`repo`
|
|
114
|
+
# const-only-family argument that is a bare `Name` resolving to a declared *variable*) found
|
|
115
|
+
# exactly one candidate, and it turned out to be an unrelated CHM-prose-as-code parsing
|
|
116
|
+
# artifact (`straincalc_1.CR6`'s `Average Strain` line, a stray heading fragment, not a real
|
|
117
|
+
# statement) rather than a genuine override -- some further override may still exist outside
|
|
118
|
+
# this corpus's ~400-instruction coverage, so this stays a scoped, corpus-verified rule, not a
|
|
119
|
+
# claim that every const-only position everywhere is now covered.
|
|
120
|
+
_CONST_ONLY_VARIABLE_REJECT_KINDS = frozenset({"const_expr", "const_expr_only"})
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _has_dotted_field(expr: S.Expr | None) -> bool:
|
|
124
|
+
"""Whether `expr` holds a `Member` on a plain name (`Status.Battery`, `T.Field`)."""
|
|
125
|
+
if isinstance(expr, S.Member):
|
|
126
|
+
return isinstance(expr.base, S.Name) or _has_dotted_field(expr.base)
|
|
127
|
+
if isinstance(expr, S.Unary):
|
|
128
|
+
return _has_dotted_field(expr.operand)
|
|
129
|
+
if isinstance(expr, S.Binary):
|
|
130
|
+
return _has_dotted_field(expr.left) or _has_dotted_field(expr.right)
|
|
131
|
+
if isinstance(expr, S.Index):
|
|
132
|
+
return any(_has_dotted_field(a) for a in expr.args)
|
|
133
|
+
return False
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _variable_refs(expr: S.Expr | None, variable_names: set[str]) -> list[str]:
|
|
137
|
+
"""Every reference to a genuine runtime variable (`variable_names`) anywhere inside `expr`,
|
|
138
|
+
in source order -- the const-only-family counterpart of `_undefined_names` above, same
|
|
139
|
+
recursion shape (walks `Unary`/`Binary`/`Index`'s own name and args/`Member`'s base/
|
|
140
|
+
`ArrayLiteral` items; `Member.member` text untouched, same reasoning as `_undefined_names`).
|
|
141
|
+
A name that resolves to anything *other* than a declared variable (a `Const`, a
|
|
142
|
+
`StructureType` name, a predefined symbol, ...) is not itself a live variable, so it is left
|
|
143
|
+
alone here even though it may still be flagged separately by `_undefined_names` if it is
|
|
144
|
+
undefined outright.
|
|
145
|
+
"""
|
|
146
|
+
if expr is None:
|
|
147
|
+
return []
|
|
148
|
+
if isinstance(expr, S.Name):
|
|
149
|
+
return [expr.name] if expr.name.upper() in variable_names else []
|
|
150
|
+
if isinstance(expr, S.Unary):
|
|
151
|
+
return _variable_refs(expr.operand, variable_names)
|
|
152
|
+
if isinstance(expr, S.Binary):
|
|
153
|
+
return _variable_refs(expr.left, variable_names) + _variable_refs(expr.right, variable_names)
|
|
154
|
+
if isinstance(expr, S.Index):
|
|
155
|
+
head = [expr.name] if expr.name.upper() in variable_names else []
|
|
156
|
+
for a in expr.args:
|
|
157
|
+
head += _variable_refs(a, variable_names)
|
|
158
|
+
return head
|
|
159
|
+
if isinstance(expr, S.Member):
|
|
160
|
+
return _variable_refs(expr.base, variable_names)
|
|
161
|
+
if isinstance(expr, S.ArrayLiteral):
|
|
162
|
+
out: list[str] = []
|
|
163
|
+
for item in expr.items:
|
|
164
|
+
out += _variable_refs(item, variable_names)
|
|
165
|
+
return out
|
|
166
|
+
return [] # Number, String: nothing to resolve
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# Variable-required kinds (`docs/type-codes.md` codes 8, 27, 29, 33, 41, 47, 48): the shared
|
|
170
|
+
# "variable or array" handler accepts a bare name, an array reference, or any expression that
|
|
171
|
+
# involves a real variable (silently promoted to a hidden temporary) -- but rejects an argument
|
|
172
|
+
# that is *itself* a compile-time constant (a bare literal, a named `Const`, or a pure constant
|
|
173
|
+
# expression) with `Invalid Variable or Array: %s.`, **unless the constant is a string**
|
|
174
|
+
# (oracle-confirmed this session: `Battery(5)`, `Battery(1+2)`, and `Battery(SomeConst)` -- a
|
|
175
|
+
# `Const SomeConst = 5` -- all rejected this way; `Battery("x")` compiles fine, no type check at
|
|
176
|
+
# all on a string constant here). Still **not** generically checked here, unlike the const-only
|
|
177
|
+
# family above: round 3 found two overriding instructions (`Move`'s third argument, `ModBusClient`'s
|
|
178
|
+
# last argument, both accepting a bare literal), and round 4's own corpus scan (this session) found
|
|
179
|
+
# a *third*, independent of either -- `MQTTPublishTable`'s optional `Longitude`/`Latitude`/
|
|
180
|
+
# `Altitude` arguments (code `148`, `mqttpublishtable_1.CR6` line 17, oracle-confirmed accepted in
|
|
181
|
+
# isolation on both CR6 and CR1000X: `MQTTPublishTable(1, 0, 5, Min, 2, -111.85, 41.76, 1)`). Three
|
|
182
|
+
# confirmed overrides found from a ~523-file corpus sampling only a fraction of each logger's
|
|
183
|
+
# ~400-instruction table make a blanket rule too risky to enable without a plausibly-still-
|
|
184
|
+
# incomplete per-instruction exception list; unlike the const-only-family rule above, no version of
|
|
185
|
+
# this trigger (bare-literal vs. "fails to fold", posed several ways during round 3 and again this
|
|
186
|
+
# round) has been found that reliably separates the genuine cases from the overrides using only the
|
|
187
|
+
# type-code table. Left unimplemented (AGENTS.md: never reject an accepted program); a future
|
|
188
|
+
# session would need to check each variable-required-coded parameter individually across all four
|
|
189
|
+
# loggers' instruction tables (or find the compiler's own per-instruction override flag) before
|
|
190
|
+
# enabling this safely.
|
|
191
|
+
_VARIABLE_REQUIRED_KINDS = frozenset({"variable_or_array"})
|
|
192
|
+
|
|
193
|
+
# Per-instruction overrides of a `variable_or_array` (kind 8) `Dest` position: `AddPrecise`'s and
|
|
194
|
+
# `MovePrecise`'s own first argument (both `params=[8, 2]`, adjacent table indices 25/26) accepts
|
|
195
|
+
# an *undeclared* name outright -- oracle-confirmed (round 7, this session, a saved fuzz false-
|
|
196
|
+
# reject reproducer, `tests/corpus/fuzz/cr6/false_reject/fac2798b6778.CR6`):
|
|
197
|
+
# `AddPrecise(UndeclaredFuzzName123, .000000111111)`/`MovePrecise(UndeclaredMoveDest, .111111)`
|
|
198
|
+
# alone both compile clean, unlike `VoltDiff`'s or `Erase`'s own kind-8 `Dest` (same code, oracle-
|
|
199
|
+
# swept the same session: both still reject an undeclared name there with the ordinary `Invalid
|
|
200
|
+
# symbol in expression: %s.`). **Not a general auto-declaration**: the name does not become usable
|
|
201
|
+
# anywhere else in the program (oracle: `AddPrecise(NewVar123,...)` followed by `NewVar123 =
|
|
202
|
+
# NewVar123 + 1` still fails, `structure.py`'s own "target does not resolve" placement check,
|
|
203
|
+
# `Invalid, or out of place expression: ...`) -- this is narrowly "this one argument position never
|
|
204
|
+
# runs the undefined-symbol check at all," the same shape round 3/4's own `Move`/`ModBusClient`/
|
|
205
|
+
# `MQTTPublishTable` overrides of the *variable-required* rule already document (`_VARIABLE_
|
|
206
|
+
# REQUIRED_KINDS`'s own docstring above), just for the *undefined-name* check instead. Modelled as a
|
|
207
|
+
# position exemption (rather than adding the name to `known`) so it costs nothing outside these two
|
|
208
|
+
# instructions' own first argument.
|
|
209
|
+
# CR6/CR1000X instructions whose compile function reports every undefined argument instead of the
|
|
210
|
+
# first one only, in the listed 0-based position order (oracle, 2026-10-04, all positions
|
|
211
|
+
# undefined: `Move(a,b,c,d)` reports b, a, d, c; the `*Spa` family b, c, a; `GOESSetup` reports
|
|
212
|
+
# its tenth argument before the eighth and ninth; `MoveBytes` 1,2,4,5,3). Positions are independent (subset probes).
|
|
213
|
+
_MULTI_REPORT_ORDER: dict[str, tuple[int, ...]] = {
|
|
214
|
+
"MOVE": (1, 0, 3, 2),
|
|
215
|
+
"RMSSPA": (1, 2, 0),
|
|
216
|
+
"STDDEVSPA": (1, 2, 0),
|
|
217
|
+
"MAXSPA": (1, 2, 0),
|
|
218
|
+
"MINSPA": (1, 2, 0),
|
|
219
|
+
"AVGSPA": (1, 2, 0),
|
|
220
|
+
"CPIFILESEND": (0, 1, 2, 3, 4),
|
|
221
|
+
"GOESSETUP": (0, 1, 2, 3, 4, 5, 6, 9, 7, 8),
|
|
222
|
+
"GOESGPS": (0, 1),
|
|
223
|
+
"CH400SENDOS": (0, 1),
|
|
224
|
+
"MOVEBYTES": (0, 1, 3, 4, 2),
|
|
225
|
+
"READIO": (1, 0),
|
|
226
|
+
"IPROUTE": (0, 1, 2),
|
|
227
|
+
"MQTTPUBLISHMETA": (0, 1, 2, 3, 4, 5),
|
|
228
|
+
# Round 14 (oracle sweep, `docs/checker.md` "Round 14 multi-report order"): positions listed
|
|
229
|
+
# are those the real compiler reports, in its order; a replaced position missing here is not
|
|
230
|
+
# reported (or not before another one).
|
|
231
|
+
"ARGOSDATAREPEAT": (0, 3),
|
|
232
|
+
"AVGRUN": (0, 2, 4, 5),
|
|
233
|
+
"AVW200": (4, 0),
|
|
234
|
+
"PRT": (0, 2),
|
|
235
|
+
"STRAINCALC": (0, 2, 3),
|
|
236
|
+
"CDM_TCSE": (7, 2),
|
|
237
|
+
"DEBUG": (2, 3, 4),
|
|
238
|
+
"DEWPOINT": (0, 1, 2),
|
|
239
|
+
"SENDVARIABLES": (0, 1, 8),
|
|
240
|
+
"EC100CONFIGURE": (0, 3),
|
|
241
|
+
"GETVARIABLES": (0, 8),
|
|
242
|
+
"GPS": (4, 0),
|
|
243
|
+
"HYDRAPROBE": (0, 1),
|
|
244
|
+
"MATRIX": (1, 2, 3),
|
|
245
|
+
"PEAKVALLEY": (0, 1, 3),
|
|
246
|
+
"SATVP": (0, 1),
|
|
247
|
+
"TIMEDCONTROL": (4, 5),
|
|
248
|
+
"SDMCD16MASK": (0, 1),
|
|
249
|
+
"SDMGENERIC": (4, 0),
|
|
250
|
+
"SDMIO16": (0, 1),
|
|
251
|
+
"WETDRYBULB": (0, 1, 2, 3),
|
|
252
|
+
"SERIALINRECORD": (1, 5),
|
|
253
|
+
"SORTSPA": (0, 2),
|
|
254
|
+
"SORTSPAINDEXED": (0, 2),
|
|
255
|
+
"SPLITSTR": (1, 0),
|
|
256
|
+
"TCPACTIVECONNECTIONS": (0, 1),
|
|
257
|
+
"TOTALRUN": (0, 2, 4),
|
|
258
|
+
"VAPORPRESSURE": (0, 1, 2),
|
|
259
|
+
"MODBUSCLIENT": (0, 5),
|
|
260
|
+
"STDDEVRUN": (0, 2, 4, 5),
|
|
261
|
+
"TCDIFF": (5, 0),
|
|
262
|
+
"PORTBRIDGE": (1, 0),
|
|
263
|
+
"SDMAO4A": (0, 1),
|
|
264
|
+
"FFTSPA": (2, 0),
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
_UNDEFINED_CHECK_EXEMPT_POSITIONS: dict[str, frozenset[int]] = {
|
|
268
|
+
"ADDPRECISE": frozenset({0, 1}),
|
|
269
|
+
"MOVEPRECISE": frozenset({0, 1}),
|
|
270
|
+
"MODEMCALLBACK": frozenset({0}), # CR6/CR1000X; fuzz cr1000x/false_reject/5fe667412d48
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
# Round 14 (oracle, CR6/CR1000X only, `docs/checker.md` "Round 14 multi-report order"): an undeclared
|
|
274
|
+
# name here compiles clean (CR300/CR350 still report `NewFile(A,"x",Nope)`: `Nope is not defined.`).
|
|
275
|
+
# `StdDevRun` StdDevType (position 8) is never resolved either (`StdDevRun(...,D(Nope))` compiles;
|
|
276
|
+
# 2026-10-24, round 34). `SDMSIO4` position 0 is resolved unless its value count is 0
|
|
277
|
+
# (`check`, `_sdmsio4_count`).
|
|
278
|
+
# `IPNetPower`, `MonitorComms` and `LogIP` have empty compile functions, so their deferred argument
|
|
279
|
+
# text is never resolved (decompiled `FUN_004352e0`/`FUN_00434e80`/`FUN_00434e90`, oracle round 58,
|
|
280
|
+
# docs/checker.md#arguments-that-are-never-resolved-round-58).
|
|
281
|
+
_EXEMPT_WHOLE_ECHO: dict[str, frozenset[int]] = {
|
|
282
|
+
"MONITORCOMMS": frozenset({0, 1, 2}),
|
|
283
|
+
"MODEMCALLBACK": frozenset({0}),
|
|
284
|
+
"IPNETPOWER": frozenset({1, 2}),
|
|
285
|
+
"LOGIP": frozenset({0, 1}),
|
|
286
|
+
"NEWFILE": frozenset({2}),
|
|
287
|
+
"STDDEVRUN": frozenset({8}),
|
|
288
|
+
"TIMEDCONTROL": frozenset({0, 1}), # `check_timedcontrol_constants`
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
# Built-in function call arity (this session, oracle-confirmed against `CR6Comp.exe`, byte-for-byte
|
|
292
|
+
# re-confirmed on `cr1000xcomp.exe` for the same probe batch -- `docs/checker.md`): a call to a
|
|
293
|
+
# `data/functions.json` built-in inside an expression (a `Name(args)` the parser resolves as
|
|
294
|
+
# `syntax.Index`, or a bare `syntax.Call` when the whole statement is just the call) with fewer
|
|
295
|
+
# arguments than that function's own `nargs` reports `Expression expects , : <echo>.`; more than
|
|
296
|
+
# `nargs` reports `No Matching, or out of place ) in expression: <echo>.`. Both are genuinely
|
|
297
|
+
# **pass 1**: oracle-confirmed to fire alongside an unrelated, earlier pass-1 `Variable not named.`,
|
|
298
|
+
# and themselves to suppress an unrelated *pass-2* `Invalid symbol in expression` finding on a
|
|
299
|
+
# different, otherwise-clean statement elsewhere in the same file -- the same "pass 1 found
|
|
300
|
+
# something anywhere in the file, pass 2 does not run at all" architecture already documented for
|
|
301
|
+
# `instr_rules.py`'s instruction-argument checks (`docs/checker.md` "Pass model").
|
|
302
|
+
#
|
|
303
|
+
# `nargs` is **not** a safe universal minimum or maximum, so neither direction is applied to every
|
|
304
|
+
# function with a decoded `argtypes`: a 92-function oracle sweep (every `data/functions.json` entry
|
|
305
|
+
# with `argtypes is not None`, tried at `nargs-1` and `nargs+1` arguments each) found real
|
|
306
|
+
# exceptions on both sides -- `NetworkTimeProtocol`/`PingIP`/`HttpPost`/`HttpPut`/`Sprintf`/`Date`
|
|
307
|
+
# all accept *fewer* than `nargs` (an undocumented optional trailing argument each); `SerialOpen`
|
|
308
|
+
# accepts `nargs+1` (a 6th, undocumented optional argument -- oracle: `SerialOpen(ComC1,9600,0,0,
|
|
309
|
+
# 1000,1)` compiles clean on CR6/CR1000X; CR350's own extracted table already lists `nargs=6` for
|
|
310
|
+
# it, so this is specifically a CR6/CR1000X table gap, not a real difference in compiler behaviour);
|
|
311
|
+
# `Sprintf` is genuinely variadic (2..9 arguments all compile clean; 10 hits an unrelated
|
|
312
|
+
# `Expression nesting level exceeded, break up expression.`, not an arity rejection). A one-
|
|
313
|
+
# argument-short call to a **zero- or one-argument** function is also excluded from the "too few"
|
|
314
|
+
# side entirely: at `nargs` 0 or 1 the "one too few" case is zero arguments, `NAME()`, which the
|
|
315
|
+
# oracle sweep found is a *different*, inconsistent message family there (most often the ordinary
|
|
316
|
+
# bare-call fallback `Invalid, or out of place expression: %s.`, sometimes a genuinely different
|
|
317
|
+
# pass-1 message, `Incomplete expression: %s.`, and for a handful -- e.g. `Sprintf()` itself,
|
|
318
|
+
# `PppOpen(1)` -- silently accepted) rather than this rule's own two messages, so it is not modelled
|
|
319
|
+
# here (AGENTS.md: implement only the confirmed subset).
|
|
320
|
+
#
|
|
321
|
+
# Both directions are therefore restricted to an explicit, oracle-verified allow-list per direction
|
|
322
|
+
# (`_ARITY_MIN_SAFE`/`_ARITY_MAX_SAFE`) -- exactly the functions this sweep found reject `nargs-1`/
|
|
323
|
+
# `nargs+1` with the expected message and nothing else (no unrelated crash, no different message, no
|
|
324
|
+
# silent accept), cross-checked identically on `cr1000xcomp.exe` for the whole probe batch (the only
|
|
325
|
+
# difference found across the two loggers in that re-run was an unrelated port-range message,
|
|
326
|
+
# `CheckPort`'s own `Port must be C1..C4 or U1..U12.` vs `Port must be C1..C8.`, nothing to do with
|
|
327
|
+
# arity). CR300/CR350 were spot-checked on `Round`/`Instr` only (not the full 92-function sweep,
|
|
328
|
+
# `docs/checker.md`); see `_ARITY_TRAILING_NEWLINE_LOGGERS` below for the one real formatting
|
|
329
|
+
# difference found there.
|
|
330
|
+
#
|
|
331
|
+
# Both maps are keyed by name **and** the `nargs` value this session's oracle sweep actually tested
|
|
332
|
+
# (the default/`cr6`/`cr1000x` `data/functions.json`, byte-identical between those two) -- a
|
|
333
|
+
# *value*, not just a name allow-list -- because a full-corpus difftest re-run (`tools/difftest.py
|
|
334
|
+
# --logger cr350`) caught a real false reject this session first got wrong: CR350's own
|
|
335
|
+
# `functions.json` gives `SerialOpen` `nargs=6` (its own table already bakes in the undocumented
|
|
336
|
+
# 6th argument CR6/CR1000X's table omits, see above), so a name-only allow-list applied `SerialOpen`
|
|
337
|
+
# to `_ARITY_MIN_SAFE` using *CR350's* `nargs` (6) and rejected the ordinary 5-argument form
|
|
338
|
+
# (`tests/corpus/chm/serialflush_1.CR6`, `examples/SBD_example_hirise*.CR6`) that CR350 genuinely
|
|
339
|
+
# accepts. `MQTTSubscribe`/`NetworkTimeProtocol` have the same shape on CR300/CR350 (a smaller
|
|
340
|
+
# `nargs` there than CR6/CR1000X, `docs/checker.md`'s own earlier per-logger `functions.json` diff),
|
|
341
|
+
# not yet oracle-tested for whether their *own* true min/max still matches their own smaller
|
|
342
|
+
# `nargs` the way `SerialOpen`'s doesn't -- so rather than assume either way, `_call_arity_problem`
|
|
343
|
+
# only fires when the profile's own `nargs` for that name is **exactly** the value this sweep
|
|
344
|
+
# tested, and silently does not check a name whose profile disagrees (AGENTS.md: under-report
|
|
345
|
+
# rather than guess at an unverified cross-logger transfer). This is why the two maps below are
|
|
346
|
+
# `{name: nargs}`, not plain `frozenset`s.
|
|
347
|
+
#
|
|
348
|
+
# **Argument-*kind* mismatches are not implemented**: the same sweep tried swapping a string literal
|
|
349
|
+
# where `argtypes` says a float/int expression and vice versa (`Round("x",1)`, `Round(1.5,"x")`,
|
|
350
|
+
# `Left(1,2)`, `Mid(1,2,3)`, `Replace(1,"x","y")`, `InStr("x","x","x",1.5)`) and the real compiler
|
|
351
|
+
# compiled every one of them clean -- `argtypes`'s 0/1/2/3 codes evidently only select how the value
|
|
352
|
+
# is *coerced* at the call site, not a compile-time type check, so there is no "wrong kind"
|
|
353
|
+
# diagnostic to reproduce (AGENTS.md: never invent a rejection with no oracle evidence for it).
|
|
354
|
+
_ARITY_MIN_SAFE = {
|
|
355
|
+
"CHECKSUM": 3,
|
|
356
|
+
"DAYLIGHTSAVING": 8,
|
|
357
|
+
"DIALMODEM": 4,
|
|
358
|
+
"EMAILRECV": 6,
|
|
359
|
+
"EMAILSEND": 6,
|
|
360
|
+
"ENCRYPTION": 7,
|
|
361
|
+
"FILECOPY": 2,
|
|
362
|
+
"FILELIST": 2,
|
|
363
|
+
"FILEOPEN": 3,
|
|
364
|
+
"FILEREAD": 3,
|
|
365
|
+
"FILEREADLINE": 3,
|
|
366
|
+
"FILERENAME": 2,
|
|
367
|
+
"FILEWRITE": 3,
|
|
368
|
+
"FINDSPA": 4,
|
|
369
|
+
"FORMATFLOAT": 2,
|
|
370
|
+
"FORMATLONG": 2,
|
|
371
|
+
"FTPCLIENT": 6,
|
|
372
|
+
"HTTPGET": 3,
|
|
373
|
+
"IIF": 3,
|
|
374
|
+
"INSTR": 4,
|
|
375
|
+
"IPINFO": 2,
|
|
376
|
+
"LEFT": 2,
|
|
377
|
+
"MID": 3,
|
|
378
|
+
"MQTTPUBLISH": 4,
|
|
379
|
+
"MQTTSUBSCRIBE": 4,
|
|
380
|
+
"REPLACE": 3,
|
|
381
|
+
"RIGHT": 2,
|
|
382
|
+
"ROUND": 2,
|
|
383
|
+
"SECSSINCE1990": 2,
|
|
384
|
+
"SERIALINBLOCK": 3,
|
|
385
|
+
"SERIALOPEN": 5,
|
|
386
|
+
"SERIALOUT": 5,
|
|
387
|
+
"SMSRECV": 3,
|
|
388
|
+
"STRCOMP": 2,
|
|
389
|
+
"TCPOPEN": 3,
|
|
390
|
+
"TIMEISBETWEEN": 4,
|
|
391
|
+
"UDPDATAGRAM": 6,
|
|
392
|
+
"UDPOPEN": 3,
|
|
393
|
+
"WATCHDOGTIMER": 2,
|
|
394
|
+
"XMLPARSE": 6,
|
|
395
|
+
# A second, smaller oracle sweep (this session, `docs/checker.md`) of every `argtypes is None`
|
|
396
|
+
# entry (the uniform-float-argument math functions, `kind` 295) found the same rule holds for
|
|
397
|
+
# its own `nargs>=2` members, `TIMER`'s own excepted below:
|
|
398
|
+
"AVE": 2,
|
|
399
|
+
"PWR": 2,
|
|
400
|
+
"ATN2": 2,
|
|
401
|
+
"IFTIME": 3,
|
|
402
|
+
"TIMEINTOINTERVAL": 3,
|
|
403
|
+
# `TIMER(...)` at `nargs-1` gets `Parameter must be a constant integer: %s.` instead (its own
|
|
404
|
+
# arguments are constant-integer-checked before arity would ever matter) -- deliberately not in
|
|
405
|
+
# this map.
|
|
406
|
+
}
|
|
407
|
+
_ARITY_MAX_SAFE = {
|
|
408
|
+
"ARRAYINDEX": 1,
|
|
409
|
+
"ASCII": 1,
|
|
410
|
+
"CHECKPORT": 1,
|
|
411
|
+
"CHR": 1,
|
|
412
|
+
"CHR$": 1,
|
|
413
|
+
"COMPORTISACTIVE": 1,
|
|
414
|
+
"DAYLIGHTSAVING": 8,
|
|
415
|
+
"DAYLIGHTSAVINGUS": 1,
|
|
416
|
+
"ENCRYPTION": 7,
|
|
417
|
+
"FILECLOSE": 1,
|
|
418
|
+
"FILECOPY": 2,
|
|
419
|
+
"FILEENCRYPT": 1,
|
|
420
|
+
"FILELIST": 2,
|
|
421
|
+
"FILEOPEN": 3,
|
|
422
|
+
"FILEREAD": 3,
|
|
423
|
+
"FILEREADLINE": 3,
|
|
424
|
+
"FILERENAME": 2,
|
|
425
|
+
"FILESIZE": 1,
|
|
426
|
+
"FILETIME": 1,
|
|
427
|
+
"FILEWRITE": 3,
|
|
428
|
+
"FINDSPA": 4,
|
|
429
|
+
"FORMATFLOAT": 2,
|
|
430
|
+
"FORMATLONG": 2,
|
|
431
|
+
"FORMATLONGLONG": 1,
|
|
432
|
+
"HEX": 1,
|
|
433
|
+
"HEXTODEC": 1,
|
|
434
|
+
"IIF": 3,
|
|
435
|
+
"INSTR": 4,
|
|
436
|
+
"IPINFO": 2,
|
|
437
|
+
"IPTRACE": 1,
|
|
438
|
+
"LEFT": 2,
|
|
439
|
+
"LEN": 1,
|
|
440
|
+
"LOADFIELDCAL": 1,
|
|
441
|
+
"LOWERCASE": 1,
|
|
442
|
+
"LTRIM": 1,
|
|
443
|
+
"MID": 3,
|
|
444
|
+
"MQTTUNSUBSCRIBE": 1,
|
|
445
|
+
"NEWFIELDCAL": 0,
|
|
446
|
+
"PINGIP": 3,
|
|
447
|
+
"PPPCLOSE": 0,
|
|
448
|
+
"REPLACE": 3,
|
|
449
|
+
"RIGHT": 2,
|
|
450
|
+
"ROUND": 2,
|
|
451
|
+
"ROUTE": 1,
|
|
452
|
+
"RTRIM": 1,
|
|
453
|
+
"SECSSINCE1990": 2,
|
|
454
|
+
"SERIALCLOSE": 1,
|
|
455
|
+
"SERIALFLUSH": 1,
|
|
456
|
+
"SERIALINBLOCK": 3,
|
|
457
|
+
"SERIALINCHK": 1,
|
|
458
|
+
"SERIALOUT": 5,
|
|
459
|
+
"SMSRECV": 3,
|
|
460
|
+
"STRCOMP": 2,
|
|
461
|
+
"TCPCLOSE": 1,
|
|
462
|
+
"TIMEISBETWEEN": 4,
|
|
463
|
+
"TRIM": 1,
|
|
464
|
+
"UPPERCASE": 1,
|
|
465
|
+
"VOICEKEY": 1,
|
|
466
|
+
"VOICENUMBER": 1,
|
|
467
|
+
"WATCHDOGTIMER": 2,
|
|
468
|
+
# Same second sweep, `nargs+1` this time: every plain single-float math function tried rejects
|
|
469
|
+
# cleanly, plus the three shared with `_ARITY_MIN_SAFE` above and the two zero-argument
|
|
470
|
+
# functions this specific pair happened to also reject cleanly (`Rnd`/`ClockChange`, unlike the
|
|
471
|
+
# inconsistent zero-argument functions in the first sweep, `docs/checker.md`'s own top-of-file
|
|
472
|
+
# comment) -- `Abs`, `Ave` (this direction only) and `Timer` excepted below.
|
|
473
|
+
"SIN": 1,
|
|
474
|
+
"COS": 1,
|
|
475
|
+
"LOG": 1,
|
|
476
|
+
"LN": 1,
|
|
477
|
+
"SQR": 1,
|
|
478
|
+
"EXP": 1,
|
|
479
|
+
"CSGN": 1,
|
|
480
|
+
"INT": 1,
|
|
481
|
+
"FRAC": 1,
|
|
482
|
+
"SGN": 1,
|
|
483
|
+
"ATN": 1,
|
|
484
|
+
"TAN": 1,
|
|
485
|
+
"FIX": 1,
|
|
486
|
+
"ASIN": 1,
|
|
487
|
+
"ACOS": 1,
|
|
488
|
+
"LOG10": 1,
|
|
489
|
+
"SINH": 1,
|
|
490
|
+
"TANH": 1,
|
|
491
|
+
"COSH": 1,
|
|
492
|
+
"FLOOR": 1,
|
|
493
|
+
"CEILING": 1,
|
|
494
|
+
"RND": 0,
|
|
495
|
+
"CLOCKCHANGE": 0,
|
|
496
|
+
"PWR": 2,
|
|
497
|
+
"ATN2": 2,
|
|
498
|
+
"IFTIME": 3,
|
|
499
|
+
"TIMEINTOINTERVAL": 3,
|
|
500
|
+
# `Abs(x,y)` gets `Incomplete expression: %s.` instead (a different message, not modelled by
|
|
501
|
+
# this rule); `Ave(x,y,z)` gets `Parameter must be a constant integer: %s.` (its own later
|
|
502
|
+
# arguments are constant-integer-checked, same shape as `Timer`); `Timer` itself gets that same
|
|
503
|
+
# constant-integer message on *this* side too. None of the three are in this map.
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
# A small group of function shapes reaches a different parser diagnostic before the ordinary
|
|
507
|
+
# argument-count handler. Each entry is restricted to the directly observed boundary: a string
|
|
508
|
+
# in a constant-integer position, or one argument beyond the extracted count.
|
|
509
|
+
_FUNCTION_CONST_INTEGER_STRING_ARGS = {
|
|
510
|
+
"SERIALOPEN": frozenset({3, 4}),
|
|
511
|
+
"TIMER": frozenset({1}),
|
|
512
|
+
}
|
|
513
|
+
_CONST_INTEGER_OVERFLOW_STRING = {"TCPOPEN": 3}
|
|
514
|
+
# Argument positions that must be an integer constant: a string, `NAN`, a float literal (`1.0`) or an
|
|
515
|
+
# integer beyond 32 bits is `Parameter must be a constant integer` (oracle, all four loggers,
|
|
516
|
+
# 2026-10-10: `TCPOpen(Addr,6785,"fuzz")`; an integer constant expression and `Hr` compile)
|
|
517
|
+
# `SerialOpen`'s 4th and 5th arguments (oracle, all four loggers, 2026-10-11: `1.0`, `999999999999`
|
|
518
|
+
# -> `Parameter must be a constant integer`; `Hr`, `1+1`, `-1` compile on CR300 and also CR6/CR1000X
|
|
519
|
+
# for `-1` only the range message `usec delay before transmit is illegal`, not modelled).
|
|
520
|
+
_CONST_INTEGER_BAD_ARGS = {"TCPOPEN": frozenset({2}), "SERIALOPEN": frozenset({3, 4})}
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
# Outside an earlier `IfTime`/`TimeIntoInterval` (`intervals.timeout_gate`; set per statement by
|
|
524
|
+
# `check_function_arity`, `None` elsewhere so other callers never apply the rule) the trailing
|
|
525
|
+
# `TimeOut` of `HTTPPut` (9th) and `FTPClient` (11th) argument, a string (also `""`), `NAN` or a
|
|
526
|
+
# float literal, is `Parameter must be a constant integer`, before the argument-count check (oracle,
|
|
527
|
+
# all four loggers). After one, the same arguments give a truncate warning / `Parameter must be
|
|
528
|
+
# integral.` instead, reported by `intervals.check`. Overflowing literals and Consts are the constant-
|
|
529
|
+
# integer error too when `intervals.timeout_value` classifies them as float (`_TIMEOUT_WIDE`, set per
|
|
530
|
+
# program by `check_function_arity`). Not modelled: a variable or function call there.
|
|
531
|
+
_TIMEOUT_GATE: list = [None]
|
|
532
|
+
_TIMEOUT_WIDE: list = [frozenset()]
|
|
533
|
+
_CONST_INTEGER_TYPE_ARGS = {"HTTPPUT": frozenset({8}), "FTPCLIENT": frozenset({10})}
|
|
534
|
+
# `Timer`'s third argument is the same error whatever came before (it resets the mode first; oracle,
|
|
535
|
+
# CR6/CR350, 2026-10-16: also after an `IfTime`).
|
|
536
|
+
_CONST_INTEGER_TYPE_ARGS_ALWAYS = {"TIMER": frozenset({2})}
|
|
537
|
+
# `Timer`'s Units and Option (second and third arguments) must be integer-typed constants: a
|
|
538
|
+
# variable, a function call, `/`, `^`, a float literal, `INF` or a `Const` whose value is not an
|
|
539
|
+
# integer or is above INT_MAX is `Parameter must be a constant integer`; an integer Option outside
|
|
540
|
+
# 0..4 is `Legal options are 0..4` (oracle, all four loggers, 2026-10-25, `docs/checker.md` "Timer
|
|
541
|
+
# arguments"). `(env, declared variables)` is set per program by `check_function_arity`.
|
|
542
|
+
_TIMER_CONTEXT: list = [None]
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _timer_arg(arg, env, declared: frozenset[str]):
|
|
546
|
+
"""`("int", value)`, `"float"` (the constant-integer error) or `None` (not judged here).
|
|
547
|
+
An integer past the signed 32-bit range is float on CR6/CR1000X; CR300/CR350 take it as out of
|
|
548
|
+
range up to magnitude 2**32. `&H` and `&B` literals wrap (`&HFFFFFFFF` is -1)."""
|
|
549
|
+
|
|
550
|
+
def wide(value: int):
|
|
551
|
+
return ("int", -(2**31)) if env.profile.name in ("cr300", "cr350") and abs(value) < 2**32 else "float"
|
|
552
|
+
|
|
553
|
+
if isinstance(arg, S.Number):
|
|
554
|
+
if isinstance(arg.value, float):
|
|
555
|
+
return "float"
|
|
556
|
+
if arg.text[:1] == "&":
|
|
557
|
+
return ("int", (arg.value + 2**31) % 2**32 - 2**31)
|
|
558
|
+
return ("int", arg.value) if -(2**31) <= arg.value < 2**31 else wide(arg.value)
|
|
559
|
+
if isinstance(arg, S.Name):
|
|
560
|
+
name = arg.name.upper()
|
|
561
|
+
if name in ("INF", "NAN"):
|
|
562
|
+
return "float"
|
|
563
|
+
if name in declared:
|
|
564
|
+
return "float"
|
|
565
|
+
try:
|
|
566
|
+
value = consteval.value_of(arg, env)
|
|
567
|
+
except consteval.NotConstant:
|
|
568
|
+
return None
|
|
569
|
+
if isinstance(value, str):
|
|
570
|
+
return None
|
|
571
|
+
if value != value or value != int(value):
|
|
572
|
+
return "float"
|
|
573
|
+
return wide(int(value)) if value > 2**31 - 1 else ("int", int(value))
|
|
574
|
+
if isinstance(arg, S.Index):
|
|
575
|
+
return "float" if arg.name.upper() in declared or arg.name.upper() in env.profile.functions else None
|
|
576
|
+
if isinstance(arg, S.Unary):
|
|
577
|
+
inner = _timer_arg(arg.operand, env, declared)
|
|
578
|
+
if inner is None or inner == "float":
|
|
579
|
+
return inner
|
|
580
|
+
value = {"-": -inner[1], "+": inner[1]}.get(arg.op, ~inner[1])
|
|
581
|
+
return ("int", (value + 2**31) % 2**32 - 2**31)
|
|
582
|
+
if isinstance(arg, S.Binary):
|
|
583
|
+
sides = [_timer_arg(arg.left, env, declared), _timer_arg(arg.right, env, declared)]
|
|
584
|
+
if None in sides:
|
|
585
|
+
return None
|
|
586
|
+
if "float" in sides or arg.op in ("/", "^"):
|
|
587
|
+
return "float"
|
|
588
|
+
try:
|
|
589
|
+
value = consteval.value_of(arg, env)
|
|
590
|
+
except consteval.NotConstant:
|
|
591
|
+
return None
|
|
592
|
+
return ("int", (int(value) + 2**31) % 2**32 - 2**31)
|
|
593
|
+
return None
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _timeout_wide(arg, profile) -> bool:
|
|
597
|
+
"""Whether a `TimeOut` argument is a float-classified constant (`intervals.timeout_value`)."""
|
|
598
|
+
from . import intervals # lazy: `intervals` imports this package's modules
|
|
599
|
+
|
|
600
|
+
return intervals.timeout_value(arg, None, profile, _TIMEOUT_WIDE[0]) == ("wide", 0)
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _not_integer_type(arg) -> bool:
|
|
604
|
+
if isinstance(arg, S.Number):
|
|
605
|
+
return isinstance(arg.value, float)
|
|
606
|
+
return isinstance(arg, S.String) or (isinstance(arg, S.Name) and arg.name.upper() == "NAN")
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _not_constant_integer(arg) -> bool:
|
|
610
|
+
if isinstance(arg, S.String):
|
|
611
|
+
return True
|
|
612
|
+
if isinstance(arg, S.Name):
|
|
613
|
+
return arg.name.upper() == "NAN"
|
|
614
|
+
if isinstance(arg, S.Number):
|
|
615
|
+
return isinstance(arg.value, float) or not -(2**31) <= arg.value < 2**31
|
|
616
|
+
return False
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
_INCOMPLETE_OVERFLOW = {"ABS": 1, "MQTTPUBLISH": 4}
|
|
620
|
+
# Observed (min, max) argument counts where they differ from the table's `nargs`, keyed by that
|
|
621
|
+
# profile `nargs` (oracle, 2026-10-03, all four loggers).
|
|
622
|
+
# `GZip` (table nargs 1) takes exactly 2 (`Filename, ZippedFilename`; CR6/CR1000X oracle 2026-10-13:
|
|
623
|
+
# 1 -> `Expression expects ,`, 3+ -> `No Matching, or out of place )`; absent on CR300/CR350).
|
|
624
|
+
# `SerialOpen` (table nargs 5, CR350 6) takes 5 or 6 arguments, CR350 5 to 8 (oracle 2026-10-13: more is
|
|
625
|
+
# `No Matching, or out of place )`, 4 or fewer `Expression expects ,`; CR350 accepted 4 or fewer).
|
|
626
|
+
_ARITY_RANGE = {
|
|
627
|
+
"NETWORKTIMEPROTOCOL": {5: (3, 4), 3: (3, 3)},
|
|
628
|
+
"GZIP": {1: (2, 2)},
|
|
629
|
+
"SERIALOPEN": {5: (5, 6), 6: (5, 8)},
|
|
630
|
+
"TIMER": {3: (3, 3)},
|
|
631
|
+
}
|
|
632
|
+
# (`Timer` takes exactly 3: fewer `Expression expects ,`, more `No Matching, or out of place )`; oracle,
|
|
633
|
+
# all four loggers, 2026-10-13, `N = Timer(1,sec)`, `Timer(1,2)`, `N = Timer(1,sec,0,0)`)
|
|
634
|
+
# Argument counts a call accepts when they are not one range, keyed by profile `nargs` (oracle,
|
|
635
|
+
# 2026-10-10, all four loggers): `HTTPPut` takes 4, 8 or 9 (2, 3, 5, 6, 7 are `few`, 10+ `many`).
|
|
636
|
+
# `FTPClient` (table nargs 6) takes 6, 10 or 11 (oracle, all four loggers, 2026-10-11: 7-9 are
|
|
637
|
+
# `Expression expects , :`, 12+ `No Matching, or out of place )`).
|
|
638
|
+
# `EmailSend` (table nargs 6) takes 9, 13 or 14; `EmailRecv` 9-13; `UDPDatagram` 6-8; `XMLParse` 8;
|
|
639
|
+
# `HTTPGet` 3-4; `UDPOpen` 3-4; `TCPOpen` 3, 4 or 6; `HTTPPost` 4, 8 or 9; `CheckSum` 3, 4 or 6
|
|
640
|
+
# (CR6 host sweep 2026-10-11, `tests/fixtures/r13_function_arity.json`; fewer is `few`, more `many`).
|
|
641
|
+
_ARITY_ALLOWED = {
|
|
642
|
+
"HTTPPUT": {5: frozenset({4, 8, 9})},
|
|
643
|
+
"FTPCLIENT": {6: frozenset({6, 10, 11})},
|
|
644
|
+
"EMAILSEND": {6: frozenset({9, 13, 14})},
|
|
645
|
+
"EMAILRECV": {6: frozenset(range(9, 14))},
|
|
646
|
+
"UDPDATAGRAM": {6: frozenset({6, 7, 8})},
|
|
647
|
+
"XMLPARSE": {6: frozenset({8})},
|
|
648
|
+
"HTTPGET": {3: frozenset({3, 4})},
|
|
649
|
+
"UDPOPEN": {3: frozenset({3, 4})},
|
|
650
|
+
"TCPOPEN": {3: frozenset({3, 4, 6})},
|
|
651
|
+
"HTTPPOST": {5: frozenset({4, 8, 9})},
|
|
652
|
+
"CHECKSUM": {3: frozenset({3, 4, 6})},
|
|
653
|
+
"EMAILRELAY": {0: frozenset({4, 5, 9, 10})},
|
|
654
|
+
}
|
|
655
|
+
# (`EmailRelay`, table nargs 0, takes 4, 5, 9 or 10; oracle, all four loggers, 2026-10-25)
|
|
656
|
+
|
|
657
|
+
# CR300/CR350 echo this rule's messages with the offending statement's text followed by a literal
|
|
658
|
+
# embedded newline, then the closing `.` alone on the next line -- CR6/CR1000X put the `.`
|
|
659
|
+
# immediately after the text on one line (oracle, `Round(X)`/`Round(X,1,2)` alone, both directions,
|
|
660
|
+
# `docs/checker.md`). The same split this file already uses for other per-logger message-shape
|
|
661
|
+
# differences (`_WHOLE_EXPR_ECHO_LOGGERS`); reused directly rather than adding a new set, since the
|
|
662
|
+
# two happen to be the same two loggers on each side for this rule too.
|
|
663
|
+
_ARITY_TRAILING_NEWLINE_LOGGERS = frozenset({"cr300", "cr350"})
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
@lru_cache(maxsize=1)
|
|
667
|
+
def _type_code_kinds() -> dict[int, str]:
|
|
668
|
+
data = json.loads(_TYPE_CODES_PATH.read_text())
|
|
669
|
+
return {int(code): entry["kind"] for code, entry in data.items()}
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def _position_kinds(params: list[int], keyword: str = "", profile=None) -> dict[int, str]:
|
|
673
|
+
"""0-based argument index -> `type_codes.json` `kind`, for every position whose type code
|
|
674
|
+
routes through the general symbol/expression compiler (`_RESOLVED_KINDS`), minus any position
|
|
675
|
+
`_UNDEFINED_CHECK_EXEMPT_POSITIONS` names for this instruction's own keyword."""
|
|
676
|
+
kinds = _type_code_kinds()
|
|
677
|
+
exempt = _UNDEFINED_CHECK_EXEMPT_POSITIONS.get(keyword.upper(), frozenset())
|
|
678
|
+
if profile is not None and profile.name in _WHOLE_EXPR_ECHO_LOGGERS:
|
|
679
|
+
exempt = exempt | _EXEMPT_WHOLE_ECHO.get(keyword.upper(), frozenset())
|
|
680
|
+
out = {}
|
|
681
|
+
for i, code in enumerate(params):
|
|
682
|
+
if i in exempt:
|
|
683
|
+
continue
|
|
684
|
+
base = code - 100 if code >= 100 else code
|
|
685
|
+
kind = kinds.get(base)
|
|
686
|
+
if kind in _RESOLVED_KINDS:
|
|
687
|
+
out[i] = kind
|
|
688
|
+
return out
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def _undefined_names(expr: S.Expr | None, known: set[str]) -> list[str]:
|
|
692
|
+
"""`_undefined_names_walk`, minus the dotted names after the first plain one: once a name is
|
|
693
|
+
`is not defined.`, a later `Undeclared.member` is not reported (oracle, CR300, 2026-10-11)."""
|
|
694
|
+
out = _undefined_names_walk(expr, known)
|
|
695
|
+
for i, n in enumerate(out):
|
|
696
|
+
if not isinstance(n, DottedName):
|
|
697
|
+
return out[: i + 1] + [m for m in out[i + 1 :] if not isinstance(m, DottedName)]
|
|
698
|
+
return out
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def _undefined_names_walk(expr: S.Expr | None, known: set[str]) -> list[str]:
|
|
702
|
+
"""Every undeclared name referenced anywhere inside `expr` (in source order), recursing into
|
|
703
|
+
unary/binary operators, array indices and array-literal items. `Member` (`Table.Field`,
|
|
704
|
+
`Struct.member`) only recurses into its own base, never its `member` text -- that is a
|
|
705
|
+
field/member name resolved against the base's own type, not a bare symbol lookup, and this
|
|
706
|
+
session found no oracle evidence for how (or whether) it is checked, so it is left alone
|
|
707
|
+
(AGENTS.md: under-report rather than guess). A name used as a `Call`-like `Index`'s own
|
|
708
|
+
function/array name *is* checked, alongside its arguments, matching how the existing bare-name
|
|
709
|
+
case already treated a direct `Index` argument before this session's own extension.
|
|
710
|
+
"""
|
|
711
|
+
if expr is None:
|
|
712
|
+
return []
|
|
713
|
+
if isinstance(expr, S.Name):
|
|
714
|
+
if expr.name.endswith(".") and len(expr.name) > 1 and (expr.name[0].isalpha() or expr.name[0] == "_"):
|
|
715
|
+
return [] # `T.` / `X.` / `Zork.`: `check_digit_members` owns a trailing-dot name
|
|
716
|
+
upper = expr.name.upper()
|
|
717
|
+
if id(expr) in getattr(known, "out_of_scope", ()):
|
|
718
|
+
return [expr.name]
|
|
719
|
+
return [] if upper in known and upper not in getattr(known, "tables", ()) else [expr.name]
|
|
720
|
+
if isinstance(expr, S.Unary):
|
|
721
|
+
return _undefined_names_walk(expr.operand, known)
|
|
722
|
+
if isinstance(expr, S.Binary):
|
|
723
|
+
return _undefined_names_walk(expr.left, known) + _undefined_names_walk(expr.right, known)
|
|
724
|
+
if isinstance(expr, S.Index):
|
|
725
|
+
head = [] if expr.name.upper() in known and id(expr) not in getattr(known, "out_of_scope", ()) else [expr.name]
|
|
726
|
+
for a in expr.args:
|
|
727
|
+
head += _undefined_names_walk(a, known)
|
|
728
|
+
return head
|
|
729
|
+
if isinstance(expr, S.Member):
|
|
730
|
+
# ... but its index arguments are (`Status.TimeStamp(Nope,1)`, oracle 2026-09-28). A
|
|
731
|
+
# table name is fine as the base (`T.Field`).
|
|
732
|
+
base = expr.base
|
|
733
|
+
out = (
|
|
734
|
+
[]
|
|
735
|
+
if isinstance(base, S.Name)
|
|
736
|
+
and base.name.upper() in known
|
|
737
|
+
and id(base) not in getattr(known, "out_of_scope", ())
|
|
738
|
+
else _undefined_names_walk(base, known)
|
|
739
|
+
)
|
|
740
|
+
if out and isinstance(base, S.Name) and getattr(known, "dotted_tables", False):
|
|
741
|
+
out = [DottedName(base.name)]
|
|
742
|
+
for a in getattr(expr, "args", None) or []:
|
|
743
|
+
out += _undefined_names_walk(a, known)
|
|
744
|
+
return out
|
|
745
|
+
if isinstance(expr, S.ArrayLiteral):
|
|
746
|
+
out: list[str] = []
|
|
747
|
+
for item in expr.items:
|
|
748
|
+
out += _undefined_names_walk(item, known)
|
|
749
|
+
return out
|
|
750
|
+
return [] # Number, String: nothing to resolve
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def _names_through_over_index(expr: S.Expr | None, known: set[str], dim_counts: dict) -> list[str]:
|
|
754
|
+
"""The undefined names of `expr` up to and including the arguments of its first over-indexed
|
|
755
|
+
subscript (`_over_indexed`): the compile of the statement ends there (oracle, CR300/CR350,
|
|
756
|
+
2026-10-12: `x = R(4,Zork) + R(5,Zork2)` for `Public R(4)` names only `Zork`)."""
|
|
757
|
+
out: list[str] = []
|
|
758
|
+
|
|
759
|
+
def walk(e: S.Expr | None) -> bool:
|
|
760
|
+
if e is None:
|
|
761
|
+
return False
|
|
762
|
+
if isinstance(e, S.Unary):
|
|
763
|
+
return walk(e.operand)
|
|
764
|
+
if isinstance(e, S.Binary):
|
|
765
|
+
return walk(e.left) or walk(e.right)
|
|
766
|
+
if isinstance(e, S.Index):
|
|
767
|
+
if e.name.upper() not in known:
|
|
768
|
+
out.append(e.name)
|
|
769
|
+
for a in e.args:
|
|
770
|
+
if walk(a):
|
|
771
|
+
return True
|
|
772
|
+
count = dim_counts.get(e.name.upper())
|
|
773
|
+
return (
|
|
774
|
+
count is not None
|
|
775
|
+
and len(e.args) > count
|
|
776
|
+
and not (len(e.args) == 3 and e.name.upper() in getattr(dim_counts, "strings", ()))
|
|
777
|
+
)
|
|
778
|
+
out.extend(_undefined_names_walk(e, known))
|
|
779
|
+
return False
|
|
780
|
+
|
|
781
|
+
walk(expr)
|
|
782
|
+
return out
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def _dim_limit(profile) -> int | None:
|
|
786
|
+
"""`_over_indexed`'s `limit`: CR6/CR1000X reject only more than three subscripts (round 16 of
|
|
787
|
+
`docs/checker.md`), CR300/CR350 any beyond the declared dimensions."""
|
|
788
|
+
return 3 if profile.name in _WHOLE_EXPR_ECHO_LOGGERS else None
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def _over_indexed(expr: S.Expr | None, dim_counts: dict[str, int], limit: int | None = None) -> bool:
|
|
792
|
+
"""Whether `expr` indexes a declared variable with more subscripts than it has dimensions
|
|
793
|
+
(`limit`: more than `limit` subscripts instead). CR300/CR350 reject `R(2,1)` for `Public R(5)`
|
|
794
|
+
and `X(1)` for a scalar; CR6/CR1000X only more than 3 (oracle, 2026-09-28)."""
|
|
795
|
+
if expr is None:
|
|
796
|
+
return False
|
|
797
|
+
if isinstance(expr, S.Index):
|
|
798
|
+
count = dim_counts.get(expr.name.upper())
|
|
799
|
+
if (
|
|
800
|
+
count is not None
|
|
801
|
+
and len(expr.args) > (count if limit is None else limit)
|
|
802
|
+
and not (len(expr.args) == 3 and expr.name.upper() in getattr(dim_counts, "strings", ()))
|
|
803
|
+
):
|
|
804
|
+
return True # (a String takes three subscripts: CR300 accepts `S(1,2,3)`, 2026-10-01)
|
|
805
|
+
return any(_over_indexed(a, dim_counts, limit) for a in expr.args)
|
|
806
|
+
if isinstance(expr, S.Unary):
|
|
807
|
+
return _over_indexed(expr.operand, dim_counts, limit)
|
|
808
|
+
if isinstance(expr, S.Binary):
|
|
809
|
+
return _over_indexed(expr.left, dim_counts, limit) or _over_indexed(expr.right, dim_counts, limit)
|
|
810
|
+
if isinstance(expr, S.Member):
|
|
811
|
+
return any(_over_indexed(a, dim_counts, limit) for a in (expr.args or []))
|
|
812
|
+
return False
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
_NO_NAMES: frozenset = frozenset()
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
class DimsState:
|
|
819
|
+
"""The CR300/CR350 compiler's running "dimension count" (oracle, CR300, 2026-10-03,
|
|
820
|
+
`docs/checker.md` "Dimensions not declared"): every reference to a declared variable, in text
|
|
821
|
+
order and across statements, sets it to that variable's number of dimensions (a scalar 0, a
|
|
822
|
+
`String` one more than declared); constants, predefined names and undefined names leave it
|
|
823
|
+
alone. An undefined name with more subscripts than the current count is
|
|
824
|
+
`Dimensions not declared`. `var_dims` maps names to counts (None: counted differently in
|
|
825
|
+
two declarations), `before` maps `id(stmt)` to the count when the statement starts."""
|
|
826
|
+
|
|
827
|
+
def __init__(self, var_dims: dict, before: dict | None = None, dim_counts: dict | None = None):
|
|
828
|
+
self.var_dims = var_dims
|
|
829
|
+
self.before = before or {}
|
|
830
|
+
self.dim_counts = dim_counts # `symbols.declared_dim_counts(program, strings=True)`
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def _stmt_exprs(stmt: S.Stmt) -> list:
|
|
834
|
+
if isinstance(stmt, S.Assignment):
|
|
835
|
+
return [stmt.target, stmt.value]
|
|
836
|
+
if isinstance(stmt, (S.If, S.ElseIf, S.DoLoop)):
|
|
837
|
+
return [stmt.cond]
|
|
838
|
+
if isinstance(stmt, S.For):
|
|
839
|
+
return [stmt.var, stmt.start, stmt.end, stmt.step]
|
|
840
|
+
if isinstance(stmt, S.Call) and stmt.tail_expr is not None:
|
|
841
|
+
return [stmt.tail_expr]
|
|
842
|
+
if isinstance(stmt, (S.Instruction, S.Call)):
|
|
843
|
+
return list(stmt.args)
|
|
844
|
+
return []
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def program_dims(program: S.Program, profile, known) -> DimsState:
|
|
848
|
+
"""The one `DimsState` every consumer shares: single-pass loggers (CR300/CR350) stop a
|
|
849
|
+
statement at its first undefined name (`known`), the others simulate over declared variables."""
|
|
850
|
+
return dims_state(program, known if profile.single_pass else _NO_NAMES)
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def dims_state(program: S.Program, known=_NO_NAMES) -> DimsState:
|
|
854
|
+
"""`DimsState` for `program`: variable dimension counts, and the count at each statement's
|
|
855
|
+
start, simulated over every statement in order (one-line `If` bodies included)."""
|
|
856
|
+
var_dims: dict[str, int | None] = {}
|
|
857
|
+
|
|
858
|
+
def declare(name: str, count: int) -> None:
|
|
859
|
+
name = name.upper()
|
|
860
|
+
var_dims[name] = count if var_dims.get(name, count) == count else None
|
|
861
|
+
|
|
862
|
+
for stmt in program.statements:
|
|
863
|
+
if isinstance(stmt, S.Declaration):
|
|
864
|
+
for v in stmt.vars:
|
|
865
|
+
declare(v.name, len(v.dims) + ((v.type or "").upper() == "STRING"))
|
|
866
|
+
elif isinstance(stmt, S.SubDef):
|
|
867
|
+
for prm in stmt.params:
|
|
868
|
+
declare(prm.name, prm.dims + ((prm.type or "").upper() == "STRING"))
|
|
869
|
+
ordered: list[S.Stmt] = []
|
|
870
|
+
present = {id(st) for st in program.statements}
|
|
871
|
+
for stmt in program.statements:
|
|
872
|
+
ordered.append(stmt)
|
|
873
|
+
if isinstance(stmt, S.If):
|
|
874
|
+
ordered.extend(st for st in (stmt.inline or []) + (stmt.inline_else or []) if id(st) not in present)
|
|
875
|
+
before: dict[int, int | None] = {}
|
|
876
|
+
state: list = [0]
|
|
877
|
+
for stmt in ordered:
|
|
878
|
+
before[id(stmt)] = state[0]
|
|
879
|
+
for expr in _stmt_exprs(stmt):
|
|
880
|
+
if _dims_walk(expr, known, var_dims, state):
|
|
881
|
+
break
|
|
882
|
+
return DimsState(var_dims, before, symbols.declared_dim_counts(program, strings=True))
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def _dims_walk(expr: S.Expr | None, known, var_dims: dict, state: list) -> bool:
|
|
886
|
+
"""Walks `expr` in text order updating `state[0]`; True when an undefined name has more
|
|
887
|
+
subscripts than the current count. With `known` empty every name counts as undefined, so only
|
|
888
|
+
declared variables (`var_dims`) move the state: that is the simulation mode."""
|
|
889
|
+
if expr is None:
|
|
890
|
+
return False
|
|
891
|
+
oos = id(expr) in getattr(known, "out_of_scope", ()) # another routine's `Dim`: undefined here
|
|
892
|
+
if isinstance(expr, S.Name):
|
|
893
|
+
upper = expr.name.upper()
|
|
894
|
+
if upper in var_dims and not oos:
|
|
895
|
+
state[0] = var_dims[upper]
|
|
896
|
+
return False
|
|
897
|
+
if isinstance(expr, S.Index):
|
|
898
|
+
upper = expr.name.upper()
|
|
899
|
+
if upper in var_dims and not oos:
|
|
900
|
+
state[0] = var_dims[upper]
|
|
901
|
+
elif (upper not in known or oos) and known is not _NO_NAMES:
|
|
902
|
+
count = sum(1 for a in expr.args if a is not None)
|
|
903
|
+
if state[0] is not None and count > state[0]:
|
|
904
|
+
return True
|
|
905
|
+
return any(_dims_walk(a, known, var_dims, state) for a in expr.args)
|
|
906
|
+
if isinstance(expr, S.Unary):
|
|
907
|
+
return _dims_walk(expr.operand, known, var_dims, state)
|
|
908
|
+
if isinstance(expr, S.Binary):
|
|
909
|
+
return _dims_walk(expr.left, known, var_dims, state) or _dims_walk(expr.right, known, var_dims, state)
|
|
910
|
+
if isinstance(expr, S.Member):
|
|
911
|
+
return _dims_walk(expr.base, known, var_dims, state) or any(
|
|
912
|
+
_dims_walk(a, known, var_dims, state) for a in expr.args or []
|
|
913
|
+
)
|
|
914
|
+
if isinstance(expr, S.ArrayLiteral):
|
|
915
|
+
return any(_dims_walk(a, known, var_dims, state) for a in expr.items)
|
|
916
|
+
return False
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
def _has_undefined_index(
|
|
920
|
+
expr: S.Expr | None, known: set[str], ds: DimsState | None = None, stmt: S.Stmt | None = None, prior: tuple = ()
|
|
921
|
+
) -> bool:
|
|
922
|
+
"""Whether CR300/CR350 follow the unresolved-name lines with `Dimensions not declared: %s`:
|
|
923
|
+
the walk over `prior` (the statement's earlier arguments, or the assignment target) then
|
|
924
|
+
`expr`, starting from the count at `stmt`'s start (`DimsState`), meets an undefined name with
|
|
925
|
+
more subscripts than the current count. Without `ds` the count is 0 (any subscript)."""
|
|
926
|
+
state = [ds.before.get(id(stmt), 0) if ds and stmt is not None else 0]
|
|
927
|
+
var_dims = ds.var_dims if ds else {}
|
|
928
|
+
return any(_dims_walk(e, known, var_dims, state) for e in (*prior, expr))
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
def _names_and_dims(
|
|
932
|
+
known: set[str], ds: DimsState | None, stmt: S.Stmt | None, prior: tuple, *exprs: S.Expr | None
|
|
933
|
+
) -> tuple[list[str], bool]:
|
|
934
|
+
"""The undefined names of `exprs` and whether `Dimensions not declared` follows them
|
|
935
|
+
(`_has_undefined_index`). When it does, the walk ends at the triggering subscripted name, so
|
|
936
|
+
the names after it are not reported (oracle, CR300, 2026-10-03: `Battery(Nope(1)+Nope2(1))`
|
|
937
|
+
names only `Nope`)."""
|
|
938
|
+
dims = _has_undefined_index(None, known, ds, stmt, (*prior, *exprs))
|
|
939
|
+
if not dims:
|
|
940
|
+
return [n for e in exprs for n in _undefined_names(e, known)], False
|
|
941
|
+
state = [ds.before.get(id(stmt), 0) if ds and stmt is not None else 0]
|
|
942
|
+
var_dims = ds.var_dims if ds else {}
|
|
943
|
+
for e in prior:
|
|
944
|
+
_dims_walk(e, known, var_dims, state)
|
|
945
|
+
out: list[str] = []
|
|
946
|
+
|
|
947
|
+
def walk(e: S.Expr | None) -> bool:
|
|
948
|
+
if e is None:
|
|
949
|
+
return False
|
|
950
|
+
oos = id(e) in getattr(known, "out_of_scope", ())
|
|
951
|
+
if isinstance(e, S.Name):
|
|
952
|
+
if e.name.upper() in var_dims and not oos:
|
|
953
|
+
state[0] = var_dims[e.name.upper()]
|
|
954
|
+
if e.name.upper() not in known or oos or e.name.upper() in getattr(known, "tables", ()):
|
|
955
|
+
out.append(e.name)
|
|
956
|
+
return False
|
|
957
|
+
if isinstance(e, S.Index):
|
|
958
|
+
upper = e.name.upper()
|
|
959
|
+
if upper in var_dims and not oos:
|
|
960
|
+
state[0] = var_dims[upper]
|
|
961
|
+
if upper not in known or oos:
|
|
962
|
+
out.append(e.name)
|
|
963
|
+
count = sum(1 for a in e.args if a is not None)
|
|
964
|
+
if state[0] is not None and count > state[0]:
|
|
965
|
+
for a in e.args: # its own arguments are still named
|
|
966
|
+
walk(a)
|
|
967
|
+
return True
|
|
968
|
+
for a in e.args:
|
|
969
|
+
if walk(a):
|
|
970
|
+
return True
|
|
971
|
+
return False
|
|
972
|
+
children: list[S.Expr | None] = []
|
|
973
|
+
if isinstance(e, S.Unary):
|
|
974
|
+
children = [e.operand]
|
|
975
|
+
elif isinstance(e, S.Binary):
|
|
976
|
+
children = [e.left, e.right]
|
|
977
|
+
elif isinstance(e, S.Member):
|
|
978
|
+
children = ([] if isinstance(e.base, S.Name) and e.base.name.upper() in known else [e.base]) + (
|
|
979
|
+
e.args or []
|
|
980
|
+
)
|
|
981
|
+
elif isinstance(e, S.ArrayLiteral):
|
|
982
|
+
children = e.items
|
|
983
|
+
for child in children:
|
|
984
|
+
if walk(child):
|
|
985
|
+
return True
|
|
986
|
+
return False
|
|
987
|
+
|
|
988
|
+
for e in exprs:
|
|
989
|
+
if walk(e):
|
|
990
|
+
break
|
|
991
|
+
return out, True
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def dimensions_message(text: str, profile, whole_statement: bool, at_eol: bool = True) -> str:
|
|
995
|
+
sep = "\n" if whole_statement and profile.fallback_echo_newline and at_eol else ""
|
|
996
|
+
return f"Dimensions not declared: {text}{sep}."
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
def _arg_text(stmt: S.Instruction, i: int) -> str:
|
|
1000
|
+
"""The argument's own verbatim source text (`Instruction.arg_spans`, `parser.py`), for the
|
|
1001
|
+
messages below that echo a whole expression rather than a single name (oracle-confirmed,
|
|
1002
|
+
`docs/checker.md`: `Scan(X+1,...)` echoes `X+1`, not a re-derived or spacing-normalised form).
|
|
1003
|
+
Falls back to the bare name (still correct for the common single-name case) if a span was
|
|
1004
|
+
somehow not captured.
|
|
1005
|
+
"""
|
|
1006
|
+
if i == 0 and getattr(stmt, "keyword", "").upper() == "SELECT":
|
|
1007
|
+
m = re.match(r"(?i)\s*Select\s+Case(.*)$", stmt.text or "")
|
|
1008
|
+
if m:
|
|
1009
|
+
return m.group(1) # every space after `Case` kept (oracle: `Select Case X+Nope`)
|
|
1010
|
+
spans = getattr(stmt, "arg_spans", None)
|
|
1011
|
+
if spans and i < len(spans) and spans[i] is not None:
|
|
1012
|
+
start, end = spans[i]
|
|
1013
|
+
return stmt.text[start:end]
|
|
1014
|
+
arg = stmt.args[i]
|
|
1015
|
+
return arg.name if isinstance(arg, S.Name) else ""
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
def _call_arg_text(text: str, index: int) -> str:
|
|
1019
|
+
"""Source text of top-level argument `index` of a call statement (`S.Call` keeps no spans)."""
|
|
1020
|
+
depth, quote, start, n = 0, False, None, 0
|
|
1021
|
+
for pos, ch in enumerate(text):
|
|
1022
|
+
if ch == '"':
|
|
1023
|
+
quote = not quote
|
|
1024
|
+
elif quote:
|
|
1025
|
+
continue
|
|
1026
|
+
elif ch == "(":
|
|
1027
|
+
depth += 1
|
|
1028
|
+
if depth == 1 and start is None:
|
|
1029
|
+
start = pos + 1
|
|
1030
|
+
elif ch == ")" or ch == ",":
|
|
1031
|
+
if ch == ")":
|
|
1032
|
+
depth -= 1
|
|
1033
|
+
if (ch == "," and depth == 1) or depth == 0:
|
|
1034
|
+
if n == index:
|
|
1035
|
+
return text[start:pos].lstrip()
|
|
1036
|
+
n, start = n + 1, pos + 1
|
|
1037
|
+
return ""
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
_IF_COND_RE = re.compile(r"(?is)^\s*(?:Else)?If(?:\s|(?=\())(.*?)\bThen\b") # `If(` too (round 36)
|
|
1041
|
+
|
|
1042
|
+
|
|
1043
|
+
def _if_condition_text(stmt: S.Stmt) -> str | None:
|
|
1044
|
+
"""The echo the real compilers use for an `If` condition: the text between `If ` and `Then`,
|
|
1045
|
+
trailing space included (oracle: `If X > 1 And Nope Then` -> `X > 1 And Nope .`)."""
|
|
1046
|
+
m = _IF_COND_RE.match(stmt.text or "")
|
|
1047
|
+
if m is None and isinstance(stmt, S.If) and stmt.inline is None:
|
|
1048
|
+
# no `Then`: the condition runs to the end of the statement (oracle, 2026-10-08)
|
|
1049
|
+
m = re.match(r"(?is)^\s*If(?:\s|(?=\())(.*)$", stmt.text or "")
|
|
1050
|
+
return m.group(1) if m else None
|
|
1051
|
+
|
|
1052
|
+
|
|
1053
|
+
_IF_NO_THEN_RE = re.compile(r"(?is)^\s*If(?:\s|(?=\())([^'_]*)$")
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
def _if_condition_text_no_then(stmt: S.Stmt) -> str | None:
|
|
1057
|
+
"""`If Nope = True` with no `Then` (accepted when the names resolve): the echo is the rest of
|
|
1058
|
+
the line (oracle, all four loggers, 2026-10-08). Lines with a comment or continuation are left
|
|
1059
|
+
alone."""
|
|
1060
|
+
text = stmt.text or ""
|
|
1061
|
+
if re.search(r"(?i)\bThen\b", text):
|
|
1062
|
+
return None
|
|
1063
|
+
m = _IF_NO_THEN_RE.match(text)
|
|
1064
|
+
return m.group(1) if m else None
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
# Instruction names resolve in no expression position (`X = Battery(X)`, `If Delay(1) Then`,
|
|
1068
|
+
# `Battery`, argument positions) except the ones that are also functions (`Randomize`, which is in
|
|
1069
|
+
# `profile.functions` anyway) and the block keywords, whose real message is `Incomplete expression`
|
|
1070
|
+
# (oracle, all four loggers, 2026-10-08: every instruction name probed, `docs/checker.md`).
|
|
1071
|
+
_CONTROL_KEYWORDS = frozenset({"CASE", "DO", "FOR", "FUNCTION", "IF", "SCAN", "SELECT", "SUB", "SUBSCAN", "WHILE"})
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
def _known_names(program: S.Program, profile) -> KnownNames:
|
|
1075
|
+
"""Every name a checked expression position may legally reference: this program's own
|
|
1076
|
+
declarations (`symbols.declared_names`), this logger's predefined symbols/functions/
|
|
1077
|
+
instruction keywords (`profile.*`), its always-or-conditionally-valid system table names
|
|
1078
|
+
(`symbols.system_table_names`), the two floating-point special values found this session to
|
|
1079
|
+
resolve outside the extracted symbol table entirely (`_EXTRA_PREDEFINED_SYMBOLS`), and, when
|
|
1080
|
+
the program declares `ESSVariables`, whichever `ess`-prefixed names it actually references
|
|
1081
|
+
(`symbols.ess_variable_names` -- round 5, `docs/checker.md`).
|
|
1082
|
+
"""
|
|
1083
|
+
resolvable = (
|
|
1084
|
+
set(profile.symbols)
|
|
1085
|
+
| set(profile.functions)
|
|
1086
|
+
| (set(profile.instructions) & _CONTROL_KEYWORDS)
|
|
1087
|
+
| symbols.system_table_names(program)
|
|
1088
|
+
| symbols.ess_variable_names(program)
|
|
1089
|
+
| _EXTRA_PREDEFINED_SYMBOLS
|
|
1090
|
+
)
|
|
1091
|
+
known = symbols.declared_names(program) | resolvable
|
|
1092
|
+
if any(isinstance(st, S.Instruction) and st.keyword.upper() == "CH400STATUS" for st in program.statements):
|
|
1093
|
+
known.add("CH400STATUS") # the instruction names the table `Sample(1, CH400Status, ...)` reads
|
|
1094
|
+
known -= _failed_consts(program, known, profile)
|
|
1095
|
+
if profile.single_pass:
|
|
1096
|
+
known -= _unclosed_structure_variables(program)
|
|
1097
|
+
# an `Alias` whose subscript names something undefined is not registered (oracle, CR300/
|
|
1098
|
+
# CR350, 2026-10-03: `Alias Q(2,Nope) = NetDir`, then `NetDir` is not defined), nor is one
|
|
1099
|
+
# whose source was never declared (`Quad not yet declared so cannot be aliased.`; oracle,
|
|
1100
|
+
# CR300, 2026-10-04, `tests/corpus/fuzz/cr300/message_diff/829cfa80394a`). A name that a
|
|
1101
|
+
# declaration or another valid `Alias` registers stays known (oracle, 2026-10-04:
|
|
1102
|
+
# `Public x` / `Alias Nope = x` and `Alias x = a` / `Alias Nope = a`, then `a`/`x` resolve).
|
|
1103
|
+
declared = symbols.declared_variable_names(program)
|
|
1104
|
+
failed, valid = set(), set(declared)
|
|
1105
|
+
for st in program.statements:
|
|
1106
|
+
if not (
|
|
1107
|
+
isinstance(st, S.Instruction)
|
|
1108
|
+
and st.keyword.upper() == "ALIAS"
|
|
1109
|
+
and len(st.args) == 2
|
|
1110
|
+
and isinstance(st.args[1], S.Name)
|
|
1111
|
+
and isinstance(st.args[0], (S.Name, S.Index))
|
|
1112
|
+
):
|
|
1113
|
+
continue
|
|
1114
|
+
src = st.args[0]
|
|
1115
|
+
bad = src.name.upper() not in declared or (
|
|
1116
|
+
isinstance(src, S.Index) and any(_undefined_names(a, known) for a in src.args if a is not None)
|
|
1117
|
+
)
|
|
1118
|
+
if not bad and src.name.upper() in declared:
|
|
1119
|
+
# `Alias is illegally dimensioned.` (empty `Q()`, a parenthesised index item
|
|
1120
|
+
# `Q((1+2))`) registers no name; the out-of-range
|
|
1121
|
+
# `Illegally dimensioned aliased name` one does (oracle, CR300/CR350, 2026-10-12)
|
|
1122
|
+
from . import structure
|
|
1123
|
+
|
|
1124
|
+
open_at = st.text.find("(")
|
|
1125
|
+
items = structure._group_items(st.text, open_at) if open_at >= 0 else None
|
|
1126
|
+
bad = (isinstance(src, S.Index) and not src.args) or bool(
|
|
1127
|
+
items
|
|
1128
|
+
and isinstance(src, S.Index)
|
|
1129
|
+
and any(i.strip().startswith("(") and i.strip().endswith(")") for i in items)
|
|
1130
|
+
)
|
|
1131
|
+
(failed if bad else valid).add(st.args[1].name.upper())
|
|
1132
|
+
# a dimensioned name from the one whose `Alias is illegally dimensioned.` ends the
|
|
1133
|
+
# statement on is not registered (round 30, `Alias X(3) = Y(9)` on `X(6)`); reuse the
|
|
1134
|
+
# declaration check over the preceding declarations of the alias's scope, with String
|
|
1135
|
+
# storage. The names it registered before stopping stay (round 30 review).
|
|
1136
|
+
from . import structure
|
|
1137
|
+
|
|
1138
|
+
env = consteval.Env(profile, dict(program.consts))
|
|
1139
|
+
for pos, st in enumerate(program.statements):
|
|
1140
|
+
if (
|
|
1141
|
+
isinstance(st, S.Instruction)
|
|
1142
|
+
and st.keyword.upper() == "ALIAS"
|
|
1143
|
+
and any(isinstance(a, S.Index) for a in st.args[1:])
|
|
1144
|
+
):
|
|
1145
|
+
view = symbols.scoped_view(S.Program("", program.statements[: pos + 1]), st)
|
|
1146
|
+
scratch, registered = Diagnostics(), set()
|
|
1147
|
+
structure._check_alias_dims(
|
|
1148
|
+
st,
|
|
1149
|
+
scratch,
|
|
1150
|
+
env,
|
|
1151
|
+
symbols.declared_dim0_sizes(view, env),
|
|
1152
|
+
symbols.declared_all_dims(view, env),
|
|
1153
|
+
registered,
|
|
1154
|
+
{},
|
|
1155
|
+
None,
|
|
1156
|
+
profile,
|
|
1157
|
+
structure._alias_string_slots(view, env),
|
|
1158
|
+
)
|
|
1159
|
+
if any(d.message == "Alias is illegally dimensioned." for d in scratch.items):
|
|
1160
|
+
failed |= {a.name.upper() for a in st.args[1:] if isinstance(a, S.Index)} - registered
|
|
1161
|
+
# a Const keeps its own binding (round 30 review, `Alias X = K(2)` with `Const K`)
|
|
1162
|
+
known -= failed - valid - set(program.consts)
|
|
1163
|
+
if profile.name in _WHOLE_EXPR_ECHO_LOGGERS:
|
|
1164
|
+
# a `Sub` is no value (`X = V`, `If V Then`, `Delay(1,V,mSec)`; oracle, CR6/CR1000X, round 71)
|
|
1165
|
+
known -= (
|
|
1166
|
+
{st.name.upper() for st in program.statements if isinstance(st, S.SubDef) and st.keyword.upper() == "SUB"}
|
|
1167
|
+
- symbols.declared_variable_names(program)
|
|
1168
|
+
- set(program.consts)
|
|
1169
|
+
- set(profile.symbols)
|
|
1170
|
+
)
|
|
1171
|
+
return KnownNames(
|
|
1172
|
+
known,
|
|
1173
|
+
tables=_value_less_tables(program, profile),
|
|
1174
|
+
dotted_tables=profile.single_pass,
|
|
1175
|
+
out_of_scope=symbols.out_of_scope_nodes(program, resolvable),
|
|
1176
|
+
)
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
def _unclosed_structure_variables(program: S.Program) -> set[str]:
|
|
1180
|
+
"""CR300/CR350: upper-case names of variables declared `As <T>` where `StructureType T` never
|
|
1181
|
+
reaches `EndStructureType`; they are `is not defined.` (oracle 2026-10-03,
|
|
1182
|
+
`docs/instructions.md` "Unclosed StructureType")."""
|
|
1183
|
+
open_type = None
|
|
1184
|
+
unclosed: set[str] = set()
|
|
1185
|
+
for st in program.statements:
|
|
1186
|
+
if (
|
|
1187
|
+
isinstance(st, S.Instruction)
|
|
1188
|
+
and st.keyword.upper() == "STRUCTURETYPE"
|
|
1189
|
+
and st.args
|
|
1190
|
+
and isinstance(st.args[0], S.Name)
|
|
1191
|
+
):
|
|
1192
|
+
open_type = st.args[0].name.upper()
|
|
1193
|
+
elif isinstance(st, S.Instruction) and st.keyword.upper() == "ENDSTRUCTURETYPE":
|
|
1194
|
+
open_type = None
|
|
1195
|
+
if open_type is not None:
|
|
1196
|
+
for st in program.statements:
|
|
1197
|
+
if isinstance(st, S.Declaration) and st.kind in ("PUBLIC", "DIM", "STATIC"):
|
|
1198
|
+
unclosed.update(v.name.upper() for v in st.vars if (v.type or "") == open_type)
|
|
1199
|
+
return unclosed
|
|
1200
|
+
|
|
1201
|
+
|
|
1202
|
+
class KnownNames(set[str]):
|
|
1203
|
+
"""`_known_names`, plus the `DataTable` names that are known only as table references: a bare
|
|
1204
|
+
`T` used as a value is undefined (`X = T`, `Battery(T)`, `Foo(X, T)`; oracle, all four
|
|
1205
|
+
loggers, 2026-09-28) while `T.Field` and `CallTable T` resolve."""
|
|
1206
|
+
|
|
1207
|
+
def __init__(
|
|
1208
|
+
self,
|
|
1209
|
+
names=(),
|
|
1210
|
+
tables: frozenset = frozenset(),
|
|
1211
|
+
dotted_tables: bool = False,
|
|
1212
|
+
out_of_scope: frozenset = frozenset(),
|
|
1213
|
+
):
|
|
1214
|
+
super().__init__(names)
|
|
1215
|
+
self.out_of_scope = out_of_scope # `symbols.out_of_scope_nodes`: ids of other-routine `Dim` uses
|
|
1216
|
+
self.tables = tables
|
|
1217
|
+
self.dotted_tables = dotted_tables # CR300/CR350: `Undeclared.member` names a table
|
|
1218
|
+
|
|
1219
|
+
def without(self, names) -> "KnownNames":
|
|
1220
|
+
return KnownNames(self - names, self.tables, self.dotted_tables, self.out_of_scope) if names else self
|
|
1221
|
+
|
|
1222
|
+
|
|
1223
|
+
def _alias_names(program: S.Program) -> set[str]:
|
|
1224
|
+
"""`Alias` names: a variable for the const-only positions (`Sample(1,IEEE4,Ux)` with
|
|
1225
|
+
`Alias A(1) = Ux` is `Variable illegal in Parameter: Ux.`; oracle, CR6/CR1000X, 2026-10-25)."""
|
|
1226
|
+
return {
|
|
1227
|
+
a.name.upper()
|
|
1228
|
+
for st in program.statements
|
|
1229
|
+
if isinstance(st, S.Instruction) and st.keyword.upper() == "ALIAS"
|
|
1230
|
+
for a in st.args[1:]
|
|
1231
|
+
if isinstance(a, S.Name)
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
|
|
1235
|
+
def _value_less_tables(program: S.Program, profile=None) -> frozenset:
|
|
1236
|
+
tables = {
|
|
1237
|
+
st.args[0].name.upper()
|
|
1238
|
+
for st in program.statements
|
|
1239
|
+
if isinstance(st, S.Instruction)
|
|
1240
|
+
and st.keyword.upper() == "DATATABLE"
|
|
1241
|
+
and st.args
|
|
1242
|
+
and isinstance(st.args[0], S.Name)
|
|
1243
|
+
}
|
|
1244
|
+
if profile is not None: # `DataTable(True,...)` leaves `True` a value (oracle, round 54)
|
|
1245
|
+
tables -= {n.upper() for n in profile.symbols}
|
|
1246
|
+
# `Status` and `Public` as a bare value are undefined on all four loggers, `Settings` on CR300/
|
|
1247
|
+
# CR350 (oracle, 2026-10-08: `X = Status`, `Battery(Public)`; CR6/CR1000X accept `X = Settings`)
|
|
1248
|
+
tables |= {"STATUS", "PUBLIC"} | ({"SETTINGS"} if profile is not None and profile.single_pass else set())
|
|
1249
|
+
# An `Alias D(3)=Status` makes `Status` a value too, even above the `Alias` (oracle, all four
|
|
1250
|
+
# loggers, 2026-10-24; a GEUS false reject, `docs/checker.md` "Aliases named Status").
|
|
1251
|
+
aliases = _alias_names(program)
|
|
1252
|
+
others = (
|
|
1253
|
+
symbols.declared_variable_names(program)
|
|
1254
|
+
| {n.upper() for n in program.consts}
|
|
1255
|
+
| {st.name.upper() for st in program.statements if isinstance(st, S.SubDef)}
|
|
1256
|
+
| aliases
|
|
1257
|
+
)
|
|
1258
|
+
return frozenset(tables - others)
|
|
1259
|
+
|
|
1260
|
+
|
|
1261
|
+
def _not_yet_declared(program: S.Program, profile) -> dict[int, frozenset[str]]:
|
|
1262
|
+
"""Per top-level `ConstDecl` (by `id`): the names it may not use yet -- itself and every
|
|
1263
|
+
`Const` or variable first declared on a later top-level line (`Const J = J`, `Const K = L` /
|
|
1264
|
+
`Const L = 2`, `Const K = Y2` / `Public Y2`): `Invalid symbol in expression` /
|
|
1265
|
+
`<name> is not defined.` (oracle, all four loggers, 2026-10-18, docs/checker.md "Round 20").
|
|
1266
|
+
Names a logger predefines stay resolvable."""
|
|
1267
|
+
stmts = program.statements
|
|
1268
|
+
variables = symbols.declared_variable_names(program)
|
|
1269
|
+
base = set(profile.symbols) | set(profile.functions) | set(profile.instructions)
|
|
1270
|
+
seen: set[str] = set()
|
|
1271
|
+
out: dict[int, frozenset[str]] = {}
|
|
1272
|
+
consts_after: dict[int, set[str]] = {}
|
|
1273
|
+
pending: set[str] = set()
|
|
1274
|
+
for i in range(len(stmts) - 1, -1, -1):
|
|
1275
|
+
if isinstance(stmts[i], S.ConstDecl):
|
|
1276
|
+
pending = pending | {cast(S.ConstDecl, stmts[i]).name.upper()}
|
|
1277
|
+
consts_after[i] = pending
|
|
1278
|
+
in_routine = False
|
|
1279
|
+
for i, st in enumerate(stmts):
|
|
1280
|
+
if isinstance(st, S.SubDef):
|
|
1281
|
+
in_routine = True
|
|
1282
|
+
elif S.ends_routine(st):
|
|
1283
|
+
in_routine = False
|
|
1284
|
+
elif in_routine:
|
|
1285
|
+
continue # routine-local names stay invisible at top level, its own Consts keep the old rule
|
|
1286
|
+
elif isinstance(st, S.ConstDecl):
|
|
1287
|
+
later = (consts_after[i] | (variables - seen)) - seen - base
|
|
1288
|
+
out[id(st)] = frozenset(later)
|
|
1289
|
+
seen.add(st.name.upper())
|
|
1290
|
+
elif isinstance(st, S.Declaration):
|
|
1291
|
+
seen.update(v.name.upper() for v in st.vars)
|
|
1292
|
+
return out
|
|
1293
|
+
|
|
1294
|
+
|
|
1295
|
+
def _failed_consts(program: S.Program, known: set[str], profile=None) -> set[str]:
|
|
1296
|
+
"""`Const` names whose value does not resolve, directly or through another failed `Const`:
|
|
1297
|
+
the real compilers never declare them, so every later use is itself unresolved (oracle, all
|
|
1298
|
+
four loggers: `Const P = Nope` / `Const R = P + 1` / `PortSet(R,1)` reports `Nope`, then `P + 1`
|
|
1299
|
+
on CR6 (`P is not defined.` on CR300), then `R`; `crbasic/AcclimaTDRLib.crb`'s `Const
|
|
1300
|
+
TDRComPort = U5` on CR1000X, which has no `U5`; `docs/checker.md` "Pass-1 instruction
|
|
1301
|
+
arguments")."""
|
|
1302
|
+
consts = [s for s in program.statements if isinstance(s, S.ConstDecl) and s.value is not None]
|
|
1303
|
+
failed: set[str] = set()
|
|
1304
|
+
if profile is not None:
|
|
1305
|
+
# a value with a bad built-in call (`4*ATN()`, `4*ATN(1,2)`) is a pass-1 error and the
|
|
1306
|
+
# constant is never declared (oracle, CR300/CR350, 2026-10-12)
|
|
1307
|
+
failed |= {
|
|
1308
|
+
c.name.upper()
|
|
1309
|
+
for c in consts
|
|
1310
|
+
if _find_arity_problem(c.value, profile) or _find_bare_function_ref(c.value, profile, frozenset())
|
|
1311
|
+
}
|
|
1312
|
+
unready = _not_yet_declared(program, profile) if profile is not None else {}
|
|
1313
|
+
changed = True
|
|
1314
|
+
while changed:
|
|
1315
|
+
changed = False
|
|
1316
|
+
for c in consts:
|
|
1317
|
+
name = c.name.upper()
|
|
1318
|
+
if name not in failed and _undefined_names(c.value, known - failed - unready.get(id(c), frozenset())):
|
|
1319
|
+
failed.add(name)
|
|
1320
|
+
changed = True
|
|
1321
|
+
return failed
|
|
1322
|
+
|
|
1323
|
+
|
|
1324
|
+
def _report_statement(
|
|
1325
|
+
stmt: S.Instruction,
|
|
1326
|
+
positions: dict[int, str],
|
|
1327
|
+
known: set[str],
|
|
1328
|
+
variable_names: set[str],
|
|
1329
|
+
profile,
|
|
1330
|
+
diags: Diagnostics,
|
|
1331
|
+
families: str = "all",
|
|
1332
|
+
dim_counts: dict[str, int] | None = None,
|
|
1333
|
+
ds: DimsState | None = None,
|
|
1334
|
+
) -> bool:
|
|
1335
|
+
"""One statement's own undefined-symbol and constant-only-family checks, at every position
|
|
1336
|
+
`_position_kinds` marks as routed through the general expression/symbol resolver -- a full
|
|
1337
|
+
expression-tree walk (`_undefined_names`), not just a direct bare-`Name` argument (round 4,
|
|
1338
|
+
this session: safe now that `_known_names` closes the two gaps -- `NAN`/`Status` -- that made
|
|
1339
|
+
this walk reject real programs when first tried in round 3, `docs/checker.md`).
|
|
1340
|
+
|
|
1341
|
+
Both the *order* positions are checked in and the *shape* of what gets reported differ by
|
|
1342
|
+
logger family (oracle-confirmed this session, `docs/checker.md` "Round 4" -- see
|
|
1343
|
+
`_CONST_FAMILY_KINDS`/`_WHOLE_EXPR_ECHO_LOGGERS` above for the evidence): CR6/CR1000X check
|
|
1344
|
+
every constant-only-family position (ascending) before any general-family position regardless
|
|
1345
|
+
of textual order; CR300/CR350 check every position in plain ascending textual order, no family
|
|
1346
|
+
split. Both stop at the first position with *any* problem (an undefined name, checked at every
|
|
1347
|
+
position, or a live-variable reference, checked at const-only-family positions only,
|
|
1348
|
+
`_CONST_ONLY_VARIABLE_REJECT_KINDS`) and report only that one -- **within one position**, the
|
|
1349
|
+
undefined-name check runs first (the two are mutually exclusive for a single name: a name is
|
|
1350
|
+
either undefined or resolves to something, so this ordering only matters for picking which
|
|
1351
|
+
message a position with *both* an undefined name and, elsewhere in the same expression, a live
|
|
1352
|
+
variable gets -- not evidenced either way this session, so undefined-first was kept as the
|
|
1353
|
+
simpler, already-existing rule). Oracle-confirmed cross-family interleaving, not just two
|
|
1354
|
+
independent passes (`WriteIO(X,BazQux)`, `X` a live variable at the const-family position 0,
|
|
1355
|
+
`BazQux` undefined at the general-family position 1: CR6/CR1000X report `Variable illegal in
|
|
1356
|
+
Parameter: X.`, never reaching `BazQux`'s own undefined-name error even though the *undefined-
|
|
1357
|
+
name* pass alone, run across both families before any variable-reject check, would reach
|
|
1358
|
+
`BazQux` first if the two checks were separate top-level passes over the statement -- they are
|
|
1359
|
+
not; `PRT(GenBad,X,3,3,3)` on CR300, `GenBad` undefined at the general-family position 0 (the
|
|
1360
|
+
textually-first position there), `X` a live variable at the const-family position 1: CR300
|
|
1361
|
+
reports only `GenBad is not defined.`, never reaching `X` at all, confirming CR300/CR350 have
|
|
1362
|
+
no family reordering for this combined check either). Neither family is known to check past the
|
|
1363
|
+
first bad position in the same statement -- under-reporting a second, independent bad position
|
|
1364
|
+
in the same call is the deliberate, safe side of that gap (AGENTS.md: never reject an accepted
|
|
1365
|
+
program), not a claim that the real compiler is silent about it. The variable-reject message
|
|
1366
|
+
itself (`Variable illegal in Parameter: %s.`) is oracle-confirmed uniform across all four
|
|
1367
|
+
loggers (`docs/checker.md`), so no per-logger branch for its own text.
|
|
1368
|
+
"""
|
|
1369
|
+
|
|
1370
|
+
def _check_position(i: int) -> bool:
|
|
1371
|
+
"""Checks position `i`, reports at most one diagnostic (CR6/CR1000X) or up to two
|
|
1372
|
+
(CR300/CR350, see below), returns whether it reported anything.
|
|
1373
|
+
|
|
1374
|
+
**CR300/CR350 do not share CR6/CR1000X's mutual exclusivity** between the undefined-name
|
|
1375
|
+
check and the const-only-family "rejects a variable" check, oracle-confirmed this round
|
|
1376
|
+
(`docs/loggers.md`): at a const-family position (`_CONST_ONLY_VARIABLE_REJECT_KINDS`), an
|
|
1377
|
+
*undefined* name gets **both** its own undefined-name line(s) *and* a `Variable illegal in
|
|
1378
|
+
Parameter: <whole arg text>.` line right after -- `TCDiff(Dest,1,BazQux,...)` on
|
|
1379
|
+
`cr300comp.exe` -> `BazQux is not defined.` then `Variable illegal in Parameter: BazQux.`,
|
|
1380
|
+
two lines, where CR6/CR1000X give only `Invalid symbol in expression: BazQux.` (one line,
|
|
1381
|
+
the existing rule, unchanged). A compound expression mixing an undefined name with a live
|
|
1382
|
+
variable (`TCDiff(Dest,1,X+BazQux,...)`, `X` declared) gives `BazQux is not defined.` (the
|
|
1383
|
+
undefined name only, per CR300/CR350's existing per-name echo) then `Variable illegal in
|
|
1384
|
+
Parameter: X+BazQux.` (the whole expression, since it is not a foldable constant either
|
|
1385
|
+
way) -- i.e. the second check's trigger is "argument contains any non-constant name
|
|
1386
|
+
reference at all" (undefined *or* a declared variable), not "is a declared variable"
|
|
1387
|
+
alone, and always echoes the whole argument once. Modelled below as one unified
|
|
1388
|
+
computation (`trigger = names or var_refs`) that happens to reduce to the pre-existing
|
|
1389
|
+
CR6/CR1000X mutual-exclusivity behaviour automatically, since `names` is always empty by
|
|
1390
|
+
the time CR6/CR1000X (`_WHOLE_EXPR_ECHO_LOGGERS`) reach the second check (a non-empty
|
|
1391
|
+
`names` already returned above, for that family only).
|
|
1392
|
+
"""
|
|
1393
|
+
if i >= len(stmt.args) or stmt.args[i] is None:
|
|
1394
|
+
return False
|
|
1395
|
+
arg = stmt.args[i]
|
|
1396
|
+
names = _undefined_names(arg, known)
|
|
1397
|
+
whole_echo = profile.name in _WHOLE_EXPR_ECHO_LOGGERS
|
|
1398
|
+
dims = False
|
|
1399
|
+
if names and not whole_echo:
|
|
1400
|
+
names, dims = _names_and_dims(known, ds, stmt, tuple(stmt.args[:i]), arg)
|
|
1401
|
+
# CR300/CR350: an undefined name at a code-20 keyword slot is also `Variable illegal`
|
|
1402
|
+
# (oracle, 2026-10-01: `PanelTemp(X,Nope)`).
|
|
1403
|
+
is_const_family = (
|
|
1404
|
+
positions[i] in _CONST_ONLY_VARIABLE_REJECT_KINDS
|
|
1405
|
+
or (bool(names) and not whole_echo and positions[i] == "datatype_kw")
|
|
1406
|
+
or (
|
|
1407
|
+
# CR6/CR1000X `fN1` slot: a variable there is `Variable illegal in Parameter`
|
|
1408
|
+
# (oracle, 2026-10-02, `docs/instructions.md` "fN1 / fnotch").
|
|
1409
|
+
whole_echo
|
|
1410
|
+
and positions[i] == "datatype_kw"
|
|
1411
|
+
and profile.fnotch.get("instructions", {}).get(stmt.keyword.upper()) == i + 1
|
|
1412
|
+
)
|
|
1413
|
+
)
|
|
1414
|
+
|
|
1415
|
+
if names:
|
|
1416
|
+
if whole_echo:
|
|
1417
|
+
diags.error(stmt.line, profile.unresolved_symbol_message.format(name=_arg_text(stmt, i)), stmt.file)
|
|
1418
|
+
return True
|
|
1419
|
+
for name in names:
|
|
1420
|
+
diags.error(stmt.line, profile.unresolved_symbol_message.format(name=name), stmt.file)
|
|
1421
|
+
if dims:
|
|
1422
|
+
# CR300/CR350: `SplitStr(ResultString(1),SearchString,...)` -> `Dimensions not
|
|
1423
|
+
# declared: ResultString(1).` and the rest of the list collapses into `Too many
|
|
1424
|
+
# parameters: SearchString.`
|
|
1425
|
+
diags.error(stmt.line, dimensions_message(_arg_text(stmt, i).strip(), profile, False), stmt.file)
|
|
1426
|
+
if is_const_family: # (oracle, CR300, 2026-10-03: `Delay(Nope(1),2,Sec)`)
|
|
1427
|
+
diags.error(
|
|
1428
|
+
stmt.line, f"Variable illegal in Parameter: {_arg_text(stmt, i)}.", stmt.file, stmt=stmt
|
|
1429
|
+
)
|
|
1430
|
+
return True
|
|
1431
|
+
if i + 1 < len(stmt.args) and stmt.args[i + 1] is not None:
|
|
1432
|
+
from .instr_rules import _next_simple_token
|
|
1433
|
+
|
|
1434
|
+
token = _next_simple_token(_arg_text(stmt, i + 1))
|
|
1435
|
+
diags.error(stmt.line, f"Too many parameters: {token}.", stmt.file)
|
|
1436
|
+
return True
|
|
1437
|
+
if not is_const_family:
|
|
1438
|
+
return True
|
|
1439
|
+
# CR300/CR350 const-family position: fall through to also run the
|
|
1440
|
+
# "Variable illegal in Parameter" check below, independent of the undefined-name
|
|
1441
|
+
# report just made (oracle-confirmed; see this method's own docstring).
|
|
1442
|
+
|
|
1443
|
+
if dim_counts and _over_indexed(arg, dim_counts, _dim_limit(profile)):
|
|
1444
|
+
# CR300/CR350: `Battery(R(2,1))` for `Public R(5)` (oracle, 2026-09-28); the argument
|
|
1445
|
+
# list ends there (`SplitStr(S(1,2),T,...)` -> `Too many parameters: T.`, 2026-10-01).
|
|
1446
|
+
# CR6/CR1000X: past three subscripts, no follow-up (`Delay(1,B(1,1,1,1),mSec)`, round 16)
|
|
1447
|
+
text = _arg_text(stmt, i)
|
|
1448
|
+
diags.error(
|
|
1449
|
+
stmt.line,
|
|
1450
|
+
dimensions_message(text if stmt.keyword.upper() == "SELECT" else text.strip(), profile, False),
|
|
1451
|
+
stmt.file,
|
|
1452
|
+
)
|
|
1453
|
+
if not whole_echo and i + 1 < len(stmt.args) and stmt.args[i + 1] is not None:
|
|
1454
|
+
from .instr_rules import _next_simple_token
|
|
1455
|
+
|
|
1456
|
+
token = _next_simple_token(_arg_text(stmt, i + 1)) or _arg_text(stmt, i + 1)
|
|
1457
|
+
diags.error(stmt.line, f"Too many parameters: {token}.", stmt.file)
|
|
1458
|
+
return True
|
|
1459
|
+
|
|
1460
|
+
if is_const_family:
|
|
1461
|
+
# a `Status.`/`Settings.`/`Public.`/table field is a variable here too (oracle, all four
|
|
1462
|
+
# loggers, round 38: `Sample(1,B,Status.SerialNumber)`)
|
|
1463
|
+
var_refs = _variable_refs(arg, variable_names) or _has_dotted_field(arg)
|
|
1464
|
+
if names or var_refs:
|
|
1465
|
+
diags.error(stmt.line, f"Variable illegal in Parameter: {_arg_text(stmt, i)}.", stmt.file, stmt=stmt)
|
|
1466
|
+
return True
|
|
1467
|
+
|
|
1468
|
+
return bool(names)
|
|
1469
|
+
|
|
1470
|
+
multi = _MULTI_REPORT_ORDER.get(stmt.keyword.upper())
|
|
1471
|
+
if multi is not None and profile.name in _WHOLE_EXPR_ECHO_LOGGERS and families == "general":
|
|
1472
|
+
reported = False
|
|
1473
|
+
for i in multi:
|
|
1474
|
+
if i in positions and _check_position(i):
|
|
1475
|
+
reported = True
|
|
1476
|
+
if reported:
|
|
1477
|
+
return True
|
|
1478
|
+
# a position the all-undefined sweep did not list is still checked on its own (oracle,
|
|
1479
|
+
# 2026-10-24, `docs/checker.md` "Positions outside the multi-report order")
|
|
1480
|
+
for i in sorted(positions):
|
|
1481
|
+
if i not in multi and positions[i] not in _CONST_FAMILY_KINDS and _check_position(i):
|
|
1482
|
+
return True
|
|
1483
|
+
return False
|
|
1484
|
+
if profile.name in _WHOLE_EXPR_ECHO_LOGGERS:
|
|
1485
|
+
order = []
|
|
1486
|
+
if families in ("all", "const"):
|
|
1487
|
+
order += sorted(i for i, k in positions.items() if k in _CONST_FAMILY_KINDS)
|
|
1488
|
+
if families in ("all", "general"):
|
|
1489
|
+
order += sorted(i for i, k in positions.items() if k not in _CONST_FAMILY_KINDS)
|
|
1490
|
+
else:
|
|
1491
|
+
order = sorted(positions)
|
|
1492
|
+
|
|
1493
|
+
for i in order:
|
|
1494
|
+
if _check_position(i):
|
|
1495
|
+
return True
|
|
1496
|
+
return False
|
|
1497
|
+
|
|
1498
|
+
|
|
1499
|
+
def _member_index_text(args: list | None) -> str:
|
|
1500
|
+
"""The `(N)` echoed for an absent DataTable member: 1 for no index, the truncated literal, and
|
|
1501
|
+
nothing for a variable, expression or negative index (oracle, CR300, docs/instructions.md)."""
|
|
1502
|
+
if not args or args[0] is None:
|
|
1503
|
+
return "(1)"
|
|
1504
|
+
index = args[0]
|
|
1505
|
+
if isinstance(index, S.Number) and index.value >= 0:
|
|
1506
|
+
return f"({int(index.value)})"
|
|
1507
|
+
return ""
|
|
1508
|
+
|
|
1509
|
+
|
|
1510
|
+
def _table_member_problem(
|
|
1511
|
+
expr: S.Expr | None, const_tables: dict[str, set[str]], data_tables: set[str], absent: frozenset[str] = frozenset()
|
|
1512
|
+
) -> tuple[str, str, str] | None:
|
|
1513
|
+
if expr is None:
|
|
1514
|
+
return None
|
|
1515
|
+
if isinstance(expr, S.Member):
|
|
1516
|
+
if isinstance(expr.base, S.Name):
|
|
1517
|
+
table = expr.base.name.upper()
|
|
1518
|
+
member = expr.member.upper()
|
|
1519
|
+
if table in const_tables and member not in const_tables[table]:
|
|
1520
|
+
return expr.base.name, expr.member, "(1)"
|
|
1521
|
+
if table in data_tables and member == "TABLEBYTESX":
|
|
1522
|
+
return expr.base.name, expr.member, "(1)"
|
|
1523
|
+
fields = getattr(data_tables, "fields", {}).get(table)
|
|
1524
|
+
if fields is not None and member not in fields and not member[:1].isdigit():
|
|
1525
|
+
return expr.base.name, expr.member, _member_index_text(expr.args)
|
|
1526
|
+
if table in data_tables and f"{table}.{member}" in absent:
|
|
1527
|
+
return expr.base.name, expr.member, _member_index_text(expr.args)
|
|
1528
|
+
for arg in expr.args or []:
|
|
1529
|
+
found = _table_member_problem(arg, const_tables, data_tables, absent)
|
|
1530
|
+
if found:
|
|
1531
|
+
return found
|
|
1532
|
+
return _table_member_problem(expr.base, const_tables, data_tables, absent)
|
|
1533
|
+
if isinstance(expr, S.Index):
|
|
1534
|
+
for arg in expr.args:
|
|
1535
|
+
found = _table_member_problem(arg, const_tables, data_tables, absent)
|
|
1536
|
+
if found:
|
|
1537
|
+
return found
|
|
1538
|
+
elif isinstance(expr, S.Unary):
|
|
1539
|
+
return _table_member_problem(expr.operand, const_tables, data_tables, absent)
|
|
1540
|
+
elif isinstance(expr, S.Binary):
|
|
1541
|
+
return _table_member_problem(expr.left, const_tables, data_tables, absent) or _table_member_problem(
|
|
1542
|
+
expr.right, const_tables, data_tables, absent
|
|
1543
|
+
)
|
|
1544
|
+
elif isinstance(expr, S.ArrayLiteral):
|
|
1545
|
+
for item in expr.items:
|
|
1546
|
+
found = _table_member_problem(item, const_tables, data_tables, absent)
|
|
1547
|
+
if found:
|
|
1548
|
+
return found
|
|
1549
|
+
return None
|
|
1550
|
+
|
|
1551
|
+
|
|
1552
|
+
class _TablesWithFields(set):
|
|
1553
|
+
fields: dict[str, set[str]] = {}
|
|
1554
|
+
|
|
1555
|
+
|
|
1556
|
+
def _data_table_fields(program: S.Program, profile) -> dict[str, set[str]]:
|
|
1557
|
+
"""Each DataTable's field names as the TDF names them, plus the table properties
|
|
1558
|
+
(`Tbl.NoSuchField` -> `Could not find Tbl.NoSuchField(1).`; oracle, all four loggers,
|
|
1559
|
+
2026-10-24, round 34). Empty when the tables cannot be built: nothing is then checked."""
|
|
1560
|
+
from .. import tables
|
|
1561
|
+
|
|
1562
|
+
try:
|
|
1563
|
+
built, _, _ = tables.build_tables(program, profile, dict(program.consts))
|
|
1564
|
+
except Exception:
|
|
1565
|
+
return {}
|
|
1566
|
+
props = {p.upper() for p in tables.TABLE_PROPERTIES}
|
|
1567
|
+
names = {
|
|
1568
|
+
st.args[0].name.upper()
|
|
1569
|
+
for st in program.statements
|
|
1570
|
+
if isinstance(st, S.Instruction)
|
|
1571
|
+
and st.keyword.upper() == "DATATABLE"
|
|
1572
|
+
and st.args
|
|
1573
|
+
and isinstance(st.args[0], S.Name)
|
|
1574
|
+
}
|
|
1575
|
+
return {t.name.upper(): {f.name.upper() for f in t.fields} | props for t in built if t.name.upper() in names}
|
|
1576
|
+
|
|
1577
|
+
|
|
1578
|
+
def check_table_members(program: S.Program, profile, diags: Diagnostics) -> None:
|
|
1579
|
+
"""Resolved ConstTable fields and, on CR6/CR1000X, DataTable fields (`_data_table_fields`)."""
|
|
1580
|
+
const_tables = symbols.const_table_fields(program)
|
|
1581
|
+
data_tables = {
|
|
1582
|
+
stmt.args[0].name.upper()
|
|
1583
|
+
for stmt in program.statements
|
|
1584
|
+
if isinstance(stmt, S.Instruction)
|
|
1585
|
+
and stmt.keyword.upper() == "DATATABLE"
|
|
1586
|
+
and stmt.args
|
|
1587
|
+
and isinstance(stmt.args[0], S.Name)
|
|
1588
|
+
}
|
|
1589
|
+
if not const_tables and not data_tables:
|
|
1590
|
+
return
|
|
1591
|
+
if not profile.single_pass:
|
|
1592
|
+
data_tables = _TablesWithFields(data_tables)
|
|
1593
|
+
data_tables.fields = _data_table_fields(program, profile)
|
|
1594
|
+
renamed: set[str] = set() # tables with a FieldNames entry exactly BytesPerRecord
|
|
1595
|
+
table = None
|
|
1596
|
+
for st in program.statements:
|
|
1597
|
+
if not isinstance(st, S.Instruction):
|
|
1598
|
+
continue
|
|
1599
|
+
kw = st.keyword.upper()
|
|
1600
|
+
if kw == "DATATABLE":
|
|
1601
|
+
table = st.args[0].name.upper() if st.args and isinstance(st.args[0], S.Name) else None
|
|
1602
|
+
elif kw == "ENDTABLE":
|
|
1603
|
+
table = None
|
|
1604
|
+
elif (
|
|
1605
|
+
kw == "FIELDNAMES"
|
|
1606
|
+
and table
|
|
1607
|
+
and any(
|
|
1608
|
+
isinstance(a, S.String) and "BYTESPERRECORD" in (f.strip().upper() for f in a.value.split(","))
|
|
1609
|
+
for a in st.args
|
|
1610
|
+
)
|
|
1611
|
+
):
|
|
1612
|
+
renamed.add(table)
|
|
1613
|
+
absent = frozenset(f"{t}.BYTESPERRECORD" for t in data_tables - renamed) if profile.single_pass else frozenset()
|
|
1614
|
+
for stmt in program.statements:
|
|
1615
|
+
exprs: list[S.Expr | None] = []
|
|
1616
|
+
if isinstance(stmt, S.Assignment):
|
|
1617
|
+
exprs = [stmt.target, stmt.value]
|
|
1618
|
+
elif isinstance(stmt, S.If):
|
|
1619
|
+
exprs = [stmt.cond]
|
|
1620
|
+
elif isinstance(stmt, S.Instruction):
|
|
1621
|
+
exprs = stmt.args
|
|
1622
|
+
elif isinstance(stmt, S.Call):
|
|
1623
|
+
exprs = stmt.args
|
|
1624
|
+
for expr in exprs:
|
|
1625
|
+
found = _table_member_problem(expr, const_tables, data_tables, absent)
|
|
1626
|
+
if found:
|
|
1627
|
+
table, member, index = found
|
|
1628
|
+
diags.error(stmt.line, f"Could not find {table}.{member}{index}.", stmt.file)
|
|
1629
|
+
break
|
|
1630
|
+
|
|
1631
|
+
|
|
1632
|
+
def _dotted_members(expr: S.Expr | None) -> list[tuple[str, S.Member | None]]:
|
|
1633
|
+
"""Every `Name.member` access (and `Name.` with a trailing dot, member `None`) in `expr`, in
|
|
1634
|
+
source order (`X.5`, `T.1(2)`, `Status.1`, `X.dat`, `T.`)."""
|
|
1635
|
+
if expr is None:
|
|
1636
|
+
return []
|
|
1637
|
+
if isinstance(expr, S.Name):
|
|
1638
|
+
return (
|
|
1639
|
+
[(expr.name[:-1], None)]
|
|
1640
|
+
if expr.name.endswith(".") and len(expr.name) > 1 and (expr.name[0].isalpha() or expr.name[0] == "_")
|
|
1641
|
+
else []
|
|
1642
|
+
)
|
|
1643
|
+
if isinstance(expr, S.Member):
|
|
1644
|
+
out = _dotted_members(expr.base)
|
|
1645
|
+
if isinstance(expr.base, S.Name) and not expr.base.name.endswith("."):
|
|
1646
|
+
out.append((expr.base.name, expr))
|
|
1647
|
+
for a in expr.args or []:
|
|
1648
|
+
out += _dotted_members(a)
|
|
1649
|
+
return out
|
|
1650
|
+
if isinstance(expr, S.Index):
|
|
1651
|
+
return [m for a in expr.args for m in _dotted_members(a)]
|
|
1652
|
+
if isinstance(expr, S.Unary):
|
|
1653
|
+
return _dotted_members(expr.operand)
|
|
1654
|
+
if isinstance(expr, S.Binary):
|
|
1655
|
+
return _dotted_members(expr.left) + _dotted_members(expr.right)
|
|
1656
|
+
if isinstance(expr, S.ArrayLiteral):
|
|
1657
|
+
return [m for item in expr.items for m in _dotted_members(item)]
|
|
1658
|
+
return []
|
|
1659
|
+
|
|
1660
|
+
|
|
1661
|
+
_TABLE_FIELDS = frozenset({"TIMESTAMP", "RECORD", "RECNUM"})
|
|
1662
|
+
|
|
1663
|
+
|
|
1664
|
+
def _unknown_status_member(m, profile) -> bool:
|
|
1665
|
+
return (
|
|
1666
|
+
not m.member[:1].isdigit() and bool(profile.status_members) and m.member.upper() not in profile.status_members
|
|
1667
|
+
)
|
|
1668
|
+
|
|
1669
|
+
|
|
1670
|
+
def _unknown_setting(m, profile) -> bool:
|
|
1671
|
+
return not m.member[:1].isdigit() and m.member.upper() not in {
|
|
1672
|
+
n.upper() for n in profile.settings_fields["settings"]
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
|
|
1676
|
+
def _unknown_public_member(m, variables: set[str]) -> bool:
|
|
1677
|
+
return not m.member[:1].isdigit() and m.member.upper() not in variables | _TABLE_FIELDS
|
|
1678
|
+
|
|
1679
|
+
|
|
1680
|
+
def check_status_member_reads_late(program: S.Program, profile, diags: Diagnostics) -> None:
|
|
1681
|
+
"""CR300/CR350: an unknown `Status.X` / `Public.X` read (see `check_digit_members`) is reported
|
|
1682
|
+
only in an otherwise clean file (oracle, 2026-10-12: a later `WatchdogTimer is not defined.`
|
|
1683
|
+
hides `Status.CardBytesFree`); run from the late stage."""
|
|
1684
|
+
if not profile.single_pass:
|
|
1685
|
+
return
|
|
1686
|
+
variables = {
|
|
1687
|
+
n for n in symbols.declared_variable_names(program) if not symbols.is_reserved_predefined_name(n, profile)
|
|
1688
|
+
}
|
|
1689
|
+
for stmt in program.statements:
|
|
1690
|
+
if isinstance(stmt, S.Assignment):
|
|
1691
|
+
exprs, target = [stmt.target, stmt.value], len(_dotted_members(stmt.target))
|
|
1692
|
+
elif isinstance(stmt, S.If):
|
|
1693
|
+
exprs, target = [stmt.cond], 0
|
|
1694
|
+
elif isinstance(stmt, (S.Instruction, S.Call)):
|
|
1695
|
+
exprs, target = stmt.args, 0
|
|
1696
|
+
else:
|
|
1697
|
+
continue
|
|
1698
|
+
for k, (base, m) in enumerate(mm for e in exprs for mm in _dotted_members(e)):
|
|
1699
|
+
if m is None or k < target:
|
|
1700
|
+
continue
|
|
1701
|
+
if (
|
|
1702
|
+
base.upper() == "STATUS"
|
|
1703
|
+
and _unknown_status_member(m, profile)
|
|
1704
|
+
or base.upper() == "PUBLIC"
|
|
1705
|
+
and _unknown_public_member(m, variables)
|
|
1706
|
+
):
|
|
1707
|
+
diags.error(stmt.line, f"Could not find {base}.{m.member}{_member_index_text(m.args)}.", stmt.file)
|
|
1708
|
+
|
|
1709
|
+
|
|
1710
|
+
def check_undefined_dotted_bases(program: S.Program, profile, diags: Diagnostics, start: int) -> None:
|
|
1711
|
+
"""CR6/CR1000X: a dotted reference whose base is not declared (`Tset1.X`, `Setings.PakBusAddress`,
|
|
1712
|
+
a misspelt structure variable `W.A`) is `Could not find Tset1.`, before the statement's other
|
|
1713
|
+
pass-2 lines (`diags.items[start:]`). The statement's `Invalid symbol` stays only for another
|
|
1714
|
+
undefined name; an assignment target adds `Invalid assignment in expression: <line>.` (oracle,
|
|
1715
|
+
round 59, docs/checker.md#undeclared-dotted-bases-round-59)."""
|
|
1716
|
+
if profile.single_pass:
|
|
1717
|
+
return
|
|
1718
|
+
known = _known_names(program, profile)
|
|
1719
|
+
for stmt in program.statements:
|
|
1720
|
+
if isinstance(stmt, S.Instruction) and stmt.keyword.upper() == "ALIAS":
|
|
1721
|
+
continue # `Alias Foo = Zed.W` compiles (alias_name_lists.json)
|
|
1722
|
+
exprs = _stmt_exprs(stmt)
|
|
1723
|
+
bases = [b for e in exprs for b, m in _dotted_members(e) if m is not None and b.upper() not in known]
|
|
1724
|
+
if not bases:
|
|
1725
|
+
continue
|
|
1726
|
+
others = [
|
|
1727
|
+
n for e in exprs for n in _undefined_names_walk(e, known) if n.upper() not in {b.upper() for b in bases}
|
|
1728
|
+
]
|
|
1729
|
+
key = (stmt.line, stmt.file)
|
|
1730
|
+
generic = ("Invalid symbol in expression", "Invalid, or out of place expression")
|
|
1731
|
+
here = [d for d in diags.items[start:] if (d.line, d.file) == key]
|
|
1732
|
+
if any(d.severity == "error" and not d.message.startswith(generic) for d in here):
|
|
1733
|
+
continue # a placement error ends the statement first (`DisplayValue must be used within ...`)
|
|
1734
|
+
if not others:
|
|
1735
|
+
diags.items[start:] = [
|
|
1736
|
+
d for d in diags.items[start:] if not ((d.line, d.file) == key and d.message.startswith(generic))
|
|
1737
|
+
]
|
|
1738
|
+
new = [Diagnostic("error", stmt.line, f"Could not find {bases[0]}.", stmt.file)]
|
|
1739
|
+
if isinstance(stmt, S.Assignment) and _dotted_members(stmt.target):
|
|
1740
|
+
new.append(Diagnostic("error", stmt.line, f"Invalid assignment in expression: {stmt.text}.", stmt.file))
|
|
1741
|
+
at = next(
|
|
1742
|
+
(
|
|
1743
|
+
i
|
|
1744
|
+
for i, d in enumerate(diags.items)
|
|
1745
|
+
if i >= start and (d.line, d.file) == key and any(d is h for h in here)
|
|
1746
|
+
),
|
|
1747
|
+
len(diags.items),
|
|
1748
|
+
)
|
|
1749
|
+
diags.items[at:at] = new
|
|
1750
|
+
|
|
1751
|
+
|
|
1752
|
+
def check_digit_members(program: S.Program, profile, diags: Diagnostics) -> None:
|
|
1753
|
+
"""A member of a declared name that no variable, data table or `Status` has (oracle, all four
|
|
1754
|
+
loggers, 2026-10-12, `docs/checker.md` "Digit-led members", "Letter-led members"):
|
|
1755
|
+
|
|
1756
|
+
- `ident.digits` (`X = X.5`, `A.1`, `T.1`, `Status.1`): `Could not find X.` for a declared
|
|
1757
|
+
variable (CR300/CR350 `Could not find DataTable X.`), `Could not find T.1(1).` for a data
|
|
1758
|
+
table or `Status` (the index echoed as `_member_index_text`), one line per access in source
|
|
1759
|
+
order, in assignment values, subscripts, `If` conditions, `For` headers and function
|
|
1760
|
+
arguments; an assignment target (`X.5 = 1`, `T.1 = 1`) adds `Invalid assignment in
|
|
1761
|
+
expression: <line>.`.
|
|
1762
|
+
- a letter-led member of a plain declared name (`X.Y`, `A.Y`, `S.Y`, `D.Y`, `K.Y`, an alias, a
|
|
1763
|
+
pointer, a routine parameter, a function name) is `Could not find X.` as well (structure-typed
|
|
1764
|
+
names and data tables / `Status` keep their members: field existence is not modelled).
|
|
1765
|
+
- a trailing dot (`T.`, `Status.`) is a valid operand for a data table or `Status`; any other
|
|
1766
|
+
name (`X.`, `Zork.`) is `Could not find X.` (CR300/CR350 `Could not find DataTable X.`).
|
|
1767
|
+
|
|
1768
|
+
Statements that also name an undefined symbol and structure-typed bases are not touched."""
|
|
1769
|
+
from . import structs
|
|
1770
|
+
|
|
1771
|
+
variables = {
|
|
1772
|
+
n for n in symbols.declared_variable_names(program) if not symbols.is_reserved_predefined_name(n, profile)
|
|
1773
|
+
}
|
|
1774
|
+
struct_types = structs._structure_members(program)
|
|
1775
|
+
kinds = structs._variable_kinds(program, struct_types)
|
|
1776
|
+
variables = {n for n in variables if not str(kinds.get(n, "")).startswith("STRUCT")}
|
|
1777
|
+
# (a `Sub S(C As Pt)` parameter keeps its members too; oracle, CR6/CR1000X, 2026-10-12)
|
|
1778
|
+
variables -= {
|
|
1779
|
+
p.name.upper()
|
|
1780
|
+
for st in program.statements
|
|
1781
|
+
if isinstance(st, S.SubDef)
|
|
1782
|
+
for p in st.params
|
|
1783
|
+
if p.type and p.type.upper() in struct_types
|
|
1784
|
+
}
|
|
1785
|
+
variables |= {str(n).upper() for n in program.consts}
|
|
1786
|
+
for stmt in program.statements:
|
|
1787
|
+
if (
|
|
1788
|
+
isinstance(stmt, S.Instruction)
|
|
1789
|
+
and stmt.keyword.upper() == "ALIAS"
|
|
1790
|
+
and len(stmt.args) == 2
|
|
1791
|
+
and isinstance(stmt.args[1], S.Name)
|
|
1792
|
+
and stmt.args[1].name.upper() != "PUBLIC"
|
|
1793
|
+
): # `Public.X` still reads the table (2026-10-24)
|
|
1794
|
+
variables.add(stmt.args[1].name.upper()) # (`Alias Xal = X`: `Xal.Y` is `Could not find Xal.`)
|
|
1795
|
+
elif isinstance(stmt, S.SubDef) and stmt.keyword.upper() == "FUNCTION":
|
|
1796
|
+
variables.add(stmt.name.upper())
|
|
1797
|
+
data_tables = {
|
|
1798
|
+
stmt.args[0].name.upper()
|
|
1799
|
+
for stmt in program.statements
|
|
1800
|
+
if isinstance(stmt, S.Instruction)
|
|
1801
|
+
and stmt.keyword.upper() == "DATATABLE"
|
|
1802
|
+
and stmt.args
|
|
1803
|
+
and isinstance(stmt.args[0], S.Name)
|
|
1804
|
+
} | {"STATUS"}
|
|
1805
|
+
known = _known_names(program, profile)
|
|
1806
|
+
for stmt in program.statements:
|
|
1807
|
+
if isinstance(stmt, S.Assignment):
|
|
1808
|
+
exprs = [stmt.target, stmt.value]
|
|
1809
|
+
elif isinstance(stmt, S.If):
|
|
1810
|
+
exprs = [stmt.cond]
|
|
1811
|
+
elif isinstance(stmt, S.For):
|
|
1812
|
+
exprs = [stmt.var, stmt.start, stmt.end, stmt.step]
|
|
1813
|
+
elif isinstance(stmt, (S.Instruction, S.Call)):
|
|
1814
|
+
exprs = stmt.args
|
|
1815
|
+
else:
|
|
1816
|
+
continue
|
|
1817
|
+
members = [m for e in exprs for m in _dotted_members(e)]
|
|
1818
|
+
if not members or any(_undefined_names(e, known) for e in exprs):
|
|
1819
|
+
continue
|
|
1820
|
+
lines = []
|
|
1821
|
+
target = len(_dotted_members(stmt.target)) if isinstance(stmt, S.Assignment) else 0
|
|
1822
|
+
for k, (base, m) in enumerate(members):
|
|
1823
|
+
if k == target and lines:
|
|
1824
|
+
break # a bad member target ends the statement (`X.Q = X.R + 1`: one line)
|
|
1825
|
+
upper = base.upper()
|
|
1826
|
+
if m is None:
|
|
1827
|
+
if upper not in data_tables:
|
|
1828
|
+
lines.append(
|
|
1829
|
+
f"Could not find DataTable {base}." if profile.single_pass else f"Could not find {base}."
|
|
1830
|
+
)
|
|
1831
|
+
elif upper == "SETTINGS" and not profile.single_pass and _unknown_setting(m, profile):
|
|
1832
|
+
# `X = Settings.Nope`, `Settings.Nope = 1` (oracle, all four, 2026-10-24, round 34)
|
|
1833
|
+
lines.append(f"Could not find Settings.{m.member}{_member_index_text(m.args)}.")
|
|
1834
|
+
elif upper == "STATUS" and not profile.single_pass and _unknown_status_member(m, profile):
|
|
1835
|
+
# an unknown Status field read (`Y = Status.Nope`, `If Status.Nope`): oracle, all
|
|
1836
|
+
# four loggers, 2026-10-12; the names: data/**/status_members.json
|
|
1837
|
+
lines.append(f"Could not find Status.{m.member}{_member_index_text(m.args)}.")
|
|
1838
|
+
elif upper in data_tables:
|
|
1839
|
+
if m.member[:1].isdigit():
|
|
1840
|
+
lines.append(f"Could not find {base}.{m.member}{_member_index_text(m.args)}.")
|
|
1841
|
+
elif upper == "PUBLIC" and k >= target and not profile.single_pass and _unknown_public_member(m, variables):
|
|
1842
|
+
# `Y = Public.Nope(2)` -> `Could not find Public.Nope(2).` (oracle, all four, 2026-10-12)
|
|
1843
|
+
lines.append(f"Could not find Public.{m.member}{_member_index_text(m.args)}.")
|
|
1844
|
+
elif upper in variables:
|
|
1845
|
+
lines.append(f"Could not find DataTable {base}." if profile.single_pass else f"Could not find {base}.")
|
|
1846
|
+
if not lines:
|
|
1847
|
+
continue
|
|
1848
|
+
for line in lines:
|
|
1849
|
+
diags.error(stmt.line, line, stmt.file)
|
|
1850
|
+
if isinstance(stmt, S.Assignment) and any(m for _, m in _dotted_members(stmt.target)):
|
|
1851
|
+
sep = "\n" if profile.fallback_echo_newline and stmt.at_eol else ""
|
|
1852
|
+
diags.error(stmt.line, f"Invalid assignment in expression: {stmt.text}{sep}.", stmt.file)
|
|
1853
|
+
|
|
1854
|
+
|
|
1855
|
+
def check_const_family_args(program: S.Program, profile, diags: Diagnostics) -> None:
|
|
1856
|
+
"""CR6/CR1000X pass 1: the constant-family instruction positions (`_CONST_FAMILY_KINDS`) are
|
|
1857
|
+
folded while the statement is parsed, so an unresolved name there (`Invalid symbol in
|
|
1858
|
+
expression: %s.`) or a live variable (`Variable illegal in Parameter: %s.`) is reported next
|
|
1859
|
+
to other pass-1 errors, while general positions stay pass 2 (`check`). Oracle, CR6, with a
|
|
1860
|
+
pass-1 `Dim Z banana` in the file: `PortSet(X,1)`, `PanelTemp(X, Nope)`, `VoltSe(X,1,Nope,...)`
|
|
1861
|
+
and `SDI12Recorder(X, Nope, ...)` are all still reported; `VoltSe(Nope,...)`, `Randomize(Nope)`
|
|
1862
|
+
and `Delay(0,Nope,mSec)` are not (`docs/checker.md` "Pass-1 instruction arguments")."""
|
|
1863
|
+
if profile.name not in _WHOLE_EXPR_ECHO_LOGGERS:
|
|
1864
|
+
return
|
|
1865
|
+
known = _known_names(program, profile)
|
|
1866
|
+
variable_names = {
|
|
1867
|
+
name
|
|
1868
|
+
for name in symbols.declared_variable_names(program)
|
|
1869
|
+
if not symbols.is_reserved_predefined_name(name, profile)
|
|
1870
|
+
} | {name for name in _alias_names(program) if name not in profile.symbols}
|
|
1871
|
+
scopes = symbols.statement_scopes(program, profile)
|
|
1872
|
+
for stmt in program.statements:
|
|
1873
|
+
if not isinstance(stmt, S.Instruction):
|
|
1874
|
+
continue
|
|
1875
|
+
record = profile.instructions.get(stmt.keyword.upper())
|
|
1876
|
+
if not record:
|
|
1877
|
+
continue
|
|
1878
|
+
if any(
|
|
1879
|
+
_find_arity_problem(a, profile) or _find_bare_function_ref(a, profile, frozenset(variable_names))
|
|
1880
|
+
for a in stmt.args
|
|
1881
|
+
if a is not None
|
|
1882
|
+
):
|
|
1883
|
+
continue # a parse error inside an argument owns the statement (`check_function_arity`)
|
|
1884
|
+
positions = _position_kinds(record["params"], stmt.keyword, profile)
|
|
1885
|
+
if positions:
|
|
1886
|
+
params, later = scopes[id(stmt)]
|
|
1887
|
+
_report_statement(
|
|
1888
|
+
stmt,
|
|
1889
|
+
positions,
|
|
1890
|
+
known.without(later - params - variable_names),
|
|
1891
|
+
variable_names | params,
|
|
1892
|
+
profile,
|
|
1893
|
+
diags,
|
|
1894
|
+
families="const",
|
|
1895
|
+
)
|
|
1896
|
+
|
|
1897
|
+
|
|
1898
|
+
def check(program: S.Program, profile, diags: Diagnostics, env: consteval.Env, skip: frozenset = frozenset()) -> None:
|
|
1899
|
+
"""Undefined-symbol checking at every `Instruction` argument position whose declared type code
|
|
1900
|
+
routes through the real expression/named-constant resolver (`_position_kinds`) -- a full
|
|
1901
|
+
expression-tree walk (`_undefined_names`), not restricted to a direct bare `Name` argument
|
|
1902
|
+
(round 4, this session; rules and evidence: `_report_statement` above, `docs/checker.md`).
|
|
1903
|
+
|
|
1904
|
+
Still deliberately narrow in scope, unchanged from round 3: only positions
|
|
1905
|
+
`_RESOLVED_KINDS` names (never an identifier/raw-text-kind position such as a `DataTable`/
|
|
1906
|
+
`ConstTable` name, `Member.member` text, or a `Sub`/`Function` argument list's own runtime
|
|
1907
|
+
scoping); `symbols.declared_names` stays whole-program and unscoped rather than tracking which
|
|
1908
|
+
names are visible inside which `Sub`/`Function` (both documented limitations, `docs/checker.md`
|
|
1909
|
+
"Not implemented" -- tightening either risks a false reject this session did not have the
|
|
1910
|
+
oracle evidence to rule out, so neither was attempted here).
|
|
1911
|
+
|
|
1912
|
+
`env` is accepted (unused) to keep this call site's signature stable for a future session
|
|
1913
|
+
that wires in the variable-required type-code check (still not implemented -- see the
|
|
1914
|
+
docstring above `_VARIABLE_REQUIRED_KINDS`; the const-only-family "rejects a variable" check
|
|
1915
|
+
is implemented, `_CONST_ONLY_VARIABLE_REJECT_KINDS`).
|
|
1916
|
+
"""
|
|
1917
|
+
known = _known_names(program, profile)
|
|
1918
|
+
variable_names = symbols.declared_variable_names(program) | (
|
|
1919
|
+
{n for n in _alias_names(program) if n not in profile.symbols} # (`Alias X = Sec` fails)
|
|
1920
|
+
if profile.name in _WHOLE_EXPR_ECHO_LOGGERS
|
|
1921
|
+
else set()
|
|
1922
|
+
)
|
|
1923
|
+
ds = program_dims(program, profile, known)
|
|
1924
|
+
dim_counts = symbols.declared_dim_counts(program, strings=True)
|
|
1925
|
+
scopes = symbols.statement_scopes(program, profile)
|
|
1926
|
+
|
|
1927
|
+
seen_avw200 = False
|
|
1928
|
+
for stmt in program.statements:
|
|
1929
|
+
if not isinstance(stmt, S.Instruction) or id(stmt) in skip:
|
|
1930
|
+
continue
|
|
1931
|
+
record = profile.instructions.get(stmt.keyword.upper())
|
|
1932
|
+
if not record:
|
|
1933
|
+
continue
|
|
1934
|
+
if stmt.keyword.upper() == "SLOWSEQUENCE" and profile.name in ("cr6", "cr1000x"):
|
|
1935
|
+
continue # `SlowSequence junk` compiles: its argument is never resolved (oracle, 2026-10-10)
|
|
1936
|
+
positions = _position_kinds(record["params"], stmt.keyword, profile)
|
|
1937
|
+
if stmt.keyword.upper() == "SDMSIO4" and _sdmsio4_count(stmt, env) == 0:
|
|
1938
|
+
positions.pop(0, None) # no values: the destination is never resolved
|
|
1939
|
+
if stmt.keyword.upper() == "AVW200" and profile.name in _WHOLE_EXPR_ECHO_LOGGERS:
|
|
1940
|
+
if seen_avw200:
|
|
1941
|
+
# after an earlier `AVW200` the ComPort and NeighborAddr are not resolved
|
|
1942
|
+
# (oracle, 2026-10-25, round 35; positions 3..10 give garbled value messages)
|
|
1943
|
+
positions.pop(1, None)
|
|
1944
|
+
positions.pop(2, None)
|
|
1945
|
+
seen_avw200 = True
|
|
1946
|
+
if (
|
|
1947
|
+
positions
|
|
1948
|
+
and stmt.keyword.upper() == "SERIALOPEN"
|
|
1949
|
+
and stmt.args
|
|
1950
|
+
and stmt.args[0]
|
|
1951
|
+
and profile.name in _WHOLE_EXPR_ECHO_LOGGERS
|
|
1952
|
+
and _undefined_names(stmt.args[0], known)
|
|
1953
|
+
):
|
|
1954
|
+
continue # `ComPort 0 illegal.` replaces every `Invalid symbol` (`cr3xx.check`)
|
|
1955
|
+
|
|
1956
|
+
if positions:
|
|
1957
|
+
# CR6/CR1000X: constant-family positions are pass 1 (`check_const_family_args`).
|
|
1958
|
+
reported = len(diags.items)
|
|
1959
|
+
here, here_variables = known, variable_names
|
|
1960
|
+
if profile.single_pass: # every position sees only earlier `Const`s (round 71)
|
|
1961
|
+
params, later = scopes[id(stmt)]
|
|
1962
|
+
here, here_variables = known.without(later - params - variable_names), variable_names | params
|
|
1963
|
+
_report_statement(
|
|
1964
|
+
stmt, positions, here, here_variables, profile, diags, families="general", dim_counts=dim_counts, ds=ds
|
|
1965
|
+
)
|
|
1966
|
+
if (
|
|
1967
|
+
stmt.keyword.upper() == "MATRIX"
|
|
1968
|
+
and profile.name in ("cr6", "cr1000x")
|
|
1969
|
+
and len(diags.items) > reported
|
|
1970
|
+
and all(d.message.startswith("Invalid symbol in expression: ") for d in diags.items[reported:])
|
|
1971
|
+
):
|
|
1972
|
+
from . import instr_rules
|
|
1973
|
+
|
|
1974
|
+
rewritten = instr_rules.matrix_undefined_operands(stmt, program, env, known, profile)
|
|
1975
|
+
if rewritten is not None:
|
|
1976
|
+
del diags.items[reported:]
|
|
1977
|
+
for message in rewritten:
|
|
1978
|
+
diags.error(stmt.line, message, stmt.file)
|
|
1979
|
+
|
|
1980
|
+
# `_VARIABLE_REQUIRED_KINDS` (`Invalid Variable or Array: %s.`) is intentionally not
|
|
1981
|
+
# applied here despite oracle evidence that it is a real rule for *some* instructions
|
|
1982
|
+
# (`Battery(5)`, `Erase(5)`, `ReadIO(5,1)`, all code 8/27/29 on CR6, all oracle-
|
|
1983
|
+
# confirmed rejected). Found as a **regression** during round 3's own testing against
|
|
1984
|
+
# the full corpus on all four loggers: `Move`'s own third argument (code 41) accepts a
|
|
1985
|
+
# literal `0` (`embracer.cr6`), and `ModBusClient`'s own last, optional argument (also
|
|
1986
|
+
# code 27, the *same* code that correctly rejects a literal for `Erase`) also accepts a
|
|
1987
|
+
# literal `2` (`modbusclient_2.CR6`) -- both on CR1000X. So the same type code does not
|
|
1988
|
+
# behave uniformly across every instruction that uses it; at least `Move` and
|
|
1989
|
+
# `ModBusClient` override it (most likely their own `compile_fn`, similar to the
|
|
1990
|
+
# const-only kinds' `Sample`/`DataType` exception, `docs/checker.md`). Still not
|
|
1991
|
+
# implemented this session either: see `docs/checker.md` "Constant-only and
|
|
1992
|
+
# variable-required type codes" for the full evidence and what a future session would
|
|
1993
|
+
# need to verify first.
|
|
1994
|
+
|
|
1995
|
+
# An `Assignment`'s own RHS (`x = <value>`): not an `Instruction` at all, so untouched by the
|
|
1996
|
+
# loop above -- a previously-unaddressed gap on *every* logger, found via this round's own
|
|
1997
|
+
# CR300/CR350 repo-mismatch chase (`WDogTimer = WatchdogTimer(0,Sec)`, `WatchdogTimer` a real
|
|
1998
|
+
# CR6/CR1000X function absent from CR300/CR350 entirely, `docs/loggers.md`). Oracle-confirmed
|
|
1999
|
+
# pass 2, same gating as the rest of this function (`BazQux(0,Sec)` in place of `WatchdogTimer`
|
|
2000
|
+
# on CR6: alone, `line N: Invalid symbol in expression: <assignment's own text>.`; preceded by
|
|
2001
|
+
# an unrelated pass-1 `Variable not named.`, suppressed entirely -- `docs/loggers.md`). Two
|
|
2002
|
+
# differences from the instruction-argument case above, both oracle-confirmed:
|
|
2003
|
+
#
|
|
2004
|
+
# 1. The whole-expression echo (CR6/CR1000X) is the **entire assignment statement's own
|
|
2005
|
+
# text**, target and `=` included (`stmt.text`, already the raw segment text with its
|
|
2006
|
+
# original indentation -- the same field `structure.py`'s "Expr" fallback echoes), not just
|
|
2007
|
+
# the value sub-expression (unlike a `Const`'s `value_span`, there is no separate span to
|
|
2008
|
+
# prefer here since the oracle's own echo includes the target).
|
|
2009
|
+
# 2. CR300/CR350 additionally emit a `Dimensions not declared: <text>` follow-up line after
|
|
2010
|
+
# their own per-name `<name> is not defined.` line(s) -- not reproduced here (no oracle
|
|
2011
|
+
# evidence yet for its exact trigger/shape beyond this one example, and it is a second,
|
|
2012
|
+
# distinct message rather than a difference in this one's own text); the per-name line(s)
|
|
2013
|
+
# alone already turn the previously-silent false accept into a correct reject.
|
|
2014
|
+
#
|
|
2015
|
+
# **Round 5 addition, oracle-confirmed (`docs/checker.md` "Round 5")**: an assignment whose own
|
|
2016
|
+
# *target* itself does not resolve (`FuzzVar = 1`, `FuzzVar` never declared) is a completely
|
|
2017
|
+
# different message family -- the generic "Expr" fallback `structure.py`'s `check_pass2` already
|
|
2018
|
+
# owns for `Call`/`Unparsed` (`Invalid, or out of place expression: <whole statement>.`, plus a
|
|
2019
|
+
# before-`BeginProg` placement follow-up) -- never this module's own `Invalid symbol in
|
|
2020
|
+
# expression: %s.`, even when the RHS is *also* undefined (`FuzzVar1 = FuzzVar2` ->
|
|
2021
|
+
# `Invalid, or out of place expression: FuzzVar1 = FuzzVar2.` alone, no second, separate
|
|
2022
|
+
# "Invalid symbol" line for `FuzzVar2`). `_undefined_names` doubles as the target-resolution
|
|
2023
|
+
# test here (an `Index`'s own base name, or a `Member`'s base, exactly like a normal read
|
|
2024
|
+
# reference -- oracle-confirmed for an undeclared array base, `X(BadIdx) = 1` with `X`
|
|
2025
|
+
# undeclared, and for an undeclared *index* on an otherwise-declared array, `X(BadIdx) = 1`
|
|
2026
|
+
# with `X` declared and `BadIdx` not -- both land in the same "Invalid, or out of place
|
|
2027
|
+
# expression" bucket, `structure.py`'s job, not checked again here). Skipping these statements
|
|
2028
|
+
# entirely (rather than still checking the RHS) is what reproduces the oracle's "one message,
|
|
2029
|
+
# not two" behaviour without `structure.py` needing to know this module's own diagnostics.
|
|
2030
|
+
# An `If` condition (block or one-line) with an unresolved name, CR6/CR1000X, pass 2 (oracle,
|
|
2031
|
+
# 2026-09-27): `If Nope Then` -> `Invalid symbol in expression: Nope .`, `If Foo(X) > 20 Then
|
|
2032
|
+
# X = 1` -> `... Foo(X) > 20 .`; suppressed by a pass-1 error. CR300/CR350 print per-name
|
|
2033
|
+
# `%s is not defined.` lines instead (not modelled).
|
|
2034
|
+
for stmt in program.statements:
|
|
2035
|
+
if not isinstance(stmt, S.If):
|
|
2036
|
+
continue
|
|
2037
|
+
names = _undefined_names(stmt.cond, known)
|
|
2038
|
+
text = _if_condition_text(stmt)
|
|
2039
|
+
if text is None:
|
|
2040
|
+
text = _if_condition_text_no_then(stmt)
|
|
2041
|
+
if (
|
|
2042
|
+
text is not None
|
|
2043
|
+
and not names
|
|
2044
|
+
and _over_indexed(stmt.cond, symbols.declared_dim_counts(program, strings=True), _dim_limit(profile))
|
|
2045
|
+
):
|
|
2046
|
+
# CR300/CR350: `If Q(1) Then` for a scalar `Q` (or `Public Q()`), `If Z(1,1) = 1 Then`
|
|
2047
|
+
# for `Public Z(3)` -> `Dimensions not declared: Q(1) .` (oracle, 2026-10-10)
|
|
2048
|
+
diags.error(stmt.line, dimensions_message(text, profile, False), stmt.file)
|
|
2049
|
+
continue
|
|
2050
|
+
if not names or text is None:
|
|
2051
|
+
continue
|
|
2052
|
+
names, dims = _names_and_dims(known, ds, stmt, (), stmt.cond)
|
|
2053
|
+
before = len(diags.items)
|
|
2054
|
+
if profile.name in _WHOLE_EXPR_ECHO_LOGGERS:
|
|
2055
|
+
diags.error(stmt.line, f"Invalid symbol in expression: {text}.", stmt.file)
|
|
2056
|
+
else:
|
|
2057
|
+
# CR300/CR350: `If Foo(X) > 20 Then` -> `Foo is not defined.`, `Dimensions not
|
|
2058
|
+
# declared: Foo(X) > 20 .`; `If Nope Then` -> `Nope is not defined.` (oracle)
|
|
2059
|
+
for name in names:
|
|
2060
|
+
diags.error(stmt.line, profile.unresolved_symbol_message.format(name=name), stmt.file)
|
|
2061
|
+
if dims:
|
|
2062
|
+
diags.error(stmt.line, dimensions_message(text, profile, False), stmt.file)
|
|
2063
|
+
if stmt.inline is not None:
|
|
2064
|
+
# the condition precedes the inline statements' own findings on the same line
|
|
2065
|
+
# (oracle, all four loggers, 2026-10-08)
|
|
2066
|
+
at = next(
|
|
2067
|
+
(i for i, d in enumerate(diags.items[:before]) if (d.line, d.file) == (stmt.line, stmt.file)), None
|
|
2068
|
+
)
|
|
2069
|
+
if at is not None:
|
|
2070
|
+
moved = diags.items[before:]
|
|
2071
|
+
del diags.items[before:]
|
|
2072
|
+
diags.items[at:at] = moved
|
|
2073
|
+
|
|
2074
|
+
# `Do While Nope` / `Loop Until (Nope <> 1)`: the condition after `While`/`Until`, like an
|
|
2075
|
+
# `If` condition (oracle, 2026-09-28: CR6 `Invalid symbol in expression: (Nope <> 1).`,
|
|
2076
|
+
# CR300 `Nope is not defined.`).
|
|
2077
|
+
open_do = 0
|
|
2078
|
+
for stmt in program.statements:
|
|
2079
|
+
if isinstance(stmt, S.DoLoop):
|
|
2080
|
+
if stmt.keyword.upper() == "DO":
|
|
2081
|
+
open_do += 1
|
|
2082
|
+
elif open_do:
|
|
2083
|
+
open_do -= 1
|
|
2084
|
+
elif profile.name in _WHOLE_EXPR_ECHO_LOGGERS:
|
|
2085
|
+
continue # `Loop Until Nope` with no `Do`: only `LOOP without DO.` (oracle, 2026-10-10)
|
|
2086
|
+
if not isinstance(stmt, S.DoLoop) or stmt.cond is None:
|
|
2087
|
+
continue
|
|
2088
|
+
names = _undefined_names(stmt.cond, known)
|
|
2089
|
+
m = re.search(r"(?i)\b(?:While|Until)\b\s*(.*)$", stmt.text or "")
|
|
2090
|
+
if (
|
|
2091
|
+
m
|
|
2092
|
+
and not names
|
|
2093
|
+
and _over_indexed(stmt.cond, symbols.declared_dim_counts(program, strings=True), _dim_limit(profile))
|
|
2094
|
+
):
|
|
2095
|
+
diags.error(stmt.line, dimensions_message(m.group(1), profile, False), stmt.file)
|
|
2096
|
+
continue
|
|
2097
|
+
if not names or not m:
|
|
2098
|
+
continue
|
|
2099
|
+
if profile.name in _WHOLE_EXPR_ECHO_LOGGERS:
|
|
2100
|
+
diags.error(stmt.line, f"Invalid symbol in expression: {m.group(1)}.", stmt.file)
|
|
2101
|
+
else:
|
|
2102
|
+
for name in names:
|
|
2103
|
+
diags.error(stmt.line, profile.unresolved_symbol_message.format(name=name), stmt.file)
|
|
2104
|
+
|
|
2105
|
+
_check_for_headers(program, profile, diags, known)
|
|
2106
|
+
_check_elseif_and_case(program, profile, diags, known)
|
|
2107
|
+
_check_function_statements(program, profile, diags, known)
|
|
2108
|
+
|
|
2109
|
+
dim_counts = symbols.declared_dim_counts(program, strings=True)
|
|
2110
|
+
struct_vars = _struct_var_names(program)
|
|
2111
|
+
functions = {
|
|
2112
|
+
st.name.upper() for st in program.statements if isinstance(st, S.SubDef) and st.keyword.upper() == "FUNCTION"
|
|
2113
|
+
}
|
|
2114
|
+
for stmt in program.statements:
|
|
2115
|
+
if (
|
|
2116
|
+
isinstance(stmt, S.Call)
|
|
2117
|
+
and not stmt.explicit
|
|
2118
|
+
and stmt.name.upper() in functions
|
|
2119
|
+
and stmt.tail_expr is None
|
|
2120
|
+
and stmt.juxtaposed is None
|
|
2121
|
+
and not any(_undefined_names(a, known) for a in stmt.args)
|
|
2122
|
+
and any(_over_indexed(a, dim_counts, _dim_limit(profile)) for a in stmt.args)
|
|
2123
|
+
):
|
|
2124
|
+
# CR300/CR350: `Fn(X(1,2))` is checked like `Y = Fn(X(1,2))` (oracle, 2026-10-10)
|
|
2125
|
+
diags.error(stmt.line, dimensions_message(stmt.text, profile, True, stmt.at_eol), stmt.file)
|
|
2126
|
+
if (
|
|
2127
|
+
isinstance(stmt, S.Call)
|
|
2128
|
+
and stmt.tail_expr is not None
|
|
2129
|
+
and not _undefined_names(stmt.tail_expr, known)
|
|
2130
|
+
and _over_indexed(stmt.tail_expr, dim_counts, _dim_limit(profile))
|
|
2131
|
+
):
|
|
2132
|
+
# CR300/CR350: `Fn(1)+X(1,2)` is checked like `Y = Fn(1)+X(1,2)` (oracle, 2026-10-10)
|
|
2133
|
+
diags.error(stmt.line, dimensions_message(stmt.text, profile, True, stmt.at_eol), stmt.file)
|
|
2134
|
+
if not isinstance(stmt, S.Assignment):
|
|
2135
|
+
continue
|
|
2136
|
+
if _undefined_names(stmt.target, known):
|
|
2137
|
+
continue # target itself unresolved: `structure.check_pass2`'s job entirely, above.
|
|
2138
|
+
if _multi_indexed_struct(stmt.target, struct_vars) or _multi_indexed_struct(stmt.value, struct_vars):
|
|
2139
|
+
continue # already reported by `check_function_arity` (pass 1)
|
|
2140
|
+
if profile.single_pass and _function_target_message(stmt, profile, frozenset()):
|
|
2141
|
+
continue # a function-named target (even a local scalar `exp(1) = 2`): same
|
|
2142
|
+
names = _undefined_names(stmt.value, known)
|
|
2143
|
+
whole_echo = profile.name in _WHOLE_EXPR_ECHO_LOGGERS
|
|
2144
|
+
if _over_indexed(stmt.target, dim_counts, 3 if whole_echo else None):
|
|
2145
|
+
# `R(2,1) = 1` for `Public R(5)`: the fallback (CR6/CR1000X only past 3 subscripts)
|
|
2146
|
+
from . import structure
|
|
2147
|
+
|
|
2148
|
+
diags.error(
|
|
2149
|
+
stmt.line, structure._out_of_place(profile, stmt.text, stmt.at_eol), stmt.file, show_file="cr3xx"
|
|
2150
|
+
)
|
|
2151
|
+
continue
|
|
2152
|
+
if not names:
|
|
2153
|
+
if _over_indexed(stmt.value, dim_counts, _dim_limit(profile)):
|
|
2154
|
+
# CR300/CR350: `X = R(2,1)` -> `Dimensions not declared: X = R(2,1)` (oracle);
|
|
2155
|
+
# CR6/CR1000X past three subscripts, round 16
|
|
2156
|
+
diags.error(stmt.line, dimensions_message(stmt.text, profile, True, stmt.at_eol), stmt.file)
|
|
2157
|
+
continue
|
|
2158
|
+
if profile.name in _WHOLE_EXPR_ECHO_LOGGERS:
|
|
2159
|
+
diags.error(stmt.line, profile.unresolved_symbol_message.format(name=stmt.text), stmt.file, stmt=stmt)
|
|
2160
|
+
else:
|
|
2161
|
+
names, dims = _names_and_dims(known, ds, stmt, (stmt.target,), stmt.value)
|
|
2162
|
+
if not dims and _over_indexed(stmt.value, dim_counts):
|
|
2163
|
+
names = _names_through_over_index(stmt.value, known, dim_counts)
|
|
2164
|
+
for name in names:
|
|
2165
|
+
diags.error(stmt.line, profile.unresolved_symbol_message.format(name=name), stmt.file)
|
|
2166
|
+
if dims or _over_indexed(
|
|
2167
|
+
stmt.value, dim_counts
|
|
2168
|
+
): # (names, then the over-indexed read: oracle, CR300, 2026-10-12)
|
|
2169
|
+
diags.error(stmt.line, dimensions_message(stmt.text, profile, True, stmt.at_eol), stmt.file)
|
|
2170
|
+
|
|
2171
|
+
|
|
2172
|
+
# the `Const ... As` types the real compilers accept (see `structure._CONST_TYPES`)
|
|
2173
|
+
_CONST_TYPES = frozenset({"LONG", "FLOAT", "DOUBLE", "STRING", "BOOLEAN"})
|
|
2174
|
+
|
|
2175
|
+
|
|
2176
|
+
def check_consts(program: S.Program, profile, diags: Diagnostics) -> None:
|
|
2177
|
+
"""`Const <name> = <value>.` initializer resolution (this round's own new check, all four
|
|
2178
|
+
loggers): a `Const`'s value expression is folded eagerly, at declaration time, so an
|
|
2179
|
+
unresolved name inside it is diagnosed there and then rather than deferred to any later
|
|
2180
|
+
argument-checking pass -- oracle-confirmed **pass 1** (ungated, unconditional), not pass 2:
|
|
2181
|
+
`Const Foo = BazQux` reports its own diagnostic *alongside* an unrelated, earlier `Public`
|
|
2182
|
+
with no name (`Variable not named.`), on every logger, where an ordinary pass-2 finding (e.g.
|
|
2183
|
+
`expressions.check`'s own instruction-argument checks) would have been completely suppressed
|
|
2184
|
+
by that same earlier pass-1 error (`docs/checker.md` "Pass model"; probe transcript,
|
|
2185
|
+
`docs/loggers.md`). Message family and per-logger shape are identical to the general
|
|
2186
|
+
undefined-symbol check above, reusing the same `_WHOLE_EXPR_ECHO_LOGGERS` split: CR6/CR1000X
|
|
2187
|
+
report one whole-expression `Invalid symbol in expression: <value text>.` line; CR300/CR350
|
|
2188
|
+
report each undefined name in the expression as its own `<name> is not defined.` line (oracle,
|
|
2189
|
+
a compound expression, `docs/loggers.md`: `Const Foo = BazQux + Corge` -> one line, `Invalid
|
|
2190
|
+
symbol in expression: BazQux + Corge.`, on CR6; two lines, `BazQux is not defined.` then
|
|
2191
|
+
`Corge is not defined.`, on CR300 -- same shape as an instruction argument's own compound
|
|
2192
|
+
expression, `_report_statement` above). `ConstDecl.value_span` (new field, `syntax.py`/
|
|
2193
|
+
`parser.py`, same technique as `Instruction.arg_spans`) captures the value expression's own
|
|
2194
|
+
source columns so the whole-expression echo is the verbatim text, not a re-derived form.
|
|
2195
|
+
|
|
2196
|
+
Narrow and deliberately conservative, mirroring `check` above: only an outright-undefined
|
|
2197
|
+
name is flagged (`_undefined_names` against the same `known` universe, so a `Const` that
|
|
2198
|
+
legitimately forward/cross-references another declared name is never flagged); a `Const`
|
|
2199
|
+
referencing a *live* runtime variable is a real, different, oracle-confirmed rule of its own
|
|
2200
|
+
(`Variable used in CONST expression.`, all loggers, `docs/loggers.md`) but not implemented
|
|
2201
|
+
here -- zero occurrences of that message anywhere in this project's own corpus sample, so
|
|
2202
|
+
there is no evidence this specific project's programs need it, and guessing its trigger
|
|
2203
|
+
shape/gating without corpus signal risks a false reject for no measured benefit.
|
|
2204
|
+
"""
|
|
2205
|
+
known = _known_names(program, profile)
|
|
2206
|
+
whole_echo = profile.name in _WHOLE_EXPR_ECHO_LOGGERS
|
|
2207
|
+
unready = _not_yet_declared(program, profile)
|
|
2208
|
+
for stmt in program.statements:
|
|
2209
|
+
if not isinstance(stmt, S.ConstDecl):
|
|
2210
|
+
continue
|
|
2211
|
+
if stmt.type and stmt.type.upper() not in _CONST_TYPES:
|
|
2212
|
+
continue # `unknown Const type` is the only message (oracle, 2026-10-10)
|
|
2213
|
+
visible = known - unready.get(id(stmt), frozenset())
|
|
2214
|
+
names = _undefined_names(stmt.value, visible)
|
|
2215
|
+
if not names or _find_arity_problem(stmt.value, profile, visible) not in (None, "deferred"):
|
|
2216
|
+
continue # a wrong-arity call read before the name wins (oracle, all four loggers, round 41)
|
|
2217
|
+
if whole_echo:
|
|
2218
|
+
if stmt.value_span is not None:
|
|
2219
|
+
text = stmt.text[stmt.value_span[0] : stmt.value_span[1]]
|
|
2220
|
+
else: # defensive: only reached if a `Const` was built without a span
|
|
2221
|
+
text = stmt.value.name if isinstance(stmt.value, S.Name) else names[0]
|
|
2222
|
+
diags.error(stmt.line, profile.unresolved_symbol_message.format(name=text), stmt.file)
|
|
2223
|
+
else:
|
|
2224
|
+
for name in names:
|
|
2225
|
+
diags.error(stmt.line, profile.unresolved_symbol_message.format(name=name), stmt.file)
|
|
2226
|
+
|
|
2227
|
+
|
|
2228
|
+
# `Data`/`DataLong` share one `compile_fn` in `data/instructions.json` (`0x406a32`) and were
|
|
2229
|
+
# oracle-confirmed identical this round (`DataLong Tables and Store Data`, same two-line message
|
|
2230
|
+
# below, byte-identical to `Data`'s own).
|
|
2231
|
+
_DATA_KEYWORDS = frozenset({"DATA", "DATALONG"})
|
|
2232
|
+
|
|
2233
|
+
|
|
2234
|
+
def check_data_params(program: S.Program, profile, diags: Diagnostics) -> None:
|
|
2235
|
+
"""`Data`/`DataLong`'s own parameters (`docs/checker.md` "Data parameters"): each must be a
|
|
2236
|
+
constant expression referencing **no name at all**, defined or not -- oracle-confirmed with a
|
|
2237
|
+
from-scratch scratch probe set (`t.cr6` under Wine, no siblings/corpus file, `docs/checker.md`
|
|
2238
|
+
"Data parameters" records the transcript):
|
|
2239
|
+
|
|
2240
|
+
- `Data SomeVar` (`SomeVar` undeclared): two lines on CR6/CR1000X, `Invalid symbol in
|
|
2241
|
+
expression: SomeVar\\n.` then `Illegal Data parameter SomeVar\\n.`; CR300/CR350 give
|
|
2242
|
+
`SomeVar is not defined.` (the existing per-logger `unresolved_symbol_message` split,
|
|
2243
|
+
`_WHOLE_EXPR_ECHO_LOGGERS`, reused as-is) in place of the first line, same second line --
|
|
2244
|
+
the embedded raw `\\n` (not a formatting artifact here, a literal newline byte in the real
|
|
2245
|
+
compiler's own message, present in `Illegal Data parameter`'s own text on *every* logger,
|
|
2246
|
+
`docs/checker.md` "Data parameters") only appears when the bad parameter is the *last* one
|
|
2247
|
+
on its physical line (`Data 1,SomeVar,3` echoes plain `SomeVar.`, no embedded newline,
|
|
2248
|
+
since a comma follows it on the same line) -- the real compiler's own internal source
|
|
2249
|
+
buffer keeps the terminating newline; ours (`stmt.text`) does not, so `_data_param_text`
|
|
2250
|
+
below adds it back only for that one shape.
|
|
2251
|
+
- `Data Batt_Volt` (`Batt_Volt` declared `Public`): only `Illegal Data parameter Batt_Volt\\n.`
|
|
2252
|
+
-- no undefined-symbol line at all, on any logger, since the name itself resolves fine; it
|
|
2253
|
+
is simply not a constant. This is why the check below cannot reuse `_undefined_names` alone
|
|
2254
|
+
the way every other rule in this module does: a Data parameter is illegal whether or not the
|
|
2255
|
+
name it references is defined, `_variable_refs(arg, known)` (reusing `known`, the full
|
|
2256
|
+
resolvable-name universe, in place of its usual `variable_names` argument) doubling as "does
|
|
2257
|
+
this expression contain any name that *does* resolve" for exactly that reason.
|
|
2258
|
+
- `Data 1,2,3` / `Data 1+2`: no error (a pure literal/arithmetic constant).
|
|
2259
|
+
|
|
2260
|
+
Stops at the first bad parameter in a statement (no oracle evidence for two bad parameters in
|
|
2261
|
+
the same `Data`, and every other per-position check in this module already stops at the first
|
|
2262
|
+
problem the same way).
|
|
2263
|
+
|
|
2264
|
+
Ungated pass 1, same as `check_consts` above (`cdmbrfull_1.CR6`: this fires alongside an
|
|
2265
|
+
earlier, unrelated `Need '=' in units definition:` pass-1 error, not suppressed by it).
|
|
2266
|
+
|
|
2267
|
+
A `Data`/`DataLong` line the parser could not resolve into a clean argument list at all --
|
|
2268
|
+
the common CHM corpus shape, a `'`-less comment-continuation line the statement's own bare-
|
|
2269
|
+
argument-list grammar absorbs (`brfull6w_1.CR6`'s `Data Tables and Store Data`, no comma
|
|
2270
|
+
anywhere in it) -- never reaches the loop below as an `Instruction` at all: `parser.py`'s
|
|
2271
|
+
`_parse_bare_arglist` parses only the first word (`Tables`) as `Data`'s own first parameter,
|
|
2272
|
+
and the rest (`and Store Data`) is left as trailing input that turns the *whole statement*
|
|
2273
|
+
into an `S.Unparsed` (`Instruction.args`/`arg_spans` never populated). On CR6/CR1000X the
|
|
2274
|
+
oracle shows the real compiler still reports exactly this module's own two-line shape for it,
|
|
2275
|
+
spanning from right after the keyword to end of line (`Invalid symbol in expression: Tables
|
|
2276
|
+
and Store Data\\n.` / `Illegal Data parameter Tables and Store Data\\n.` -- not `Tables\\n.`
|
|
2277
|
+
alone, the substring our own parser would have captured as parameter 0's span had it not given
|
|
2278
|
+
up) -- `_check_unparsed_data` below reconstructs that same span textually (the whole line minus
|
|
2279
|
+
the keyword and its following whitespace) rather than trying to make the parser itself recover
|
|
2280
|
+
a partial argument list for this one shape. **CR300/CR350 are genuinely different here, not
|
|
2281
|
+
just a message-template swap**: oracle-confirmed (`docs/checker.md` "Data parameters"),
|
|
2282
|
+
`Data Tables and Store Data` there tokenises `and` as the real logical `AND` operator (a
|
|
2283
|
+
reserved word on those two loggers this shape happens to expose), so it becomes a compound
|
|
2284
|
+
`Tables AND (Store Data)` parse attempt -- three separate `%s is not defined.` lines (`Tables`,
|
|
2285
|
+
`Store`, `Data`, each its own undefined name) and `Incomplete expression: %s.` (not `Invalid
|
|
2286
|
+
symbol in expression`) as the wrapper, before the same `Illegal Data parameter %s.` line. Not
|
|
2287
|
+
implemented: reproducing it needs its own compound-expression walk this session did not build
|
|
2288
|
+
(AGENTS.md: under-report rather than guess), so `_check_unparsed_data` is scoped to
|
|
2289
|
+
`_WHOLE_EXPR_ECHO_LOGGERS` only and CR300/CR350 fall through unchanged to whatever later,
|
|
2290
|
+
already-existing pass would otherwise handle this `S.Unparsed` statement.
|
|
2291
|
+
"""
|
|
2292
|
+
known = _known_names(program, profile)
|
|
2293
|
+
for stmt in program.statements:
|
|
2294
|
+
if isinstance(stmt, S.Instruction) and stmt.keyword.upper() in _DATA_KEYWORDS:
|
|
2295
|
+
_check_data_instruction(stmt, known, profile, diags)
|
|
2296
|
+
elif isinstance(stmt, S.Unparsed):
|
|
2297
|
+
_check_unparsed_data(stmt, diags, profile, program)
|
|
2298
|
+
|
|
2299
|
+
|
|
2300
|
+
def _data_param_text(stmt: S.Instruction, i: int) -> str:
|
|
2301
|
+
text = _arg_text(stmt, i)
|
|
2302
|
+
if i == len(stmt.args) - 1:
|
|
2303
|
+
text += "\n" # last parameter on the line: oracle-confirmed embedded newline, see above.
|
|
2304
|
+
return text
|
|
2305
|
+
|
|
2306
|
+
|
|
2307
|
+
def _check_data_instruction(stmt: S.Instruction, known: set[str], profile, diags: Diagnostics) -> None:
|
|
2308
|
+
for i, arg in enumerate(stmt.args):
|
|
2309
|
+
if arg is None:
|
|
2310
|
+
continue
|
|
2311
|
+
undefined = _undefined_names(arg, known)
|
|
2312
|
+
resolved_refs = _variable_refs(arg, known)
|
|
2313
|
+
if not undefined and not resolved_refs:
|
|
2314
|
+
continue # a pure constant expression: legal.
|
|
2315
|
+
text = _data_param_text(stmt, i)
|
|
2316
|
+
if undefined:
|
|
2317
|
+
if profile.name in _WHOLE_EXPR_ECHO_LOGGERS:
|
|
2318
|
+
diags.error(stmt.line, profile.unresolved_symbol_message.format(name=text), stmt.file)
|
|
2319
|
+
else:
|
|
2320
|
+
for name in undefined:
|
|
2321
|
+
diags.error(stmt.line, profile.unresolved_symbol_message.format(name=name), stmt.file)
|
|
2322
|
+
diags.error(stmt.line, f"Illegal Data parameter {text}.", stmt.file)
|
|
2323
|
+
return # stop at the first bad parameter, see docstring above.
|
|
2324
|
+
|
|
2325
|
+
|
|
2326
|
+
def _check_unparsed_data(stmt: S.Unparsed, diags: Diagnostics, profile, program) -> None:
|
|
2327
|
+
"""`Data foo bar`: CR6/CR1000X `Invalid symbol in expression: foo bar` (whole text); CR300/
|
|
2328
|
+
CR350 each leading word as in a prose line (`Data Tables and Store Data` -> Tables, Store,
|
|
2329
|
+
Data), then `Incomplete expression: foo bar` (oracle, 2026-10-01). Both end with `Illegal Data
|
|
2330
|
+
parameter`."""
|
|
2331
|
+
if not stmt.reason.startswith("unexpected trailing input starting at"):
|
|
2332
|
+
return
|
|
2333
|
+
stripped = stmt.text.lstrip()
|
|
2334
|
+
upper = stripped.upper()
|
|
2335
|
+
for kw in ("DATALONG", "DATA"): # longer name first: "DATALONG" also starts with "DATA".
|
|
2336
|
+
if upper.startswith(kw) and len(stripped) > len(kw) and stripped[len(kw)].isspace():
|
|
2337
|
+
text = stripped[len(kw) :].lstrip() + "\n"
|
|
2338
|
+
if profile.name in _WHOLE_EXPR_ECHO_LOGGERS:
|
|
2339
|
+
diags.error(stmt.line, f"Invalid symbol in expression: {text}.", stmt.file)
|
|
2340
|
+
else:
|
|
2341
|
+
from . import cr3xx
|
|
2342
|
+
|
|
2343
|
+
for name in cr3xx._leading_unknown_words(text, cr3xx.prose_known(program, profile)):
|
|
2344
|
+
diags.error(stmt.line, profile.unresolved_symbol_message.format(name=name), stmt.file)
|
|
2345
|
+
diags.error(stmt.line, f"Incomplete expression: {text}.", stmt.file)
|
|
2346
|
+
diags.error(stmt.line, f"Illegal Data parameter {text}.", stmt.file)
|
|
2347
|
+
return
|
|
2348
|
+
|
|
2349
|
+
|
|
2350
|
+
def _call_arity_problem(name: str, args: list[S.Expr | None], profile) -> str | None:
|
|
2351
|
+
"""`"few"`/`"many"`/`None` for one call's own argument count against `data/functions.json`'s
|
|
2352
|
+
`nargs`, restricted to `_ARITY_MIN_SAFE`/`_ARITY_MAX_SAFE` (the oracle-verified-safe subset,
|
|
2353
|
+
this module's own top-of-file comment) -- and, within that, only when *this profile's own*
|
|
2354
|
+
`nargs` for the name still matches the value the oracle sweep tested (`_ARITY_MIN_SAFE[name]`/
|
|
2355
|
+
`_ARITY_MAX_SAFE[name]`): a profile whose own table disagrees (`SerialOpen` on CR350, `docs/
|
|
2356
|
+
checker.md`) is silently left unchecked for that name, rather than trusting an unverified
|
|
2357
|
+
transfer of this session's CR6/CR1000X-only evidence. A name absent from `profile.functions`
|
|
2358
|
+
(not a built-in function at all -- a declared `Sub`/`Function`, or unresolved) is never this
|
|
2359
|
+
rule's concern.
|
|
2360
|
+
"""
|
|
2361
|
+
record = profile.functions.get(name.upper())
|
|
2362
|
+
if not record:
|
|
2363
|
+
return None
|
|
2364
|
+
n = len(args)
|
|
2365
|
+
if name.upper() == "CTYPE": # table nargs is 0; the call takes (value, type) (oracle, 4 loggers)
|
|
2366
|
+
return None if None in args or n == 2 else "few" if n == 1 else "many" if n > 2 else None
|
|
2367
|
+
nargs = record.get("nargs")
|
|
2368
|
+
if nargs is None:
|
|
2369
|
+
return None
|
|
2370
|
+
upper = name.upper()
|
|
2371
|
+
if any(i < len(args) and isinstance(args[i], S.String) for i in _FUNCTION_CONST_INTEGER_STRING_ARGS.get(upper, ())):
|
|
2372
|
+
return "constint"
|
|
2373
|
+
if any(i < n and _not_constant_integer(args[i]) for i in _CONST_INTEGER_BAD_ARGS.get(upper, ())):
|
|
2374
|
+
return "constint"
|
|
2375
|
+
if _TIMEOUT_GATE[0] is False and any(
|
|
2376
|
+
i < n and (_not_integer_type(args[i]) or _timeout_wide(args[i], profile))
|
|
2377
|
+
for i in _CONST_INTEGER_TYPE_ARGS.get(upper, ())
|
|
2378
|
+
):
|
|
2379
|
+
return "constint"
|
|
2380
|
+
if any(i < n and _not_integer_type(args[i]) for i in _CONST_INTEGER_TYPE_ARGS_ALWAYS.get(upper, ())):
|
|
2381
|
+
return "constint"
|
|
2382
|
+
if upper == "TIMER" and n == 3 and _TIMER_CONTEXT[0] is not None:
|
|
2383
|
+
kinds = [_timer_arg(a, *_TIMER_CONTEXT[0]) if a is not None else None for a in args[1:]]
|
|
2384
|
+
if "float" in kinds:
|
|
2385
|
+
return "constint"
|
|
2386
|
+
if kinds[1] is not None and not 0 <= kinds[1][1] <= 4:
|
|
2387
|
+
return "timer_option"
|
|
2388
|
+
if _CONST_INTEGER_OVERFLOW_STRING.get(upper) == nargs and len(args) == nargs + 1 and isinstance(args[-1], S.String):
|
|
2389
|
+
return "constint"
|
|
2390
|
+
if _INCOMPLETE_OVERFLOW.get(upper) == nargs and len(args) == nargs + 1:
|
|
2391
|
+
return "incomplete"
|
|
2392
|
+
if n == 0 and (nargs > 0 or record.get("argspec")):
|
|
2393
|
+
# `X = Int()`, `SerialFlush()`: the generic fallback on the whole statement, pass 1, all
|
|
2394
|
+
# four loggers (oracle, 2026-09-27).
|
|
2395
|
+
return "empty"
|
|
2396
|
+
if allowed := _ARITY_ALLOWED.get(upper, {}).get(nargs):
|
|
2397
|
+
return None if n in allowed else "few" if n < max(allowed) else "many"
|
|
2398
|
+
if bounds := _ARITY_RANGE.get(upper, {}).get(nargs):
|
|
2399
|
+
return "few" if n < bounds[0] else "many" if n > bounds[1] else None
|
|
2400
|
+
if n < nargs and _ARITY_MIN_SAFE.get(name.upper()) == nargs:
|
|
2401
|
+
return "few"
|
|
2402
|
+
if n > nargs and _ARITY_MAX_SAFE.get(name.upper()) == nargs:
|
|
2403
|
+
return "many"
|
|
2404
|
+
return None
|
|
2405
|
+
|
|
2406
|
+
|
|
2407
|
+
def _find_arity_problem(expr: S.Expr | None, profile, known: set[str] | None = None) -> str | None:
|
|
2408
|
+
"""The first (left-to-right, outer-before-inner) built-in-function call anywhere inside `expr`
|
|
2409
|
+
with a bad argument count, `_call_arity_problem`'s own `"few"`/`"many"`/`None`. Same recursion
|
|
2410
|
+
shape as `_undefined_names`/`_variable_refs` above (`Unary`/`Binary`/`Index`'s own name and
|
|
2411
|
+
args/`Member.base`/`ArrayLiteral` items); a `Member`'s own `.member` args (`Status.Battery(1)`
|
|
2412
|
+
-- a *field* call, never a built-in function) are deliberately not checked, same reasoning as
|
|
2413
|
+
`_undefined_names` leaving `Member.member` alone.
|
|
2414
|
+
|
|
2415
|
+
Checks an `Index` node itself (a real parser-recognised call, or an array reference that
|
|
2416
|
+
happens to share a built-in function's name -- `_call_arity_problem` only fires when the name is
|
|
2417
|
+
genuinely in `profile.functions`, so an ordinary array reference is never affected) before
|
|
2418
|
+
recursing into its own arguments, matching the outer-call-first order the oracle showed for a
|
|
2419
|
+
single-argument-short outer call around an otherwise-fine inner one (`docs/checker.md`); a
|
|
2420
|
+
*doubly* bad-arity nested call (both the outer and an inner call wrong) is not oracle-tested
|
|
2421
|
+
either way and is left as this function's own documented order (outer first) rather than guessed
|
|
2422
|
+
at further.
|
|
2423
|
+
|
|
2424
|
+
With `known` (CR6/CR1000X), a bad call whose own arguments name something undefined is
|
|
2425
|
+
`"deferred"`: the statement goes to pass 2's `Invalid symbol in expression` instead (oracle,
|
|
2426
|
+
round 38: `S = Right(Nope)`, `Battery(Sin(Nope,1))`; `Sin(1,2) + Nope` and a name in a surplus
|
|
2427
|
+
argument keep the arity error).
|
|
2428
|
+
"""
|
|
2429
|
+
if expr is None:
|
|
2430
|
+
return None
|
|
2431
|
+
if known is not None and isinstance(expr, (S.Name, S.Index)) and expr.name.upper() not in known:
|
|
2432
|
+
# read left to right: an undefined name before the failing call defers it too (round 39:
|
|
2433
|
+
# `X = Nope + Sin(1,2)`, `Round(Nope, Sin(1,2))`; each `Sub` argument is its own expression)
|
|
2434
|
+
return "deferred"
|
|
2435
|
+
if isinstance(expr, S.Index):
|
|
2436
|
+
direct = _call_arity_problem(expr.name, expr.args, profile)
|
|
2437
|
+
if direct and known is not None:
|
|
2438
|
+
# only names the parser reaches: a surplus argument is never read (`Right(S,10,Nope)`
|
|
2439
|
+
# keeps `No Matching`, round 38)
|
|
2440
|
+
read = expr.args
|
|
2441
|
+
while direct != "few" and read and _call_arity_problem(expr.name, read, profile) not in (None, "few"):
|
|
2442
|
+
read = read[:-1]
|
|
2443
|
+
if any(_undefined_names(a, known) for a in read):
|
|
2444
|
+
return "deferred"
|
|
2445
|
+
if direct:
|
|
2446
|
+
return direct
|
|
2447
|
+
for a in expr.args:
|
|
2448
|
+
found = _find_arity_problem(a, profile, known)
|
|
2449
|
+
if found:
|
|
2450
|
+
return found
|
|
2451
|
+
return None
|
|
2452
|
+
if isinstance(expr, S.Unary):
|
|
2453
|
+
return _find_arity_problem(expr.operand, profile, known)
|
|
2454
|
+
if isinstance(expr, S.Binary):
|
|
2455
|
+
return _find_arity_problem(expr.left, profile, known) or _find_arity_problem(expr.right, profile, known)
|
|
2456
|
+
if isinstance(expr, S.Member):
|
|
2457
|
+
if expr.args is not None and len(expr.args) > 2:
|
|
2458
|
+
# a table field takes at most (index, records back): `Tb.X(1,1,1)`, `Public.V(1,1,1)`
|
|
2459
|
+
# -> `No Matching, or out of place )` (oracle, all four loggers, 2026-10-01). Raised by
|
|
2460
|
+
# the pass-2 compile of the statement, not by pass 1 (message tracer, 2026-10-10), so
|
|
2461
|
+
# `check_function_arity` reports it through `held`.
|
|
2462
|
+
return "many_member"
|
|
2463
|
+
return _find_arity_problem(expr.base, profile, known)
|
|
2464
|
+
if isinstance(expr, S.ArrayLiteral):
|
|
2465
|
+
for item in expr.items:
|
|
2466
|
+
found = _find_arity_problem(item, profile, known)
|
|
2467
|
+
if found:
|
|
2468
|
+
return found
|
|
2469
|
+
return None
|
|
2470
|
+
return None # Number, Name, String: no call to check
|
|
2471
|
+
|
|
2472
|
+
|
|
2473
|
+
def _arity_message(kind: str, text: str, profile, at_eol: bool = True, whole_statement: bool = False) -> str:
|
|
2474
|
+
if kind == "timer_option":
|
|
2475
|
+
return "Legal options are 0..4"
|
|
2476
|
+
if kind in ("constint", "incomplete"):
|
|
2477
|
+
verb = "Parameter must be a constant integer: " if kind == "constint" else "Incomplete expression: "
|
|
2478
|
+
sep = "\n" if profile.name in _ARITY_TRAILING_NEWLINE_LOGGERS and at_eol else ""
|
|
2479
|
+
return f"{verb}{text}{sep}."
|
|
2480
|
+
if kind == "empty":
|
|
2481
|
+
sep = "\n" if whole_statement and at_eol and profile.fallback_echo_newline else ""
|
|
2482
|
+
return f"Invalid, or out of place expression: {text}{sep}."
|
|
2483
|
+
verb = "Expression expects , : " if kind == "few" else "No Matching, or out of place ) in expression: "
|
|
2484
|
+
if profile.name in _ARITY_TRAILING_NEWLINE_LOGGERS and at_eol: # not before a `'` comment
|
|
2485
|
+
return f"{verb}{text}\n."
|
|
2486
|
+
return f"{verb}{text}."
|
|
2487
|
+
|
|
2488
|
+
|
|
2489
|
+
def _is_bare_reference_problem(name: str, profile, declared_variables: frozenset[str]) -> bool:
|
|
2490
|
+
"""True when `name` is a built-in function (`data/<logger>/functions.json`) that can take at
|
|
2491
|
+
least one argument -- a required one (`nargs>=1`) or a purely-optional one (`nargs==0` but
|
|
2492
|
+
`argspec` non-zero: `EMAILRELAY`'s own shape, the sole `nargs==0` outlier, oracle-confirmed
|
|
2493
|
+
identical across all four loggers' own tables -- `docs/checker.md`) -- so a bare reference to
|
|
2494
|
+
it with no `(` at all is `Expression expects ( : %s.`.
|
|
2495
|
+
|
|
2496
|
+
Unlike `_call_arity_problem`'s off-by-one arity check, this is not restricted to
|
|
2497
|
+
`_ARITY_MIN_SAFE`/`_ARITY_MAX_SAFE`: the oracle sweep for *that* rule needed a safe subset
|
|
2498
|
+
because some functions accept fewer/more than their own table `nargs` (an undocumented
|
|
2499
|
+
optional argument). This rule only asks whether the call has *no* argument list whatsoever,
|
|
2500
|
+
which every corpus example with `nargs>=1` -- `Sin`, `Timer`, `FindSpa`, `TCPOpen`,
|
|
2501
|
+
`SerialOpen`, `SerialOut`, `EmailSend`, `HTTPPut`, `IPTrace`, `SecsSince1990`,
|
|
2502
|
+
`DaylightSaving` -- confirms uniformly (`docs/checker.md`). A genuinely zero-argument function
|
|
2503
|
+
(`argspec` also 0: `Rnd`, `Signature`, `LineNum`, `PPPOpen`, `PPPClose`, `TimeUntilTransmit`,
|
|
2504
|
+
`IntDV`, `NewFieldCal`, `CType`, `AbsLong`, `ClockChange`) is oracle-confirmed to compile clean
|
|
2505
|
+
bare, with or without parens, on all four loggers -- this rule leaves those alone.
|
|
2506
|
+
|
|
2507
|
+
A name the program itself declares as a genuine runtime variable
|
|
2508
|
+
(`symbols.declared_variable_names` -- `Public`/`Dim`/`Static`/a `Sub`/`Function` parameter)
|
|
2509
|
+
shadows the built-in function of the same name for bare-identifier resolution and is never
|
|
2510
|
+
flagged here -- found by this session's own false-reject sweep (`examples/embracer.cr6`'s
|
|
2511
|
+
`Dim sgn As Long, exp As Long, mant As Long` followed by the plain arithmetic
|
|
2512
|
+
`... + (exp << 13) + ...`, which the real compiler resolves as the *variable* `exp`, not the
|
|
2513
|
+
math function `Exp`, and compiles clean).
|
|
2514
|
+
"""
|
|
2515
|
+
if name.upper() in declared_variables:
|
|
2516
|
+
return False
|
|
2517
|
+
record = profile.functions.get(name.upper())
|
|
2518
|
+
if not record:
|
|
2519
|
+
return False
|
|
2520
|
+
nargs = record.get("nargs")
|
|
2521
|
+
if nargs is None:
|
|
2522
|
+
return False
|
|
2523
|
+
return nargs > 0 or bool(record.get("argspec"))
|
|
2524
|
+
|
|
2525
|
+
|
|
2526
|
+
def _find_bare_function_ref(expr: S.Expr | None, profile, declared_variables: frozenset[str]) -> bool:
|
|
2527
|
+
"""Whether a `Name` node anywhere inside `expr` (same recursion shape as
|
|
2528
|
+
`_find_arity_problem`) bare-references a function `_is_bare_reference_problem` flags -- a
|
|
2529
|
+
built-in function used as a plain name, with no argument list at all, rather than as a call
|
|
2530
|
+
(`S.Index`). An `Index` node's own name is by definition already followed by `(`, so only its
|
|
2531
|
+
*arguments* are recursed into, matching `Randomize(Sin)` -> flags the nested `Sin`, not
|
|
2532
|
+
`Randomize` itself (oracle-confirmed, `docs/checker.md`).
|
|
2533
|
+
"""
|
|
2534
|
+
if expr is None:
|
|
2535
|
+
return False
|
|
2536
|
+
if isinstance(expr, S.Name):
|
|
2537
|
+
return _is_bare_reference_problem(expr.name, profile, declared_variables)
|
|
2538
|
+
if isinstance(expr, S.Index):
|
|
2539
|
+
return any(_find_bare_function_ref(a, profile, declared_variables) for a in expr.args)
|
|
2540
|
+
if isinstance(expr, S.Unary):
|
|
2541
|
+
return _find_bare_function_ref(expr.operand, profile, declared_variables)
|
|
2542
|
+
if isinstance(expr, S.Binary):
|
|
2543
|
+
return _find_bare_function_ref(expr.left, profile, declared_variables) or _find_bare_function_ref(
|
|
2544
|
+
expr.right, profile, declared_variables
|
|
2545
|
+
)
|
|
2546
|
+
if isinstance(expr, S.Member):
|
|
2547
|
+
return _find_bare_function_ref(expr.base, profile, declared_variables)
|
|
2548
|
+
if isinstance(expr, S.ArrayLiteral):
|
|
2549
|
+
return any(_find_bare_function_ref(item, profile, declared_variables) for item in expr.items)
|
|
2550
|
+
return False # Number, String: no name to check
|
|
2551
|
+
|
|
2552
|
+
|
|
2553
|
+
def _bare_function_message(text: str, profile, whole_statement: bool, at_eol: bool = True) -> str:
|
|
2554
|
+
# `whole_statement`: CR300/CR350 embed a literal newline before the closing `.` only when
|
|
2555
|
+
# `text` is the *whole* logical statement (`Call`/`Assignment`, matching `_arity_message`'s
|
|
2556
|
+
# own `_ARITY_TRAILING_NEWLINE_LOGGERS` convention for their arity messages) -- **not** for an
|
|
2557
|
+
# `Instruction` argument's own sub-span. Oracle-confirmed, this session: `Randomize(Sin)` on
|
|
2558
|
+
# `cr300comp.exe`/`cr350comp.exe` gives the plain, single-line `Expression expects ( : Sin.`
|
|
2559
|
+
# (no embedded newline), while `X = Sin` alone gives `Expression expects ( : X = Sin\n.`
|
|
2560
|
+
# (`docs/checker.md`).
|
|
2561
|
+
if whole_statement and at_eol and profile.name in _ARITY_TRAILING_NEWLINE_LOGGERS:
|
|
2562
|
+
return f"Expression expects ( : {text}\n."
|
|
2563
|
+
return f"Expression expects ( : {text}."
|
|
2564
|
+
|
|
2565
|
+
|
|
2566
|
+
def _struct_var_names(program: S.Program) -> frozenset[str]:
|
|
2567
|
+
from . import structs # lazy: structs imports this module
|
|
2568
|
+
|
|
2569
|
+
types = structs._structure_members(program)
|
|
2570
|
+
if not types:
|
|
2571
|
+
return frozenset()
|
|
2572
|
+
return frozenset(n for n, k in structs._variable_kinds(program, types).items() if k.startswith("STRUCT:"))
|
|
2573
|
+
|
|
2574
|
+
|
|
2575
|
+
def _multi_indexed_struct(expr: S.Expr | None, struct_vars: frozenset[str]) -> bool:
|
|
2576
|
+
"""True when `expr` contains a structure-variable reference with two or more indices
|
|
2577
|
+
(`s1(10,2)`, `s2(1,2).value`): a structure reference takes at most one (`s1(1).value(2)`
|
|
2578
|
+
is fine -- the member's own index is separate). Same walk shape as `_find_arity_problem`."""
|
|
2579
|
+
if expr is None:
|
|
2580
|
+
return False
|
|
2581
|
+
if isinstance(expr, S.Index):
|
|
2582
|
+
return (expr.name.upper() in struct_vars and len(expr.args) >= 2) or any(
|
|
2583
|
+
_multi_indexed_struct(a, struct_vars) for a in expr.args
|
|
2584
|
+
)
|
|
2585
|
+
if isinstance(expr, S.Unary):
|
|
2586
|
+
return _multi_indexed_struct(expr.operand, struct_vars)
|
|
2587
|
+
if isinstance(expr, S.Binary):
|
|
2588
|
+
return _multi_indexed_struct(expr.left, struct_vars) or _multi_indexed_struct(expr.right, struct_vars)
|
|
2589
|
+
if isinstance(expr, S.Member):
|
|
2590
|
+
return _multi_indexed_struct(expr.base, struct_vars) or any(
|
|
2591
|
+
_multi_indexed_struct(a, struct_vars) for a in expr.args or []
|
|
2592
|
+
)
|
|
2593
|
+
return False
|
|
2594
|
+
|
|
2595
|
+
|
|
2596
|
+
def _hold(held: list | None, diags: Diagnostics, problem: str) -> None:
|
|
2597
|
+
if held is not None and problem == "many_member":
|
|
2598
|
+
held.append(diags.items[-1])
|
|
2599
|
+
|
|
2600
|
+
|
|
2601
|
+
def check_function_arity(program: S.Program, profile, diags: Diagnostics, held: list | None = None) -> None:
|
|
2602
|
+
"""Built-in function call arity (`Expression expects , : %s.` / `No Matching, or out of place )
|
|
2603
|
+
in expression: %s.`) *and* a bare function-name reference with no argument list at all
|
|
2604
|
+
(`Expression expects ( : %s.`, `_is_bare_reference_problem`/`_find_bare_function_ref`) --
|
|
2605
|
+
this module's own top-of-file comment for the arity oracle evidence, `docs/checker.md`
|
|
2606
|
+
("Expression expects ( (bare function name)") for the bare-reference evidence, and the pass-1
|
|
2607
|
+
tier this needs (wired unconditionally in `checker/__init__.py`, alongside
|
|
2608
|
+
`instr_rules.check`/`check_too_many`, not inside the gated `check` above). The bare-reference
|
|
2609
|
+
check runs first in each branch below and, when it fires, is the statement's only diagnostic
|
|
2610
|
+
from this function (matching the oracle: a bare reference is a parse-time concern that
|
|
2611
|
+
preempts the arity check, which only ever sees a real call with a real, if wrong, argument
|
|
2612
|
+
count).
|
|
2613
|
+
|
|
2614
|
+
At most one diagnostic per statement, matching the oracle (a second, independent bad-arity call
|
|
2615
|
+
later in the *same* statement is never additionally reported -- confirmed both across two
|
|
2616
|
+
`Instruction` argument positions and, separately, within one `Call`'s own argument list):
|
|
2617
|
+
|
|
2618
|
+
- `Instruction`: the first argument position (ascending, plain textual order -- **not** the
|
|
2619
|
+
constant-family reordering `_report_statement` above uses for a *different* check; arity is a
|
|
2620
|
+
raw parse-time concern, oracle-confirmed unaffected by that semantic reordering, `docs/
|
|
2621
|
+
checker.md`) whose own expression tree contains a bad-arity call, echoed as *that argument's
|
|
2622
|
+
own verbatim text* (`_arg_text`, matching `Randomize(Round(X))` -> echoes `Round(X)`, not the
|
|
2623
|
+
whole statement -- oracle-confirmed, `docs/checker.md`).
|
|
2624
|
+
- `Call` (a bare statement, `Round(Number,1,2)` alone, or a builtin used for its side effect):
|
|
2625
|
+
the call's own arity (if `stmt.name` is itself a built-in function) or any nested call inside
|
|
2626
|
+
its own argument list, echoed as the *whole statement's own verbatim text* (`stmt.text` --
|
|
2627
|
+
oracle-confirmed, `WatchdogTimer(0,Round(X))` -> echoes the whole statement, unlike the
|
|
2628
|
+
`Instruction`-argument case above; `Call` has no per-argument span to prefer, and the oracle
|
|
2629
|
+
shows it uses the whole-statement span here even for a call nested inside one of its own
|
|
2630
|
+
arguments).
|
|
2631
|
+
- `Assignment`: `stmt.value`'s own expression tree, echoed as the whole statement's own verbatim
|
|
2632
|
+
text (`stmt.text`, target and `=` included -- same convention `check` above already uses for
|
|
2633
|
+
this module's other assignment-RHS echo, `docs/checker.md` "Round 5"). The target itself is
|
|
2634
|
+
not checked (a function call is never a valid assignment target; not oracle-tested, left
|
|
2635
|
+
alone rather than guessed at).
|
|
2636
|
+
"""
|
|
2637
|
+
declared_variables = frozenset(symbols.declared_variable_names(program))
|
|
2638
|
+
if profile.single_pass:
|
|
2639
|
+
# CR300/CR350 accept a local `Dim exp` / parameter named like a built-in function but
|
|
2640
|
+
# resolve its bare uses as the function (oracle, 2026-10-08)
|
|
2641
|
+
declared_variables = declared_variables - frozenset(profile.functions)
|
|
2642
|
+
known: set[str] | None = None
|
|
2643
|
+
from . import intervals, structs, structure # lazy: all import this module
|
|
2644
|
+
|
|
2645
|
+
struct_types = structs._structure_members(program)
|
|
2646
|
+
kinds = structs._variable_kinds(program, struct_types) if struct_types else {}
|
|
2647
|
+
struct_vars = _struct_var_names(program)
|
|
2648
|
+
sub_names = {
|
|
2649
|
+
st.name.upper() for st in program.statements if isinstance(st, S.SubDef) and st.keyword.upper() == "SUB"
|
|
2650
|
+
}
|
|
2651
|
+
start = len(diags.items)
|
|
2652
|
+
mine: list = []
|
|
2653
|
+
table_mine: list = [] # the `mine` entries from DataTable output instructions
|
|
2654
|
+
in_table = False
|
|
2655
|
+
seen_timeint = False
|
|
2656
|
+
_TIMEOUT_WIDE[0] = intervals.wide_constants(program, profile)
|
|
2657
|
+
_TIMER_CONTEXT[0] = (consteval.Env(profile, dict(program.consts)), frozenset(n.upper() for n in declared_variables))
|
|
2658
|
+
arity_known = _known_names(program, profile) if profile.name in _WHOLE_EXPR_ECHO_LOGGERS else None
|
|
2659
|
+
|
|
2660
|
+
def arity(expr):
|
|
2661
|
+
problem = _find_arity_problem(expr, profile, arity_known)
|
|
2662
|
+
return None if problem == "deferred" else problem
|
|
2663
|
+
|
|
2664
|
+
for stmt in program.statements:
|
|
2665
|
+
_TIMEOUT_GATE[0], seen_timeint = intervals.timeout_gate(seen_timeint, intervals.gate_text(stmt))
|
|
2666
|
+
if isinstance(stmt, S.Instruction):
|
|
2667
|
+
kw = stmt.keyword.upper()
|
|
2668
|
+
in_table = kw == "DATATABLE" or (in_table and kw != "ENDTABLE")
|
|
2669
|
+
# outside a table the "must be used inside a table declaration" check wins
|
|
2670
|
+
table_only = kw in instr_data.TABLE_ONLY_INSTRUCTIONS and not in_table
|
|
2671
|
+
for i, arg in enumerate(stmt.args):
|
|
2672
|
+
if arg is None:
|
|
2673
|
+
continue
|
|
2674
|
+
if not table_only and _multi_indexed_struct(arg, struct_vars):
|
|
2675
|
+
diags.error(stmt.line, _arity_message("many", _arg_text(stmt, i), profile, False), stmt.file)
|
|
2676
|
+
mine.append(diags.items[-1])
|
|
2677
|
+
if in_table:
|
|
2678
|
+
table_mine.append(diags.items[-1])
|
|
2679
|
+
break
|
|
2680
|
+
if kw not in ("ALIAS", "UNITS") and _find_bare_function_ref(arg, profile, declared_variables):
|
|
2681
|
+
msg = _bare_function_message(_arg_text(stmt, i), profile, whole_statement=False)
|
|
2682
|
+
diags.error(stmt.line, msg, stmt.file)
|
|
2683
|
+
break
|
|
2684
|
+
problem = _find_arity_problem(arg, profile) # (not deferred: `PanelTemp(X,Sin(Nope,1))`)
|
|
2685
|
+
if problem == "empty":
|
|
2686
|
+
diags.error(stmt.line, _arity_message(problem, _arg_text(stmt, i), profile), stmt.file)
|
|
2687
|
+
break
|
|
2688
|
+
if problem in ("many_member", "constint", "timer_option") and table_only:
|
|
2689
|
+
break # the "must be used inside a table declaration" error wins
|
|
2690
|
+
if problem:
|
|
2691
|
+
diags.error(stmt.line, _arity_message(problem, _arg_text(stmt, i), profile, False), stmt.file)
|
|
2692
|
+
_hold(held, diags, problem)
|
|
2693
|
+
break
|
|
2694
|
+
elif isinstance(stmt, S.Call):
|
|
2695
|
+
# A bare call with no argument list at all (`TCPOpen` alone, no trailing `(`
|
|
2696
|
+
# anywhere in this statement's own text) is textually indistinguishable, once
|
|
2697
|
+
# parsed, from an explicit empty call (`TCPOpen()`, also `args=[]`) -- `stmt.text`
|
|
2698
|
+
# is this statement's own source segment (`parser.py`'s `seg_text`), so checking it
|
|
2699
|
+
# for `(` recovers exactly that distinction without touching the parser (`docs/
|
|
2700
|
+
# checker.md`: `SerialOpen`/`TCPOpen`/`Timer`/`IPTrace` alone, each split from its
|
|
2701
|
+
# own argument list onto the next source line, all `Expression expects (`).
|
|
2702
|
+
if (
|
|
2703
|
+
not stmt.args
|
|
2704
|
+
and "(" not in stmt.text
|
|
2705
|
+
and _is_bare_reference_problem(stmt.name, profile, declared_variables)
|
|
2706
|
+
):
|
|
2707
|
+
diags.error(
|
|
2708
|
+
stmt.line,
|
|
2709
|
+
_bare_function_message(stmt.text, profile, whole_statement=True, at_eol=stmt.at_eol),
|
|
2710
|
+
stmt.file,
|
|
2711
|
+
)
|
|
2712
|
+
continue
|
|
2713
|
+
# a `Function` call statement continued as an expression (`Fn(1)+Sin`) is checked as
|
|
2714
|
+
# the whole expression, like `X = Fn(1)+Sin` (oracle, 2026-10-10)
|
|
2715
|
+
call_exprs = [stmt.tail_expr] if stmt.tail_expr is not None else stmt.args
|
|
2716
|
+
if any(_find_bare_function_ref(a, profile, declared_variables) for a in call_exprs):
|
|
2717
|
+
diags.error(
|
|
2718
|
+
stmt.line,
|
|
2719
|
+
_bare_function_message(stmt.text, profile, whole_statement=True, at_eol=stmt.at_eol),
|
|
2720
|
+
stmt.file,
|
|
2721
|
+
)
|
|
2722
|
+
continue
|
|
2723
|
+
bad = next((i for i, a in enumerate(stmt.args) if _multi_indexed_struct(a, struct_vars)), None)
|
|
2724
|
+
if bad is not None:
|
|
2725
|
+
# `Foo(A(1,2).V)`: the argument is echoed alone (oracle, 2026-10-03)
|
|
2726
|
+
diags.error(
|
|
2727
|
+
stmt.line, _arity_message("many", _call_arg_text(stmt.text, bad), profile, False), stmt.file
|
|
2728
|
+
)
|
|
2729
|
+
mine.append(diags.items[-1])
|
|
2730
|
+
continue
|
|
2731
|
+
problem = _call_arity_problem(stmt.name, stmt.args, profile)
|
|
2732
|
+
in_arg = None
|
|
2733
|
+
if not problem:
|
|
2734
|
+
for k, a in enumerate(call_exprs):
|
|
2735
|
+
problem = arity(a)
|
|
2736
|
+
if problem:
|
|
2737
|
+
# inside a `Sub` call the offending argument alone is echoed (oracle,
|
|
2738
|
+
# 2026-10-13, all four loggers); a `Function` statement echoes it whole
|
|
2739
|
+
if stmt.tail_expr is None and stmt.juxtaposed is None and stmt.name.upper() in sub_names:
|
|
2740
|
+
in_arg = k
|
|
2741
|
+
break
|
|
2742
|
+
if problem:
|
|
2743
|
+
if problem == "constint" and any(
|
|
2744
|
+
d.line == stmt.line
|
|
2745
|
+
and d.file == stmt.file
|
|
2746
|
+
and d.message.startswith("Parameter must be a constant integer:")
|
|
2747
|
+
for d in diags.items
|
|
2748
|
+
):
|
|
2749
|
+
continue
|
|
2750
|
+
if (
|
|
2751
|
+
not profile.single_pass
|
|
2752
|
+
and stmt.name.upper() == "SERIALOPEN"
|
|
2753
|
+
and stmt.args
|
|
2754
|
+
and any(
|
|
2755
|
+
d.line == stmt.line and d.file == stmt.file and d.message.startswith("ComPort ")
|
|
2756
|
+
for d in diags.items
|
|
2757
|
+
)
|
|
2758
|
+
):
|
|
2759
|
+
continue # the ComPort error ends the statement (oracle, CR6/CR1000X, round 37)
|
|
2760
|
+
if in_arg is not None:
|
|
2761
|
+
diags.error(
|
|
2762
|
+
stmt.line,
|
|
2763
|
+
_arity_message(problem, _call_arg_text(stmt.text, in_arg).strip(), profile, False),
|
|
2764
|
+
stmt.file,
|
|
2765
|
+
)
|
|
2766
|
+
_hold(held, diags, problem)
|
|
2767
|
+
continue
|
|
2768
|
+
diags.error(
|
|
2769
|
+
stmt.line, _arity_message(problem, stmt.text, profile, stmt.at_eol, whole_statement=True), stmt.file
|
|
2770
|
+
)
|
|
2771
|
+
_hold(held, diags, problem)
|
|
2772
|
+
elif isinstance(stmt, S.If):
|
|
2773
|
+
# All four loggers, pass 1 (oracle, 2026-09-27): `If TimeIsBetween(0,15,60,sec,1) Then`
|
|
2774
|
+
# -> `No Matching, or out of place ) in expression: TimeIsBetween(0,15,60,sec,1) .`
|
|
2775
|
+
# (the condition echo, no CR300/CR350 newline).
|
|
2776
|
+
problem = arity(stmt.cond)
|
|
2777
|
+
struct_cond = not problem and _multi_indexed_struct(stmt.cond, struct_vars)
|
|
2778
|
+
problem = problem or ("many" if struct_cond else None)
|
|
2779
|
+
text = _if_condition_text(stmt)
|
|
2780
|
+
if problem == "empty" and text is not None:
|
|
2781
|
+
diags.error(stmt.line, _arity_message(problem, text, profile), stmt.file)
|
|
2782
|
+
elif problem == "timer_option" and text is not None:
|
|
2783
|
+
diags.error(stmt.line, _arity_message(problem, text, profile), stmt.file)
|
|
2784
|
+
elif problem == "constint" and text is not None:
|
|
2785
|
+
diags.error(stmt.line, f"Parameter must be a constant integer: {text}.", stmt.file)
|
|
2786
|
+
elif problem and text is not None:
|
|
2787
|
+
verb = (
|
|
2788
|
+
"Expression expects , : " if problem == "few" else "No Matching, or out of place ) in expression: "
|
|
2789
|
+
)
|
|
2790
|
+
diags.error(stmt.line, f"{verb}{text}.", stmt.file)
|
|
2791
|
+
_hold(held, diags, problem)
|
|
2792
|
+
if struct_cond:
|
|
2793
|
+
mine.append(diags.items[-1])
|
|
2794
|
+
elif isinstance(stmt, S.ConstDecl) and stmt.value_span:
|
|
2795
|
+
# `Const CRLF = CHR(13,1) + CHR(10)` -> `No Matching, or out of place ) in expression:
|
|
2796
|
+
# CHR(13,1) + CHR(10).`, the value echoed, all four loggers (oracle, 2026-10-01)
|
|
2797
|
+
problem = arity(stmt.value) # (deferred: round 41)
|
|
2798
|
+
text = stmt.text[stmt.value_span[0] : stmt.value_span[1]]
|
|
2799
|
+
if problem == "empty":
|
|
2800
|
+
# `Const Pi = 4*ATN() 'c` -> `Invalid, or out of place expression: 4*ATN() .`,
|
|
2801
|
+
# the value to the comment, no CR300 newline (oracle, 2026-10-01)
|
|
2802
|
+
diags.error(
|
|
2803
|
+
stmt.line, f"Invalid, or out of place expression: {stmt.text[stmt.value_span[0] :]}.", stmt.file
|
|
2804
|
+
)
|
|
2805
|
+
elif problem:
|
|
2806
|
+
verb = (
|
|
2807
|
+
"Expression expects , : " if problem == "few" else "No Matching, or out of place ) in expression: "
|
|
2808
|
+
)
|
|
2809
|
+
diags.error(stmt.line, f"{verb}{text}.", stmt.file)
|
|
2810
|
+
elif isinstance(stmt, S.Assignment):
|
|
2811
|
+
if known is None:
|
|
2812
|
+
known = _known_names(program, profile)
|
|
2813
|
+
if _undefined_names(stmt.target, known):
|
|
2814
|
+
# An undeclared target defers the whole statement to pass 2 (the fallback and
|
|
2815
|
+
# its placement follow-up there; oracle: `Y=EXP()` before `BeginProg`).
|
|
2816
|
+
continue
|
|
2817
|
+
if _multi_indexed_struct(stmt.target, struct_vars):
|
|
2818
|
+
# a structure reference takes one index; oracle 2026-10-03, docs/checker.md
|
|
2819
|
+
diags.error(stmt.line, structure._out_of_place(profile, stmt.text, stmt.at_eol), stmt.file)
|
|
2820
|
+
mine.append(diags.items[-1])
|
|
2821
|
+
continue
|
|
2822
|
+
target_message = _function_target_message(stmt, profile, declared_variables)
|
|
2823
|
+
if target_message:
|
|
2824
|
+
diags.error(stmt.line, target_message, stmt.file)
|
|
2825
|
+
continue
|
|
2826
|
+
if _find_bare_function_ref(stmt.value, profile, declared_variables):
|
|
2827
|
+
diags.error(
|
|
2828
|
+
stmt.line,
|
|
2829
|
+
_bare_function_message(stmt.text, profile, whole_statement=True, at_eol=stmt.at_eol),
|
|
2830
|
+
stmt.file,
|
|
2831
|
+
)
|
|
2832
|
+
continue
|
|
2833
|
+
problem = arity(stmt.value)
|
|
2834
|
+
struct_value = not problem and _multi_indexed_struct(stmt.value, struct_vars)
|
|
2835
|
+
if struct_value:
|
|
2836
|
+
problem = "many"
|
|
2837
|
+
if (
|
|
2838
|
+
profile.single_pass
|
|
2839
|
+
and not stmt.compound
|
|
2840
|
+
and isinstance(stmt.value, S.Index)
|
|
2841
|
+
and structs._operand_kind(stmt.target, kinds, struct_types) == "S"
|
|
2842
|
+
):
|
|
2843
|
+
diags.items.append(Diagnostic("error", stmt.line, structs._WRONG_TYPE, stmt.file, cascade=False))
|
|
2844
|
+
mine.append(diags.items[-1])
|
|
2845
|
+
if problem:
|
|
2846
|
+
diags.error(
|
|
2847
|
+
stmt.line,
|
|
2848
|
+
_arity_message(problem, stmt.text, profile, stmt.at_eol, whole_statement=True),
|
|
2849
|
+
stmt.file,
|
|
2850
|
+
stmt=stmt,
|
|
2851
|
+
)
|
|
2852
|
+
_hold(held, diags, problem)
|
|
2853
|
+
if struct_value:
|
|
2854
|
+
mine.append(diags.items[-1])
|
|
2855
|
+
_TIMEOUT_GATE[0] = None
|
|
2856
|
+
_TIMEOUT_WIDE[0] = frozenset()
|
|
2857
|
+
if not profile.single_pass and table_mine:
|
|
2858
|
+
# CR6/CR1000X: only the first DataTable instruction is reported, and none at all once a
|
|
2859
|
+
# statement outside a table has one (oracle, 2026-10-03)
|
|
2860
|
+
outside = any(d not in table_mine for d in mine)
|
|
2861
|
+
keep = [] if outside else table_mine[:1]
|
|
2862
|
+
drop = [d for d in table_mine if d not in keep]
|
|
2863
|
+
diags.items[start:] = [d for d in diags.items[start:] if d not in drop]
|
|
2864
|
+
mine[:] = [d for d in mine if d not in drop]
|
|
2865
|
+
if profile.single_pass and any(d not in mine for d in diags.items[start:]):
|
|
2866
|
+
# CR300/CR350: any call-arity error in the file suppresses every structure-index one,
|
|
2867
|
+
# before or after it (oracle, 2026-10-03)
|
|
2868
|
+
diags.items[start:] = [d for d in diags.items[start:] if d not in mine]
|
|
2869
|
+
|
|
2870
|
+
|
|
2871
|
+
def _function_target_message(stmt: S.Assignment, profile, declared: frozenset[str]) -> str | None:
|
|
2872
|
+
"""An assignment whose target is a built-in function (oracle, all four loggers, 2026-10-08):
|
|
2873
|
+
`Sin = 1` / `Timer = X` -> `Expression expects ( : <stmt>`; `Rnd = 2`, `Sin(X) = 3`,
|
|
2874
|
+
`Round(1,2) = 3` -> `Invalid assignment in expression: <stmt>`; a call with a wrong argument
|
|
2875
|
+
count gets that count's message (`Sin(1,2) = 3`, `Round(1) = 2`, `Sin() = 3`). Only arities
|
|
2876
|
+
this module already trusts (`_call_arity_problem`, or exactly the table `nargs`) are reported."""
|
|
2877
|
+
target = stmt.target
|
|
2878
|
+
if stmt.compound or not isinstance(target, (S.Name, S.Index)):
|
|
2879
|
+
return None
|
|
2880
|
+
name = target.name.upper()
|
|
2881
|
+
record = profile.functions.get(name)
|
|
2882
|
+
if not record or name in declared:
|
|
2883
|
+
return None
|
|
2884
|
+
sep = "\n" if profile.fallback_echo_newline and stmt.at_eol else ""
|
|
2885
|
+
if isinstance(target, S.Name):
|
|
2886
|
+
if _is_bare_reference_problem(target.name, profile, declared):
|
|
2887
|
+
return _bare_function_message(stmt.text, profile, whole_statement=True, at_eol=stmt.at_eol)
|
|
2888
|
+
return (
|
|
2889
|
+
f"Invalid assignment in expression: {stmt.text}{sep}."
|
|
2890
|
+
if record.get("nargs") == 0 and not record.get("argspec")
|
|
2891
|
+
else None
|
|
2892
|
+
)
|
|
2893
|
+
problem = _call_arity_problem(target.name, target.args, profile)
|
|
2894
|
+
if problem in ("few", "many", "empty"):
|
|
2895
|
+
return _arity_message(problem, stmt.text, profile, stmt.at_eol, whole_statement=True)
|
|
2896
|
+
if problem is None and None not in target.args and record.get("nargs") == len(target.args) and record.get("nargs"):
|
|
2897
|
+
return f"Invalid assignment in expression: {stmt.text}{sep}."
|
|
2898
|
+
return None
|
|
2899
|
+
|
|
2900
|
+
|
|
2901
|
+
def check_function_call_counts(program: S.Program, profile, diags: Diagnostics) -> None:
|
|
2902
|
+
"""A user `Function` called with too few arguments -> `Call argument required.` then the
|
|
2903
|
+
fallback; with too many -> `No Matching, or out of place ) in expression: %s.`. The echo is the
|
|
2904
|
+
assignment, the `If` condition, or the instruction argument holding the call. Pass 1 on
|
|
2905
|
+
CR6/CR1000X (a later `X = Q` is not reported), all four loggers (oracle, 2026-09-28,
|
|
2906
|
+
`docs/checker.md` "Function argument counts")."""
|
|
2907
|
+
from . import structure
|
|
2908
|
+
|
|
2909
|
+
functions = {
|
|
2910
|
+
st.name.upper(): st for st in program.statements if isinstance(st, S.SubDef) and st.keyword == "FUNCTION"
|
|
2911
|
+
}
|
|
2912
|
+
if not functions:
|
|
2913
|
+
return
|
|
2914
|
+
known = _known_names(program, profile)
|
|
2915
|
+
missing = [1]
|
|
2916
|
+
|
|
2917
|
+
def problem(expr: S.Expr | None) -> str | None:
|
|
2918
|
+
for node in _index_nodes(expr):
|
|
2919
|
+
fn = functions.get(node.name.upper())
|
|
2920
|
+
if fn is None:
|
|
2921
|
+
continue
|
|
2922
|
+
required = sum(1 for p in fn.params if p.default is None)
|
|
2923
|
+
if len(node.args) < required:
|
|
2924
|
+
# one message per missing required argument, nested calls in the arguments add
|
|
2925
|
+
# theirs, a later sibling call adds none (oracle, 2026-10-12)
|
|
2926
|
+
missing[0] = 0
|
|
2927
|
+
for inner in _index_nodes(node):
|
|
2928
|
+
f2 = functions.get(inner.name.upper())
|
|
2929
|
+
if f2 is not None:
|
|
2930
|
+
missing[0] += max(0, sum(1 for p in f2.params if p.default is None) - len(inner.args))
|
|
2931
|
+
return "few"
|
|
2932
|
+
if len(node.args) > len(fn.params):
|
|
2933
|
+
return "many"
|
|
2934
|
+
return None
|
|
2935
|
+
|
|
2936
|
+
def report(stmt: S.Stmt, kind: str, echo: str, whole: bool) -> None:
|
|
2937
|
+
if kind == "few":
|
|
2938
|
+
for _ in range(missing[0]): # one per missing required argument (oracle, 2026-10-12)
|
|
2939
|
+
diags.error(stmt.line, "Call argument required.", stmt.file)
|
|
2940
|
+
text = (
|
|
2941
|
+
structure._out_of_place(profile, echo, stmt.at_eol)
|
|
2942
|
+
if whole
|
|
2943
|
+
else f"Invalid, or out of place expression: {echo}."
|
|
2944
|
+
)
|
|
2945
|
+
else:
|
|
2946
|
+
sep = "\n" if whole and profile.fallback_echo_newline and stmt.at_eol else ""
|
|
2947
|
+
text = f"No Matching, or out of place ) in expression: {echo}{sep}."
|
|
2948
|
+
diags.error(stmt.line, text, stmt.file)
|
|
2949
|
+
|
|
2950
|
+
def visit(stmt: S.Stmt) -> None:
|
|
2951
|
+
if isinstance(stmt, S.Assignment):
|
|
2952
|
+
kind = problem(stmt.target) or problem(stmt.value)
|
|
2953
|
+
if (
|
|
2954
|
+
kind
|
|
2955
|
+
and _undefined_names(stmt.target, known)
|
|
2956
|
+
and not (
|
|
2957
|
+
profile.single_pass
|
|
2958
|
+
and (
|
|
2959
|
+
isinstance(stmt.target, S.Name)
|
|
2960
|
+
or (isinstance(stmt.target, S.Index) and stmt.target.name.upper() in known)
|
|
2961
|
+
)
|
|
2962
|
+
)
|
|
2963
|
+
):
|
|
2964
|
+
kind = None # an undefined target never reaches the value's call (oracle, all four
|
|
2965
|
+
# loggers, 2026-10-11, docs/checker.md "Function calls after an undefined target")
|
|
2966
|
+
if kind:
|
|
2967
|
+
report(stmt, kind, stmt.text, True)
|
|
2968
|
+
elif isinstance(stmt, S.If):
|
|
2969
|
+
kind = problem(stmt.cond)
|
|
2970
|
+
echo = _if_condition_text(stmt)
|
|
2971
|
+
if kind and echo is not None:
|
|
2972
|
+
report(stmt, kind, echo, False)
|
|
2973
|
+
for inner in (stmt.inline or []) + (stmt.inline_else or []):
|
|
2974
|
+
visit(inner)
|
|
2975
|
+
elif isinstance(stmt, S.Instruction):
|
|
2976
|
+
for i, arg in enumerate(stmt.args):
|
|
2977
|
+
kind = problem(arg)
|
|
2978
|
+
if kind:
|
|
2979
|
+
report(stmt, kind, _arg_text(stmt, i).strip(), False)
|
|
2980
|
+
break
|
|
2981
|
+
elif isinstance(stmt, S.Call):
|
|
2982
|
+
# `F1(1,2)` as a statement echoes the whole line like `X = F1(1,2)` (oracle,
|
|
2983
|
+
# 2026-10-02, all four loggers, `docs/checker.md` "Function argument counts").
|
|
2984
|
+
if not stmt.explicit and stmt.tail_expr is not None and (kind := problem(stmt.tail_expr)):
|
|
2985
|
+
report(stmt, kind, stmt.text, True)
|
|
2986
|
+
return
|
|
2987
|
+
fn = functions.get(stmt.name.upper())
|
|
2988
|
+
if fn is None and stmt.tail_expr is None and stmt.juxtaposed is None:
|
|
2989
|
+
# a `Sub` call: a user `Function` called wrongly inside one argument echoes that
|
|
2990
|
+
# argument (oracle, 2026-10-13, all four loggers)
|
|
2991
|
+
for i, arg in enumerate(stmt.args):
|
|
2992
|
+
if kind := problem(arg):
|
|
2993
|
+
report(stmt, kind, _call_arg_text(stmt.text, i).strip(), False)
|
|
2994
|
+
return
|
|
2995
|
+
bare = not stmt.args and not (stmt.text or "").lstrip()[len(stmt.name) :].lstrip().startswith("(")
|
|
2996
|
+
if not stmt.explicit and fn is not None and not bare: # a bare `Fn` (no parentheses) is accepted (oracle)
|
|
2997
|
+
required = sum(1 for p in fn.params if p.default is None)
|
|
2998
|
+
kind = "few" if len(stmt.args) < required else "many" if len(stmt.args) > len(fn.params) else None
|
|
2999
|
+
missing[0] = max(1, required - len(stmt.args))
|
|
3000
|
+
if kind:
|
|
3001
|
+
report(stmt, kind, stmt.text, True)
|
|
3002
|
+
elif (
|
|
3003
|
+
m := re.match(r"\s*" + re.escape(stmt.name) + r"\s*(?=\()", stmt.text or "", re.I)
|
|
3004
|
+
) and structure._group_end(stmt.text, m.end()) is None:
|
|
3005
|
+
# a user `Function` called as a statement needs its `)` (a `Sub` call does
|
|
3006
|
+
# not): oracle, 2026-10-13, all four loggers
|
|
3007
|
+
report(stmt, "many", stmt.text, True)
|
|
3008
|
+
else:
|
|
3009
|
+
# a wrong call nested in an argument: the whole statement is echoed (oracle,
|
|
3010
|
+
# 2026-10-13, all four loggers)
|
|
3011
|
+
for arg in stmt.args:
|
|
3012
|
+
if kind := problem(arg):
|
|
3013
|
+
report(stmt, kind, stmt.text, True)
|
|
3014
|
+
return
|
|
3015
|
+
|
|
3016
|
+
for stmt in program.statements:
|
|
3017
|
+
visit(stmt)
|
|
3018
|
+
|
|
3019
|
+
|
|
3020
|
+
def _index_nodes(expr: S.Expr | None):
|
|
3021
|
+
"""Every `Index` (call or subscript) inside `expr`, outermost first."""
|
|
3022
|
+
if expr is None:
|
|
3023
|
+
return
|
|
3024
|
+
if isinstance(expr, S.Index):
|
|
3025
|
+
yield expr
|
|
3026
|
+
for a in expr.args:
|
|
3027
|
+
yield from _index_nodes(a)
|
|
3028
|
+
elif isinstance(expr, S.Unary):
|
|
3029
|
+
yield from _index_nodes(expr.operand)
|
|
3030
|
+
elif isinstance(expr, S.Binary):
|
|
3031
|
+
yield from _index_nodes(expr.left)
|
|
3032
|
+
yield from _index_nodes(expr.right)
|
|
3033
|
+
elif isinstance(expr, S.Member):
|
|
3034
|
+
for a in expr.args or []:
|
|
3035
|
+
yield from _index_nodes(a)
|
|
3036
|
+
|
|
3037
|
+
|
|
3038
|
+
_FOR_HEADER_RE = re.compile(r"(?i)^\s*For\s(.*?)=(.*?)\sTo\s(.*?)(?:\sStep\s(.*))?$")
|
|
3039
|
+
|
|
3040
|
+
|
|
3041
|
+
def _check_for_headers(program: S.Program, profile, diags: Diagnostics, known) -> None:
|
|
3042
|
+
"""Undefined names in a `For` header (oracle, 2026-10-01). CR6/CR1000X: `Invalid symbol in
|
|
3043
|
+
expression: %s.` for the start, end and step text (each echoed raw between its keywords, one
|
|
3044
|
+
separating space dropped either side: `For X = A to 2` -> ` A`), and for the loop variable on
|
|
3045
|
+
its matching `Next` line (`For j = 1 To 2` -> `j .`). CR300/CR350: per-name `%s is not
|
|
3046
|
+
defined.` on the `For` line in source order, plus `Dimensions not declared: j(1) .` for an
|
|
3047
|
+
undefined indexed loop variable."""
|
|
3048
|
+
ds = program_dims(program, profile, known)
|
|
3049
|
+
pending: list[tuple[S.For, str]] = []
|
|
3050
|
+
for stmt in program.statements:
|
|
3051
|
+
if (
|
|
3052
|
+
isinstance(stmt, S.Instruction)
|
|
3053
|
+
and stmt.keyword.upper() == "NEXT"
|
|
3054
|
+
and pending
|
|
3055
|
+
and not re.match(r"(?i)\s*Next\s+(Sub)?Scan\b", stmt.text or "")
|
|
3056
|
+
):
|
|
3057
|
+
loop, var_text = pending.pop()
|
|
3058
|
+
if var_text:
|
|
3059
|
+
diags.error(stmt.line, f"Invalid symbol in expression: {var_text}.", stmt.file)
|
|
3060
|
+
continue
|
|
3061
|
+
if not isinstance(stmt, S.For):
|
|
3062
|
+
continue
|
|
3063
|
+
m = _FOR_HEADER_RE.match(stmt.text or "")
|
|
3064
|
+
parts = [
|
|
3065
|
+
(stmt.var, m.group(1) if m else None),
|
|
3066
|
+
(stmt.start, m.group(2) if m else None),
|
|
3067
|
+
(stmt.end, m.group(3) if m else None),
|
|
3068
|
+
(stmt.step, m.group(4) if m else None),
|
|
3069
|
+
]
|
|
3070
|
+
whole_echo = profile.name in _WHOLE_EXPR_ECHO_LOGGERS
|
|
3071
|
+
var_text = ""
|
|
3072
|
+
for i, (expr, text) in enumerate(parts):
|
|
3073
|
+
names = _undefined_names(expr, known) if expr is not None else []
|
|
3074
|
+
if not names:
|
|
3075
|
+
if (
|
|
3076
|
+
i
|
|
3077
|
+
and text is not None
|
|
3078
|
+
and _over_indexed(expr, symbols.declared_dim_counts(program, strings=True), _dim_limit(profile))
|
|
3079
|
+
):
|
|
3080
|
+
# `For N = 1 To B(1,1,1,1)`: the header part echoed (oracle, 2026-10-13)
|
|
3081
|
+
diags.error(stmt.line, dimensions_message(text, profile, False), stmt.file)
|
|
3082
|
+
continue
|
|
3083
|
+
if whole_echo:
|
|
3084
|
+
if text is None:
|
|
3085
|
+
continue
|
|
3086
|
+
if i == 0:
|
|
3087
|
+
var_text = text
|
|
3088
|
+
else:
|
|
3089
|
+
diags.error(stmt.line, f"Invalid symbol in expression: {text}.", stmt.file)
|
|
3090
|
+
else:
|
|
3091
|
+
for name in names:
|
|
3092
|
+
diags.error(stmt.line, profile.unresolved_symbol_message.format(name=name), stmt.file)
|
|
3093
|
+
if i == 0 and text is not None and _has_undefined_index(expr, known, ds, stmt):
|
|
3094
|
+
diags.error(stmt.line, dimensions_message(text, profile, False), stmt.file)
|
|
3095
|
+
pending.append((stmt, var_text))
|
|
3096
|
+
|
|
3097
|
+
|
|
3098
|
+
def _check_elseif_and_case(program: S.Program, profile, diags: Diagnostics, known) -> None:
|
|
3099
|
+
"""`ElseIf` conditions and `Case` values (oracle, 2026-10-01). An `ElseIf` is reported like an
|
|
3100
|
+
`If`. A `Case` echoes the text after `Case ` on CR6/CR1000X, preceded by one `Case outside
|
|
3101
|
+
Select.` per item up to the first item whose value (or `To` lower bound) is undefined (none
|
|
3102
|
+
when an `Is` comparison or an upper bound fails first): `Case 1, 2, Nope` -> three.
|
|
3103
|
+
CR300/CR350 print each undefined name; there a `Select Case` with an undefined name opens no
|
|
3104
|
+
block, so its `Case`s give `Case outside Select.` and its end `EndSelect w/o Select`."""
|
|
3105
|
+
ds = program_dims(program, profile, known)
|
|
3106
|
+
whole_echo = profile.name in _WHOLE_EXPR_ECHO_LOGGERS
|
|
3107
|
+
orphans: list[bool] = [] # per open Select: True when CR300 did not open it
|
|
3108
|
+
open_ifs = 0
|
|
3109
|
+
for stmt in program.statements:
|
|
3110
|
+
kw = stmt.keyword.upper() if isinstance(stmt, S.Instruction) else ""
|
|
3111
|
+
if isinstance(stmt, S.Instruction) and (kw == "SELECT"):
|
|
3112
|
+
# (an over-indexed selector opens no block either, on all four loggers)
|
|
3113
|
+
orphans.append(
|
|
3114
|
+
bool(stmt.args)
|
|
3115
|
+
and (
|
|
3116
|
+
(not whole_echo and bool(_undefined_names(stmt.args[0], known)))
|
|
3117
|
+
or (
|
|
3118
|
+
not _undefined_names(stmt.args[0], known)
|
|
3119
|
+
and _over_indexed(
|
|
3120
|
+
stmt.args[0], symbols.declared_dim_counts(program, strings=True), _dim_limit(profile)
|
|
3121
|
+
)
|
|
3122
|
+
)
|
|
3123
|
+
)
|
|
3124
|
+
)
|
|
3125
|
+
continue
|
|
3126
|
+
if kw == "ENDSELECT" or (kw == "END" and re.match(r"(?i)\s*End\s+Select\b", stmt.text or "")):
|
|
3127
|
+
if orphans and orphans.pop():
|
|
3128
|
+
diags.error(stmt.line, "EndSelect w/o Select", stmt.file)
|
|
3129
|
+
continue
|
|
3130
|
+
if isinstance(stmt, S.If) and stmt.inline is None:
|
|
3131
|
+
open_ifs += 1
|
|
3132
|
+
elif kw == "ENDIF" or (kw == "END" and re.match(r"(?i)\s*End\s+If\b", stmt.text or "")):
|
|
3133
|
+
open_ifs = max(0, open_ifs - 1)
|
|
3134
|
+
if isinstance(stmt, S.ElseIf):
|
|
3135
|
+
if whole_echo and not open_ifs:
|
|
3136
|
+
continue # `ElseIf Nope Then` with no `If`: only `Case with no Select.`
|
|
3137
|
+
names = _undefined_names(stmt.cond, known)
|
|
3138
|
+
text = _if_condition_text(stmt)
|
|
3139
|
+
if (
|
|
3140
|
+
text is not None
|
|
3141
|
+
and not names
|
|
3142
|
+
and _over_indexed(stmt.cond, symbols.declared_dim_counts(program, strings=True), _dim_limit(profile))
|
|
3143
|
+
):
|
|
3144
|
+
diags.error(stmt.line, dimensions_message(text, profile, False), stmt.file)
|
|
3145
|
+
continue
|
|
3146
|
+
if not names or text is None:
|
|
3147
|
+
continue
|
|
3148
|
+
if whole_echo:
|
|
3149
|
+
diags.error(stmt.line, f"Invalid symbol in expression: {text}.", stmt.file)
|
|
3150
|
+
else:
|
|
3151
|
+
for name in names:
|
|
3152
|
+
diags.error(stmt.line, profile.unresolved_symbol_message.format(name=name), stmt.file)
|
|
3153
|
+
if _has_undefined_index(stmt.cond, known, ds, stmt):
|
|
3154
|
+
diags.error(stmt.line, dimensions_message(text, profile, False), stmt.file)
|
|
3155
|
+
continue
|
|
3156
|
+
if not isinstance(stmt, S.Case) or stmt.is_else or re.match(r"(?i)\s*Case\s+Else", stmt.text or ""):
|
|
3157
|
+
continue # (`Case Elsewhere` is a `Case Else`, `checker/structure.py`)
|
|
3158
|
+
if orphans and orphans[-1]:
|
|
3159
|
+
diags.error(stmt.line, "Case outside Select.", stmt.file)
|
|
3160
|
+
names: list[str] = []
|
|
3161
|
+
outside = 0
|
|
3162
|
+
for index, item in enumerate(stmt.items, 1):
|
|
3163
|
+
if item[0] == "value":
|
|
3164
|
+
lower, upper = item[1], None
|
|
3165
|
+
elif item[0] == "range":
|
|
3166
|
+
lower, upper = item[1], item[2]
|
|
3167
|
+
else:
|
|
3168
|
+
lower, upper = None, item[2]
|
|
3169
|
+
low, high = _undefined_names(lower, known), _undefined_names(upper, known)
|
|
3170
|
+
if low and not outside and not names:
|
|
3171
|
+
outside = index
|
|
3172
|
+
names += low + high
|
|
3173
|
+
if not names:
|
|
3174
|
+
cm = re.match(r"(?i)\s*Case\s(.*)$", stmt.text or "")
|
|
3175
|
+
if cm:
|
|
3176
|
+
counts = symbols.declared_dim_counts(program, strings=True)
|
|
3177
|
+
if any(
|
|
3178
|
+
_over_indexed(e, counts, _dim_limit(profile))
|
|
3179
|
+
for item in stmt.items
|
|
3180
|
+
for e in item[1:]
|
|
3181
|
+
if isinstance(e, S.Expr)
|
|
3182
|
+
):
|
|
3183
|
+
# the whole item list after `Case ` is echoed (oracle, 2026-10-13)
|
|
3184
|
+
diags.error(stmt.line, dimensions_message(cm.group(1), profile, False), stmt.file)
|
|
3185
|
+
continue
|
|
3186
|
+
if whole_echo:
|
|
3187
|
+
m = re.match(r"(?i)\s*Case\s(.*)$", stmt.text or "")
|
|
3188
|
+
if not m:
|
|
3189
|
+
continue
|
|
3190
|
+
for _ in range(outside):
|
|
3191
|
+
diags.error(stmt.line, "Case outside Select.", stmt.file)
|
|
3192
|
+
diags.error(stmt.line, f"Invalid symbol in expression: {m.group(1)}.", stmt.file)
|
|
3193
|
+
else:
|
|
3194
|
+
for name in names:
|
|
3195
|
+
diags.error(stmt.line, profile.unresolved_symbol_message.format(name=name), stmt.file)
|
|
3196
|
+
|
|
3197
|
+
|
|
3198
|
+
def _check_function_statements(program: S.Program, profile, diags: Diagnostics, known) -> None:
|
|
3199
|
+
"""A built-in function written as a statement (`Timer(Nope,Sec,0)`) with an undefined name in
|
|
3200
|
+
its arguments (oracle, 2026-10-01): CR6/CR1000X `Invalid symbol in expression: <statement>.`
|
|
3201
|
+
(indentation and trailing spaces kept); CR300/CR350 each name, then `Dimensions not declared:
|
|
3202
|
+
<statement>` when an undefined name is indexed."""
|
|
3203
|
+
ds = program_dims(program, profile, known)
|
|
3204
|
+
first_on_line: dict[tuple, S.Stmt] = {}
|
|
3205
|
+
for stmt in program.statements:
|
|
3206
|
+
first_on_line.setdefault((stmt.line, stmt.file), stmt)
|
|
3207
|
+
for stmt in program.statements:
|
|
3208
|
+
if not isinstance(stmt, S.Call) or stmt.explicit or stmt.name.upper() not in profile.functions:
|
|
3209
|
+
continue
|
|
3210
|
+
args = stmt.args
|
|
3211
|
+
if stmt.name.upper() == "SERIALOPEN" and profile.name in _WHOLE_EXPR_ECHO_LOGGERS:
|
|
3212
|
+
from .cr3xx import bare_unknown_delay
|
|
3213
|
+
|
|
3214
|
+
if bare_unknown_delay(stmt, known):
|
|
3215
|
+
args = args[:3] + args[4:] # recovered by the parser (`cr3xx.check`)
|
|
3216
|
+
names = [n for a in args if a is not None for n in _undefined_names(a, known)]
|
|
3217
|
+
# (its own `Expr illegally occurs before BeginProg.` does not hide it, unless an earlier
|
|
3218
|
+
# statement of the line owns that placement error: `X = 1 : Timer(Nope,1,0)`)
|
|
3219
|
+
if not names or any(
|
|
3220
|
+
(d.line, d.file) == (stmt.line, stmt.file)
|
|
3221
|
+
and not (
|
|
3222
|
+
d.message == "Expr illegally occurs before BeginProg." and first_on_line[(stmt.line, stmt.file)] is stmt
|
|
3223
|
+
)
|
|
3224
|
+
for d in diags.errors
|
|
3225
|
+
):
|
|
3226
|
+
continue # (`SerialOpen(COMC1,...)` is already reported by `cr3xx.check`)
|
|
3227
|
+
if profile.name in _WHOLE_EXPR_ECHO_LOGGERS:
|
|
3228
|
+
diags.error(stmt.line, f"Invalid symbol in expression: {stmt.text}.", stmt.file)
|
|
3229
|
+
continue
|
|
3230
|
+
names, dims = _names_and_dims(known, ds, stmt, (), *stmt.args)
|
|
3231
|
+
for name in names:
|
|
3232
|
+
diags.error(stmt.line, profile.unresolved_symbol_message.format(name=name), stmt.file)
|
|
3233
|
+
if dims:
|
|
3234
|
+
diags.error(stmt.line, dimensions_message(stmt.text, profile, True, stmt.at_eol), stmt.file)
|
|
3235
|
+
|
|
3236
|
+
|
|
3237
|
+
# Parameter codes whose plain array-element argument is bounds-checked, and instructions whose own
|
|
3238
|
+
# compile function takes that argument over (oracle, 2026-10-01: `AddPrecise(X,A(9))` compiles).
|
|
3239
|
+
_BOUNDS_CHECKED_CODES = frozenset({2, 8, 24, 27, 28, 29, 41})
|
|
3240
|
+
_BOUNDS_UNCHECKED = frozenset({"ADDPRECISE", "MOVEPRECISE"})
|
|
3241
|
+
# Codes 27/29 only for oracle-probed instructions (2026-10-02, docs/checker.md "Destination bounds").
|
|
3242
|
+
_BOUNDS_PROBED = {
|
|
3243
|
+
24: frozenset({"SDMIO16", "PORTPAIRCONFIG"}), # (Float and Long, oracle 2026-10-12)
|
|
3244
|
+
27: frozenset({"MOVE", "ERASE", "GETRECORD", "SPLITSTR", "MOVEBYTES", "SDI12RECORDER"}),
|
|
3245
|
+
29: frozenset(
|
|
3246
|
+
{
|
|
3247
|
+
"REALTIME",
|
|
3248
|
+
"READIO",
|
|
3249
|
+
"PORTGET",
|
|
3250
|
+
"AVGSPA",
|
|
3251
|
+
"MAXSPA",
|
|
3252
|
+
"MINSPA",
|
|
3253
|
+
"RMSSPA",
|
|
3254
|
+
"STDDEVSPA",
|
|
3255
|
+
"SORTSPA",
|
|
3256
|
+
"SORTSPAINDEXED",
|
|
3257
|
+
}
|
|
3258
|
+
),
|
|
3259
|
+
}
|
|
3260
|
+
|
|
3261
|
+
|
|
3262
|
+
def check_instruction_index_bounds(program: S.Program, profile, diags: Diagnostics, env: consteval.Env) -> None:
|
|
3263
|
+
"""`Battery(A(9))` for `Public A(5)` -> `Variable A out of bounds.` (oracle, 2026-10-01): pass 2
|
|
3264
|
+
on CR6/CR1000X, the late stage on CR300/CR350 (only in a file with no other error). Only an
|
|
3265
|
+
argument that is itself an element of a Float array, indexed by number literals: index 0 or
|
|
3266
|
+
past its dimension (a single index on a multi-dimensional array against the first dimension).
|
|
3267
|
+
On CR6/CR1000X a scalar is shape `(1,)` and subscripts beyond the declared dimensions count as
|
|
3268
|
+
size-1 dimensions (`R(1,2)`, `X(2)`, `B(1,1,2)` are out of bounds; `R(4,1)`, `X(1,1)` compile);
|
|
3269
|
+
CR300/CR350 report those as `Dimensions not declared`. An expression (`A(6)+1`), `A(-1)`, a
|
|
3270
|
+
Long array and an assignment (`X = A(6)`, checked at run time) all compile."""
|
|
3271
|
+
padded = profile.name in _WHOLE_EXPR_ECHO_LOGGERS
|
|
3272
|
+
tables = symbols.scope_tables(program, env, scalars=padded)
|
|
3273
|
+
for stmt in program.statements:
|
|
3274
|
+
if (
|
|
3275
|
+
isinstance(stmt, S.Instruction)
|
|
3276
|
+
and stmt.keyword.upper() == "WHILE"
|
|
3277
|
+
and len(stmt.args) == 1
|
|
3278
|
+
and isinstance(stmt.args[0], S.Index)
|
|
3279
|
+
):
|
|
3280
|
+
candidates = [(2, stmt.args[0])]
|
|
3281
|
+
elif isinstance(stmt, (S.If, S.ElseIf, S.DoLoop)) and isinstance(stmt.cond, S.Index):
|
|
3282
|
+
# a condition that is one element (`If A(3) Then`, `While A(0)`, `ElseIf A(5) Then`):
|
|
3283
|
+
# checked like a code-2 argument (oracle, all four loggers, 2026-10-12; `If A(3) > 1`
|
|
3284
|
+
# and `Y = A(3)` compile)
|
|
3285
|
+
candidates = [(2, stmt.cond)]
|
|
3286
|
+
elif (
|
|
3287
|
+
isinstance(stmt, S.Instruction)
|
|
3288
|
+
and stmt.keyword.upper() not in _BOUNDS_UNCHECKED
|
|
3289
|
+
and (record := profile.instructions.get(stmt.keyword.upper()))
|
|
3290
|
+
):
|
|
3291
|
+
candidates = [
|
|
3292
|
+
(record["params"][i] if i < len(record["params"]) else None, arg)
|
|
3293
|
+
for i, arg in enumerate(stmt.args)
|
|
3294
|
+
# (`FieldCal` options 0 and 4 never resolve `KnownVar`, oracle 2026-10-12:
|
|
3295
|
+
# `Meter(3)` of `Meter(2)` compiles)
|
|
3296
|
+
if not (
|
|
3297
|
+
stmt.keyword.upper() == "FIELDCAL"
|
|
3298
|
+
and i == 6
|
|
3299
|
+
and stmt.args
|
|
3300
|
+
and isinstance(stmt.args[0], S.Number)
|
|
3301
|
+
and stmt.args[0].value in (0, 4)
|
|
3302
|
+
)
|
|
3303
|
+
]
|
|
3304
|
+
else:
|
|
3305
|
+
continue
|
|
3306
|
+
scope = tables(stmt)
|
|
3307
|
+
dims, types, arrays = scope.dims, scope.types, scope.arrays
|
|
3308
|
+
for code, arg in candidates:
|
|
3309
|
+
if code not in _BOUNDS_CHECKED_CODES or not isinstance(arg, S.Index) or not arg.args:
|
|
3310
|
+
continue
|
|
3311
|
+
if (
|
|
3312
|
+
code in _BOUNDS_PROBED
|
|
3313
|
+
and isinstance(stmt, S.Instruction)
|
|
3314
|
+
and stmt.keyword.upper() not in _BOUNDS_PROBED[code]
|
|
3315
|
+
):
|
|
3316
|
+
continue
|
|
3317
|
+
name = arg.name.upper()
|
|
3318
|
+
shape = dims.get(name)
|
|
3319
|
+
# Code 27 (Move/Erase/GetRecord/SplitStr destinations) also checks Long and String
|
|
3320
|
+
# arrays (oracle, 2026-10-02); the other codes are only probed for Float.
|
|
3321
|
+
if (
|
|
3322
|
+
not shape
|
|
3323
|
+
or (
|
|
3324
|
+
types.get(name) != "FLOAT"
|
|
3325
|
+
and not (code == 27 and name in arrays)
|
|
3326
|
+
and not (code in (24, 29) and types.get(name) == "LONG")
|
|
3327
|
+
)
|
|
3328
|
+
or not all(isinstance(a, S.Number) and isinstance(a.value, int) for a in arg.args)
|
|
3329
|
+
):
|
|
3330
|
+
continue
|
|
3331
|
+
index = [cast(S.Number, a).value for a in arg.args]
|
|
3332
|
+
if len(index) == 1 and len(shape) > 1:
|
|
3333
|
+
# against the first dimension, not the element count (oracle, all four loggers,
|
|
3334
|
+
# round 38: `Battery(M(4))` of `M(3,4)` is out of bounds)
|
|
3335
|
+
bad = not 1 <= index[0] <= shape[0]
|
|
3336
|
+
elif len(index) == len(shape) or (padded and len(index) > len(shape)):
|
|
3337
|
+
shape = shape + (1,) * (len(index) - len(shape))
|
|
3338
|
+
bad = any(not 1 <= k <= n for k, n in zip(index, shape))
|
|
3339
|
+
else:
|
|
3340
|
+
continue
|
|
3341
|
+
if bad:
|
|
3342
|
+
diags.error(stmt.line, f"Variable {arg.name} out of bounds.", stmt.file)
|
|
3343
|
+
|
|
3344
|
+
|
|
3345
|
+
def check_splitstr_element_reps(program: S.Program, profile, diags: Diagnostics, env) -> None:
|
|
3346
|
+
"""`SplitStr(F(i),...,Reps,...)` into an element of a one-dimensional non-String array:
|
|
3347
|
+
`Variable F out of bounds.` when `i + Reps - 1` exceeds its size, whatever the option (oracle,
|
|
3348
|
+
all four loggers, 2026-10-12: `Public F(3)` with `F(1)`/Reps 6 or `F(2)`/Reps 3, Long arrays too,
|
|
3349
|
+
and `G((1+2)) As String * 10`, a parenthesised dimension making a Float array; a String array
|
|
3350
|
+
`T(3)` compiles with Reps 6)."""
|
|
3351
|
+
strings = {
|
|
3352
|
+
v.name.upper()
|
|
3353
|
+
for st in program.statements
|
|
3354
|
+
if isinstance(st, S.Declaration)
|
|
3355
|
+
for v in st.vars
|
|
3356
|
+
if (v.type or "").upper() == "STRING" and not v.paren_dim
|
|
3357
|
+
}
|
|
3358
|
+
tables = symbols.scope_tables(program, env)
|
|
3359
|
+
for stmt in program.statements:
|
|
3360
|
+
if not isinstance(stmt, S.Instruction) or stmt.keyword.upper() != "SPLITSTR" or len(stmt.args) < 4:
|
|
3361
|
+
continue
|
|
3362
|
+
dest = stmt.args[0]
|
|
3363
|
+
if (
|
|
3364
|
+
not isinstance(dest, S.Index)
|
|
3365
|
+
or len(dest.args) != 1
|
|
3366
|
+
or not isinstance(dest.args[0], S.Number)
|
|
3367
|
+
or not isinstance(dest.args[0].value, int)
|
|
3368
|
+
):
|
|
3369
|
+
continue
|
|
3370
|
+
name = dest.name.upper()
|
|
3371
|
+
shape = tables(stmt).dims.get(name)
|
|
3372
|
+
if not shape or len(shape) != 1 or name in strings or name not in tables(stmt).arrays:
|
|
3373
|
+
continue
|
|
3374
|
+
try:
|
|
3375
|
+
reps = consteval.value_of(stmt.args[3], env)
|
|
3376
|
+
except consteval.NotConstant, consteval.DivideByZero:
|
|
3377
|
+
continue
|
|
3378
|
+
if isinstance(reps, bool) or not isinstance(reps, (int, float)) or reps != int(reps) or reps < 1:
|
|
3379
|
+
continue
|
|
3380
|
+
if dest.args[0].value + int(reps) - 1 > shape[0]:
|
|
3381
|
+
diags.error(stmt.line, f"Variable {dest.name} out of bounds.", stmt.file)
|
|
3382
|
+
|
|
3383
|
+
|
|
3384
|
+
def _sdmsio4_count(stmt: S.Instruction, env) -> int | None:
|
|
3385
|
+
"""`SDMSIO4`'s value count (eighth argument), when constant."""
|
|
3386
|
+
if len(stmt.args) < 8 or stmt.args[7] is None:
|
|
3387
|
+
return None
|
|
3388
|
+
try:
|
|
3389
|
+
n = consteval.value_of(stmt.args[7], env)
|
|
3390
|
+
except consteval.NotConstant, consteval.DivideByZero:
|
|
3391
|
+
return None
|
|
3392
|
+
return int(n) if isinstance(n, (int, float)) and not isinstance(n, bool) and n == int(n) else None
|
|
3393
|
+
|
|
3394
|
+
|
|
3395
|
+
def check_sdmsio4_count(program: S.Program, profile, diags: Diagnostics, env) -> None:
|
|
3396
|
+
"""`SDMSIO4(D(i),...,N,...)` stores N values from `D(i)`: `Variable D out of bounds.` when
|
|
3397
|
+
`i + N - 1` exceeds a one-dimensional `D` (a scalar holds one); oracle, CR6/CR1000X,
|
|
3398
|
+
2026-10-24: `D(4)` takes 4 from `D(1)` and 3 from `D(2)`, not 5 or 4; `X` takes 1, not 2."""
|
|
3399
|
+
tables = symbols.scope_tables(program, env, scalars=True)
|
|
3400
|
+
for stmt in program.statements:
|
|
3401
|
+
if not isinstance(stmt, S.Instruction) or stmt.keyword.upper() != "SDMSIO4" or not stmt.args:
|
|
3402
|
+
continue
|
|
3403
|
+
dest, n = stmt.args[0], _sdmsio4_count(stmt, env)
|
|
3404
|
+
if not isinstance(dest, (S.Name, S.Index)) or not n or n < 1:
|
|
3405
|
+
continue
|
|
3406
|
+
shape = tables(stmt).dims.get(dest.name.upper())
|
|
3407
|
+
if not shape or len(shape) != 1:
|
|
3408
|
+
continue
|
|
3409
|
+
start = 1
|
|
3410
|
+
if isinstance(dest, S.Index):
|
|
3411
|
+
if len(dest.args) != 1 or not isinstance(dest.args[0], S.Number) or not isinstance(dest.args[0].value, int):
|
|
3412
|
+
continue
|
|
3413
|
+
start = dest.args[0].value
|
|
3414
|
+
if start + n - 1 > shape[0]:
|
|
3415
|
+
diags.error(stmt.line, f"Variable {dest.name} out of bounds.", stmt.file)
|
|
3416
|
+
|
|
3417
|
+
|
|
3418
|
+
def check_timedcontrol_constants(program: S.Program, profile, diags: Diagnostics) -> None:
|
|
3419
|
+
"""CR6/CR1000X `TimedControl` Size and Interval (positions 0 and 1) must be constants: a
|
|
3420
|
+
variable or an undeclared name gives `Size must be a constant.` / `Interval must be a
|
|
3421
|
+
constant.`, both when both are bad (oracle, 2026-10-24, round 34)."""
|
|
3422
|
+
if profile.name not in _WHOLE_EXPR_ECHO_LOGGERS:
|
|
3423
|
+
return
|
|
3424
|
+
consts = {str(n).upper() for n in program.consts} | set(profile.symbols)
|
|
3425
|
+
for stmt in program.statements:
|
|
3426
|
+
if not isinstance(stmt, S.Instruction) or stmt.keyword.upper() != "TIMEDCONTROL":
|
|
3427
|
+
continue
|
|
3428
|
+
for i, what in ((0, "Size"), (1, "Interval")):
|
|
3429
|
+
if i < len(stmt.args) and stmt.args[i] is not None and _names_in(stmt.args[i]) - consts:
|
|
3430
|
+
diags.error(stmt.line, f"{what} must be a constant.", stmt.file)
|
|
3431
|
+
|
|
3432
|
+
|
|
3433
|
+
def _names_in(expr) -> set[str]:
|
|
3434
|
+
if isinstance(expr, S.Name):
|
|
3435
|
+
return {expr.name.upper()}
|
|
3436
|
+
if isinstance(expr, S.Index):
|
|
3437
|
+
return {expr.name.upper()}.union(*(_names_in(a) for a in expr.args if a is not None))
|
|
3438
|
+
if isinstance(expr, S.Unary):
|
|
3439
|
+
return _names_in(expr.operand)
|
|
3440
|
+
if isinstance(expr, S.Binary):
|
|
3441
|
+
return _names_in(expr.left) | _names_in(expr.right)
|
|
3442
|
+
if isinstance(expr, S.Member):
|
|
3443
|
+
return _names_in(expr.base)
|
|
3444
|
+
return set()
|
|
3445
|
+
|
|
3446
|
+
|
|
3447
|
+
_CALIBRATE_MIN = {"cr6": 6, "cr1000x": 6, "cr300": 51, "cr350": 51}
|
|
3448
|
+
|
|
3449
|
+
|
|
3450
|
+
def check_calibrate_size(program: S.Program, profile, diags: Diagnostics, env) -> None:
|
|
3451
|
+
"""`Calibrate(Dest)` needs at least 6 Float elements from `Dest` to the end of its array on
|
|
3452
|
+
CR6/CR1000X, 51 on CR300/CR350 (whole array, `Dest()` or `Dest(i)`), else `Variable Dest out of
|
|
3453
|
+
bounds.` (oracle, 2026-10-12: sizes 1..5 fail and 6..100 compile on CR6; 1..50 fail and 51..100
|
|
3454
|
+
compile on CR300/CR350; `Public K()` is one element)."""
|
|
3455
|
+
need = _CALIBRATE_MIN.get(profile.name)
|
|
3456
|
+
if need is None:
|
|
3457
|
+
return
|
|
3458
|
+
tables = symbols.scope_tables(program, env, scalars=True)
|
|
3459
|
+
for stmt in program.statements:
|
|
3460
|
+
if not isinstance(stmt, S.Instruction) or stmt.keyword.upper() != "CALIBRATE" or not stmt.args:
|
|
3461
|
+
continue
|
|
3462
|
+
dest = stmt.args[0]
|
|
3463
|
+
if not isinstance(dest, (S.Name, S.Index)):
|
|
3464
|
+
continue
|
|
3465
|
+
shape = tables(stmt).dims.get(dest.name.upper())
|
|
3466
|
+
if not shape or len(shape) != 1:
|
|
3467
|
+
continue
|
|
3468
|
+
start = 1
|
|
3469
|
+
if isinstance(dest, S.Index) and dest.args:
|
|
3470
|
+
if len(dest.args) != 1 or not isinstance(dest.args[0], S.Number) or not isinstance(dest.args[0].value, int):
|
|
3471
|
+
continue
|
|
3472
|
+
start = dest.args[0].value
|
|
3473
|
+
if shape[0] - start + 1 < need:
|
|
3474
|
+
diags.error(stmt.line, f"Variable {dest.name} out of bounds.", stmt.file)
|
|
3475
|
+
|
|
3476
|
+
|
|
3477
|
+
def check_splitstr_whole_array(program: S.Program, profile, diags: Diagnostics, env) -> None:
|
|
3478
|
+
"""`SplitStr(A(),...,Reps,...)` with `A` an array and no subscript: `Variable A out of bounds.`
|
|
3479
|
+
when `Reps` exceeds the element count (oracle, 2026-10-11, all four loggers: `Public S(5)` with
|
|
3480
|
+
Reps 6 or 20, `Public M(2,3)` with 7, `Public S((2+3))` with 6 are out of bounds; Reps 5 and 6
|
|
3481
|
+
for `M(2,3)` compile; Reps 0, 1 and -1 compile). Float, Long and String arrays alike. A
|
|
3482
|
+
subscripted destination (`SplitStr(S(1),...,6,7)` compiles) is not this rule. Run after
|
|
3483
|
+
`instr_rules.check_option_values`: `SplitStr(S(),...,6,-1)` prints `Invalid option -1` first."""
|
|
3484
|
+
tables = symbols.scope_tables(program, env)
|
|
3485
|
+
for stmt in program.statements:
|
|
3486
|
+
if not isinstance(stmt, S.Instruction) or stmt.keyword.upper() != "SPLITSTR":
|
|
3487
|
+
continue
|
|
3488
|
+
dims, arrays = tables(stmt).dims, tables(stmt).arrays
|
|
3489
|
+
if not stmt.args or not isinstance(stmt.args[0], S.Index) or stmt.args[0].args or len(stmt.args) < 4:
|
|
3490
|
+
continue
|
|
3491
|
+
dest = stmt.args[0]
|
|
3492
|
+
name = dest.name.upper()
|
|
3493
|
+
shape = dims.get(name)
|
|
3494
|
+
if not shape or name not in arrays:
|
|
3495
|
+
continue
|
|
3496
|
+
try:
|
|
3497
|
+
reps = consteval.value_of(stmt.args[3], env)
|
|
3498
|
+
except consteval.DivideByZero as exc:
|
|
3499
|
+
# oracle, 2026-10-12: with integer operands CR6/CR1000X name the Reps expression (`1/0`,
|
|
3500
|
+
# `1 / 0`, `(1/0)`, `1/0+1`, `-1/0`, `0/0`, `K/(K-3)`); every other case is a plus
|
|
3501
|
+
# infinity (out of bounds, all four loggers: CR6/CR1000X `2.5/0`, `1/0.0`; CR300/CR350
|
|
3502
|
+
# also `1/0`, `5/0`) when the Reps is itself `positive/0`, and `0/0`, `-1/0` compile on
|
|
3503
|
+
# CR300/CR350. `1/0+1` on CR300/CR350 is out of bounds too (not modelled).
|
|
3504
|
+
text = _arg_text(stmt, 3).strip()
|
|
3505
|
+
if "." not in text and not profile.single_pass:
|
|
3506
|
+
diags.error(stmt.line, f"Divide by zero in expression: {text}.", stmt.file)
|
|
3507
|
+
elif (
|
|
3508
|
+
isinstance(stmt.args[3], S.Binary)
|
|
3509
|
+
and stmt.args[3].op == "/"
|
|
3510
|
+
and isinstance(exc.numerator, (int, float))
|
|
3511
|
+
and exc.numerator > 0
|
|
3512
|
+
):
|
|
3513
|
+
diags.error(stmt.line, f"Variable {dest.name} out of bounds.", stmt.file)
|
|
3514
|
+
continue
|
|
3515
|
+
except consteval.NotConstant:
|
|
3516
|
+
continue
|
|
3517
|
+
if not isinstance(reps, int) or isinstance(reps, bool):
|
|
3518
|
+
continue
|
|
3519
|
+
total = 1
|
|
3520
|
+
for n in shape:
|
|
3521
|
+
total *= n
|
|
3522
|
+
if reps > total:
|
|
3523
|
+
diags.error(stmt.line, f"Variable {dest.name} out of bounds.", stmt.file)
|
|
3524
|
+
|
|
3525
|
+
|
|
3526
|
+
def check_subcall_element_bounds(program: S.Program, profile, diags: Diagnostics, env) -> None:
|
|
3527
|
+
"""`Sub` call with an array element literal argument out of its declared range: `S1(P(10))` for
|
|
3528
|
+
`Public P(9)` -> `Variable P out of bounds.` (oracle, 2026-10-11, all four loggers; `Call S1(P(10))`
|
|
3529
|
+
and `S1 (P(0))` too, `S1(R(4))` for `Public R(3)`, `S1(S(6))` for `Public S(5) As String * 20`,
|
|
3530
|
+
`S1(F9(10))` for `Public F9(9)`). Valid: 1..n per dimension. Checked for a single-subscript or
|
|
3531
|
+
per-dimension subscript on a 1-D or multi-dimensional array with number-literal subscripts only;
|
|
3532
|
+
a negative literal (`P(-1)`), an expression (`P(10)+1`, `P(I)`), a single subscript on a
|
|
3533
|
+
multi-dimensional array (`M(6)` on `M(2,3)` is out of bounds on the real compiler but not
|
|
3534
|
+
modelled) and `P(1,1)` on a 1-D array (`Dimensions not declared` on CR300/CR350) are not this rule.
|
|
3535
|
+
Functions are not checked (`X = FN(P(10))`)."""
|
|
3536
|
+
subs = symbols.declared_subs(program)
|
|
3537
|
+
tables = symbols.scope_tables(program, env)
|
|
3538
|
+
for stmt in program.statements:
|
|
3539
|
+
if not isinstance(stmt, S.Call) or stmt.name.upper() not in subs:
|
|
3540
|
+
continue
|
|
3541
|
+
dims = tables(stmt).dims
|
|
3542
|
+
for arg in stmt.args:
|
|
3543
|
+
if not isinstance(arg, S.Index) or not arg.args:
|
|
3544
|
+
continue
|
|
3545
|
+
shape = dims.get(arg.name.upper())
|
|
3546
|
+
index = [a.value for a in arg.args if isinstance(a, S.Number) and not isinstance(a.value, float)]
|
|
3547
|
+
if not shape or len(index) != len(arg.args) or len(index) != len(shape) or any(k < 0 for k in index):
|
|
3548
|
+
continue
|
|
3549
|
+
if any(not 1 <= k <= n for k, n in zip(index, shape)):
|
|
3550
|
+
diags.error(stmt.line, f"Variable {arg.name} out of bounds.", stmt.file)
|
|
3551
|
+
|
|
3552
|
+
|
|
3553
|
+
def check_recursive_functions(program: S.Program, profile, diags: Diagnostics) -> None:
|
|
3554
|
+
"""A `Function` called inside its own body (to its `EndFunction`, or the end of the file when
|
|
3555
|
+
there is none) -> `Cannot recursively call functions.`, pass 1, all four loggers (oracle,
|
|
3556
|
+
2026-10-01: `Return F(a)`, `X = F(a) + 1`, `If F(a) > 1 Then`; a `Sub` calling itself and
|
|
3557
|
+
`F = 1` compile)."""
|
|
3558
|
+
current: str | None = None
|
|
3559
|
+
for stmt in program.statements:
|
|
3560
|
+
if isinstance(stmt, S.SubDef):
|
|
3561
|
+
current = stmt.name.upper() if stmt.keyword == "FUNCTION" else None
|
|
3562
|
+
continue
|
|
3563
|
+
if isinstance(stmt, S.Instruction) and (
|
|
3564
|
+
stmt.keyword.upper() in ("ENDFUNCTION", "ENDSUB")
|
|
3565
|
+
or re.match(r"(?i)\s*End\s+(Function|Sub)\b", stmt.text or "")
|
|
3566
|
+
):
|
|
3567
|
+
current = None
|
|
3568
|
+
continue
|
|
3569
|
+
if current is None:
|
|
3570
|
+
continue
|
|
3571
|
+
exprs: list[S.Expr | None] = []
|
|
3572
|
+
if isinstance(stmt, S.Assignment):
|
|
3573
|
+
exprs.append(stmt.value)
|
|
3574
|
+
elif isinstance(stmt, (S.If, S.ElseIf, S.DoLoop)):
|
|
3575
|
+
exprs.append(stmt.cond)
|
|
3576
|
+
elif isinstance(stmt, (S.Instruction, S.Call)):
|
|
3577
|
+
exprs += list(stmt.args)
|
|
3578
|
+
elif isinstance(stmt, S.For):
|
|
3579
|
+
exprs += [stmt.start, stmt.end, stmt.step]
|
|
3580
|
+
if any(node.name.upper() == current for e in exprs for node in _index_nodes(e)):
|
|
3581
|
+
diags.error(stmt.line, "Cannot recursively call functions.", stmt.file)
|