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,661 @@
1
+ """Warnings: post-table "declared but never called" (needs the whole post-`#If`-resolution
2
+ statement list, `tables_hook`-gated, pass 2) plus a small set of *ungated* warnings that the real
3
+ compiler emits regardless of whether the file has other errors (oracle-confirmed per warning
4
+ below, and generally in `docs/instructions.md`'s "Two of these run even on a failed compile"
5
+ section) -- reachability ("never reached at run time"), the CR6/CR1000X fast-scan
6
+ background-calibration note, and the CR300/CR350 fixed-set `fnotch` value check. Rules and oracle
7
+ evidence: `docs/instructions.md`; the pre-existing table-never-called rule's own evidence stays in
8
+ `docs/checker.md`/`docs/tdf-rules.md` section 13.
9
+ """
10
+
11
+ import re
12
+
13
+ from .. import consteval
14
+ from .. import syntax as S
15
+ from ..diagnostics import Diagnostic, Diagnostics
16
+ from ..profiles import Profile
17
+ from . import instr_rules, structure, symbols
18
+
19
+
20
+ def _table_arg_name(stmt: S.Instruction) -> str | None:
21
+ if stmt.args and isinstance(stmt.args[0], S.Name):
22
+ return stmt.args[0].name
23
+ return None
24
+
25
+
26
+ def _with_inline_branches(statements):
27
+ """The statements plus those in every single-line `If ... Then stmt [Else stmt]` branch, at any
28
+ depth. A `CallTable` there counts as a use even when the branch is never taken (oracle,
29
+ CR6/CR1000X, 2026-10-11: `If Batt > 1 Then CallTable (Msgs)`, `If False Then CallTable (Msgs)`,
30
+ `If Batt > 1 Then Battery (Batt) Else CallTable (Msgs)`, `If ... Then CallTable (Msgs) : Battery`
31
+ -- `emailrecv_1.CR6` has its only call this way, and no `Table ... never called` warning)."""
32
+ for stmt in statements:
33
+ yield stmt
34
+ if isinstance(stmt, S.If):
35
+ yield from _with_inline_branches(stmt.inline or ())
36
+ yield from _with_inline_branches(stmt.inline_else or ())
37
+
38
+
39
+ def check(program: S.Program, result, diags: Diagnostics) -> None:
40
+ # User `DataTable(...)...EndTable` blocks only -- deliberately *not* `result.tables` (which
41
+ # also carries the logger's own system tables, `Status`/`DataTableInfo`/`GlobalConfigTable`/
42
+ # the `Public` table, none of which are ever `CallTable`d and must not get this warning).
43
+ declared: dict[str, str] = {}
44
+ for stmt in program.statements:
45
+ if isinstance(stmt, S.Instruction) and stmt.keyword.upper() == "DATATABLE":
46
+ name = _table_arg_name(stmt)
47
+ if name:
48
+ from .instr_data import _const_table_name
49
+
50
+ name = _const_table_name(name, program)
51
+ declared[name.upper()] = name
52
+
53
+ # "Called" is deliberately not just `CallTable`: `AcceptDataRecords`' own TableName argument
54
+ # also counts (oracle-confirmed regression, `acceptdatarecords_1.CR6`: `AcceptDataRecords
55
+ # (3000,32769,CR1000XTable)` with no `CallTable` at all still gets no warning). Rather than
56
+ # enumerate every instruction with a table-name argument, any bare-name argument anywhere
57
+ # that matches a declared table counts as a use -- a table name is not reused as an ordinary
58
+ # variable, so this is a safe superset of the real "called" set.
59
+ from .instr_data import _const_table_name
60
+
61
+ called = {
62
+ name
63
+ for stmt in _with_inline_branches(program.statements)
64
+ if isinstance(stmt, S.Instruction) and stmt.keyword.upper() != "DATATABLE"
65
+ for arg in stmt.args
66
+ if isinstance(arg, S.Name)
67
+ # (a String `Const` names the table its value spells, round 67)
68
+ for name in (arg.name.upper(), _const_table_name(arg.name, program).upper())
69
+ if name in declared
70
+ }
71
+ for key, name in declared.items():
72
+ if key not in called:
73
+ # oracle-confirmed (docs/tdf-rules.md #13, `fixeduncalled`): fires whenever no
74
+ # `CallTable`/`CallTable(...)` for this table survives `#If` resolution, regardless
75
+ # of `DataInterval`/fixed size; `Main.CR6`'s `Shunt_Data` is the repo's own example
76
+ # (its only `CallTable` is inside a false-by-default `#If`).
77
+ diags.warning(0, f"Table {name}, is declared but never called.")
78
+
79
+
80
+ # --- Ungated warnings (checker/__init__.py calls `check_early` regardless of pass-1 errors; see
81
+ # module docstring and docs/instructions.md for the oracle evidence that these fire even on an
82
+ # otherwise-failed compile) -------------------------------------------------------------------
83
+
84
+
85
+ def _check_uint1_dims(program: S.Program, profile: Profile, diags: Diagnostics, env: consteval.Env) -> None:
86
+ """CR6/CR1000X: a UINT1 variable whose element count is not a multiple of 4 is padded, one
87
+ warning per variable on its declaration line, also in a failed compile (oracle, round 56)."""
88
+ if profile.name not in ("cr6", "cr1000x"):
89
+ return
90
+ for stmt in program.statements:
91
+ if not isinstance(stmt, S.Declaration):
92
+ continue
93
+ for v in stmt.vars:
94
+ if v.type != "UINT1":
95
+ continue
96
+ count = 1
97
+ for d in v.dims:
98
+ try:
99
+ size = consteval.value_of(d, env)
100
+ except consteval.NotConstant:
101
+ break
102
+ if not isinstance(size, int) or isinstance(size, bool) or size < 1:
103
+ break
104
+ count *= size
105
+ else:
106
+ if count % 4:
107
+ diags.warning(
108
+ stmt.line,
109
+ "Adjusting dimensionality of UINT1 so number of bytes evenly divisible by 4",
110
+ stmt.file,
111
+ )
112
+
113
+
114
+ def check_early(program: S.Program, profile: Profile, diags: Diagnostics, env: consteval.Env) -> None:
115
+ _check_uint1_dims(program, profile, diags, env)
116
+ _check_reachability(program, profile, diags, env)
117
+ _check_fnotch(program, profile, diags, env)
118
+ # Numeric `Scan`/`DataInterval` value checks: also ungated (oracle-confirmed,
119
+ # `docs/instructions.md`), implemented in `instr_rules.py` alongside this module's other
120
+ # required-parameter check since they are instruction-argument-shaped, not warnings.
121
+ instr_rules.check_scan_params(program, profile, diags, env)
122
+ instr_rules.check_scan_max_interval(program, profile, diags, env)
123
+ instr_rules.check_data_interval(program, profile, diags, env)
124
+
125
+
126
+ def _kw(stmt: S.Stmt) -> str:
127
+ if isinstance(stmt, S.Instruction):
128
+ return stmt.keyword.upper()
129
+ if isinstance(stmt, S.SubDef):
130
+ return stmt.keyword.upper() # SUB / FUNCTION
131
+ if isinstance(stmt, S.Unparsed):
132
+ word = re.match(r"\s*(DataLong|Data)\s", stmt.text or "", re.I)
133
+ if word:
134
+ return word.group(1).upper() # `Data foo bar`: still a `Data` line
135
+ return type(stmt).__name__.upper()
136
+
137
+
138
+ # Instruction keywords that are declarations/structural markers, never "executed" and so never
139
+ # flagged by `_check_reachability` even when they sit right after unreachable code (oracle:
140
+ # `docs/instructions.md` -- `Dim`/`Public`/`Const`/`ConstTable`/`Alias`/`Units`/`DataTable`/
141
+ # `StructureType`/`ObjectType` bodies are all silent; `BeginProg`/`EndProg` themselves were never
142
+ # observed flagged either). `SLOWSEQUENCE` is handled separately (it *resets* reachability, see
143
+ # below, rather than merely being skipped).
144
+ _NON_EXECUTABLE_KEYWORDS = frozenset(
145
+ {
146
+ "BEGINPROG",
147
+ "ENDPROG",
148
+ "DATATABLE",
149
+ "ENDTABLE",
150
+ "ALIAS",
151
+ "UNITS",
152
+ "CONSTTABLE",
153
+ "ENDCONSTTABLE",
154
+ "STRUCTURE",
155
+ "ENDSTRUCTURE",
156
+ "STRUCTURETYPE",
157
+ "ENDSTRUCTURETYPE",
158
+ "OBJECTTYPE",
159
+ "ENDOBJECTTYPE",
160
+ "DATA",
161
+ "DATALONG", # (oracle, 2026-10-01: `Data 1,2` after `NextScan` is not flagged)
162
+ "SEQUENTIALMODE",
163
+ "PIPELINEMODE", # (oracle, CR6, 2026-10-02: not flagged after `NextScan`)
164
+ }
165
+ )
166
+
167
+
168
+ def _is_program_end(stmt: S.Stmt) -> bool:
169
+ """Bare `End` and `End Prog` (any whitespace between the words) are never flagged as unreachable
170
+ and do not stop the search (oracle, all four loggers, 2026-10-12: `End` then `a = 1` after an
171
+ endless `Scan` warns only for `a = 1`)."""
172
+ if not (isinstance(stmt, S.Instruction) and stmt.keyword.upper() == "END"):
173
+ return False
174
+ return not stmt.args or (
175
+ len(stmt.args) == 1 and isinstance(stmt.args[0], S.Name) and stmt.args[0].name.upper() == "PROG"
176
+ )
177
+
178
+
179
+ def _display_name(stmt: S.Stmt, profile: Profile) -> str:
180
+ """The name the real compiler echoes in `%s never reached at run time.` -- an `Instruction`'s
181
+ canonical table spelling (`Delay`, `Scan`, proper-cased; oracle:
182
+ `tests/corpus/fuzz/cr6/mode_diff/19a402de98a4.CR6`/`cr1000x/mode_diff/cf78b8596121.json`), or
183
+ `Expr`, the compiler's own generic bare-expression-statement pseudo-instruction (`data/
184
+ instructions.json` index 5; already used the same way by `structure.py`'s "before BeginProg"
185
+ rule) for anything else -- a plain assignment (oracle: `Expr never reached`, this session's own
186
+ minimal probe) or a statement that failed to parse as a recognised instruction at all (oracle:
187
+ `tests/corpus/fuzz/cr6/message_diff/caf5c15cd16b.CR6`, a mutated `EndProg` that fails to parse
188
+ gets both `Invalid, or out of place expression:` *and* `Expr never reached at run time.`)."""
189
+ if isinstance(stmt, S.Instruction):
190
+ record = profile.instructions.get(stmt.keyword.upper())
191
+ return record["name"] if record else stmt.keyword
192
+ if isinstance(stmt, (S.DoLoop, S.For)):
193
+ # oracle: `Do never reached`, `Loop never reached`, `FOR never reached` (table spelling)
194
+ keyword = stmt.keyword.upper() if isinstance(stmt, S.DoLoop) else "FOR"
195
+ record = profile.instructions.get(keyword)
196
+ return record["name"] if record else keyword
197
+ if isinstance(stmt, S.Call):
198
+ return "Call" # `Call S(1)`, `S(1)`: oracle, CR6/CR300, 2026-10-08 (docs/fuzz-findings.md)
199
+ return "Expr"
200
+
201
+
202
+ def _open_conditional(statements: list[S.Stmt], start: int, end: int) -> int:
203
+ """How many `If`/`Select` blocks opened inside `statements[start:end]` are still open at its
204
+ end. Statements after `NextScan` inside them are not flagged; the one that closes the last of
205
+ them is (oracle, CR6: `If X Then` / `NextScan` / `X = Q` gives no warning, `If X Then` /
206
+ `NextScan` / `EndIf` gives `EndIf never reached at run time.`)."""
207
+ depth = 0
208
+ for stmt in statements[start:end]:
209
+ kw = _kw(stmt)
210
+ if (kw == "IF" and getattr(stmt, "inline", None) is None) or kw == "SELECT":
211
+ depth += 1
212
+ elif kw in ("ENDIF", "ENDSELECT") and depth:
213
+ depth -= 1
214
+ return depth
215
+
216
+
217
+ def _scan_span(statements: list[S.Stmt], start: int) -> tuple[int, bool]:
218
+ """`statements[start]` is a top-level `SCAN`. Returns (index right after its matching
219
+ `NEXTSCAN`, whether an `EXITSCAN` appears anywhere in between). `SubScan`'s own block uses the
220
+ distinct `SUBSCAN`/`NEXTSUBSCAN` keywords (`data/instructions.json`), so a same-keyword depth
221
+ counter here never confuses the two."""
222
+ depth = 1
223
+ has_exit = False
224
+ j = start + 1
225
+ n = len(statements)
226
+ while j < n and depth > 0:
227
+ kw = _kw(statements[j])
228
+ if kw == "SCAN":
229
+ depth += 1
230
+ elif kw == "NEXTSCAN":
231
+ depth -= 1
232
+ elif kw == "EXITSCAN" or any(
233
+ _kw(x) == "EXITSCAN"
234
+ for x in (getattr(statements[j], "inline", None) or [])
235
+ + (getattr(statements[j], "inline_else", None) or [])
236
+ ):
237
+ has_exit = True # (also in a one-line `If X Then ExitScan`, oracle 2026-10-10)
238
+ j += 1
239
+ return j, has_exit
240
+
241
+
242
+ def _scan_count_is_zero(stmt: S.Instruction, env: consteval.Env) -> bool | None:
243
+ """The `Scan(Interval,Units,BufferOption,Count)` `Count` argument's constant value compared to
244
+ 0 (`True`/`False`), or `None` when it is missing or not a compile-time constant -- in which
245
+ case the caller must *not* treat the scan as provably infinite (AGENTS.md: never guess into a
246
+ false positive)."""
247
+ args = stmt.args
248
+ if len(args) < 4 or args[3] is None:
249
+ return None
250
+ try:
251
+ return consteval.value_of(args[3], env) == 0
252
+ except consteval.NotConstant:
253
+ return None
254
+
255
+
256
+ def _check_reachability(program: S.Program, profile: Profile, diags: Diagnostics, env: consteval.Env) -> None:
257
+ """`%s never reached at run time.` -- oracle-confirmed (`docs/instructions.md`) to fire for the
258
+ *first* executable top-level statement that textually follows a `Scan(...,0)` (loop forever)
259
+ with no `ExitScan` anywhere in its body, up to (but not including) the next `SlowSequence`
260
+ (which starts a genuinely separate, concurrently-scheduled task, oracle-confirmed *not*
261
+ flagged) or the end of the statement list. Fires even when the file has unrelated errors
262
+ elsewhere (unlike `instructions.py`'s pass-2 checks) and, once found, this checker stops
263
+ looking for a second one -- every corpus/oracle example found only ever shows one, matching
264
+ this project's other "one diagnostic, then silence" findings (`docs/checker.md`).
265
+
266
+ A `Sub`/`Function` body is skipped entirely (its own statements are reachable whenever the sub
267
+ is called, independent of where its definition sits in the file).
268
+ """
269
+ if any(d.message.startswith("Illegal END or EXIT ") for d in diags.items):
270
+ return # a pass-1 error of this kind hides the warning (oracle, CR6, 2026-10-03)
271
+ statements = program.statements
272
+ n = len(statements)
273
+ dead = False
274
+ open_depth = 0
275
+ sub_depth = 0
276
+ begun = False
277
+ tasks = profile.pipeline_tasks["tasks"]
278
+ pipeline_delays = {
279
+ id(st)
280
+ for st, _ in structure.pipeline_task_errors(program, profile)
281
+ if _kw(st) == "DELAY" or tasks.get(_kw(st), {}).get("hides_unreached")
282
+ }
283
+ i = 0
284
+ while i < n:
285
+ stmt = statements[i]
286
+ kw = _kw(stmt)
287
+
288
+ if sub_depth > 0:
289
+ if kw in ("SUB", "FUNCTION"):
290
+ sub_depth += 1
291
+ elif S.ends_routine(stmt):
292
+ sub_depth -= 1
293
+ i += 1
294
+ continue
295
+ if kw in ("SUB", "FUNCTION"):
296
+ sub_depth = 1
297
+ i += 1
298
+ continue
299
+
300
+ if kw == "BEGINPROG":
301
+ begun = True
302
+ if kw == "SLOWSEQUENCE":
303
+ # A `SlowSequence` starts a genuinely separate, concurrently-scheduled task (oracle:
304
+ # `docs/instructions.md` -- a `SlowSequence`/`Scan`/`NextScan` right after an infinite,
305
+ # no-`ExitScan` main scan is never itself flagged). This checker has no oracle evidence
306
+ # either way for reachability *within* a slow sequence's own scan chain (a second,
307
+ # chained slow `Scan...NextScan` the way the main sequence supports, `docs/checker.md`'s
308
+ # own CHM "Scan Loop #1/#2" example) or for its own `EndSequence` terminator, which a
309
+ # false positive here (10 corpus regressions during development, all real, oracle-
310
+ # accepted programs, e.g. `ftpclient_1.CR6`'s `EndSequence`) showed is *not* flagged
311
+ # either. Rather than guess at slow-sequence-internal reachability, stop this analysis
312
+ # entirely once a `SlowSequence` is seen -- AGENTS.md: never guess into a false warning.
313
+ return
314
+
315
+ if kw == "SCAN" and isinstance(stmt, S.Instruction):
316
+ if dead:
317
+ diags.warning(stmt.line, f"{_display_name(stmt, profile)} never reached at run time.", stmt.file)
318
+ return
319
+ j, has_exit = _scan_span(statements, i)
320
+ count_is_zero = _scan_count_is_zero(stmt, env)
321
+ dead = bool(count_is_zero) and not has_exit
322
+ if not begun and profile.name in ("cr6", "cr1000x"):
323
+ dead = False # `Scan illegally occurs before BeginProg.`: the scan is not
324
+ # compiled, so nothing after it is unreachable (oracle, 2026-10-12, fuzz R15)
325
+ closer = statements[j - 1] if j <= n and j > i + 1 else None
326
+ if dead and closer is not None and hasattr(closer, "hoisted_last"):
327
+ # `If C Then NextScan` closes the endless `Scan` from inside a one-line `If`: the
328
+ # `If`'s own end is the first thing after it (oracle, all four loggers, 2026-10-12);
329
+ # more statements after the `NextScan` in the body silence the check
330
+ if closer.hoisted_last and not (
331
+ j < n and (statements[j].line, statements[j].file) == (closer.line, closer.file)
332
+ ):
333
+ diags.warning(closer.line, "EndIf never reached at run time.", closer.file)
334
+ return
335
+ open_depth = _open_conditional(statements, i, j)
336
+ i = j
337
+ continue
338
+
339
+ if dead and open_depth:
340
+ if kw in ("ENDIF", "ENDSELECT"):
341
+ open_depth -= 1
342
+ elif (isinstance(stmt, S.If) and stmt.inline is None) or kw == "SELECT":
343
+ open_depth += 1
344
+ if open_depth:
345
+ i += 1
346
+ continue
347
+ if dead and id(stmt) in pipeline_delays:
348
+ i += 1 # its pipeline-mode error replaces its own warning (oracle); later ones follow
349
+ continue
350
+ if dead and (kw in structure.SCAN_ONLY or kw == "SUBSCAN"):
351
+ return # its compile error ends pass 2 before the reachability check (oracle; a `SubScan` after the `NextScan` is outside any `Scan`)
352
+ if dead and (isinstance(stmt, S.If) or kw in ("SELECT", "NEXTSCAN")):
353
+ # An unreachable `If`/`Select` (block or one-line), or a stray `NextScan`, silences the
354
+ # check for the rest of the file (oracle, CR6: `If X Then` / `EndIf` / `X = 2` after a `NextScan` gives no
355
+ # warning at all; `docs/instructions.md`).
356
+ return
357
+ if dead:
358
+ # An `ExitScan` outside any `Scan` compiles to nothing (oracle: `ExitScan not within a
359
+ # Scan.` and no warning), unlike `ContinueScan`.
360
+ is_declaration = (
361
+ isinstance(stmt, (S.Declaration, S.ConstDecl, S.Label))
362
+ or kw in _NON_EXECUTABLE_KEYWORDS
363
+ or kw == "EXITSCAN"
364
+ or _is_program_end(stmt)
365
+ )
366
+ if not is_declaration:
367
+ name = _display_name(stmt, profile)
368
+ if (
369
+ isinstance(stmt, S.Call)
370
+ and not stmt.explicit
371
+ and not stmt.tail_ignored
372
+ and profile.name in ("cr6", "cr1000x")
373
+ ):
374
+ # a bare `Nope`/`Nope(x)`/`Abs(x)` that is no Sub or Function is `Expr`; a
375
+ # variable gives no warning at all (oracle, CR6/CR1000X, 2026-10-12, fuzz R15)
376
+ routines = {st.name.upper() for st in statements if isinstance(st, S.SubDef)}
377
+ if stmt.name.upper() not in routines:
378
+ if stmt.name.upper() in symbols.declared_variable_names(program):
379
+ return
380
+ name = "Expr"
381
+ diags.warning(stmt.line, f"{name} never reached at run time.", stmt.file)
382
+ return
383
+ i += 1
384
+
385
+
386
+ def check_background_calibration(program: S.Program, profile: Profile, diags: Diagnostics, env: consteval.Env) -> None:
387
+ """`When Main Sequence Scan %d is running, background calibration will be disabled.` --
388
+ oracle-confirmed (`docs/instructions.md`) to fire once per *main-sequence* `Scan` (not one
389
+ declared under `SlowSequence`, whose own minimum interval, 100mSec, is far above every
390
+ threshold found) whose folded `Interval`/`Units` is below a logger-specific threshold in whole
391
+ milliseconds (`profile.background_calibration`, empty for CR300/CR350: oracle-probed down to a
392
+ 100us scan interval with no such warning). Reported at the *last* statement's line, like
393
+ `structure.py`'s `No BeginProg in Program.` (same oracle-confirmed convention, independently
394
+ re-derived here since `structure.py` is a different workstream's owned file, `PLAN.md`).
395
+
396
+ **Round 6, gating correction**: unlike `_check_reachability`/`_check_fnotch` (still called from
397
+ `check_early`, genuinely ungated -- oracle-confirmed, `docs/instructions.md` "Two ungated call
398
+ sites"), this warning is **not** ungated on CR6/CR1000X either, despite living in the same
399
+ `check_early` function until this round and being covered by that same doc section's blanket
400
+ claim. Oracle-confirmed (a fresh sweep this round, an unrelated undefined-symbol error placed
401
+ both *before* and, separately, *after* an otherwise-calibration-warning-triggering `Scan(1,
402
+ mSec,...)` in the same file): the warning is suppressed **entirely** whenever the file has any
403
+ error anywhere, regardless of that error's own position relative to the `Scan` -- unlike
404
+ `_check_reachability`, which still fires on a later statement even with an earlier, unrelated
405
+ `Invalid symbol in expression` error present (`Delay never reached at run time.` alongside it,
406
+ same probe file). A warning-only file (no errors, e.g. a reachability warning with no error
407
+ anywhere) still gets *both* warnings together, confirming this is specifically errors, not "any
408
+ diagnostic", that gates it. Moved to `checker/__init__.py`'s own final `if not diags.errors:`
409
+ tier (alongside `warnings.check`/`tables_hook.attach`, the same "only on an otherwise-clean
410
+ compile" gate already used there) instead of `check_early`'s unconditional call site -- this is
411
+ the only change; the rule and message above are unchanged. Found as a side effect of developing
412
+ `instr_rules.check_scan_min_interval` (this round): dozens of pre-existing `false_accept`/
413
+ `message_diff` fuzz reproducers (`tests/corpus/fuzz/cr6/message_diff/ba333c2ee146.CR6` and
414
+ siblings) already showed this exact extra, spurious warning line alongside an unrelated real
415
+ error -- a genuine, previously-undocumented false-accept-shaped bug independent of this round's
416
+ own `Scan`-interval work, not a regression it introduced.
417
+ """
418
+ data = profile.background_calibration
419
+ if not data:
420
+ return
421
+ threshold_ms = data["threshold_ms"]
422
+ statements = program.statements
423
+ last_line = statements[-1].line if statements else 1
424
+
425
+ # A scan with measurement time warns when its slack (interval minus time) is under
426
+ # `busy_slack` units of 100 us, or under its longest integration time plus a per-logger constant
427
+ # (oracle, 2026-10-10: CR6 `Scan(100,mSec)` + `Delay(0,97700,uSec)` warns, 97600 does not;
428
+ # `VoltSe(...,250)` alone warns up to Scan(10,mSec), not 11; CR1000X delay boundary
429
+ # 98000/97900). Such a scan never uses the empty-scan interval threshold above.
430
+ from . import scan_time
431
+ from .instr_measure import is_sequential
432
+
433
+ # Not in SequentialMode (oracle, CR6, 2026-10-10: not even for `Scan(1,mSec)`).
434
+ if is_sequential(program, profile, env):
435
+ return
436
+ busy_slack = data.get("busy_slack", 0)
437
+ busy, timed = set(), set()
438
+ for b in scan_time.budgets(program, env, profile):
439
+ if b.total:
440
+ timed.add(id(b.scan))
441
+ # a scan whose only timed instructions are PulseCounts never warns (oracle, CR6,
442
+ # 2026-10-10: Scan(1..9,mSec) + PulseCount)
443
+ gap = b.max_integ + scan_time.MODELS[profile.name].busy_extra if b.max_integ > 0 else 0
444
+ if b.interval - b.total < max(busy_slack, gap) and not b.pulse_only:
445
+ busy.add(id(b.scan))
446
+ in_slow = False
447
+ main_scan_index = 0
448
+ for stmt in statements:
449
+ kw = _kw(stmt)
450
+ if kw == "SLOWSEQUENCE":
451
+ in_slow = True
452
+ continue
453
+ if kw != "SCAN" or in_slow or not isinstance(stmt, S.Instruction):
454
+ continue
455
+ main_scan_index += 1
456
+ interval_ms = _scan_interval_ms(stmt, env)
457
+ if id(stmt) in busy or (id(stmt) not in timed and interval_ms is not None and interval_ms < threshold_ms):
458
+ diags.warning(
459
+ last_line,
460
+ f"When Main Sequence Scan {main_scan_index} is running, background calibration will be disabled.",
461
+ )
462
+
463
+
464
+ def _value_in(args: list, position: int, values: list, env) -> bool:
465
+ """Whether 1-based argument `position` is a constant among `values`."""
466
+ try:
467
+ return position <= len(args) and consteval.value_of(args[position - 1], env) in values
468
+ except consteval.NotConstant:
469
+ return False
470
+
471
+
472
+ def check_slow_scan_skips(program: S.Program, profile: Profile, diags: Diagnostics, env: consteval.Env) -> None:
473
+ """`Slow Seq %d, Scan %d, may skip scans if running with Scan %d.` (round 54, oracle CR6 and
474
+ CR1000X, `docs/checker.md` "Task instructions in PipeLine Mode"): at the last statement's line,
475
+ for every slow-sequence `Scan` that holds a measurement instruction of
476
+ `pipeline_tasks["slow_scan_tasks"]` and every main-sequence `Scan` whose constant interval is not
477
+ shorter than the slow one's; nothing in SequentialMode or with errors elsewhere. Order: slow
478
+ sequence, slow scan, main scan."""
479
+ tasks = profile.pipeline_tasks.get("slow_scan_tasks")
480
+ if not tasks:
481
+ return
482
+ from .instr_measure import is_sequential
483
+
484
+ if is_sequential(program, profile, env):
485
+ return
486
+ rules = profile.pipeline_tasks["tasks"]
487
+ mains: list[float | None] = []
488
+ slows: list[list] = [] # [sequence, scan, interval ms or None, has a task]
489
+ seq = scan_no = 0
490
+ cur: list | None = None
491
+ for stmt in _with_inline_branches(program.statements):
492
+ kw = _kw(stmt)
493
+ if kw == "NEXT" and re.match(r"(?i)\s*Next\s+Scan\b", stmt.text or ""):
494
+ kw = "NEXTSCAN"
495
+ if kw == "SLOWSEQUENCE":
496
+ seq, scan_no = seq + 1, 0
497
+ elif kw == "SCAN" and isinstance(stmt, S.Instruction):
498
+ interval = _scan_interval_ms(stmt, env)
499
+ if seq == 0:
500
+ mains.append(interval)
501
+ else:
502
+ scan_no += 1
503
+ cur = [seq, scan_no, interval, False]
504
+ slows.append(cur)
505
+ elif kw == "NEXTSCAN":
506
+ cur = None
507
+ elif cur is not None and kw in tasks and isinstance(stmt, S.Instruction):
508
+ constants = rules.get(kw, {}).get("constant_values", {}) # (`Delay` option 0 only)
509
+ # (`Delay(0,0,...)` and `PortSet(...,1)` start no task, oracle round 55)
510
+ no_task = profile.pipeline_tasks.get("slow_scan_no_task", {}).get(kw, {})
511
+ try:
512
+ cur[3] = (
513
+ cur[3]
514
+ or all(
515
+ consteval.value_of(stmt.args[int(p) - 1], env) in allowed for p, allowed in constants.items()
516
+ )
517
+ and not any(_value_in(stmt.args, int(p), vals, env) for p, vals in no_task.items())
518
+ )
519
+ except consteval.NotConstant, IndexError:
520
+ pass
521
+ last_line = program.statements[-1].line if program.statements else 1
522
+ for seq_no, scan_no, interval, has_task in slows:
523
+ for main_no, main_interval in enumerate(mains, 1):
524
+ if has_task and interval is not None and main_interval is not None and interval <= main_interval:
525
+ diags.warning(
526
+ last_line, f"Slow Seq {seq_no}, Scan {scan_no}, may skip scans if running with Scan {main_no}."
527
+ )
528
+
529
+
530
+ def _scan_interval_ms(stmt: S.Instruction, env: consteval.Env) -> float | None:
531
+ """The `Scan` interval in milliseconds as the compiler holds it (100 us units from the 32-bit
532
+ seconds/microseconds of `instr_rules.scan_split`, so a wrapped `Scan(2147483647,Min,..)` is
533
+ negative and counts as shorter than any threshold)."""
534
+ split = instr_rules.scan_split(stmt, env)
535
+ if split is None:
536
+ return None
537
+ units = split[0] * 10000 + (abs(split[1]) // 100) * (1 if split[1] >= 0 else -1)
538
+ units &= 0xFFFFFFFF
539
+ return (units - 2**32 if units >= 2**31 else units) / 10
540
+
541
+
542
+ def _check_fnotch(program: S.Program, profile: Profile, diags: Diagnostics, env: consteval.Env) -> None:
543
+ """`Invalid fnotch value, must be 50,60 400 or 4000` (CR300/CR350 only, no trailing period --
544
+ verbatim oracle text, `docs/instructions.md`) -- every CR300/CR350 instruction with a `fN1`
545
+ filter-frequency argument (found exhaustively: every instruction whose `data/<logger>/
546
+ instructions.json` `params` contains type code 20, `checker/warnings.py`'s sibling
547
+ `docs/instructions.md` cross-checks each one's argument position against its own CHM `Syntax`
548
+ line) restricts it to exactly `{50, 60, 400, 4000}`; CR6/CR1000X accept any value in a
549
+ continuous range instead (oracle: `PanelTemp(...,15000)` compiles clean on both, `15000` is
550
+ even the CHM's own example value) so `profile.fnotch` is empty there and this is a no-op.
551
+ Fires even when the file has unrelated errors elsewhere (oracle: `docs/instructions.md`), so
552
+ (like `_check_reachability`/`_check_background_calibration`) this is called ungated.
553
+ """
554
+ data = profile.fnotch
555
+ if not data or "valid" not in data:
556
+ return # CR6/CR1000X carry a minimum-frequency error instead (`instr_measure.check_pass1_values`)
557
+ valid = set(data["valid"])
558
+ message = data["message"]
559
+ positions: dict[str, int] = data["instructions"]
560
+
561
+ for stmt in program.statements:
562
+ if not isinstance(stmt, S.Instruction):
563
+ continue
564
+ pos = positions.get(stmt.keyword.upper())
565
+ if pos is None:
566
+ continue
567
+ idx = pos - 1
568
+ if idx >= len(stmt.args) or stmt.args[idx] is None:
569
+ continue
570
+ arg = stmt.args[idx]
571
+ if not consteval.is_constant(arg, env):
572
+ continue # a variable here is a different, unrelated check's concern (not ours).
573
+ try:
574
+ value = consteval.value_of(arg, env)
575
+ except consteval.NotConstant:
576
+ continue
577
+ if value not in valid:
578
+ diags.warning(stmt.line, message, stmt.file)
579
+
580
+
581
+ _TRUNCATE_CALLS = {"IFTIME": 3, "TIMEINTOINTERVAL": 3}
582
+ _TRUNCATE_CALL_RE = re.compile(r"(?i)\b(IfTime|TimeIntoInterval)\s*\(")
583
+ _ARITY_ERROR_PREFIXES = (
584
+ "Expression expects , :",
585
+ "No Matching, or out of place )",
586
+ "Invalid, or out of place expression:",
587
+ )
588
+
589
+
590
+ def _call_args(text: str, start: int) -> list[str] | None:
591
+ """The top-level argument texts of the call whose `(` ends just before `start`."""
592
+ depth, args, cur, i = 1, [], "", start
593
+ while i < len(text):
594
+ ch = text[i]
595
+ if ch == '"':
596
+ j = text.find('"', i + 1)
597
+ if j < 0:
598
+ return None
599
+ cur += text[i : j + 1]
600
+ i = j + 1
601
+ continue
602
+ if ch in "({":
603
+ depth += 1
604
+ elif ch in ")}":
605
+ depth -= 1
606
+ if depth == 0:
607
+ args.append(cur)
608
+ return [x for x in args] if any(x.strip() for x in args) else []
609
+ elif ch == "," and depth == 1:
610
+ args.append(cur)
611
+ cur = ""
612
+ i += 1
613
+ continue
614
+ cur += ch
615
+ i += 1
616
+ return None
617
+
618
+
619
+ def add_truncate_warnings(program: S.Program, profile, diags: Diagnostics) -> None:
620
+ """All four loggers (oracle, 2026-10-12, `docs/checker.md` "Round 14 truncate warnings"): an
621
+ `IfTime`/`TimeIntoInterval` call that fails its argument count prints `Warning: Parameter will
622
+ truncate to the nearest integer.` once per missing argument and once per argument that holds a
623
+ variable, at its line before the arity error (`Expression expects , :`, `No Matching, or out of
624
+ place )`, or the empty-call `Invalid, or out of place expression:`). Not modelled: the same warnings
625
+ of a valid call with a variable argument (printed twice, in both passes)."""
626
+ variables = symbols.declared_variable_names(program)
627
+ for stmt in program.statements:
628
+ text = getattr(stmt, "text", None) or ""
629
+ m = _TRUNCATE_CALL_RE.search(text)
630
+ if not m:
631
+ continue
632
+ args = _call_args(text, m.end())
633
+ if args is None:
634
+ continue
635
+ need = _TRUNCATE_CALLS[m.group(1).upper()]
636
+ if len(args) == need:
637
+ continue
638
+ with_vars = sum(
639
+ 1
640
+ for a in args
641
+ if any(w.upper() in variables for w in re.findall(r"[A-Za-z_]\w*", re.sub(r'"[^"]*"', "", a)))
642
+ )
643
+ count = max(need - len(args), 0) + with_vars
644
+ key = (stmt.file, stmt.line)
645
+ at = next(
646
+ (
647
+ j
648
+ for j, d in enumerate(diags.items)
649
+ if (d.file, d.line) == key
650
+ and d.severity == "error"
651
+ and d.message.startswith(_ARITY_ERROR_PREFIXES)
652
+ and m.group(1).lower() in d.message.lower()
653
+ ),
654
+ None,
655
+ )
656
+ if at is None or not count:
657
+ continue
658
+ diags.items[at:at] = [
659
+ Diagnostic("warning", stmt.line, "Parameter will truncate to the nearest integer.", stmt.file)
660
+ for _ in range(count)
661
+ ]