opencrbasic 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. opencrbasic/__init__.py +3 -0
  2. opencrbasic/__main__.py +5 -0
  3. opencrbasic/analysis.py +184 -0
  4. opencrbasic/bindings.py +1034 -0
  5. opencrbasic/catalog.py +173 -0
  6. opencrbasic/checkconfig.py +144 -0
  7. opencrbasic/checker/__init__.py +977 -0
  8. opencrbasic/checker/cr3xx.py +3118 -0
  9. opencrbasic/checker/ess.py +81 -0
  10. opencrbasic/checker/expressions.py +3581 -0
  11. opencrbasic/checker/instr_data.py +1802 -0
  12. opencrbasic/checker/instr_measure.py +1547 -0
  13. opencrbasic/checker/instr_rules.py +3540 -0
  14. opencrbasic/checker/instructions.py +17 -0
  15. opencrbasic/checker/intervals.py +678 -0
  16. opencrbasic/checker/longconv.py +133 -0
  17. opencrbasic/checker/scan_time.py +580 -0
  18. opencrbasic/checker/storage.py +456 -0
  19. opencrbasic/checker/structs.py +240 -0
  20. opencrbasic/checker/structure.py +6213 -0
  21. opencrbasic/checker/symbols.py +609 -0
  22. opencrbasic/checker/terminals.py +249 -0
  23. opencrbasic/checker/unused.py +186 -0
  24. opencrbasic/checker/var_types.py +564 -0
  25. opencrbasic/checker/warnings.py +661 -0
  26. opencrbasic/checks.py +491 -0
  27. opencrbasic/cli.py +391 -0
  28. opencrbasic/commands.py +128 -0
  29. opencrbasic/compiler.py +320 -0
  30. opencrbasic/consteval.py +327 -0
  31. opencrbasic/data/background_calibration.json +4 -0
  32. opencrbasic/data/before_beginprog.json +99 -0
  33. opencrbasic/data/cr1000x/background_calibration.json +4 -0
  34. opencrbasic/data/cr1000x/before_beginprog.json +138 -0
  35. opencrbasic/data/cr1000x/ess_variables.json +1087 -0
  36. opencrbasic/data/cr1000x/fnotch.json +29 -0
  37. opencrbasic/data/cr1000x/functions.json +1337 -0
  38. opencrbasic/data/cr1000x/instructions.json +4836 -0
  39. opencrbasic/data/cr1000x/measure.json +121 -0
  40. opencrbasic/data/cr1000x/mode.json +111 -0
  41. opencrbasic/data/cr1000x/pipeline_tasks.json +88 -0
  42. opencrbasic/data/cr1000x/settings_fields.json +321 -0
  43. opencrbasic/data/cr1000x/status_members.json +318 -0
  44. opencrbasic/data/cr1000x/symbols.json +872 -0
  45. opencrbasic/data/cr1000x/terminals.json +225 -0
  46. opencrbasic/data/cr300/before_beginprog.json +42 -0
  47. opencrbasic/data/cr300/ess_variables.json +1087 -0
  48. opencrbasic/data/cr300/fnotch.json +22 -0
  49. opencrbasic/data/cr300/functions.json +1261 -0
  50. opencrbasic/data/cr300/instructions.json +2837 -0
  51. opencrbasic/data/cr300/measure.json +33 -0
  52. opencrbasic/data/cr300/mode.json +121 -0
  53. opencrbasic/data/cr300/settings_fields.json +259 -0
  54. opencrbasic/data/cr300/status_members.json +303 -0
  55. opencrbasic/data/cr300/symbols.json +584 -0
  56. opencrbasic/data/cr300/terminals.json +193 -0
  57. opencrbasic/data/cr350/before_beginprog.json +44 -0
  58. opencrbasic/data/cr350/ess_variables.json +1087 -0
  59. opencrbasic/data/cr350/fnotch.json +22 -0
  60. opencrbasic/data/cr350/functions.json +1262 -0
  61. opencrbasic/data/cr350/instructions.json +2848 -0
  62. opencrbasic/data/cr350/measure.json +35 -0
  63. opencrbasic/data/cr350/mode.json +121 -0
  64. opencrbasic/data/cr350/settings_fields.json +292 -0
  65. opencrbasic/data/cr350/status_members.json +303 -0
  66. opencrbasic/data/cr350/symbols.json +572 -0
  67. opencrbasic/data/cr350/terminals.json +190 -0
  68. opencrbasic/data/ess_variables.json +1087 -0
  69. opencrbasic/data/fnotch.json +34 -0
  70. opencrbasic/data/functions.json +1337 -0
  71. opencrbasic/data/instructions.json +4965 -0
  72. opencrbasic/data/measure.json +123 -0
  73. opencrbasic/data/mode.json +121 -0
  74. opencrbasic/data/notices_cr6.json +890 -0
  75. opencrbasic/data/pipeline_tasks.json +84 -0
  76. opencrbasic/data/settings_fields.json +322 -0
  77. opencrbasic/data/status_members.json +319 -0
  78. opencrbasic/data/status_table.json +684 -0
  79. opencrbasic/data/symbols.json +902 -0
  80. opencrbasic/data/terminals.json +195 -0
  81. opencrbasic/data/type_codes.json +249 -0
  82. opencrbasic/diagnostics.py +196 -0
  83. opencrbasic/editing.py +827 -0
  84. opencrbasic/formatting.py +310 -0
  85. opencrbasic/helpdocs.py +677 -0
  86. opencrbasic/hover.py +667 -0
  87. opencrbasic/inlay.py +169 -0
  88. opencrbasic/lexer.py +183 -0
  89. opencrbasic/lsp/__init__.py +5 -0
  90. opencrbasic/lsp/__main__.py +7 -0
  91. opencrbasic/lsp/config.py +202 -0
  92. opencrbasic/lsp/features.py +602 -0
  93. opencrbasic/lsp/server.py +741 -0
  94. opencrbasic/lsp/session.py +1189 -0
  95. opencrbasic/lsp/worker.py +128 -0
  96. opencrbasic/mode.py +143 -0
  97. opencrbasic/navigation.py +401 -0
  98. opencrbasic/parser.py +3264 -0
  99. opencrbasic/positions.py +274 -0
  100. opencrbasic/preprocess.py +269 -0
  101. opencrbasic/profiles.py +266 -0
  102. opencrbasic/provenance.py +17 -0
  103. opencrbasic/quickfix.py +331 -0
  104. opencrbasic/rename.py +662 -0
  105. opencrbasic/signature.py +33 -0
  106. opencrbasic/source.py +600 -0
  107. opencrbasic/sources.py +125 -0
  108. opencrbasic/syntax.py +337 -0
  109. opencrbasic/tables.py +2173 -0
  110. opencrbasic/tables_hook.py +14 -0
  111. opencrbasic/tdf.py +142 -0
  112. opencrbasic/timing.py +565 -0
  113. opencrbasic-1.0.0.dist-info/METADATA +223 -0
  114. opencrbasic-1.0.0.dist-info/RECORD +117 -0
  115. opencrbasic-1.0.0.dist-info/WHEEL +4 -0
  116. opencrbasic-1.0.0.dist-info/entry_points.txt +3 -0
  117. opencrbasic-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1547 @@
1
+ """Instruction-specific measurement/I-O semantic checks that are not the channel/port *terminal*
2
+ range model (`terminals.py`, `data/<logger>/terminals.json`). Value-set and value-range checks for
3
+ `Range` codes, `SW12` ports, `Delay`, `CS616`, `TimerInput` and `ExciteV`/`ExciteI`, per logger,
4
+ driven by `data/<logger>/measure.json`. Pass 2 (`docs/checker.md` "Two-pass compilation"): called
5
+ from `checker/__init__.py` alongside `instructions.check`, inside the `if not diags.errors:` gate,
6
+ so (matching every existing pass-2 check) it never fires when the file has an earlier, unrelated
7
+ error. All rules below are oracle-confirmed per logger this round; see `docs/instructions.md`
8
+ "Measurement/I-O instruction checks" for the evidence and each logger's coverage.
9
+ """
10
+
11
+ import math
12
+ import re
13
+ from typing import TypeGuard, cast
14
+
15
+ from .. import consteval
16
+ from .. import syntax as S
17
+ from ..diagnostics import Diagnostic, Diagnostics
18
+ from ..profiles import Profile
19
+ from . import longconv, scan_time, symbols
20
+
21
+
22
+ def check(program: S.Program, profile: Profile, diags: Diagnostics, env: consteval.Env) -> None:
23
+ measure = profile.measure
24
+ if not measure:
25
+ return
26
+ file_reported = False # only the first bad `FileManage`/`RunProgram` file name is reported
27
+ sequential: bool | None = None
28
+ from .instr_data import _with_inline
29
+
30
+ # a one-line If's statement too (oracle, CR6/CR1000X, round 58, docs/checker.md#one-line-if-statements-in-tier-c-round-58)
31
+ slow = False
32
+ for stmt in _with_inline(program.statements):
33
+ if not isinstance(stmt, S.Instruction):
34
+ continue
35
+ kw = stmt.keyword.upper()
36
+ slow = kw == "SLOWSEQUENCE" or slow and kw != "ENDPROG"
37
+ if kw == "SW12" and "sw12_ports" in measure:
38
+ _check_sw12(stmt, measure, env, diags)
39
+ if kw == "DELAY" and "delay" in measure:
40
+ if sequential is None:
41
+ sequential = is_sequential(program, profile, env)
42
+ _check_delay(stmt, measure["delay"], env, diags, sequential, slow)
43
+ if kw == "EXCITEV" and measure.get("delay", {}).get("task_memory") and len(stmt.args) == 3:
44
+ delay = _int_value(stmt.args[2], env)
45
+ if delay is not None and scan_time.task_memory_overflow(max(scan_time.excite_delay_us(delay) - 10, 0)):
46
+ _task_memory_full(stmt, diags)
47
+ if kw == "CS616" and "cs616_min_reps_pos" in measure:
48
+ _check_cs616_reps(stmt, measure, env, diags)
49
+ if kw in ("EXCITEV", "EXCITEI") and "excite_max_mv" in measure:
50
+ _check_excite_magnitude(stmt, measure, env, diags)
51
+ if kw == "GPS" and "gps" in measure:
52
+ _check_structure_members(stmt, program, measure["gps"], env, diags)
53
+ _check_gps_nmea(stmt, program, env, diags)
54
+ _check_gps_port(stmt, measure["gps"], env, diags)
55
+ if kw == "CH400STATUS" and "CH400STATUS" in profile.instructions:
56
+ _check_ch400status(stmt, env, diags)
57
+ if kw == "FFT" and "FFT" in profile.instructions:
58
+ _check_fft_source(stmt, symbols.declared_all_dims(symbols.scoped_view(program, stmt), env), env, diags)
59
+ if kw == "SDI12RECORDER" and profile.single_pass:
60
+ _check_sdi12_structure_dest(stmt, program, diags)
61
+ if kw == "TABLEFILE" and "tablefile" in measure:
62
+ _check_tablefile(stmt, measure["tablefile"], env, diags)
63
+ for rule in measure.get("string_prefixes", ()):
64
+ if kw == rule["instr"] and not (file_reported and "ext" in rule):
65
+ file_reported = _check_string_prefix(stmt, rule, env, diags) or file_reported
66
+
67
+
68
+ def check_const_ranges(flat: list, profile: Profile, diags: Diagnostics, env: consteval.Env) -> None:
69
+ """Data-driven constant-parameter checks (`measure.json` `const_ranges`, `docs/instructions.md`
70
+ "Constant-parameter range checks"): the parameter at `pos` converts as C's `(long)` and must
71
+ lie in `min..max` or in `values`, else `message` (`{value}` is the converted number, or that
72
+ of the parameter at `show`). `wrap16` rules (`StaticRoute` addresses) keep the low 16 bits of
73
+ the converted number. A variable or unresolved name is another check's error."""
74
+ by_instr: dict[str, list[dict]] = {}
75
+ for rule in profile.measure.get("const_ranges", ()):
76
+ by_instr.setdefault(rule["instr"], []).append(rule)
77
+ if not by_instr:
78
+ return
79
+ from .instr_rules import cdm_null_module, cdm_unsupported_module
80
+
81
+ names = longconv.float_names(flat), longconv.literal_names(flat)
82
+ for stmt in flat:
83
+ if not isinstance(stmt, S.Instruction):
84
+ continue
85
+ if stmt.keyword.upper().startswith("CDM_") and (
86
+ cdm_null_module(stmt, env) or cdm_unsupported_module(stmt, env)
87
+ ):
88
+ continue
89
+ tccomp = bool(stmt.args) and stmt.keyword.upper() == "CDM_TCCOMP" and _int_value(stmt.args[0], env) == 224
90
+ for rule in by_instr.get(stmt.keyword.upper(), ()):
91
+ if tccomp and rule["pos"] == 5:
92
+ _tccomp_before_channel(stmt, env, profile, names, diags)
93
+ _apply_const_rule(stmt, rule, env, profile, names, diags)
94
+ if tccomp and rule["pos"] == 5:
95
+ _tccomp_after_channel(stmt, env, profile, names, diags)
96
+
97
+
98
+ def _tccomp_before_channel(stmt, env, profile, names, diags) -> None:
99
+ """`CDM_TCComp(TEMP120, ...)`: a negative channel first gives `Burst measurements unsupported,
100
+ input channel must be > 0!` (oracle, CR6/CR1000X, 2026-10-10; docs/instructions.md "CPI
101
+ measurement time")."""
102
+ channel = _long_arg(stmt, 5, {}, env, profile, names)
103
+ if channel is not None and channel < 0:
104
+ diags.error(stmt.line, "Burst measurements unsupported, input channel must be > 0!", stmt.file)
105
+
106
+
107
+ def _tccomp_after_channel(stmt, env, profile, names, diags) -> None:
108
+ """After the channel message: `Reps cycle beyond last channel!` when `channel - 1 + Reps > 20`
109
+ (a channel outside 1..20 counts as 1), then `Thermocouple type unsupported.` for a type
110
+ outside 0..7."""
111
+ channel, reps = (_long_arg(stmt, pos, {}, env, profile, names) for pos in (5, 4))
112
+ if channel is not None and reps is not None:
113
+ if not 1 <= channel <= 20:
114
+ channel = 1
115
+ if channel - 1 + reps > 20:
116
+ diags.error(stmt.line, "Reps cycle beyond last channel!", stmt.file)
117
+ tc_type = _long_arg(stmt, 6, {}, env, profile, names)
118
+ if tc_type is not None and not 0 <= tc_type <= 7:
119
+ diags.error(stmt.line, "Thermocouple type unsupported.", stmt.file)
120
+
121
+
122
+ def _apply_cdm_channel(stmt, rule, env, profile, names, diags) -> None:
123
+ """CDM analog measurement channel (`channel_by_module`: module value -> last channel; oracle,
124
+ CR6/CR1000X, 2026-10-12, docs/instructions.md "CDM measurement channels"). A negative channel
125
+ is a two-measurement pair counted by magnitude; `-2147483648` (a string, overflow) is only
126
+ the range line; `Reps` that run past the last channel are `Reps cycle input beyond last
127
+ channel!` unless `Reps` is itself out of range."""
128
+ last = rule["channel_by_module"].get(str(_int_value(stmt.args[0], env)))
129
+ value = _long_arg(stmt, rule["pos"], rule, env, profile, names)
130
+ if last is None or value is None:
131
+ return
132
+ int_min = -(2**31)
133
+ magnitude = -value if int_min < value < 0 else value
134
+ role = rule["role"]
135
+ if role == "ic":
136
+ if value == int_min or magnitude < 1 or magnitude > last:
137
+ diags.error(
138
+ stmt.line, rule["message"].format(value=magnitude if value != int_min else value, max=last), stmt.file
139
+ )
140
+ elif role == "rc":
141
+ reps = _long_arg(stmt, 4, rule, env, profile, names) if len(stmt.args) >= 4 else None
142
+ if value > last or (1 <= value and reps is not None and 1 <= reps <= 65536 and value - 1 + reps > last):
143
+ diags.error(stmt.line, rule["message"], stmt.file)
144
+ elif role == "tm" and int_min < value < 0 and magnitude > last:
145
+ diags.error(stmt.line, rule["message"], stmt.file)
146
+
147
+
148
+ def _apply_const_rule(stmt, rule, env, profile, names, diags) -> None:
149
+ if "channel_by_module" in rule:
150
+ _apply_cdm_channel(stmt, rule, env, profile, names, diags)
151
+ return
152
+ if "skip_modules" in rule and stmt.args and str(_int_value(stmt.args[0], env)) in rule["skip_modules"]:
153
+ return
154
+ raw = _raw_arg(stmt, rule["pos"], env)
155
+ if raw is None or (rule.get("skip_strings") and isinstance(raw, str)):
156
+ return
157
+ if "invalid_raw" in rule:
158
+ if _matches_invalid_raw(raw, rule["invalid_raw"]):
159
+ diags.error(stmt.line, rule["message"].format(value=consteval.to_long(raw)), stmt.file)
160
+ return
161
+ value = _long_arg(stmt, rule["pos"], rule, env, profile, names)
162
+ if value is None:
163
+ return
164
+ if "lower_gate" in rule and value < rule["lower_gate"]:
165
+ return
166
+ if value in rule.get("allow", ()):
167
+ return
168
+ if value in rule.get("warn_values", ()):
169
+ diags.warning(stmt.line, rule["warn_message"], stmt.file)
170
+ return
171
+ if "by_module" in rule:
172
+ # a per-device port set (oracle 2026-10-12); a module outside the table is left alone
173
+ allowed = rule["by_module"].get(str(_int_value(stmt.args[0], env)), rule.get("values"))
174
+ if allowed is None or value in allowed:
175
+ return
176
+ elif value in rule["values"] if "values" in rule else rule["min"] <= value <= rule["max"]:
177
+ return
178
+ shown = _long_arg(stmt, rule.get("show", rule["pos"]), rule, env, profile, names)
179
+ if shown is not None:
180
+ diags.error(stmt.line, rule["message"].format(value=shown), stmt.file)
181
+
182
+
183
+ def _matches_invalid_raw(value: object, invalid: dict) -> bool:
184
+ if invalid.get("strings") and isinstance(value, str):
185
+ return True
186
+ if not isinstance(value, (int, float)):
187
+ return False
188
+ if invalid.get("nan") and value != value:
189
+ return True
190
+ if value == float("inf") or value == float("-inf"):
191
+ return False
192
+ if invalid.get("outside_long") and (value < -2147483648 or value > 2147483647):
193
+ return True
194
+ return value in invalid.get("values", ())
195
+
196
+
197
+ def _long_arg(stmt: S.Instruction, pos: int, rule: dict, env: consteval.Env, profile: Profile, names) -> int | None:
198
+ value = _raw_arg(stmt, pos, env)
199
+ if value is None:
200
+ return None
201
+ if rule.get("string_zero") and isinstance(value, str):
202
+ return 0
203
+ if rule.get("inf_max") and isinstance(value, (int, float)) and value >= 2**128 - 2**103:
204
+ return 2147483647 # (a Settling time that is a float infinity, `1E39`/`INF`, round 31)
205
+ if not rule.get("wrap16"):
206
+ # The profile-aware conversion (`checker/longconv.py`) where it covers the expression;
207
+ # `to_long` for the forms it does not (function calls, `TRUE`).
208
+ converted = longconv.long_value(stmt.args[pos - 1], env, profile, *names)
209
+ return consteval.to_long(value) if converted is None else converted
210
+ # A string or NaN is 0; a number outside Long range is 0 on CR6/CR1000X and unobserved
211
+ # (`overflow: null`, skipped) on CR300/CR350; infinity is 65535 (not modelled).
212
+ if isinstance(value, str) or value != value:
213
+ return 0
214
+ if abs(value) == float("inf"):
215
+ return None
216
+ if abs(value) >= 2**31:
217
+ return rule["overflow"]
218
+ return int(value) & 0xFFFF
219
+
220
+
221
+ def _raw_arg(stmt: S.Instruction, pos: int, env: consteval.Env):
222
+ return _fnotch_value(stmt.args[pos - 1], env) if pos <= len(stmt.args) and stmt.args[pos - 1] is not None else None
223
+
224
+
225
+ def _check_string_prefix(stmt: S.Instruction, rule: dict, env: consteval.Env, diags: Diagnostics) -> bool:
226
+ """A constant file name's drive (`prefixes`) and, for `FileManage`/`RunProgram` (`ext`), its
227
+ extension: with option bits 2 or 4 set the text after the last `.` must start with `crb` or
228
+ the logger's own program extension, else `FileManage name <name> invalid extension.`
229
+ (docs/instructions.md#filemanage-drive-and-dnp-comport-constants). True when it reported."""
230
+ idx = rule["pos"] - 1
231
+ if idx >= len(stmt.args) or stmt.args[idx] is None:
232
+ return False
233
+ value = _const_value(stmt.args[idx], env)
234
+ if not isinstance(value, str):
235
+ return False
236
+ drive = value[:4]
237
+ if drive.upper() not in (f"{p}:" for p in rule["prefixes"]):
238
+ diags.error(stmt.line, rule["message"].format(drive=drive), stmt.file)
239
+ return True
240
+ option = _int_value(stmt.args[1], env) if "ext" in rule and len(stmt.args) > 1 else None
241
+ if option is not None and abs(option) < 2**31 and option & 6:
242
+ ext = value.strip().rpartition(".")[2].upper() if "." in value else ""
243
+ if not ext.startswith(("CRB", rule["ext"])):
244
+ diags.error(stmt.line, f"FileManage name {value} invalid extension.", stmt.file)
245
+ return True
246
+ return False
247
+
248
+
249
+ def _check_ch400status(stmt: S.Instruction, env: consteval.Env, diags: Diagnostics) -> None:
250
+ """`CH400Status(Interval, Units)`: the converted interval times the unit, in microseconds,
251
+ must be a whole number of seconds; docs/instructions.md "CH400Status interval and FFT source length"."""
252
+ if len(stmt.args) < 2:
253
+ return
254
+ value, unit = _int_value(stmt.args[0], env), _const_value(stmt.args[1], env)
255
+ if value is None or not isinstance(unit, (int, float)) or int(unit) not in _DELAY_US_PER_UNIT:
256
+ return
257
+ if value * _DELAY_US_PER_UNIT[int(unit)] % 1_000_000:
258
+ diags.error(stmt.line, "CH400Status update interval must be an integer multiple of 1 second.", stmt.file)
259
+
260
+
261
+ def _check_fft_source(stmt: S.Instruction, dims: dict, env: consteval.Env, diags: Diagnostics) -> None:
262
+ """`FFT(Source, Res, N, ...)`: the source from the given element on must hold `N` values
263
+ (`N` a power of two), else `<name> must be of length >= N.`"""
264
+ if len(stmt.args) < 3 or not isinstance(stmt.args[0], (S.Name, S.Index)):
265
+ return
266
+ n = _int_value(stmt.args[2], env)
267
+ shape = dims.get(stmt.args[0].name.upper())
268
+ if n is None or n < 1 or n & (n - 1) or shape is None:
269
+ return
270
+ offset = _flat_offset(stmt.args[0], shape)
271
+ if offset is not None and math.prod(shape) - offset < n:
272
+ diags.error(stmt.line, f"{stmt.args[0].name} must be of length >= {n}.", stmt.file)
273
+
274
+
275
+ def _check_sdi12_structure_dest(stmt: S.Instruction, program: S.Program, diags: Diagnostics) -> None:
276
+ """CR300/CR350: a StructureType variable as the `SDI12Recorder` destination gives `Could not
277
+ find DataTable <argument text>.`; docs/instructions.md#sdi12recorder-structure-destination."""
278
+ dest = stmt.args[0] if stmt.args else None
279
+ if not isinstance(dest, (S.Name, S.Index)) or (isinstance(dest, S.Index) and None in dest.args):
280
+ return
281
+ type_name = symbols.declared_variable_types(program).get(dest.name.upper())
282
+ if type_name is not None and any(
283
+ isinstance(st, S.Instruction)
284
+ and st.keyword.upper() == "STRUCTURETYPE"
285
+ and st.args
286
+ and isinstance(st.args[0], S.Name)
287
+ and st.args[0].name.upper() == type_name
288
+ for st in program.statements
289
+ ):
290
+ from .expressions import _arg_text
291
+
292
+ diags.error(stmt.line, f"Could not find DataTable {_arg_text(stmt, 0)}.", stmt.file)
293
+
294
+
295
+ def _check_tablefile(stmt: S.Instruction, cfg: dict, env: consteval.Env, diags: Diagnostics) -> None:
296
+ """`TableFile` file name drive and file-type option; docs/instructions.md#tablefile-drive."""
297
+ name = _name_text(stmt.args[0], env) if stmt.args else None
298
+ if not name:
299
+ return
300
+ if name[:4].upper() not in (f"{d}:" for d in cfg["drives"]):
301
+ diags.error(stmt.line, f"Invalid drive or no drive specified in filename {name}.", stmt.file)
302
+ option = _int_value(stmt.args[1], env) if len(stmt.args) > 1 else None
303
+ if option is not None and cfg.get("option_mod100"):
304
+ low = abs(int(math.fmod(option, 100)))
305
+ if option >= 0 and low >= 64 and name[:4].upper() in (f"{d}:" for d in cfg.get("tob3_drives", ())):
306
+ diags.error(stmt.line, "TOB3 TableFile output only allowed on CRD: drive.", stmt.file)
307
+ if 24 <= low <= 31 or low >= 40:
308
+ diags.error(stmt.line, "Invalid file type option.", stmt.file)
309
+
310
+
311
+ def _name_text(arg: S.Expr | None, env: consteval.Env) -> str | None:
312
+ """A constant file name; `Status.SerialNumber` folds to the empty string (oracle)."""
313
+ if (
314
+ isinstance(arg, S.Member)
315
+ and isinstance(arg.base, S.Name)
316
+ and arg.args is None
317
+ and (arg.base.name.upper(), arg.member.upper()) == ("STATUS", "SERIALNUMBER")
318
+ ):
319
+ return ""
320
+ if isinstance(arg, S.Binary) and arg.op in ("+", "&"):
321
+ left, right = _name_text(arg.left, env), _name_text(arg.right, env)
322
+ return None if left is None or right is None else left + right
323
+ value = _const_value(arg, env)
324
+ return value if isinstance(value, str) else None
325
+
326
+
327
+ def _check_structure_members(
328
+ stmt: S.Instruction, program: S.Program, cfg: dict, env: consteval.Env, diags: Diagnostics
329
+ ) -> None:
330
+ """Data-driven checks on members of a StructureType used as an instruction destination."""
331
+ rules = cfg.get("structure_members", ())
332
+ if not rules or not stmt.args:
333
+ return
334
+ dest = stmt.args[0]
335
+ if not isinstance(dest, (S.Name, S.Index)):
336
+ return
337
+ type_name = symbols.declared_variable_types(program).get(dest.name.upper())
338
+ if type_name is None:
339
+ return
340
+ members = _structure_members(program, type_name)
341
+ for rule in rules:
342
+ member = members.get(rule["member"])
343
+ if member is None or (member.type or "FLOAT").upper() != rule["type"]:
344
+ continue
345
+ size = _int_value(member.string_size, env)
346
+ if size is not None and size < rule["min_string_size"]:
347
+ diags.error(stmt.line, rule["message"], stmt.file)
348
+
349
+
350
+ def _structure_members(program: S.Program, type_name: str) -> dict[str, S.VarDecl]:
351
+ members: dict[str, S.VarDecl] = {}
352
+ active = False
353
+ for candidate in program.statements:
354
+ if isinstance(candidate, S.Instruction):
355
+ kw = candidate.keyword.upper()
356
+ if kw == "STRUCTURETYPE":
357
+ active = bool(
358
+ candidate.args
359
+ and isinstance(candidate.args[0], S.Name)
360
+ and candidate.args[0].name.upper() == type_name
361
+ )
362
+ continue
363
+ if kw == "ENDSTRUCTURETYPE" and active:
364
+ break
365
+ if active and isinstance(candidate, S.Declaration) and candidate.kind == "STRUCTUREMEMBER":
366
+ members.update({var.name.upper(): var for var in candidate.vars})
367
+ return members
368
+
369
+
370
+ def check_pass1_values(program: S.Program, profile: Profile, diags: Diagnostics, env: consteval.Env) -> None:
371
+ """CR6/CR1000X pass 1 value checks on const-family positions: `Range` code, then `fN1`
372
+ (position order within one statement). Oracle: both are reported next to an earlier pass-1
373
+ error (`Dim Z banana`, `Delay(0,5,Foo)`), unlike the tier-C checks of `check`
374
+ (`docs/instructions.md` "fN1 / fnotch"). A statement that already has an error on its line
375
+ (unresolved name, variable in a constant position) is abandoned before these run."""
376
+ from . import expressions, scan_time
377
+
378
+ measure = profile.measure
379
+ fnotch = profile.fnotch
380
+ ranges = measure.get("range_code_instructions", {}) if measure else {}
381
+ fn_pos = fnotch.get("instructions", {}) if fnotch and "min_hz" in fnotch else {}
382
+ if not ranges and not fn_pos:
383
+ return
384
+ variable_names = frozenset(
385
+ name
386
+ for name in symbols.declared_variable_names(program)
387
+ if not symbols.is_reserved_predefined_name(name, profile)
388
+ )
389
+ earlier = {(d.line, d.file) for d in diags.items if d.severity == "error"}
390
+ flat = []
391
+ for st in program.statements:
392
+ flat.append(st)
393
+ if isinstance(st, S.If):
394
+ flat.extend((st.inline or []) + (st.inline_else or []))
395
+ for stmt in flat:
396
+ if not isinstance(stmt, S.Instruction):
397
+ continue
398
+ kw = stmt.keyword.upper()
399
+ if kw not in ranges and kw not in fn_pos:
400
+ continue
401
+ if (stmt.line, stmt.file) in earlier:
402
+ continue
403
+ if any(
404
+ expressions._find_arity_problem(a, profile)
405
+ or expressions._find_bare_function_ref(a, profile, variable_names)
406
+ for a in stmt.args
407
+ if a is not None
408
+ ):
409
+ continue
410
+ if kw in ranges:
411
+ # a bridge checks its one Range twice, `BrHalf4W`/`BrFull6W` each of their two
412
+ # (oracle, CR6/CR1000X, round 59)
413
+ for pos in ranges[kw] if isinstance(ranges[kw], list) else [ranges[kw]]:
414
+ _check_range_code(stmt, measure, pos, env, diags)
415
+ if kw in fn_pos:
416
+ _check_fnotch_min(stmt, fnotch, fn_pos[kw], env, diags, scan_time.MODELS[profile.name].integ_num)
417
+
418
+
419
+ _INF_NAMES = {"INF": float("inf"), "NAN": float("nan")}
420
+
421
+
422
+ def _fnotch_value(arg: S.Expr | None, env: consteval.Env):
423
+ """Folded `fN1` value; `NAN`/`INF` (not named constants in `consteval`) handled here."""
424
+ inner, sign = arg, 1.0
425
+ while isinstance(inner, S.Unary) and inner.op == "-":
426
+ inner, sign = inner.operand, -sign
427
+ if isinstance(inner, S.Name) and inner.name.upper() in _INF_NAMES and inner.name.upper() not in env.consts:
428
+ return sign * _INF_NAMES[inner.name.upper()]
429
+ return _const_value(arg, env)
430
+
431
+
432
+ def _check_fnotch_min(
433
+ stmt: S.Instruction, cfg: dict, pos: int, env: consteval.Env, diags: Diagnostics, integ_num: float
434
+ ) -> None:
435
+ """`Invalid fnotch, minimum allowed is N Hz.` (CR6 2, CR1000X .5; docs/instructions.md). Zero
436
+ (also as a float, `1E-50`) and +-INF pass, NAN, strings and values under the logger's minimum
437
+ fail; there is no maximum. A negative value fails when its integration code
438
+ `int(integ_num / fN1 + 0.5)` is negative, so a large one passes (CR6 `-1E5`, round 31)."""
439
+ from .scan_time import _f32
440
+
441
+ idx = pos - 1
442
+ if idx >= len(stmt.args) or stmt.args[idx] is None:
443
+ return
444
+ value = _fnotch_value(stmt.args[idx], env)
445
+ if value is None:
446
+ return
447
+ if isinstance(value, str):
448
+ bad = True
449
+ else:
450
+ value = float(value)
451
+ f = _f32(value)
452
+ bad = f != f or (
453
+ f != 0
454
+ and abs(f) != float("inf")
455
+ and (value < cfg["min_hz"] if f > 0 else _f32(_f32(integ_num) / f) <= -1.5)
456
+ )
457
+ if bad:
458
+ times = 2 if stmt.keyword.upper() in cfg.get("twice", ()) else 1
459
+ for _ in range(times):
460
+ diags.error(stmt.line, cfg["message"], stmt.file)
461
+
462
+
463
+ def _const_value(arg: S.Expr | None, env: consteval.Env):
464
+ """The folded constant value of `arg`, or `None` if it is missing, a variable, or otherwise
465
+ not resolvable at compile time (in every case, some *other* check's concern, not this one's --
466
+ matching `terminals.py`'s own convention)."""
467
+ if arg is None or not consteval.is_constant(arg, env):
468
+ return None
469
+ try:
470
+ return consteval.value_of(arg, env)
471
+ except consteval.NotConstant:
472
+ return None
473
+
474
+
475
+ def _int_value(arg: S.Expr | None, env: consteval.Env) -> int | None:
476
+ v = _const_value(arg, env)
477
+ if isinstance(v, int): # (a huge integer literal: `math.isfinite` would overflow, round 69)
478
+ return v
479
+ return int(v) if isinstance(v, float) and math.isfinite(v) else None
480
+
481
+
482
+ def _check_gps_nmea(stmt: S.Instruction, program: S.Program, env: consteval.Env, diags: Diagnostics) -> None:
483
+ """`GPS`'s optional fifth argument must be a String variable of at least 96 characters
484
+ (declared size; 24 when none is given); docs/instructions.md#gps-ports."""
485
+ if len(stmt.args) < 5 or stmt.args[4] is None:
486
+ return
487
+ arg = stmt.args[4]
488
+ if isinstance(arg, S.String):
489
+ bad = True
490
+ elif isinstance(arg, (S.Name, S.Index)):
491
+ decl = next(
492
+ (
493
+ v
494
+ for st in reversed(symbols.scoped_view(program, stmt).statements)
495
+ if isinstance(st, S.Declaration) and st.kind in ("PUBLIC", "DIM", "STATIC")
496
+ for v in st.vars
497
+ if v.name.upper() == arg.name.upper()
498
+ ),
499
+ None,
500
+ )
501
+ if decl is None:
502
+ return
503
+ if (decl.type or "FLOAT").upper() != "STRING":
504
+ bad = (decl.type or "FLOAT").upper() in ("FLOAT", "LONG", "BOOLEAN", "DOUBLE")
505
+ else:
506
+ size_expr = decl.string_size
507
+ if isinstance(size_expr, S.Binary) and size_expr.op == "=":
508
+ size_expr = size_expr.left # `As String * 16 = {...}` parses the initializer in
509
+ size = _int_value(size_expr, env) if size_expr is not None else 24
510
+ bad = size is not None and size < 96
511
+ else:
512
+ return
513
+ if bad:
514
+ diags.error(stmt.line, "Optional parameter must be an array of type string of length >= 100", stmt.file)
515
+
516
+
517
+ def _check_gps_port(stmt: S.Instruction, cfg: dict, env: consteval.Env, diags: Diagnostics) -> None:
518
+ """GPS port sign selects PPS or NMEA synchronization; docs/instructions.md#gps-ports."""
519
+ if len(stmt.args) < 2:
520
+ return
521
+ port = _int_value(stmt.args[1], env)
522
+ if port is None:
523
+ return
524
+ if abs(port) not in cfg["ports"]:
525
+ diags.error(stmt.line, cfg["port_message"], stmt.file)
526
+ elif port > 0 and port != cfg["pps_port"]:
527
+ diags.error(stmt.line, cfg["pps_message"], stmt.file)
528
+
529
+
530
+ # --- Range codes: `Invalid range code, valid options are: mV5000, mV1000, mV200, or AutoRange.`
531
+ # (CR6/CR1000X only; CR300/CR350 have no such check -- their own `mv5000`/`mv200` etc. are simply
532
+ # not symbols at all, an unrelated, already-handled undefined-symbol rejection, `docs/
533
+ # instructions.md`). Fires for `VoltSe`/`VoltDiff`/`TCSe`/`TCDiff`/`CurrentSe`'s own `Range`
534
+ # argument (position 3 on all five) when the folded value is not one of the CHM's documented
535
+ # "mV5000/mV1000/mV200/AutoRange" family, including their open-detection `...C` siblings.
536
+ def _check_range_code(stmt: S.Instruction, measure: dict, pos: int, env: consteval.Env, diags: Diagnostics) -> None:
537
+ idx = pos - 1
538
+ if idx >= len(stmt.args):
539
+ return
540
+ value = _const_value(stmt.args[idx], env)
541
+ if value is None:
542
+ return
543
+ valid = measure["range_codes"]
544
+ # any string is invalid, `"800"` too (oracle, CR6/CR1000X, round 58)
545
+ if isinstance(value, str) or int(value) not in valid:
546
+ diags.error(stmt.line, measure["range_code_message"], stmt.file)
547
+
548
+
549
+ # --- `SW12`: `Invalid SW12 port %d.` -- an exact, small, per-logger set of valid ports (both a
550
+ # plain-integer slot number and the named `SW12_n` constant resolve to different raw values but
551
+ # both compile; every other value in the probed neighbourhood fails), `docs/instructions.md`.
552
+ def _check_sw12(stmt: S.Instruction, measure: dict, env: consteval.Env, diags: Diagnostics) -> None:
553
+ if not stmt.args:
554
+ return
555
+ value = _int_value(stmt.args[0], env)
556
+ if isinstance(stmt.args[0], S.String) and measure.get("string_port_int_min"):
557
+ value = -2147483648 # (`SW12("x",0)`, oracle CR6/CR350 2026-10-12: a string folds to INT_MIN)
558
+ if value is None:
559
+ return
560
+ if value not in measure["sw12_ports"]:
561
+ text = measure.get("sw12_port_messages", {}).get(str(value), f"Invalid SW12 port {value}.")
562
+ diags.error(stmt.line, text, stmt.file)
563
+
564
+
565
+ # --- `Delay`: `Delay(Option, Interval, Units)`. Three checks, oracle-confirmed per logger
566
+ # (`docs/instructions.md`):
567
+ # - `Illegal option %d` (all four loggers) when `Option` is not 0, 1 or 2 -- shown value is the
568
+ # folded number truncated to an unsigned byte (oracle: `Option=-1` shows as `255`).
569
+ # - `Delay interval cannot be of type String.` (all four loggers, any `Option`) when `Interval`
570
+ # folds to a string constant.
571
+ # - Depending on `Option` (0 = measurement-task-sequence delay, 1 = processing delay, 2 = SDM
572
+ # delay): `Option=0` needs `Interval` to itself be a compile-time constant
573
+ # (`Measurement delay must have constant interval.`) and, once resolved, at most 60 minutes
574
+ # (`Measurement Delay Exceeds 60 min.`) -- both CR6/CR1000X only, oracle-confirmed absent on
575
+ # CR300/CR350 (`Delay(0,61,min)` compiles clean there); `Option=1` needs the resolved interval
576
+ # to be strictly less than 24 hours (`Max Delay is 24 Hours`, all four loggers); `Option=2`
577
+ # needs `Interval` to be a compile-time constant (`SDM Delay interval must be constant.`, all
578
+ # four loggers).
579
+ _DELAY_US_PER_UNIT = {0: 1, 1: 1_000, 2: 1_000_000, 3: 60_000_000, 4: 3_600_000_000, 5: 86_400_000_000}
580
+
581
+
582
+ def _delay_option_byte(value: int) -> int:
583
+ """`Option` is taken as an unsigned byte (256 acts as 0, 257 as 1, -254 as 2). A constant
584
+ outside the Long range is a float32: its low byte is 0, or 255 above the float32 maximum
585
+ (oracle, all four loggers; docs/instructions.md#delay). Not modelled: CR300/CR350 report
586
+ 255 for exactly 2147483648."""
587
+ if value > 3.4028235e38:
588
+ return 255
589
+ if not -(2**31) <= value < 2**31:
590
+ return 0
591
+ return value % 256
592
+
593
+
594
+ # `Program too big for task memory` repeats for every further task word until the 4096-byte
595
+ # message buffer is full (`compiler.py`); 100 copies always fill it
596
+ _TASK_MEMORY_COPIES = 100
597
+
598
+
599
+ def _task_memory_full(stmt: S.Instruction, diags: Diagnostics) -> None:
600
+ for _ in range(_TASK_MEMORY_COPIES):
601
+ diags.error(stmt.line, "Program too big for task memory", stmt.file)
602
+
603
+
604
+ def _check_delay(
605
+ stmt: S.Instruction, cfg: dict, env: consteval.Env, diags: Diagnostics, sequential: bool = False, slow: bool = False
606
+ ) -> None:
607
+ args = stmt.args
608
+ option_arg = args[0] if len(args) > 0 else None
609
+ interval_arg = args[1] if len(args) > 1 else None
610
+ units_arg = args[2] if len(args) > 2 else None
611
+
612
+ option = _int_value(option_arg, env)
613
+ if option is not None:
614
+ option = _delay_option_byte(option)
615
+ if option not in (0, 1, 2):
616
+ diags.error(stmt.line, f"Illegal option {option}", stmt.file)
617
+
618
+ interval_value = _const_value(interval_arg, env)
619
+ if isinstance(interval_value, str):
620
+ diags.error(stmt.line, "Delay interval cannot be of type String.", stmt.file)
621
+ return # oracle: the string-type rejection is the only diagnostic for a string interval.
622
+
623
+ if option == 0 and cfg.get("option0_limit"):
624
+ if sequential:
625
+ # SequentialMode checks neither the interval nor its expansion (oracle, CR6, round 57)
626
+ return
627
+ if interval_arg is not None and not consteval.is_constant(interval_arg, env):
628
+ diags.error(stmt.line, "Measurement delay must have constant interval.", stmt.file)
629
+ return
630
+ unit = _const_value(units_arg, env)
631
+ if (
632
+ not isinstance(interval_value, (int, float))
633
+ or isinstance(interval_value, bool)
634
+ or not math.isfinite(interval_value)
635
+ or not isinstance(unit, int)
636
+ or isinstance(unit, bool)
637
+ or unit not in _DELAY_US_PER_UNIT
638
+ ):
639
+ return
640
+ # the unsigned 32-bit microsecond count, so a negative interval is too long as well
641
+ # (oracle, CR6/CR1000X, rounds 35 and 57; docs/checker.md#task-memory-overflow-of-long-delays-round-57)
642
+ us = scan_time.delay_us(interval_value, unit)
643
+ if us > cfg["option0_limit"]:
644
+ diags.error(stmt.line, "Measurement Delay Exceeds 60 min.", stmt.file)
645
+ if cfg.get("task_memory") and scan_time.task_memory_overflow(us):
646
+ _task_memory_full(stmt, diags)
647
+ elif option == 1 and cfg.get("option1_limit"):
648
+ us = _delay_interval_us(interval_arg, units_arg, env)
649
+ if us is not None and us >= cfg["option1_limit"]:
650
+ diags.error(stmt.line, "Max Delay is 24 Hours", stmt.file)
651
+ elif option == 2 and cfg.get("option2_constant", True):
652
+ if interval_arg is not None and not consteval.is_constant(interval_arg, env):
653
+ diags.error(stmt.line, "SDM Delay interval must be constant.", stmt.file)
654
+ elif interval_value == 0 and cfg.get("option0_limit"): # (CR6/CR1000X data only)
655
+ # `Delay(2,0,Sec)` (oracle, CR6/CR1000X, 2026-10-25, round 35; 1 and -1 compile)
656
+ diags.error(stmt.line, "Invalid time specified.", stmt.file)
657
+ elif slow and cfg.get("sdm_slow_sequence"):
658
+ # written after `SlowSequence` in the program body, also in SequentialMode and in a
659
+ # one-line If; a Sub it calls is not checked (oracle, CR6/CR1000X, round 64,
660
+ # docs/checker.md#sdm-delay-in-a-slow-sequence-round-64)
661
+ diags.error(stmt.line, "SDM Delay Not Allowed in Slow Sequence.", stmt.file)
662
+
663
+
664
+ def _delay_interval_us(interval_arg, units_arg, env: consteval.Env) -> float | None:
665
+ value = _const_value(interval_arg, env)
666
+ unit = _const_value(units_arg, env)
667
+ if not consteval.is_finite_number(value) or not consteval.is_finite_number(unit) or value < 0:
668
+ return None
669
+ if value >= 2**31: # outside the Long range: no limit applies (oracle, CR6/CR1000X, round 16)
670
+ return None
671
+ us_per_unit = _DELAY_US_PER_UNIT.get(int(unit))
672
+ return value * us_per_unit if us_per_unit is not None else None
673
+
674
+
675
+ # --- `CS616`: `Invalid reps %d, must be > 0` for a non-positive `Reps` (CR6 only, oracle-
676
+ # confirmed; not chased on CR1000X/CR300/CR350 this round, `docs/instructions.md`).
677
+ def _check_cs616_reps(stmt: S.Instruction, measure: dict, env: consteval.Env, diags: Diagnostics) -> None:
678
+ pos = measure["cs616_min_reps_pos"]
679
+ idx = pos - 1
680
+ if idx >= len(stmt.args):
681
+ return
682
+ value = _int_value(stmt.args[idx], env)
683
+ if value is not None and value <= 0:
684
+ diags.error(stmt.line, f"Invalid reps {value}, must be > 0", stmt.file)
685
+
686
+
687
+ # --- `ExciteV`/`ExciteI`: `Excitation exceeds 2500.000000.` when `|ExmV|`/`|ExmI|` > 2500 (CR6
688
+ # only, oracle-confirmed; CR1000X accepts at least 2501 mV with no error, so this is deliberately
689
+ # not applied there -- `docs/instructions.md`). `ExciteI` does not exist on CR1000X/CR300/CR350 at
690
+ # all (`docs/loggers.md`), so this only ever matches on CR6 regardless.
691
+ def _check_excite_magnitude(stmt: S.Instruction, measure: dict, env: consteval.Env, diags: Diagnostics) -> None:
692
+ pos = measure["excite_magnitude_pos"]
693
+ idx = pos - 1
694
+ if idx >= len(stmt.args):
695
+ return
696
+ value = _const_value(stmt.args[idx], env)
697
+ if isinstance(value, (int, float)) and abs(value) > measure["excite_max_mv"]:
698
+ diags.error(stmt.line, f"Excitation exceeds {float(measure['excite_max_mv']):.6f}.", stmt.file)
699
+
700
+
701
+ _UNIT_NAMES = {1: "mSec", 2: "Sec", 3: "Min"}
702
+ _OPENERS = frozenset({"SELECT", "WHILE"})
703
+ _CLOSERS = frozenset({"ENDIF", "ENDSELECT", "NEXTSUBSCAN", "WEND"})
704
+
705
+
706
+ def is_sequential(program: S.Program, profile: Profile, env: consteval.Env) -> bool:
707
+ """`mode.decide` == "Sequential"; a failure is left for the compile's own `mode.decide` call
708
+ to record (here it reads as Pipeline)."""
709
+ from .. import mode
710
+
711
+ try:
712
+ return (
713
+ mode.decide(
714
+ program.statements,
715
+ profile,
716
+ lambda e: consteval.is_constant(e, env),
717
+ lambda e: consteval.value_of(e, env),
718
+ )
719
+ == "Sequential"
720
+ )
721
+ except Exception:
722
+ return False
723
+
724
+
725
+ def check_scan_delay_budget(
726
+ program: S.Program, profile: Profile, diags: Diagnostics, env: consteval.Env, zero: bool = False
727
+ ) -> None:
728
+ """CR6/CR1000X: the measurement time of a main `Scan` (`checker/scan_time.py`, 100 us units)
729
+ above its interval -> `Scan interval too short for measurement by %d %s.` at `NextScan`, the
730
+ excess rounded up to the scan's own unit (`Scan(50,mSec)` + `Delay(0,1,Sec)` -> `by 951 mSec`;
731
+ `Scan(1,Sec)` + `Delay(0,1,Sec)` -> `by 1 Sec`; a `SubScan(0,0,n)` body counts n times).
732
+ A `SubScan(Interval,...)` body longer than its interval gives `SubScan interval too short for
733
+ measurement by %d %s.` at its `NextSubScan`. A Sec/Min `Scan` whose interval is 0 or past
734
+ `INT32_MAX` counts as interval 0. The zero-interval scans (`zero=True`) are reported even
735
+ though the scan's own parameter error already failed the compile, so the caller runs them
736
+ ahead of the error-gated tier C. SequentialMode never reports it. docs/instructions.md "Scan
737
+ time budget"."""
738
+ if profile.name not in ("cr6", "cr1000x"):
739
+ return
740
+ from . import scan_time
741
+
742
+ # SequentialMode never reports it (oracle, CR6/CR1000X, 2026-10-08; docs/fuzz-findings.md).
743
+ if is_sequential(program, profile, env):
744
+ return
745
+ for b in scan_time.budgets(program, env, profile, include_zero=zero):
746
+ if (b.interval == 0) != zero:
747
+ continue
748
+ for stmt, amount, unit in b.sub_short:
749
+ diags.error(
750
+ stmt.line,
751
+ f"SubScan interval too short for measurement by {amount} {scan_time.UNIT_NAMES[unit]}.",
752
+ stmt.file,
753
+ )
754
+ if b.total % 2**32 > b.interval: # unsigned: a negative total also fails (round 31)
755
+ amount = scan_time.amount(scan_time.i32(b.total - b.interval), b.msg_unit)
756
+ diags.error(
757
+ b.end.line,
758
+ f"Scan interval too short for measurement by {amount} {scan_time.UNIT_NAMES[b.msg_unit]}.",
759
+ b.end.file,
760
+ )
761
+
762
+
763
+ def check_cpi_time(program: S.Program, profile: Profile, diags: Diagnostics, env: consteval.Env) -> None:
764
+ """CR6/CR1000X: a `Scan` shorter than the CPI measurement time of a module -> `Insufficient
765
+ time (%d ms) for measurements from CPI address %d (needs at least %d ms).`, on the program's
766
+ last line, only when the compile has no other error (docs/instructions.md "CPI measurement
767
+ time"). The binary walks the module list in order of first use; for each module it totals the
768
+ module's time in every scan holding CDM instructions, keeps per scan the (first) module with
769
+ the largest total so far, and then reports every such scan whose interval is below that
770
+ figure, so a program with k modules can print up to k lines per scan, naming the leader so far.
771
+ Modelled: `CDM_TCComp(TEMP120, ...)`, `Reps*430+275` microseconds rounded up to 100 us per
772
+ statement, summed per address and rounded up to whole ms. Every occurrence counts once wherever
773
+ it sits (loops, `If` branches, `Select` cases in SequentialMode). A `SubScan(Interval,Units,n)`
774
+ body counts n times at `Interval` spacing (zero: its own length); an interval shorter than the
775
+ body gives `SubScan interval too short for measurements from CPI address %d by %d uSec.` first.
776
+ Programs with any other CDM instruction, an unreadable instruction or a SubScan form not
777
+ probed are skipped whole."""
778
+ if profile.name not in ("cr6", "cr1000x") or not program.statements:
779
+ return
780
+ from .instr_rules import _US_PER_UNIT, _scan_raw
781
+
782
+ scans: list[dict] = [] # {"ms": interval, "times": {addr: 100 us units}, "short": [(addr, by)]}
783
+ order: list[int] = [] # CPI addresses in order of first use
784
+ scan: dict | None = None
785
+ sub: tuple[int, int] | None = None # open SubScan: (count, interval in 100 us units)
786
+ sub_start: dict[int, int] = {}
787
+ sequential = is_sequential(program, profile, env)
788
+ depth = 0 # open If/For/Do/Select blocks
789
+ items: list[tuple[S.Stmt, bool]] = []
790
+ for stmt in program.statements:
791
+ kw = stmt.keyword.upper() if isinstance(stmt, S.Instruction) else ""
792
+ text = stmt.text or ""
793
+ if (
794
+ isinstance(stmt, S.For)
795
+ or (isinstance(stmt, S.DoLoop) and stmt.keyword == "DO")
796
+ or (isinstance(stmt, S.If) and stmt.inline is None)
797
+ or kw in _OPENERS
798
+ ):
799
+ items.append((stmt, depth > 0))
800
+ depth += 1
801
+ continue
802
+ if (
803
+ (kw == "NEXT" and not re.match(r"(?i)\s*Next\s+(Sub)?Scan\b", text))
804
+ or (isinstance(stmt, S.DoLoop) and stmt.keyword == "LOOP")
805
+ or kw in _CLOSERS - {"NEXTSUBSCAN"}
806
+ or (kw == "END" and re.match(r"(?i)\s*End\s+(If|Select)\b", text))
807
+ ):
808
+ depth = max(depth - 1, 0)
809
+ items.append((stmt, depth > 0))
810
+ if isinstance(stmt, S.If):
811
+ items += [(child, True) for child in (stmt.inline or []) + (stmt.inline_else or [])]
812
+ for stmt, conditional in items:
813
+ kw = stmt.keyword.upper() if isinstance(stmt, S.Instruction) else ""
814
+ text = stmt.text or ""
815
+ if kw == "NEXT" and re.match(r"(?i)\s*Next\s+Scan\b", text):
816
+ kw = "NEXTSCAN"
817
+ elif kw == "NEXT" and re.match(r"(?i)\s*Next\s+SubScan\b", text):
818
+ kw = "NEXTSUBSCAN"
819
+ if kw.startswith("CDM_") and kw != "CDM_TCCOMP" or kw == "CPIADDMODULE2":
820
+ return
821
+ if isinstance(stmt, S.Instruction) and (kw == "SCAN"):
822
+ raw = _scan_raw(stmt, env)
823
+ if raw is None:
824
+ return
825
+ scan = {"ms": raw[0] * _US_PER_UNIT[raw[1]] // 1000, "times": {}, "short": []}
826
+ scans.append(scan)
827
+ elif scan is None:
828
+ continue
829
+ elif kw == "NEXTSCAN":
830
+ if sub is not None:
831
+ return
832
+ scan = None
833
+ elif isinstance(stmt, S.Instruction) and (kw == "SUBSCAN"):
834
+ args = [_value(a, env) for a in stmt.args]
835
+ count = args[2] if len(args) == 3 else None
836
+ step = 0
837
+ if not isinstance(count, int) or isinstance(count, bool) or count < 1 or sub is not None:
838
+ return
839
+ if args[0] != 0:
840
+ unit = _US_PER_UNIT.get(args[1]) if isinstance(args[1], int) else None
841
+ if unit is None or not consteval.is_finite_number(args[0]) or args[0] <= 0:
842
+ return
843
+ step = int(args[0]) * unit // 100
844
+ if sequential and step <= 10: # "SequentialMode SubScan interval must be > 1 mS."
845
+ return
846
+ if step * count > scan["ms"] * 10: # "Product of SubScan interval and iterations ..."
847
+ return
848
+ sub = (count, step)
849
+ sub_start = dict(scan["times"])
850
+ elif kw == "NEXTSUBSCAN":
851
+ if sub is None:
852
+ return
853
+ count, step = sub
854
+ for addr, units in list(scan["times"].items()):
855
+ length = units - sub_start.get(addr, 0)
856
+ if length > 0:
857
+ if step and length > step:
858
+ scan["short"].append((addr, (length - step) * 100))
859
+ scan["times"][addr] = units + (step or length) * (count - 1)
860
+ sub = None
861
+ elif isinstance(stmt, S.Instruction) and (kw == "CDM_TCCOMP"):
862
+ module, addr, reps = (_int_value(stmt.args[i], env) if i < len(stmt.args) else None for i in (0, 1, 3))
863
+ if module != 224 or addr is None or not 1 <= addr <= 120 or reps is None or reps < 1:
864
+ return
865
+ if conditional and not sequential: # "not allowed inside of a conditional statement"
866
+ return
867
+ if addr not in order:
868
+ order.append(addr)
869
+ scan["times"][addr] = scan["times"].get(addr, 0) + -(-(reps * 430 + 275) // 100)
870
+ last = program.statements[-1].line
871
+ leader: dict[int, tuple[int, int]] = {} # scan index -> (ms needed, address)
872
+ for addr in order:
873
+ for i, sc in enumerate(scans):
874
+ if not sc["times"]:
875
+ continue
876
+ for a, by in sc["short"]:
877
+ if a == addr:
878
+ diags.error(last, f"SubScan interval too short for measurements from CPI address {a} by {by} uSec.")
879
+ need = -(-sc["times"].get(addr, 0) // 10)
880
+ if need > leader.get(i, (0, 0))[0]:
881
+ leader[i] = (need, addr)
882
+ for i, sc in enumerate(scans):
883
+ if i in leader and sc["ms"] < leader[i][0]:
884
+ diags.error(
885
+ last,
886
+ f"Insufficient time ({sc['ms']} ms) for measurements from CPI "
887
+ f"address {leader[i][1]} (needs at least {leader[i][0]} ms).",
888
+ )
889
+
890
+
891
+ def _value(expr: S.Expr | None, env: consteval.Env) -> object | None:
892
+ if expr is None or not consteval.is_constant(expr, env):
893
+ return None
894
+ try:
895
+ return consteval.value_of(expr, env)
896
+ except consteval.NotConstant:
897
+ return None
898
+
899
+
900
+ # `Reps` (the second argument) of measurement instructions, oracle-probed 2026-10-01 (the decompiled
901
+ # CR6 check `FUN_005163b0` is shared by 23 instructions; only the ones probed on a logger are
902
+ # listed). CR6/CR1000X: `Invalid reps %d, must be > 0` (no period) for Reps <= 0, and `Variable %s
903
+ # out of bounds.` when the Float destination cannot hold the output count (also for negative
904
+ # output counts). Optional outputs adjust that count below. CR300/CR350: `Invalid reps 0, must
905
+ # be > 0.` for Reps 0 only.
906
+ # CR300/CR350 (round 31): all of these check the destination like VoltSe (round 30); Reps <= 0
907
+ # is `Invalid reps %d, must be > 0.`, except a negative Reps on Therm/PeriodAvg, which instead
908
+ # (with PulseCount) overruns the destination.
909
+ _SINGLE_PASS_REPS = frozenset(
910
+ {
911
+ "VOLTSE",
912
+ "VOLTDIFF",
913
+ "TCSE",
914
+ "TCDIFF",
915
+ "BRHALF",
916
+ "BRHALF3W",
917
+ "BRHALF4W",
918
+ "BRFULL",
919
+ "BRFULL6W",
920
+ "THERM107",
921
+ "THERM108",
922
+ "THERM109",
923
+ "CURRENTSE",
924
+ "PERIODAVG",
925
+ "PULSECOUNT",
926
+ }
927
+ )
928
+ _NEGATIVE_REPS_SILENT = frozenset({"THERM107", "THERM108", "THERM109", "PERIODAVG"})
929
+ _NEGATIVE_REPS_DEST = frozenset({"THERM107", "THERM108", "THERM109", "PERIODAVG", "PULSECOUNT"})
930
+ _REPS_CHECKED = {
931
+ "cr6": frozenset(
932
+ {
933
+ "VOLTSE",
934
+ "VOLTDIFF",
935
+ "TCDIFF",
936
+ "TCSE",
937
+ "BRHALF",
938
+ "BRHALF3W",
939
+ "BRHALF4W",
940
+ "BRFULL",
941
+ "BRFULL6W",
942
+ "RESISTANCE",
943
+ "RESISTANCE2W",
944
+ "RESISTANCE3W",
945
+ "PULSECOUNT",
946
+ "CS616",
947
+ "PERIODAVG",
948
+ "CURRENTSE",
949
+ "THERM107",
950
+ "THERM108",
951
+ "THERM109",
952
+ }
953
+ ),
954
+ "cr1000x": frozenset(
955
+ {
956
+ "VOLTSE",
957
+ "VOLTDIFF",
958
+ "TCDIFF",
959
+ "TCSE",
960
+ "CURRENTSE",
961
+ "PERIODAVG",
962
+ "PULSECOUNT",
963
+ "BRHALF",
964
+ "BRHALF3W",
965
+ "BRHALF4W",
966
+ "BRFULL",
967
+ "BRFULL6W",
968
+ "CS616",
969
+ "THERM107",
970
+ "THERM108",
971
+ "THERM109",
972
+ }
973
+ ),
974
+ "cr300": _SINGLE_PASS_REPS,
975
+ "cr350": _SINGLE_PASS_REPS,
976
+ }
977
+ _REPS_DEST_SIZE = frozenset(
978
+ {
979
+ "VOLTSE",
980
+ "VOLTDIFF",
981
+ "TCDIFF",
982
+ "TCSE",
983
+ "CURRENTSE",
984
+ "PERIODAVG",
985
+ "BRHALF",
986
+ "BRHALF3W",
987
+ "BRHALF4W",
988
+ "BRFULL",
989
+ "BRFULL6W",
990
+ "RESISTANCE",
991
+ "RESISTANCE2W",
992
+ "RESISTANCE3W",
993
+ "PULSECOUNT",
994
+ "CS616",
995
+ }
996
+ )
997
+
998
+
999
+ def _measurement_output_count(stmt: S.Instruction, reps: int, env: consteval.Env) -> int:
1000
+ """Optional extra outputs, from the bridge/resistance builders and round-25 oracle probes.
1001
+
1002
+ The option is an integer equal to 1, not a Boolean: True (-1) does not enable it.
1003
+ Bridges return one extra value per repetition; resistance returns one extra value overall.
1004
+ """
1005
+ kw = stmt.keyword.upper()
1006
+ option_index = {"BRHALF4W": 14, "BRFULL6W": 14, "RESISTANCE": 13, "RESISTANCE2W": 10, "RESISTANCE3W": 10}.get(kw)
1007
+ if option_index is None or len(stmt.args) <= option_index:
1008
+ return reps
1009
+ if _int_value(stmt.args[option_index], env) != 1:
1010
+ return reps
1011
+ return reps * 2 if kw in {"BRHALF4W", "BRFULL6W"} else reps + 1
1012
+
1013
+
1014
+ def _measurement_aliases(program: S.Program, tables, env: consteval.Env):
1015
+ """Alias destinations: `(backing elements, start offset, diagnostic name, declared size)`,
1016
+ the size None for a bare alias name (rounds 26 and 30).
1017
+
1018
+ Bind at the alias declaration, so a later local shadow of its backing name does not
1019
+ change the storage.
1020
+ """
1021
+ aliases = {}
1022
+ routine = None
1023
+ for st in program.statements:
1024
+ if isinstance(st, S.SubDef):
1025
+ routine = st
1026
+ elif S.ends_routine(st):
1027
+ routine = None
1028
+ if not isinstance(st, S.Instruction) or st.keyword.upper() != "ALIAS" or not st.args:
1029
+ continue
1030
+ target = st.args[0]
1031
+ if not isinstance(target, (S.Name, S.Index)):
1032
+ continue
1033
+ scope = tables(st)
1034
+ shape = scope.dims.get(target.name.upper())
1035
+ param = (
1036
+ next((p for p in routine.params if p.name.upper() == target.name.upper()), None)
1037
+ if routine is not None
1038
+ else None
1039
+ )
1040
+ if param is not None and (param.type or "FLOAT").upper() == "FLOAT":
1041
+ # a Float parameter's own header shape backs the alias (round 30 review, `Sub S(P(4))`)
1042
+ shape = tuple(_int_value(d, env) for d in param.dim_exprs) or (1,)
1043
+ if any(n is None or n < 1 for n in shape):
1044
+ continue
1045
+ elif shape is None or scope.types.get(target.name.upper()) != "FLOAT":
1046
+ continue
1047
+ # Alias declaration subscripts may be named constants.
1048
+ if isinstance(target, S.Index):
1049
+ indices = [_int_value(a, env) for a in target.args]
1050
+ if any(i is None for i in indices):
1051
+ continue
1052
+ indices = cast(list[int], indices)
1053
+ if len(indices) == 1:
1054
+ offset = indices[0] - 1
1055
+ elif len(indices) == len(shape) and all(1 <= i <= n for i, n in zip(indices, shape)):
1056
+ offset = 0
1057
+ for i, n in zip(indices, shape):
1058
+ offset = offset * n + i - 1
1059
+ else:
1060
+ continue
1061
+ else:
1062
+ offset = 0
1063
+ total = math.prod(shape)
1064
+ declared_names = {
1065
+ v.name.upper(): v.name
1066
+ for decl in symbols.scoped_view(program, st).statements
1067
+ if isinstance(decl, S.Declaration)
1068
+ for v in decl.vars
1069
+ }
1070
+ entries = {}
1071
+ for alias in st.args[1:]:
1072
+ if not isinstance(alias, (S.Name, S.Index)):
1073
+ break
1074
+ size = (
1075
+ 1 if isinstance(alias, S.Name) else (_int_value(alias.args[0], env) if len(alias.args) == 1 else None)
1076
+ )
1077
+ if size is None or size < 1 or not 0 <= offset < total:
1078
+ break
1079
+ entries[alias.name.upper()] = (
1080
+ total,
1081
+ offset,
1082
+ declared_names.get(target.name.upper(), target.name) if offset == 0 else alias.name,
1083
+ None if isinstance(alias, S.Name) else size,
1084
+ )
1085
+ offset += size
1086
+ aliases[id(st)] = entries
1087
+ cache = {}
1088
+ parameters = {
1089
+ id(tables(st)): {p.name.upper() for p in st.params} for st in program.statements if isinstance(st, S.SubDef)
1090
+ }
1091
+
1092
+ def lookup(stmt, dest):
1093
+ if dest.name.upper() in tables(stmt).dims or dest.name.upper() in parameters.get(id(tables(stmt)), set()):
1094
+ return None
1095
+ key = id(tables(stmt))
1096
+ if key not in cache:
1097
+ visible = {}
1098
+ for st in symbols.scoped_view(program, stmt).statements:
1099
+ visible.update(aliases.get(id(st), {}))
1100
+ cache[key] = visible
1101
+ entry = cache[key].get(dest.name.upper())
1102
+ if entry is not None and isinstance(dest, S.Index) and entry[3] is None:
1103
+ return None # a subscripted bare alias is not modelled
1104
+ return entry
1105
+
1106
+ return lookup
1107
+
1108
+
1109
+ def _alias_overrun(stmt: S.Instruction, dest, alias, count: int, profile: Profile, env: consteval.Env) -> bool:
1110
+ """An alias destination's explicit index must lie within its declared size; the outputs
1111
+ must then fit the backing storage from the destination element (round 30). CR6/CR1000X
1112
+ count from the alias's own start, CR300/CR350 from the backing variable's start."""
1113
+ total, offset, _, size = alias
1114
+ index = 0
1115
+ if isinstance(dest, S.Index):
1116
+ k = _index_value(dest.args[0], env) if len(dest.args) == 1 else None
1117
+ if k is None:
1118
+ return False
1119
+ if not 1 <= k <= size:
1120
+ return True
1121
+ index = k - 1
1122
+ available = total - index - (0 if profile.single_pass else offset)
1123
+ return count < 0 or count > available
1124
+
1125
+
1126
+ def check_reps(program: S.Program, profile: Profile, diags: Diagnostics, env: consteval.Env) -> None:
1127
+ checked = _REPS_CHECKED.get(profile.name, frozenset())
1128
+ if not checked:
1129
+ return
1130
+ from . import symbols
1131
+
1132
+ tables = symbols.scope_tables(program, env, scalars=True, parameters=True)
1133
+ alias_storage = _measurement_aliases(program, tables, env)
1134
+ for stmt in program.statements:
1135
+ if not isinstance(stmt, S.Instruction) or stmt.keyword.upper() not in checked or len(stmt.args) < 2:
1136
+ continue
1137
+ scope = tables(stmt)
1138
+ reps = _int_value(stmt.args[1], env)
1139
+ if reps is None and isinstance(_const_value(stmt.args[1], env), str):
1140
+ # `Therm107(T,"fuzz",...)` is 0 on CR300; CR6/CR1000X take any string as INT_MIN
1141
+ # (`PulseCount(R(1),"2",C1,...)`, oracle, 2026-10-25, round 35)
1142
+ reps = 0 if profile.single_pass else -2147483648
1143
+ if reps is None:
1144
+ continue
1145
+ if not profile.single_pass and not -(2**31) <= reps < 2**31:
1146
+ reps = -2147483648 # outside the Long range (oracle, CR1000X, round 67)
1147
+ if profile.single_pass:
1148
+ # before this line's other diagnostics, e.g. its fnotch warning (oracle, CR300, fuzz
1149
+ # `84bd80cc420c`); destination bounds: rounds 30-31
1150
+ kw = stmt.keyword.upper()
1151
+ messages: list[str | None] = []
1152
+ if reps == 0 or (reps < 0 and kw not in _NEGATIVE_REPS_SILENT):
1153
+ messages.append(f"Invalid reps {reps}, must be > 0.")
1154
+ if reps > 0 or (reps < 0 and kw in _NEGATIVE_REPS_DEST):
1155
+ messages.append(_dest_overrun(stmt, stmt.args[0], reps, profile, env, alias_storage, scope))
1156
+ at = next(
1157
+ (i for i, d in enumerate(diags.items) if (d.line, d.file) == (stmt.line, stmt.file)), len(diags.items)
1158
+ )
1159
+ diags.items[at:at] = [Diagnostic("error", stmt.line, m, stmt.file) for m in messages if m]
1160
+ continue
1161
+ if reps <= 0 and stmt.keyword.upper() != "CS616": # (CS616: `_check_cs616_reps`)
1162
+ diags.error(stmt.line, f"Invalid reps {reps}, must be > 0", stmt.file)
1163
+ dest = stmt.args[0]
1164
+ if reps == 0 or not isinstance(dest, (S.Name, S.Index)):
1165
+ continue
1166
+ if stmt.keyword.upper() not in _REPS_DEST_SIZE:
1167
+ # Therm107/108/109: no output count, but an alias index past its size (round 31)
1168
+ alias = alias_storage(stmt, dest) if isinstance(dest, (S.Name, S.Index)) else None
1169
+ if alias is not None and _alias_overrun(stmt, dest, alias, 0, profile, env):
1170
+ diags.error(stmt.line, f"Variable {alias[2]} out of bounds.", stmt.file)
1171
+ continue
1172
+ message = _dest_overrun(stmt, dest, reps, profile, env, alias_storage, scope)
1173
+ if message is not None:
1174
+ diags.error(stmt.line, message, stmt.file)
1175
+ if reps > 1 and stmt.keyword.upper() == "CURRENTSE" and profile.name == "cr6":
1176
+ diags.error(stmt.line, "Reps >1 not allowed in this device.", stmt.file) # (round 31)
1177
+ _check_bridge_burst(program, profile, diags, env)
1178
+ _check_bridge_span(program, profile, diags, env)
1179
+ _check_pulse_span(program, profile, diags, env)
1180
+
1181
+
1182
+ # CR6 bridges: (channel, ExChan, MeasPEx argument indexes, measurement groups, channel stride)
1183
+ _BRIDGE_SPAN = {
1184
+ "BRHALF": (3, 4, 5, 1, 1),
1185
+ "BRHALF3W": (3, 4, 5, 2, 1),
1186
+ "BRFULL": (3, 4, 5, 1, 2),
1187
+ "BRHALF4W": (4, 5, 6, 2, 2),
1188
+ "BRFULL6W": (4, 5, 6, 2, 2),
1189
+ }
1190
+
1191
+
1192
+ def _check_bridge_span(program: S.Program, profile: Profile, diags: Diagnostics, env: consteval.Env) -> None:
1193
+ """CR6 bridge span validator (round 31, docs/checker.md "Bridge channel spans"): an excitation
1194
+ start inside the measurement span -> `Measurement channels and excitation channels overlap.`;
1195
+ the first excitation, else (Reps > 1) measurement, channel past U12 -> `Channel %d exceeds
1196
+ number of valid channels.` In line order after the destination bounds error. The start channel
1197
+ itself is `terminals.py`'s."""
1198
+ if profile.name != "cr6":
1199
+ return
1200
+ for stmt in program.statements:
1201
+ if not isinstance(stmt, S.Instruction) or stmt.keyword.upper() not in _BRIDGE_SPAN:
1202
+ continue
1203
+ cp, xp, mp, groups, stride = _BRIDGE_SPAN[stmt.keyword.upper()]
1204
+ if len(stmt.args) <= mp:
1205
+ continue
1206
+ reps, chan, ex, meas = (_int_value(stmt.args[i], env) for i in (1, cp, xp, mp))
1207
+ if (
1208
+ (reps is None or chan is None or ex is None or meas is None)
1209
+ or reps < 1
1210
+ or not (201 <= chan <= 212 and 201 <= ex <= 212)
1211
+ or (stride == 2 and chan % 2 == 0)
1212
+ ):
1213
+ continue
1214
+ s, e, meas = chan - 201, ex - 201, max(meas, 1)
1215
+ meas_last = s + (reps - 1) * groups * stride + (groups - 1) * stride
1216
+ ex_last = e + -(-reps // meas) - 1
1217
+ found = []
1218
+ if s < e <= meas_last: # (the binary's other branch, e < s, cannot fire for Reps >= 1)
1219
+ found.append("Measurement channels and excitation channels overlap.")
1220
+ past = next((c for c in range(e + 1, ex_last + 1) if c + 1 > 12), None)
1221
+ if past is None and ex_last <= 11 and reps > 1:
1222
+ past = next((c for c in range(s + stride, meas_last + 1, stride) if c + 1 > 12), None)
1223
+ if past is not None:
1224
+ found.append(f"Channel {past + 1} exceeds number of valid channels.")
1225
+ _insert_in_line_order(diags, stmt, found)
1226
+
1227
+
1228
+ def _insert_in_line_order(diags: Diagnostics, stmt: S.Instruction, texts: list[str]) -> None:
1229
+ """After this tier's errors on earlier lines and on this line, before later lines."""
1230
+ at = next(
1231
+ (i for i, d in enumerate(diags.items) if d.severity == "error" and d.file == stmt.file and d.line > stmt.line),
1232
+ len(diags.items),
1233
+ )
1234
+ diags.items[at:at] = [Diagnostic("error", stmt.line, text, stmt.file) for text in texts]
1235
+
1236
+
1237
+ def _pulse_index(code: int) -> tuple[int, int] | None:
1238
+ """(port index, highest index Reps may reach) of a CR6 PulseCount channel (round 31). From
1239
+ 201 up, the port converter keeps `code % 200` (`412` is U12, `400` C4; docs/checker.md)."""
1240
+ idx = code - 101 if 101 <= code <= 116 else code % 200 + 3 if code >= 201 and code % 200 <= 12 else None
1241
+ return None if idx is None else (idx, 4 if idx < 4 else 16) # one past C4 / U12
1242
+
1243
+
1244
+ def _check_pulse_span(program: S.Program, profile: Profile, diags: Diagnostics, env: consteval.Env) -> None:
1245
+ """CR6 `PulseCount`: Reps channels from the start must stay in its terminal family ->
1246
+ `Invalid PulseCount reps.` after the destination bounds error (round 31, docs/checker.md
1247
+ "Bridge channel spans"; CR1000X: the `pulse_chan_cr1000x` terminal span)."""
1248
+ if profile.name != "cr6":
1249
+ return
1250
+ for stmt in program.statements:
1251
+ if not isinstance(stmt, S.Instruction) or stmt.keyword.upper() != "PULSECOUNT" or len(stmt.args) < 3:
1252
+ continue
1253
+ reps, chan = _int_value(stmt.args[1], env), _int_value(stmt.args[2], env)
1254
+ port = _pulse_index(chan) if chan is not None else None
1255
+ if reps is None or port is None:
1256
+ continue
1257
+ if max(reps, 1) - 1 + port[0] > port[1]:
1258
+ _insert_in_line_order(diags, stmt, ["Invalid PulseCount reps."])
1259
+
1260
+
1261
+ def _dest_overrun(stmt, dest, reps, profile, env, alias_storage, scope) -> str | None:
1262
+ """`Variable %s out of bounds.` when the Float destination cannot hold the output count."""
1263
+ if not isinstance(dest, (S.Name, S.Index)):
1264
+ return None
1265
+ count = _measurement_output_count(stmt, reps, env)
1266
+ alias = alias_storage(stmt, dest)
1267
+ if alias is not None:
1268
+ return f"Variable {alias[2]} out of bounds." if _alias_overrun(stmt, dest, alias, count, profile, env) else None
1269
+ if scope.types.get(dest.name.upper()) != "FLOAT" or dest.name.upper() not in scope.dims:
1270
+ return None
1271
+ shape = scope.dims[dest.name.upper()]
1272
+ offset = _flat_offset(dest, shape, env)
1273
+ if offset is None:
1274
+ return None
1275
+ total = math.prod(shape)
1276
+ return f"Variable {dest.name} out of bounds." if count < 0 or count > total - offset else None
1277
+
1278
+
1279
+ def _check_bridge_burst(program: S.Program, profile: Profile, diags: Diagnostics, env: consteval.Env) -> None:
1280
+ """The shared bridge builder checks burst MeasPEx after resolving its destination."""
1281
+ if profile.name not in {"cr6", "cr1000x"}:
1282
+ return
1283
+ for stmt in program.statements:
1284
+ if (
1285
+ not isinstance(stmt, S.Instruction)
1286
+ or stmt.keyword.upper() not in {"BRHALF4W", "BRFULL6W"}
1287
+ or len(stmt.args) < 7
1288
+ ):
1289
+ continue
1290
+ reps, channel, meas = (_int_value(stmt.args[i], env) for i in (1, 4, 6))
1291
+ if reps is None or channel is None or meas is None or meas < 1:
1292
+ continue
1293
+ # Only valid burst channels: invalid channels have their own conversion diagnostics.
1294
+ valid = (-211 <= channel <= -201 and channel % 2 != 0) if profile.name == "cr6" else -8 <= channel <= -1
1295
+ if valid and meas != reps:
1296
+ diags.error(stmt.line, "When channel < 0, measurements per excitation must equal reps.", stmt.file)
1297
+
1298
+
1299
+ # Destination size rules (oracle, 2026-10-02; docs/checker.md#destination-bounds): elements the
1300
+ # Float destination must hold from its start -> `Variable %s out of bounds.`. `(per_rep,)` scales the
1301
+ # Reps argument (index 1); `REALTIME` needs a fixed nine (Therm107-109: `check_reps`, round 31).
1302
+ # Only CalFile also rejects negative Reps.
1303
+ _DEST_FIXED = {"REALTIME": 9}
1304
+ _DEST_PER_REP = {
1305
+ "CALFILE": 1,
1306
+ "AVGRUN": 1,
1307
+ "MOVE": 1,
1308
+ "MINRUN": 1,
1309
+ "MAXRUN": 1,
1310
+ "TOTALRUN": 1,
1311
+ "STDDEVRUN": 1, # (the other running statistics: oracle, CR6/CR1000X, round 35)
1312
+ "PRTCALC": 1,
1313
+ } # (oracle, CR6/CR1000X, round 57)
1314
+ _DEST_NEGATIVE_OOB = frozenset({"CALFILE", "PRTCALC"})
1315
+ _RUNNING_STATS = frozenset({"MINRUN", "MAXRUN", "TOTALRUN", "STDDEVRUN"}) # (AvgRun not probed)
1316
+
1317
+
1318
+ # Element counts the CR6 instruction builders pass to the shared destination resolver, read from the
1319
+ # decompiled builders and confirmed on the real compilers (docs/checker.md "Builder destination counts").
1320
+ # Arguments the builder stores in a byte are reduced modulo 256 (`_byte`).
1321
+ _SDM_MODE_VALUES = {
1322
+ "CS7500": (2, 2, 4, 3, 11, 3, 4, 4),
1323
+ "LI7200": (2, 5, 10, 12, 10, 5, 1, 5),
1324
+ "LI7700": (3, 5, 9, 13, 17, 3, 7, 9),
1325
+ }
1326
+ _TGA_VALUES = ((2, 3, 4), (5, 7, 9), (8, 10, 12), (19, 25, 31), (21, 27, 33))
1327
+ _EC100_VALUES = (8, 12, 13, 15, 36)
1328
+ _BUILDER_FIXED = {
1329
+ "CSAT3B": ((0, 5),),
1330
+ "CSAT3BMONITOR": ((0, 4),),
1331
+ "HYDRAPROBE": ((0, 11), (1, 4)),
1332
+ "GOESGPS": ((0, 6), (1, 7)),
1333
+ "RECTPOLAR": ((0, 2), (1, 2)),
1334
+ }
1335
+
1336
+
1337
+ def _byte(value: int) -> int:
1338
+ return value & 0xFF
1339
+
1340
+
1341
+ def _builder_counts(kw: str, args: list, env: consteval.Env) -> list[tuple[int, int]]:
1342
+ """`(argument, elements)` pairs a destination or source must hold from its start."""
1343
+
1344
+ def arg(i: int) -> int | None:
1345
+ return _int_value(args[i], env) if i < len(args) else None
1346
+
1347
+ if kw in _BUILDER_FIXED:
1348
+ return [(i, n) for i, n in _BUILDER_FIXED[kw] if i < len(args)]
1349
+ if kw == "SDMGENERIC":
1350
+ return [(0, count)] if (count := arg(5)) is not None else []
1351
+ if kw == "SDMSW8A":
1352
+ return [(0, _byte(count))] if (count := arg(1)) is not None else []
1353
+ if kw == "SDMCD16AC":
1354
+ return [(0, 16 * _byte(count))] if (count := arg(1)) is not None else []
1355
+ if kw == "CSAT3":
1356
+ if (count := arg(1)) is None:
1357
+ return []
1358
+ reps = _byte(count)
1359
+ return [(0, 5 * reps)] if reps < 128 else [] # a signed byte: negative is `Out of memory.`
1360
+ if kw == "PEAKVALLEY":
1361
+ return [(i, count) for i in (0, 1, 3) if i < len(args)] if (count := arg(2)) is not None else []
1362
+ if kw in _SDM_MODE_VALUES:
1363
+ reps, mode = arg(1), arg(3)
1364
+ if reps is None or mode is None or _byte(mode) > 7:
1365
+ return []
1366
+ return [(0, _SDM_MODE_VALUES[kw][_byte(mode)] * _byte(reps))]
1367
+ if kw == "TGA":
1368
+ first, second = arg(2), arg(3)
1369
+ if first is None or second is None or not (1 <= _byte(first) <= 5 and 1 <= _byte(second) <= 3):
1370
+ return []
1371
+ return [(0, _TGA_VALUES[_byte(first) - 1][_byte(second) - 1])]
1372
+ if kw == "EC100":
1373
+ option = arg(2)
1374
+ if option is None or (option != 2718 and _byte(option) > 3):
1375
+ return []
1376
+ return [(0, _EC100_VALUES[4 if option == 2718 else _byte(option)])]
1377
+ return []
1378
+
1379
+
1380
+ # Spatial family (oracle, 2026-10-02, all four loggers): argument 3, the source, needs Swath
1381
+ # elements (Float or Long; negative Swath is out of bounds); the destination needs a fixed 2
1382
+ # (Max/Min: value + location), Swath (SortSpa) or 2 * Swath (SortSpaIndexed); Avg/StdDev/RMS
1383
+ # impose nothing beyond the index rule. Entries are (fixed, per Swath).
1384
+ _SPA_DEST = {"MAXSPA": (2, 0), "MINSPA": (2, 0), "SORTSPA": (0, 1), "SORTSPAINDEXED": (0, 2)}
1385
+ # (oracle, round 56: `MaxSpa(L,...)` -> `Dest type must be Float.`, `SortSpa(LongDest,6,Float)` ->
1386
+ # `Source and Dest types must be the same.`)
1387
+ _SPA_TYPED = frozenset({"MAXSPA", "MINSPA", "SORTSPA"}) # (SortSpaIndexed: expressions.py)
1388
+ _SPA_SOURCE = frozenset({"AVGSPA", "MAXSPA", "MINSPA", "RMSSPA", "STDDEVSPA", "SORTSPA", "SORTSPAINDEXED"})
1389
+
1390
+
1391
+ def check_dest_bounds(program: S.Program, profile: Profile, diags: Diagnostics, env: consteval.Env) -> None:
1392
+ from . import symbols
1393
+
1394
+ tables = symbols.scope_tables(program, env, scalars=True)
1395
+ # the running statistics also bound a routine's scalar parameter (`Sub R(Q)` / `MinRun(Q,2,...)`
1396
+ # is `Variable Q out of bounds.`; oracle, CR6/CR1000X, 2026-10-25, round 35)
1397
+ with_parameters = symbols.scope_tables(program, env, scalars=True, parameters=True)
1398
+ dims: dict[str, tuple[int, ...]] = {}
1399
+ types: dict[str, str] = {}
1400
+ arrays: set[str] = set()
1401
+
1402
+ def overruns(arg: S.Expr | None, need: int, negative: bool, kinds: tuple[str, ...]) -> TypeGuard[S.Name | S.Index]:
1403
+ if not isinstance(arg, (S.Name, S.Index)):
1404
+ return False
1405
+ shape = dims.get(arg.name.upper())
1406
+ if shape is None or types.get(arg.name.upper()) not in kinds:
1407
+ return False
1408
+ offset = _flat_offset(arg, shape)
1409
+ if offset is None:
1410
+ return False
1411
+ total = 1
1412
+ for n in shape:
1413
+ total *= n
1414
+ if offset >= total: # an index past the dimension is the index rule's
1415
+ return False
1416
+ return negative or need > total - offset
1417
+
1418
+ names = longconv.float_names(program.statements), longconv.literal_names(program.statements)
1419
+ alias_storage = _measurement_aliases(program, tables, env)
1420
+ for stmt in program.statements:
1421
+ scope = tables(stmt)
1422
+ dims, types, arrays = scope.dims, scope.types, scope.arrays
1423
+ if (
1424
+ isinstance(stmt, S.Instruction)
1425
+ and stmt.keyword.upper() == "CDM_TCCOMP"
1426
+ and len(stmt.args) > 3
1427
+ and isinstance(stmt.args[2], (S.Name, S.Index))
1428
+ ):
1429
+ # Reps (fourth) of the destination (third); negative Reps overruns too (oracle, CR6/
1430
+ # CR1000X, 2026-10-10; docs/instructions.md "CPI measurement time")
1431
+ reps = longconv.long_value(stmt.args[3], env, profile, *names)
1432
+ if reps is not None and overruns(stmt.args[2], reps, reps < 0, ("FLOAT",)):
1433
+ diags.error(stmt.line, f"Variable {stmt.args[2].name} out of bounds.", stmt.file)
1434
+ continue
1435
+ if not isinstance(stmt, S.Instruction) or not stmt.args or not isinstance(stmt.args[0], (S.Name, S.Index)):
1436
+ continue
1437
+ kw = stmt.keyword.upper()
1438
+ if kw in _RUNNING_STATS:
1439
+ scope = with_parameters(stmt)
1440
+ dims, types, arrays = scope.dims, scope.types, scope.arrays
1441
+ dest = stmt.args[0]
1442
+ name = dest.name.upper()
1443
+ bad: list[S.Name | S.Index] = []
1444
+ if kw in _SPA_SOURCE and len(stmt.args) > 2:
1445
+ swath = _int_value(stmt.args[1], env)
1446
+ if swath is not None:
1447
+ fixed, per = _SPA_DEST.get(kw, (0, 0))
1448
+ if (fixed or per) and overruns(
1449
+ dest, fixed + per * swath, per > 0 and swath < 0, ("FLOAT", "LONG")
1450
+ ): # (Long too, round 56)
1451
+ bad.append(dest)
1452
+ if overruns(source := stmt.args[2], swath, swath < 0, ("FLOAT", "LONG")):
1453
+ bad.append(source)
1454
+ elif kw == "SPLITSTR": # a scalar destination takes one value (Reps > 1 overruns it)
1455
+ reps = _int_value(stmt.args[3], env) if len(stmt.args) > 3 else None
1456
+ if (
1457
+ isinstance(dest, S.Name)
1458
+ and name in dims
1459
+ and name not in arrays
1460
+ and reps is not None
1461
+ and reps > 1
1462
+ and types.get(name) in ("FLOAT", "STRING")
1463
+ ): # a structure takes Reps 3
1464
+ bad.append(dest)
1465
+ elif (
1466
+ kw == "CSAT3"
1467
+ and len(stmt.args) > 1
1468
+ and (reps := _int_value(stmt.args[1], env)) is not None
1469
+ and _byte(reps) >= 128
1470
+ ):
1471
+ # a negative signed-byte Reps fails the builder's allocation before the destination
1472
+ # is resolved; the compile ends there (round 31, docs/checker.md "Bridge channel spans")
1473
+ diags.error(stmt.line, "Out of memory.", stmt.file)
1474
+ return
1475
+ elif (
1476
+ kw in _BUILDER_FIXED
1477
+ or kw in ("SDMGENERIC", "SDMSW8A", "SDMCD16AC", "CSAT3", "PEAKVALLEY", "TGA", "EC100")
1478
+ or kw in _SDM_MODE_VALUES
1479
+ ):
1480
+ # SDMGeneric checks any non-String destination, including a negative count
1481
+ kinds = ("FLOAT", "LONG", "BOOLEAN") if kw == "SDMGENERIC" else ("FLOAT",)
1482
+ for i, need in _builder_counts(kw, stmt.args, env):
1483
+ if overruns(target := stmt.args[i], need, need < 0, kinds):
1484
+ bad.append(target)
1485
+ elif kw in _DEST_FIXED or kw in _DEST_PER_REP:
1486
+ negative = False
1487
+ if kw in _DEST_FIXED:
1488
+ need = _DEST_FIXED[kw]
1489
+ else:
1490
+ reps = _int_value(stmt.args[1], env) if len(stmt.args) > 1 else None
1491
+ if reps is None or (reps < 0 and kw not in _DEST_NEGATIVE_OOB):
1492
+ continue
1493
+ negative = reps < 0
1494
+ need = reps * _DEST_PER_REP[kw]
1495
+ alias = alias_storage(stmt, dest) if kw in _DEST_PER_REP else None
1496
+ if alias is not None:
1497
+ # an alias destination counts from the alias (oracle, CR6/CR1000X, round 57: PRTCalc,
1498
+ # CalFile, Move and the running statistics)
1499
+ if _alias_overrun(stmt, dest, alias, -1 if negative else need, profile, env):
1500
+ diags.error(stmt.line, f"Variable {alias[2]} out of bounds.", stmt.file)
1501
+ elif overruns(dest, need, negative, ("FLOAT",)):
1502
+ bad.append(dest)
1503
+ # single-pass loggers: before this line's warnings and channel errors (oracle)
1504
+ for arg in reversed(bad) if profile.single_pass else bad:
1505
+ diag = Diagnostic("error", stmt.line, f"Variable {arg.name} out of bounds.", stmt.file)
1506
+ if profile.single_pass:
1507
+ at = next(
1508
+ (i for i, d in enumerate(diags.items) if (d.line, d.file) == (stmt.line, stmt.file)),
1509
+ len(diags.items),
1510
+ )
1511
+ diags.items.insert(at, diag)
1512
+ else:
1513
+ diags.items.append(diag)
1514
+ if kw in _SPA_TYPED and len(stmt.args) > 2 and profile.name in ("cr6", "cr1000x"):
1515
+ source = stmt.args[2]
1516
+ dest_type = types.get(name)
1517
+ source_type = types.get(source.name.upper()) if isinstance(source, (S.Name, S.Index)) else None
1518
+ if kw in ("MAXSPA", "MINSPA") and dest_type not in (None, "FLOAT"):
1519
+ diags.error(stmt.line, "Dest type must be Float.", stmt.file)
1520
+ elif kw not in ("MAXSPA", "MINSPA") and dest_type and source_type and dest_type != source_type:
1521
+ diags.error(stmt.line, "Source and Dest types must be the same.", stmt.file)
1522
+
1523
+
1524
+ def _index_value(arg: S.Expr | None, env: consteval.Env | None) -> int | None:
1525
+ """An integer literal index, or with `env` any integer constant (`X(K)`, round 30 review)."""
1526
+ if isinstance(arg, S.Number) and isinstance(arg.value, int):
1527
+ return arg.value
1528
+ v = _const_value(arg, env) if env is not None else None
1529
+ return v if isinstance(v, int) and not isinstance(v, bool) else None
1530
+
1531
+
1532
+ def _flat_offset(dest: S.Name | S.Index, shape: tuple[int, ...], env: consteval.Env | None = None) -> int | None:
1533
+ """0-based row-major offset of the destination's first element, or None if not literal
1534
+ (or, with `env`, constant)."""
1535
+ if isinstance(dest, S.Name) or not dest.args or all(a is None for a in dest.args):
1536
+ return 0
1537
+ index = [k for a in dest.args if (k := _index_value(a, env)) is not None]
1538
+ if len(index) != len(dest.args):
1539
+ return None
1540
+ if len(index) == 1:
1541
+ return index[0] - 1 if 1 <= index[0] else None
1542
+ if len(index) != len(shape) or any(not 1 <= k <= n for k, n in zip(index, shape)):
1543
+ return None
1544
+ offset = 0
1545
+ for k, n in zip(index, shape):
1546
+ offset = offset * n + (k - 1)
1547
+ return offset