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,977 @@
1
+ """Semantic checker: `check(program, profile, diags, result)`.
2
+
3
+ Two passes, mirroring the real compiler's own architecture (`docs/checker.md` "Pass model",
4
+ oracle-confirmed): **pass 1** parses every statement into a record and is lenient about *shape* --
5
+ an unrecognised line becomes an `Expr`/`Call` record without error at this stage. Pass-1 errors are
6
+ parsing and declaration errors (parameter-count/shape by type code, `Variable not named.`,
7
+ `Units`/`Const`/`Alias` truncation, array-dimension limits, block matching); every one in the file
8
+ is reported regardless of what else is wrong. **Pass 2** runs only when pass 1 reported nothing at
9
+ all, and compiles the records in order: this is where the generic "Expr"/implicit-`Call` fallback
10
+ (`Invalid, or out of place expression: %s.`, `Sub %s not defined.`) and every instruction's own
11
+ semantic handler (`expressions.py`'s undefined-symbol/constant-only checks, `instructions.py`'s
12
+ channel/port checks, `instr_data.py`'s table-structure checks, `instr_measure.py`'s value checks)
13
+ live. Pass 2 stops entirely at the first **placement** error (a statement occurring somewhere it
14
+ structurally cannot, e.g. before `BeginProg`) -- `structure.check_pass2` is both the "Expr" fallback
15
+ *and* the only place that currently detects a placement error (the before-`BeginProg` case; two
16
+ other real placement shapes are not yet implemented, `docs/checker.md`), so its return value (the
17
+ placement error's own line, or `None`) is what `check` below uses to truncate every other pass-2
18
+ module's diagnostics to the same cutoff, without those modules needing to coordinate it themselves.
19
+
20
+ Runs, in order:
21
+
22
+ 1. `structure.check_pass1` -- `BeginProg`/`EndProg` presence, `Scan`/`NextScan` and `If`/`Select`
23
+ block matching, declaration/array-dimension errors, `Units`/`Const`/`Alias` truncation.
24
+ 2. `expressions.check_consts` -- an undefined name inside a `Const`'s own value expression
25
+ (`<name> is not defined.`/`Invalid symbol in expression: %s.`, per-logger). Also pass 1 (oracle
26
+ evidence, `docs/loggers.md`: `Const` folding happens eagerly at declaration time, so this
27
+ accumulates alongside an unrelated, earlier `Variable not named.` rather than being suppressed
28
+ by it the way the *rest* of `expressions.py`'s pass-2 checks would be). `expressions.
29
+ check_data_params` -- `Data`/`DataLong`'s own parameters, each required to be a constant
30
+ expression with no name reference at all, defined or not (`Invalid symbol in expression: %s.`/
31
+ `Illegal Data parameter %s.`, `docs/checker.md`) -- runs right after it, also ungated pass 1,
32
+ the same way.
33
+ 3. `cr3xx.check` -- CR300/CR350 only (a no-op on every other logger): `SerialOpen`'s own `ComPort`
34
+ argument and every other `Serial*` function's own first argument (`docs/loggers.md`). Also
35
+ ungated, oracle-confirmed the same way as step 2.
36
+ 4. `instr_rules.check` -- a required-parameter position with no value at all ("Invalid or missing
37
+ parameter.", `checker/instr_rules.py`, owned alongside this package). Also pass 1 (oracle
38
+ evidence, `docs/checker.md`: a file with several earlier "Variable not named." errors still
39
+ gets its own, later "Invalid or missing parameter." reported). `structure.
40
+ check_missing_param_followup` runs right after it, also ungated: the physical line the
41
+ shortfall's own wrapped argument list spills onto gets its own "Invalid, or out of place
42
+ expression: %s." when every name it references resolves (`docs/checker.md` "Missing-parameter
43
+ followup"). `instr_rules.check_too_many` --
44
+ the mirror image, an argument list running past the declared count ("Too many parameters: %s.",
45
+ round 5, `docs/checker.md`) -- runs alongside it, also pass 1 (oracle-confirmed the same way).
46
+ `expressions.check_function_arity` -- the same shortfall/overflow shape, but for a *built-in
47
+ function* call inside an expression rather than an instruction's own argument list ("Expression
48
+ expects , : %s."/"No Matching, or out of place ) in expression: %s.", `docs/checker.md`); also
49
+ pass 1, oracle-confirmed the same way.
50
+ 5. `warnings.check_early` -- reachability/fnotch; ungated, fires even when the file has other
51
+ errors elsewhere (`docs/instructions.md`). `warnings.check_background_calibration` is a
52
+ related but separately-gated check, run at step 11 below instead (round 6, `docs/checker.md`).
53
+
54
+ If, and only if, steps 1-5 found nothing:
55
+
56
+ 6. `structure.check_pass2` -- the generic "Expr"/implicit-`Call` fallback and the before-`BeginProg`
57
+ placement check (returns the stop, if any).
58
+ 7. `expressions.check` -- a bare identifier or compound expression used at an instruction argument
59
+ position that resolves nowhere, and the constant-only-family "rejects a variable" check
60
+ (deliberately narrow; see that module's docstring). `instr_data.check_unclosed_calltable` runs
61
+ alongside it, then `cr3xx.check_unknown_line_words` (CR300/CR350 only, one leading-word `%s is
62
+ not defined.` line per `S.Unparsed` statement's own generic
63
+ fallback, `docs/loggers.md` "Round 8").
64
+ 8. `instructions.check` -- per-instruction argument checks layered on top of the generic type-code
65
+ parsing (the analog-channel/port terminal range checks, `terminals.py`); `instr_data.
66
+ check_table_structure` (table-structure placement) and `instr_measure.check` (`Range`/`SW12`/
67
+ `Delay`/`CS616`/`TimerInput`/`ExciteV`/`ExciteI` value checks, `docs/instructions.md`) run
68
+ alongside it.
69
+ 9. Every error step 6-8 added is truncated to the earliest stop (expanded-source position) of steps 6-8.
70
+
71
+ Then, regardless of errors so far:
72
+
73
+ 9b. `cr3xx.check_too_many_cascade` (CR300/CR350 only, a no-op on every other logger): once any
74
+ undefined-name-class or genuine `Too many parameters:` finding has fired anywhere above, in
75
+ any tier, every following two-plus-argument recognised instruction to end of file gets its own
76
+ spurious `Too many parameters: %s.` in place of whatever it would otherwise have found
77
+ (`checker/cr3xx.py`'s own module comment for the oracle evidence). Run here, unconditionally,
78
+ rather than inside step 7's own tier-B block, because the taint can originate from a tier-A
79
+ finding that skips tier B/C entirely (`docs/loggers.md`).
80
+ 10. `mode.decide` -- Sequential/PipelineMode (`mode.py`, reusing the parser's own folded `Const`
81
+ values via `consteval.Env`, as the previous placeholder checker did).
82
+ 11. When the compile has no errors so far: `warnings.check_background_calibration` (round 6, see
83
+ that function's own docstring for why it is not part of step 5 above), then `tables_hook.
84
+ attach`, when the module is available (it is written by a different concurrent workstream,
85
+ `PLAN.md`; guarded so this checker still runs standalone while that module is in progress),
86
+ then `warnings.check` ("table declared but never called") and
87
+ `instr_data.check_duplicate_fields`.
88
+
89
+ Full instruction-signature checks (the rest of `instructions.py`) and a general expression-tree
90
+ name/type resolution (the rest of `expressions.py`) are future work; see `docs/checker.md` and
91
+ `PROGRESS.md`'s phase-3 row for current coverage and the oracle evidence behind each rule above.
92
+ """
93
+
94
+ import dataclasses
95
+ import os
96
+ from contextvars import ContextVar
97
+
98
+ from .. import consteval, mode
99
+ from .. import syntax as S
100
+ from ..diagnostics import Diagnostic, Diagnostics
101
+ from ..profiles import Profile
102
+ from . import (
103
+ cr3xx,
104
+ ess,
105
+ expressions,
106
+ instr_data,
107
+ instr_measure,
108
+ instr_rules,
109
+ instructions,
110
+ intervals,
111
+ storage,
112
+ structs,
113
+ structure,
114
+ var_types,
115
+ warnings,
116
+ )
117
+
118
+ _current_result: ContextVar = ContextVar("opencrbasic_checker_result", default=None)
119
+
120
+
121
+ def _is_unreached(d) -> bool:
122
+ return d.severity == "warning" and d.message.endswith(" never reached at run time.")
123
+
124
+
125
+ def _run_pass(fn, *args) -> None:
126
+ """Never let one checker pass's bug take down the whole compile (AGENTS.md: a crash is worse
127
+ than a wrong-but-returned answer; `docs/fuzz-findings.md` "Hang"/"Crash" -- both found by
128
+ fuzzing, both in code this session does not exhaustively re-verify). A pass that raises is
129
+ treated as having found nothing further this run: whatever diagnostics it already recorded
130
+ (via `diags`, mutated in place) stand, and every other pass still runs. No oracle text can ever
131
+ exist for "opencrbasic itself broke", so this never invents a diagnostic to paper over it --
132
+ the caller is responsible for still returning a usable `CompileResult` either way.
133
+ """
134
+ try:
135
+ fn(*args)
136
+ except Exception as exc:
137
+ _record_exception(fn, exc)
138
+ if os.environ.get("OPENCRBASIC_STRICT") == "1":
139
+ raise
140
+
141
+
142
+ # Instructions whose compile function rejects the placement (`TimerInput must be used within a
143
+ # Scan...NextScan.`, `MenuItem must be used within DisplayMenu..EndMenu.`) before resolving their own
144
+ # arguments: an undeclared name in them is not reported (oracle, CR6/CR1000X, 2026-10-12, `docs/checker.md`
145
+ # "Round 14 placement before arguments"). `PulseCount` resolves its arguments first and is not listed.
146
+ _PLACEMENT_FIRST = {
147
+ "TIMERINPUT": "a Scan...NextScan.",
148
+ "MENUITEM": "DisplayMenu..EndMenu.",
149
+ "MENURECOMPILE": "DisplayMenu..EndMenu.",
150
+ }
151
+
152
+
153
+ def _placement_rejected(program: S.Program, profile, diags: Diagnostics) -> frozenset:
154
+ """`id()`s of the `_PLACEMENT_FIRST` statements that already carry their placement error."""
155
+ if profile.single_pass:
156
+ return frozenset()
157
+ found = set()
158
+ for stmt in program.statements:
159
+ if not isinstance(stmt, S.Instruction) or stmt.keyword.upper() not in _PLACEMENT_FIRST:
160
+ continue
161
+ kw = stmt.keyword.upper()
162
+ text = f"{profile.instructions[kw]['name']} must be used within {_PLACEMENT_FIRST[kw]}"
163
+ if any(
164
+ d.severity == "error" and d.line == stmt.line and d.file == stmt.file and d.message == text
165
+ for d in diags.items
166
+ ):
167
+ found.add(id(stmt))
168
+ return frozenset(found)
169
+
170
+
171
+ def _with_inline(program: S.Program) -> S.Program:
172
+ """A copy of `program` whose statement list also holds each one-line `If`'s statements, right
173
+ after the `If`."""
174
+ statements: list[S.Stmt] = []
175
+ flat_at: list[int] = [] # raw statement index -> index in the flattened list
176
+ for stmt in program.statements:
177
+ flat_at.append(len(statements))
178
+ statements.append(stmt)
179
+ if isinstance(stmt, S.If):
180
+ statements.extend((stmt.inline or []) + (stmt.inline_else or []))
181
+ flat_at.append(len(statements))
182
+ # (`include_error_at` indexes the raw list: re-index it, or each earlier one-line `If` body
183
+ # would shift the CR300/CR350 cascade start too early)
184
+ return dataclasses.replace(
185
+ program,
186
+ statements=statements,
187
+ include_error_at=[flat_at[min(i, len(flat_at) - 1)] for i in program.include_error_at],
188
+ )
189
+
190
+
191
+ def _run_pass_capture(fn, *args):
192
+ """Like `_run_pass`, but returns `fn`'s own return value (or `None` on an exception) -- for
193
+ `structure.check_pass2`, whose return value (the placement-error cutoff line) the caller needs.
194
+ """
195
+ try:
196
+ return fn(*args)
197
+ except Exception as exc:
198
+ _record_exception(fn, exc)
199
+ if os.environ.get("OPENCRBASIC_STRICT") == "1":
200
+ raise
201
+ return None
202
+
203
+
204
+ def _record_exception(fn, exc: Exception) -> None:
205
+ result = _current_result.get()
206
+ if result is not None:
207
+ result.record_implementation_failure(f"{fn.__module__}.{fn.__name__}", exc)
208
+
209
+
210
+ def check(program: S.Program, profile: Profile, diags: Diagnostics, result) -> None:
211
+ token = _current_result.set(result)
212
+ try:
213
+ _check(program, profile, diags, result)
214
+ finally:
215
+ _current_result.reset(token)
216
+
217
+
218
+ def _place_index_bounds(bounds: Diagnostics, diags: Diagnostics, program: S.Program, single_pass: bool = False) -> None:
219
+ """CR6/CR1000X `Variable %s out of bounds.` from the index check (`bounds`), which is appended
220
+ after tier C, with two corrections (oracle, round 9, docs/checker.md "Index bounds placement"):
221
+ a message the destination check already printed on the line is not printed twice
222
+ (`VoltSe(A(11),1,..,13,..)`), and for `PortGet` the destination (argument 1) comes before the
223
+ port (argument 2), so it is inserted ahead of `Invalid port ...` (`PortSet(1,A(11))` keeps the
224
+ port first). CR300/CR350 (`single_pass`): only the first correction (round 31, `Therm109(A(6),
225
+ ..)`, `VoltSe(A(6),..)`; docs/checker.md "Measurement destination and channel spans")."""
226
+ portget = (
227
+ set()
228
+ if single_pass
229
+ else {
230
+ (st.file, st.line)
231
+ for st in program.statements
232
+ if isinstance(st, S.Instruction) and st.keyword.upper() == "PORTGET"
233
+ }
234
+ )
235
+ kept = []
236
+ printed = list(diags.items) # before any insertion below
237
+ for n, d in enumerate(bounds.items):
238
+ same = lambda o: (o.file, o.line, o.message) == (d.file, d.line, d.message) # noqa: E731
239
+ if sum(map(same, bounds.items[:n])) < sum(map(same, printed)):
240
+ continue # the destination check printed it
241
+ at = next(
242
+ (
243
+ i
244
+ for i, o in enumerate(diags.items)
245
+ if (o.file, o.line) == (d.file, d.line) and o.message.startswith("Invalid port")
246
+ ),
247
+ None,
248
+ )
249
+ if at is not None and (d.file, d.line) in portget:
250
+ diags.items.insert(at, d)
251
+ else:
252
+ kept.append(d)
253
+ bounds.items = kept
254
+
255
+
256
+ def _check(program: S.Program, profile: Profile, diags: Diagnostics, result) -> None:
257
+ # `Unknown instruction: #...` ends the compile on all four loggers: nothing after that
258
+ # statement is reported (oracle, 2026-10-10: `#a` then `#b` -> only `#a`)
259
+ stop = next((i for i, st in enumerate(program.statements) if S.hash_statement(st)), None)
260
+ if stop is not None:
261
+ program = dataclasses.replace(
262
+ program, statements=[*program.statements[:stop], S.cut_after_hash(program.statements[stop])]
263
+ )
264
+ if program.hash_cut is not None:
265
+ del diags.items[program.hash_cut :] # parser findings of later lines
266
+ program = structure.open_program_at_slowsequence(program, profile)
267
+ env = consteval.Env(profile, dict(program.consts))
268
+
269
+ _run_pass(structure.check_pass1, program, profile, diags, env)
270
+ # Also pass 1 (unconditional -- oracle-confirmed, `docs/loggers.md`): a `Const`'s own value
271
+ # expression is folded eagerly at declaration time, so an undefined name inside it is
272
+ # reported alongside pass-1's other declaration errors, not deferred to pass 2's gated
273
+ # `expressions.check` below.
274
+ _run_pass(expressions.check_consts, program, profile, diags)
275
+ _run_pass(instr_data.check_constant_destinations, program, profile, diags, env)
276
+ # CR6/CR1000X: constant-family instruction arguments resolve in pass 1 too.
277
+ _run_pass(expressions.check_const_family_args, _with_inline(program), profile, diags)
278
+ # CR6/CR1000X: `Range` code and `fN1` value checks, same pass (`docs/instructions.md`).
279
+ _run_pass(instr_measure.check_pass1_values, program, profile, diags, env)
280
+ # `TimeIntoInterval`/`IfTime` constant arguments, a parser check (`checker/intervals.py`).
281
+ _run_pass(var_types.check_string_operands, program, profile, diags)
282
+ _run_pass(intervals.check, program, profile, diags, env)
283
+ _run_pass(instr_rules.check_const_int_args, program, profile, diags, env)
284
+ _run_pass(expressions.check_function_call_counts, program, profile, diags)
285
+ _run_pass(expressions.check_recursive_functions, program, profile, diags)
286
+ # `Data`/`DataLong` parameter constant-only checking (`expressions.py`'s own docstring for the
287
+ # oracle evidence). Also ungated pass 1, same reasoning as `check_consts` just above.
288
+ _run_pass(expressions.check_data_params, program, profile, diags)
289
+ # CR300/CR350 only (a no-op on every other logger, `checker/cr3xx.py`): `SerialOpen`'s own
290
+ # `ComPort` argument, and every other `Serial*` function's own first argument. Also ungated
291
+ # (oracle-confirmed, `docs/loggers.md`).
292
+ _run_pass(cr3xx.check, program, profile, diags)
293
+ _run_pass(cr3xx.check_dropped_tail, program, profile, diags)
294
+ _run_pass(instr_rules.check, _with_inline(program), profile, diags) # (a one-line `If` body too)
295
+ _run_pass(instr_rules.check_return_placement, program, profile, diags)
296
+ # The leftover line a required-parameter shortfall's own wrapped argument list spills onto
297
+ # (`checker/structure.py`'s `check_missing_param_followup` docstring for the oracle evidence):
298
+ # also ungated, since it fires precisely because the call just above already found something.
299
+ _run_pass(structure.check_missing_param_followup, program, profile, diags)
300
+ _run_pass(instr_rules.check_too_many, _with_inline(program), profile, diags)
301
+ _run_pass(instr_rules.check_essinitialize_too_many, program, profile, diags)
302
+ _run_pass(instr_rules.check_snmpvariable_too_many, program, profile, diags)
303
+ _run_pass(instr_rules.check_snmp_oid, program, profile, diags, env)
304
+ _run_pass(instr_rules.check_modemcallback_baud, program, profile, diags)
305
+ _run_pass(instr_rules.check_serialin_destination, program, profile, diags)
306
+ _run_pass(instr_rules.check_serialin_comport, _with_inline(program), profile, diags, env)
307
+ _run_pass(instr_rules.check_emailrecv_destination, program, profile, diags)
308
+ _run_pass(instr_rules.check_tcpopen_connections, program, profile, diags, env)
309
+ _run_pass(instr_rules.check_tcpopen_handle_type, program, profile, diags)
310
+ # `AddPrecise`/`MovePrecise`'s own `PrecisionVariable` argument (`instr_rules.py`'s own
311
+ # `_PRECISION_VARIABLE_POSITIONS` docstring for the oracle evidence). Also pass 1,
312
+ # unconditional -- oracle-confirmed the same way as the two calls just above.
313
+ _run_pass(instr_rules.check_precision_variable, program, profile, diags)
314
+ _run_pass(instr_rules.check_vibrating_wire_diagnostics, program, profile, diags)
315
+ _run_pass(instr_rules.check_iif_types, _with_inline(program), profile, diags)
316
+ # Built-in function call arity (`Expression expects , : %s.` / `No Matching, or out of place )
317
+ # in expression: %s.`, `checker/expressions.py`'s `check_function_arity`, own module docstring
318
+ # for the oracle evidence and the safe allow-lists). Also pass 1, unconditional -- oracle-
319
+ # confirmed the same way as the two calls just above (`docs/checker.md`).
320
+ _run_pass(var_types.check_ctype, program, profile, diags)
321
+ _run_pass(var_types.check_pointer_deref, program, profile, diags)
322
+ held_member: list = [] # compile-stage `Member` index errors, see `pass1_errors` below
323
+ _run_pass(expressions.check_function_arity, _with_inline(program), profile, diags, held_member)
324
+ # Ungated on CR6/CR1000X (unlike step 2 below): reachability/background-calibration/fnotch all
325
+ # oracle-confirmed to fire even when the file has other errors elsewhere (`checker/warnings.py`,
326
+ # `docs/instructions.md`). **Not** shared by CR300/CR350 (`profile.early_warnings_gated`,
327
+ # `docs/loggers.md`): there, this same call waits until tier B and the table `Reps` checks are
328
+ # clean.
329
+ if not profile.single_pass and not diags.errors:
330
+ # CR6/CR1000X: an unknown structure member is a pass-1 error (`checker/structs.py`).
331
+ _run_pass(structs.check_members, program, profile, diags, _with_inline(program))
332
+ early = Diagnostics() # CR6/CR1000X `Scan`/`DataInterval` value errors, see below
333
+ if not profile.early_warnings_gated:
334
+ early_start = len(diags.items)
335
+ errors_before_early = len(diags.errors)
336
+ _run_pass(warnings.check_early, program, profile, diags, env)
337
+ if not profile.single_pass and not errors_before_early:
338
+ _run_pass(instr_measure.check_scan_delay_budget, program, profile, diags, env, True)
339
+ # Pass-2 findings that neither gate pass 2 nor tier C (oracle, CR6/CR1000X, 2026-10-03:
340
+ # `Scan(0,Sec,1,0)` plus a later `z = Q`, a bad channel or an orphan `Sample` reports
341
+ # both) but end it past a placement stop, and hide the unclosed-block checks.
342
+ early.items = [d for d in diags.items[early_start:] if d.severity == "error"]
343
+ diags.items[early_start:] = [d for d in diags.items[early_start:] if d.severity != "error"]
344
+
345
+ bounds = Diagnostics()
346
+ pass2_start, flat = 0, program # (both set before pass 2 reports anything they bound)
347
+ fields = Diagnostics() # `SetSetting`/`SetStatus` field-name findings, see below
348
+ # CR6/CR1000X `X not allowed outside of Scan..NextScan in PipeLine Mode.` and `DO .. LOOP
349
+ # cannot span across sequences.`: pass 2 errors that do not hide tier C (oracle: printed
350
+ # together with the instruction's own `Invalid channel`/`Invalid port` and `Scan interval too
351
+ # short` errors), merged ahead of the tier-C findings below.
352
+ pipe = Diagnostics()
353
+
354
+ ranges = Diagnostics() # `measure.json` `const_ranges` findings, see below
355
+ leading = Diagnostics() # pass-2 findings that print ahead of their line's other findings
356
+ claims = Diagnostics() # terminal-ownership conflicts, see below
357
+ links = structure.CallLinks()
358
+
359
+ def before_cutoff(diagnostic) -> bool: # tier B (below) redefines it once a stop is known
360
+ return True
361
+
362
+ held_items: list = []
363
+ if held_member:
364
+ # A table/status field with 3+ indices fails in the statement's compile (pass 2), not in
365
+ # pass 1: it neither stops pass 2 nor hides the placement error, but any pass-1 error hides
366
+ # it, and it is only reached up to the first placement stop (oracle, all four loggers,
367
+ # 2026-10-10; message tracer: pass-1 frames for `Sin(1,2)`, compile frames for these).
368
+ held_items = [d for d in diags.items if any(d is h for h in held_member)]
369
+ diags.items[:] = [d for d in diags.items if not any(d is h for h in held_member)]
370
+ if diags.errors:
371
+ held_items = []
372
+ _run_pass(storage.check_pass1, program, profile, diags, env) # CR6/CR1000X `Out of memory.`
373
+ _run_pass(instr_data.drop_unreached_destinations, program, profile, diags, env)
374
+ pass1_errors = bool(diags.errors)
375
+ result.pass1_errors = pass1_errors
376
+ pass1_items = [d for d in diags.items if d.severity == "error"]
377
+ if pass1_errors and not profile.single_pass:
378
+ # CR6/CR1000X: any pass-1 error hides the reachability warning (oracle, 2026-10-12)
379
+ diags.items[:] = [d for d in diags.items if not _is_unreached(d)]
380
+ generic_errors: list = [] # pass-2 argument/expression findings (CR6/CR1000X: no tier-C gate)
381
+ unknown_table_diags: list = [] # `Could not find DataTable T.`: ends the walk, gates nothing above it
382
+ if pass1_errors:
383
+ early.items = [] # hidden by any pass-1 error (oracle, CR6/CR1000X, 2026-10-03)
384
+ soft: list = [] # stage-2a findings that gate nothing (`instr_data.WIND_SUBINTERVAL`)
385
+ if not diags.errors:
386
+ # CR6/CR1000X instruction checks between pass 1 and pass 2's expression tier; an error
387
+ # here skips tier B (`instr_data.check_stage2a`).
388
+ _run_pass(instr_data.check_stage2a, program, profile, diags, env)
389
+ soft = [d for d in diags.items if d.message == instr_data.WIND_SUBINTERVAL]
390
+ diags.items[:] = [d for d in diags.items if d.message != instr_data.WIND_SUBINTERVAL]
391
+ if diags.errors:
392
+ diags.items[:] = [d for d in diags.items if not _is_unreached(d)] # (oracle, 2026-10-12)
393
+ # hidden by a stage-2a error too; only a negative `DataInterval` ahead of a table
394
+ # `Reps`/bounds error survives (round 52,
395
+ # docs/checker.md#datainterval-before-a-table-reps-error-round-52)
396
+ order = {(st.file, st.line): i for i, st in enumerate(program.statements)}
397
+ first = min(order.get((d.file, d.line), 0) for d in diags.errors)
398
+ early.items = [
399
+ d
400
+ for d in early.items
401
+ if d.message.startswith("DataInterval TintoInt") and order.get((d.file, d.line), 0) < first
402
+ ]
403
+ if not diags.errors or profile.single_pass:
404
+ # Pass 2, tier B (see this module's docstring): the generic "Expr"/implicit-`Call`
405
+ # fallback (`structure.check_pass2`) and the undefined-symbol/constant-only-family checks
406
+ # on a *recognised* instruction's own arguments (`expressions.check`) freely accumulate
407
+ # with each other -- oracle-confirmed, `docs/checker.md` "Pass model": `Randomize(FooBar)`
408
+ # (an `expressions.check` finding) followed by a separate bare `qux quux` (a
409
+ # `structure.check_pass2` finding) reports *both*. `structure.check_pass2` also reports
410
+ # where (if anywhere) tier B should be considered cut off, matching the real compiler's own
411
+ # "stop entirely at the first placement error" behaviour.
412
+ pass2_start = len(diags.items)
413
+ _run_pass(instr_rules.check_spi_open_order, _with_inline(program), profile, leading)
414
+ # Block-closer messages come before a same-line placement error (`EndIf illegally
415
+ # occurs before BeginProg.` follows `END IF without IF...`).
416
+ # CR300/CR350: collected aside in `leading` (printed first on their line, but they do not
417
+ # count as errors for the placement stop; `cr3xx.drop_block_closer_errors_after_errors`).
418
+ _run_pass(structure.check_block_closers, program, profile, leading if profile.single_pass else diags)
419
+ # Tier B also checks the statements of a one-line `If` (oracle: `If X Then Y = 1` ->
420
+ # `Invalid, or out of place expression: Y = 1.`).
421
+ flat = _with_inline(program)
422
+ n_generic = len(diags.items)
423
+ stop_line = _run_pass_capture(structure.check_pass2, flat, profile, diags, env, links)
424
+ generic_errors += diags.items[n_generic:]
425
+ # Every stop is one expanded-source position (`links.positions`), the earliest wins; an
426
+ # included file has its own line numbers, so a physical line cannot order them.
427
+ # Like the `Scan` value errors, a nested `Scan` neither gates tier C nor survives past a
428
+ # placement stop (oracle, CR6/CR1000X, 2026-10-03: a later bad channel is still reported).
429
+ if not profile.single_pass:
430
+ early.items[:0] = [d for d in diags.items[pass2_start:] if d.message == structure.NESTED_SCAN]
431
+ diags.items[pass2_start:] = [d for d in diags.items[pass2_start:] if d.message != structure.NESTED_SCAN]
432
+ stops = [links.cutoff] if stop_line is not None and links.cutoff else []
433
+ unreached_off: set = set() # statements whose own failure replaces their reachability warning
434
+ stray_stop = _run_pass_capture(structure.check_stray_controls, program, profile, diags)
435
+ if stray_stop is not None:
436
+ stops.append(stray_stop)
437
+ _run_pass(structure.check_pipeline_task_placement, program, profile, pipe)
438
+ _run_pass(structure.check_slow_sequence_blocks, program, profile, pipe)
439
+ menu_stop = _run_pass_capture(instr_data.check_table_and_menu_placement, program, profile, diags, env)
440
+ if menu_stop is not None:
441
+ stops.append(menu_stop)
442
+ # An unknown `CallTable` target ends pass 2 there like a placement error (oracle, all four
443
+ # loggers: a later `NextScan with no Scan.`/`x = 1` is not reported).
444
+ # (CR300/CR350 order these differently, `check_scan_placement_late`: not modelled.)
445
+ if not profile.single_pass:
446
+ for stmt, _name in instr_data.unknown_calltables(program):
447
+ stops.append((stmt.file, stmt.line))
448
+ unreached_off.add((stmt.file, stmt.line))
449
+ for stmt, _message, ends in instr_data.unknown_table_references(program, profile):
450
+ if ends: # (`GetRecord`/`ResetTable`/`FileMark` of a missing table, round 14)
451
+ stops.append((stmt.file, stmt.line))
452
+ # An orphan `EndTable`/empty table ends pass 2 too (reported in tier C, `check_table_
453
+ # structure`), cutting a later placement error.
454
+ if not profile.single_pass:
455
+ early_table_stop = _run_pass_capture(
456
+ instr_data.check_table_structure, _with_inline(program), profile, Diagnostics()
457
+ )
458
+ if early_table_stop is not None:
459
+ stops.append(early_table_stop)
460
+ stop_at = min((links.positions[k] for k in stops if k in links.positions), default=None)
461
+
462
+ def before_cutoff(diagnostic) -> bool:
463
+ if stop_at is None:
464
+ return True
465
+ return links.positions.get((diagnostic.file, diagnostic.line), stop_at) <= stop_at
466
+
467
+ # The reachability warning is printed by the walk that stops at these (oracle, 2026-10-12)
468
+ kept = [
469
+ d
470
+ for d in diags.items[:pass2_start]
471
+ if not (_is_unreached(d) and (not before_cutoff(d) or (d.file, d.line) in unreached_off))
472
+ ]
473
+ diags.items[:pass2_start] = kept
474
+ pass2_start = len(kept)
475
+ n_generic = len(diags.items)
476
+ _run_pass(expressions.check, flat, profile, diags, env, _placement_rejected(flat, profile, diags))
477
+ _run_pass(expressions.check_timedcontrol_constants, flat, profile, diags)
478
+ _run_pass(expressions.check_table_members, flat, profile, diags)
479
+ _run_pass(expressions.check_digit_members, flat, profile, diags)
480
+ _run_pass(expressions.check_undefined_dotted_bases, flat, profile, diags, pass2_start)
481
+ _run_pass(instr_rules.check_debug_args, flat, profile, diags, env)
482
+ _run_pass(instr_rules.check_serialbrk_comport, flat, profile, leading, env)
483
+ if not profile.single_pass:
484
+ # compiled after the included file was read: no `<file>` prefix (oracle, CR6/CR1000X,
485
+ # round 40, docs/checker.md#include-prefix-of-pass-2-symbols-round-40)
486
+ renamed = {
487
+ id(d): dataclasses.replace(d, show_file=False)
488
+ for d in diags.items[pass2_start:]
489
+ if d.message.startswith("Invalid symbol in expression")
490
+ }
491
+ diags.items[pass2_start:] = [renamed.get(id(d), d) for d in diags.items[pass2_start:]]
492
+ # (tier C gates on the identity of these: keep `generic_errors` pointing at them)
493
+ generic_errors[:] = [renamed.get(id(d), d) for d in generic_errors]
494
+ # a `FieldNames` argument is resolved by its owning output instruction (docs/checker.md
495
+ # "FieldNames owner line", round 54)
496
+ window = diags.items[pass2_start:]
497
+ moved, added = instr_data.move_fieldnames_symbol_errors(flat, window)
498
+ diags.items[pass2_start:] = window
499
+ generic_errors[:] = [m for g in generic_errors if (m := moved.get(id(g), g)) is not None] + added
500
+ # `PulseCount must be used within a Scan...NextScan.` follows its own statement's symbol
501
+ # error (oracle, CR6/CR1000X, round 40)
502
+ window = diags.items[pass2_start:]
503
+ for d in [d for d in window if d.message.endswith("must be used within a Scan...NextScan.")]:
504
+ after = [
505
+ i
506
+ for i, o in enumerate(window)
507
+ if (o.file, o.line) == (d.file, d.line) and o.message.startswith("Invalid symbol in expression")
508
+ ]
509
+ if after and after[-1] > window.index(d):
510
+ window.remove(d)
511
+ window.insert(after[-1], d)
512
+ diags.items[pass2_start:] = window
513
+ generic_errors += diags.items[n_generic:]
514
+ _run_pass(instr_rules.check_mqtt_publish_payload, flat, profile, diags)
515
+ if not profile.single_pass:
516
+ _run_pass(instr_data.check_eventcount_tables, flat, profile, diags)
517
+ _run_pass(ess.check, flat, profile, diags, env)
518
+ # Collected aside: reported with pass 2 but not hiding the tier-C checks (oracle:
519
+ # `PortSet(1,A(9))` gives both `Invalid port 1 ...` and `Variable A out of bounds.`).
520
+ if not profile.single_pass:
521
+ _run_pass(expressions.check_instruction_index_bounds, flat, profile, bounds, env)
522
+ _run_pass(instr_rules.check_avgrun_options, flat, profile, bounds, env)
523
+ _run_pass(instr_data.check_output_datatype, flat, profile, bounds, env)
524
+ _run_pass(instr_data.check_odd_pair_reps, flat, profile, leading, env)
525
+ _run_pass(instr_rules.check_option_values, flat, profile, bounds, env)
526
+ _run_pass(instr_rules.check_serialinrecord, flat, profile, bounds, env)
527
+ _run_pass(expressions.check_splitstr_whole_array, flat, profile, bounds, env)
528
+ _run_pass(expressions.check_splitstr_element_reps, flat, profile, bounds, env)
529
+ _run_pass(expressions.check_sdmsio4_count, flat, profile, bounds, env)
530
+ _run_pass(expressions.check_calibrate_size, flat, profile, bounds, env)
531
+ _run_pass(expressions.check_subcall_element_bounds, flat, profile, bounds, env)
532
+ _run_pass(instr_rules.check_fieldcal_variable_args, flat, profile, bounds, env)
533
+ _run_pass(instr_rules.check_pulsecount_channels, flat, profile, bounds)
534
+ _run_pass(instr_rules.check_terminal_conflicts, flat, profile, claims, env)
535
+ _run_pass(instr_rules.check_sdi12_nan_fill, flat, profile, bounds, env)
536
+ _run_pass(instr_rules.check_dnp_update, flat, profile, leading, env)
537
+ _run_pass(instr_rules.check_portbridge_array, flat, profile, bounds, env)
538
+ _run_pass(instr_rules.check_cdm_address_types, flat, profile, bounds, env)
539
+ _run_pass(instr_rules.check_avw200_scan_time, flat, profile, bounds, env)
540
+ _run_pass(instr_rules.check_epi_scan_buffers, flat, profile, bounds, env)
541
+ bounds.items = [d for d in bounds.items if before_cutoff(d)]
542
+ pipe.items = [d for d in pipe.items if before_cutoff(d)]
543
+ leading.items = [d for d in leading.items if before_cutoff(d)]
544
+ claims.items = [d for d in claims.items if before_cutoff(d)]
545
+ _run_pass(instr_rules.check_sdm_address, program, profile, diags, env)
546
+ # Collected aside like the index bounds: a finding neither hides tier C (oracle: a bad
547
+ # `SemaphoreGet` beside a bad `VoltDiff` channel reports both) nor is hidden by tier B.
548
+ if not profile.single_pass:
549
+ _run_pass(instr_measure.check_const_ranges, flat.statements, profile, ranges, env)
550
+ ranges.items = [d for d in ranges.items if before_cutoff(d)]
551
+ # `Could not find DataTable %s.` (round 6, `checker/instr_data.py`'s
552
+ # `check_unclosed_calltable` docstring): a `CallTable` referencing a `DataTable` that was
553
+ # declared but never closed with `EndTable`. Also tier B, oracle-confirmed the same way as
554
+ # the other two calls just above (accumulates with an unrelated `expressions.check` finding
555
+ # rather than being suppressed by it).
556
+ n_calltable = len(diags.items)
557
+ _run_pass(instr_data.check_unclosed_calltable, program, diags, profile)
558
+ # The unknown-table error ends the walk there but does not hide the tier-C findings above
559
+ # it (oracle, all four loggers, 2026-10-13: `Sample` outside a table, then an unknown
560
+ # `CallTable`, prints both)
561
+ unknown_table_diags += [
562
+ d for d in diags.items[n_calltable:] if d.message.startswith("Could not find DataTable ")
563
+ ]
564
+ generic_errors += unknown_table_diags
565
+ # `SetSetting`/`SetStatus` field names: collected aside like the index bounds, so a finding
566
+ # neither hides tier C nor is hidden by tier-B findings (CR6/CR1000X; CR300/CR350 run it
567
+ # below, only when nothing else was found). `docs/instructions.md`.
568
+ if not profile.single_pass:
569
+ _run_pass(instr_rules.check_settings_fields, flat, profile, diags, fields, env)
570
+ fields.items = [d for d in fields.items if before_cutoff(d)]
571
+ # CR300/CR350 only (a no-op on every other logger, `checker/cr3xx.py`): one leading-word
572
+ # `%s is not defined.` line per bare word of an `S.Unparsed` statement's own source text,
573
+ # spliced in immediately ahead of `structure.check_pass2`'s generic "Invalid, or out of
574
+ # place expression" fallback for it (`docs/loggers.md` "Round 8"). Reacts to `diags.items`
575
+ # already emitted above, the same after-the-fact pattern `check_too_many_cascade` below
576
+ # also uses.
577
+ _run_pass(cr3xx.check_unknown_line_words, flat, profile, diags)
578
+ _run_pass(cr3xx.check_for_header, flat, profile, diags) # (`For ... To 1 Step-1`)
579
+ # CR300/CR350 only (a no-op on every other logger, `checker/cr3xx.py`): one `%s is not
580
+ # defined.` line per undefined bare-name argument of an `S.Call` that itself resolved to
581
+ # nothing, spliced in between that call's own `{name} is not defined.` line (`structure.
582
+ # _check_call_pass2`) and the generic fallback for it (`docs/loggers.md` "CDM_* argument
583
+ # names"). Same after-the-fact `diags.items` pattern as `check_unknown_line_words` above.
584
+ _run_pass(cr3xx.check_undefined_call_args, flat, profile, diags)
585
+ _run_pass(cr3xx.check_float_destinations, flat, profile, diags)
586
+ _run_pass(cr3xx.check_undefined_arg_aborts, flat, profile, diags)
587
+ _run_pass(cr3xx.drop_too_many_after_variable_illegal, flat, profile, diags)
588
+ _run_pass(cr3xx.drop_calltable_error_with_other_errors, program, profile, diags)
589
+ # Every pass-2 error past the placement-error's own line is dropped, reproducing the
590
+ # real compiler's "stop entirely" behaviour without any of the modules above needing
591
+ # to coordinate the cutoff themselves (`docs/checker.md` "Pass model"). Warnings and
592
+ # anything already present before this pass (pass-1 errors -- none, since `not
593
+ # diags.errors` just gated this whole block, but defensive) are left untouched.
594
+ diags.items = diags.items[:pass2_start] + [
595
+ d for d in diags.items[pass2_start:] if d.severity != "error" or not d.line or before_cutoff(d)
596
+ ]
597
+ over = [
598
+ d
599
+ for d in diags.items[pass2_start:]
600
+ if d.severity == "error" and d.message.startswith("Dimensions not declared")
601
+ ]
602
+ if over and not profile.single_pass:
603
+ # CR6/CR1000X: more than three subscripts is a pass-1 error: it hides the pass-2
604
+ # symbol errors, the asides and tier C (oracle, round 40,
605
+ # docs/checker.md#over-subscripted-arrays-are-pass-1-round-40)
606
+ diags.items = diags.items[:pass2_start] + [
607
+ d for d in diags.items[pass2_start:] if not d.message.startswith("Invalid symbol in expression")
608
+ ]
609
+ generic_errors = [g for g in generic_errors if not any(g is d for d in over)]
610
+ for aside in (bounds, fields, ranges, leading, pipe, claims, early):
611
+ aside.items = []
612
+
613
+ if profile.single_pass:
614
+ _run_pass(structure.commit_deferred_sub_error, links, profile, diags)
615
+
616
+ if profile.single_pass and not diags.errors:
617
+ # CR300/CR350 reject an executable `DataTable` before the table-field checks that would
618
+ # otherwise diagnose its body (`structure.check_data_table_placement`).
619
+ _run_pass(structure.check_data_table_placement, program, profile, diags)
620
+
621
+ late_start = len(diags.items)
622
+ late_stop = None
623
+ if profile.single_pass and all(
624
+ d.message.startswith(("Could not find ", "Cannot find DataTable ")) for d in diags.errors
625
+ ):
626
+ # CR300/CR350: runs unless an earlier finding other than an unknown `CallTable` table
627
+ # exists (`docs/instructions.md`, SetSetting and SetStatus field names).
628
+ _run_pass(instr_rules.check_settings_fields, _with_inline(program), profile, diags, fields, env)
629
+ _run_pass(instr_rules.check_option_values, _with_inline(program), profile, bounds, env)
630
+ if not diags.errors:
631
+ # After passes 1 and 2 are clean: CR300/CR350 table `Reps`/bounds, then unclosed
632
+ # `Scan`/`For`/`Do`/`If` at end of program.
633
+ if profile.single_pass:
634
+ _run_pass(expressions.check_instruction_index_bounds, program, profile, bounds, env)
635
+ _run_pass(instr_data.check_output_datatype, program, profile, bounds, env)
636
+ _run_pass(expressions.check_status_member_reads_late, program, profile, bounds)
637
+ _run_pass(expressions.check_splitstr_whole_array, program, profile, bounds, env)
638
+ _run_pass(expressions.check_splitstr_element_reps, program, profile, bounds, env)
639
+ _run_pass(expressions.check_calibrate_size, program, profile, bounds, env)
640
+ _run_pass(expressions.check_subcall_element_bounds, program, profile, bounds, env)
641
+ _run_pass(instr_rules.check_fieldcal_variable_args, program, profile, bounds, env)
642
+ _run_pass(instr_rules.check_matrix_destination, program, profile, bounds)
643
+ _run_pass(instr_rules.check_avgrun_options, program, profile, bounds, env)
644
+ _run_pass(instr_data.check_eventcount_tables, program, profile, bounds)
645
+ _run_pass(instr_data.check_table_reps_late, program, profile, diags, env)
646
+ if profile.single_pass:
647
+ _run_pass(instr_rules.check_dnp_update, _with_inline(program), profile, diags, env)
648
+ _run_pass(instr_rules.check_portbridge_array, _with_inline(program), profile, diags, env)
649
+ if (
650
+ not diags.errors
651
+ or (
652
+ profile.single_pass
653
+ and all(d.message.startswith(("Could not find ", "Cannot find DataTable ")) for d in diags.errors)
654
+ )
655
+ ) and profile.early_warnings_gated:
656
+ # CR300/CR350: the early warnings follow the table `Reps` checks and precede `CallTable`
657
+ # placement and the unclosed-block checks (oracle: `Illegal Reps 0` hides an fnotch
658
+ # warning; `DO without LOOP.` and a `CallTable` placement error do not).
659
+ _run_pass(warnings.check_early, program, profile, diags, env)
660
+ n_late = len(diags.items)
661
+ stop = _run_pass_capture(structure.check_scan_placement_late, program, profile, diags)
662
+ # a `GetRecord`/`ResetTable`/`FileMark` of a missing table ends the walk (oracle, round 14)
663
+ ref_lines = [st.line for st, _m, ends in instr_data.unknown_table_references(program, profile) if ends]
664
+ if profile.single_pass:
665
+ ref_lines += [st.line for st, _name in instr_data.unknown_calltables(program)]
666
+ if ref_lines:
667
+ diags.items[n_late:] = [
668
+ d for d in diags.items[n_late:] if d.severity != "error" or d.line <= min(ref_lines)
669
+ ]
670
+ if profile.single_pass:
671
+ # the dead statement that fails here prints no reachability warning, nor do later
672
+ # ones (oracle, CR300/CR350, 2026-10-12)
673
+ diags.items[:] = [d for d in diags.items if not (_is_unreached(d) and d.line >= min(ref_lines))]
674
+ if profile.single_pass and stop is not None and ref_lines and stop[1] < min(ref_lines):
675
+ # the earlier of the two ends the walk: a stray `NextScan` hides a later unknown table
676
+ drop = [
677
+ i
678
+ for i, d in enumerate(diags.items)
679
+ if (
680
+ d.severity == "error"
681
+ and d.message.startswith("Could not find ")
682
+ and d.file == stop[0]
683
+ and d.line > stop[1]
684
+ )
685
+ ]
686
+ late_start -= sum(1 for i in drop if i < late_start)
687
+ diags.items[:] = [d for i, d in enumerate(diags.items) if i not in set(drop)]
688
+ late_stop = stop
689
+ if stop is not None:
690
+ # The walk ends there, so later warnings are never printed (oracle: `SetSetting`
691
+ # before `BeginProg` hides an fnotch warning further down).
692
+ diags.items = [d for d in diags.items if d.severity != "warning" or d.file != stop[0] or d.line <= stop[1]]
693
+ if profile.single_pass:
694
+ held_items = [d for d in held_items if late_stop is None or d.line <= late_stop[1]]
695
+ if any(not d.message.endswith("illegally occurs before BeginProg.") for d in diags.errors):
696
+ held_items = []
697
+ else:
698
+ held_items = [d for d in held_items if before_cutoff(d)]
699
+ # (the failed statement's own undefined-symbol findings are not reported: the index error
700
+ # aborts its compile -- `LoggerID=Status.StationName(1,1,Nope)`, oracle, cr6)
701
+ own = {(d.file, d.line) for d in held_items}
702
+ own_stmts = {d.stmt for d in held_items if d.stmt} # tagged: exact statement identity
703
+ diags.items[:] = [
704
+ d
705
+ for d in diags.items
706
+ if not (
707
+ ((d.stmt in own_stmts) if d.stmt else (d.file, d.line) in own)
708
+ and d.severity == "error"
709
+ and (d.message.startswith("Invalid symbol in expression") or d.message.endswith("is not defined."))
710
+ )
711
+ ]
712
+ diags.items.extend(held_items)
713
+ generic_errors += held_items
714
+ # CR300/CR350: these late errors do not start the `Too many parameters` cascade (oracle:
715
+ # `Sample (1,Nope,FP2)` gives only `Nope is not defined.`).
716
+ diags.items[late_start:] = [dataclasses.replace(d, cascade=False) for d in diags.items[late_start:]]
717
+ tier_c_start = len(diags.items)
718
+ table_stop = None
719
+ gate = [
720
+ d
721
+ for d in diags.errors
722
+ if not (
723
+ any(d is g for g in unknown_table_diags)
724
+ or not profile.single_pass
725
+ and (any(d is g for g in generic_errors) or instr_data.is_table_name_diag(d))
726
+ )
727
+ ]
728
+ if not gate:
729
+ # Pass 2, tier C: the real compiler never reaches an instruction's own channel/range/
730
+ # table/value validation once *either* tier A or tier B recorded any error anywhere in the
731
+ # file -- oracle-confirmed, `docs/checker.md` "Two-pass compilation"/"Pass model":
732
+ # `ExciteV(Vx1,...)` (an `expressions.check` finding, tier B) on an earlier line suppresses
733
+ # a later, otherwise-invalid `TCDiff` channel argument entirely -- no accumulation across
734
+ # this boundary, unlike within tier B itself above.
735
+ _run_pass(instr_measure.check_reps, program, profile, diags, env)
736
+ _run_pass(instr_measure.check_dest_bounds, program, profile, diags, env)
737
+ _run_pass(var_types.check, program, profile, diags, env)
738
+
739
+ if profile.single_pass:
740
+ _run_pass(instr_measure.check_const_ranges, _with_inline(program).statements, profile, diags, env)
741
+ _run_pass(instructions.check, program, profile, diags, env)
742
+ # Also pass 2 (oracle-confirmed the same way, `docs/instructions.md`): output-processing
743
+ # instructions placed outside `DataTable...EndTable`, and an orphan `EndTable`.
744
+ table_stop = _run_pass_capture(instr_data.check_table_structure, _with_inline(program), profile, diags)
745
+ _run_pass(instr_data.check_fieldnames, program, profile, diags, env)
746
+ apply_diags = Diagnostics()
747
+ _run_pass(structure.check_apply_restart_sequence, program, profile, apply_diags)
748
+ for extra in apply_diags.items: # in source order among the tier-C findings
749
+ at = next(
750
+ (
751
+ i
752
+ for i in range(tier_c_start, len(diags.items))
753
+ if diags.items[i].severity == "error" and diags.items[i].line > extra.line
754
+ ),
755
+ len(diags.items),
756
+ )
757
+ diags.items.insert(at, extra)
758
+ _run_pass(instr_rules.check_samplefieldcal, _with_inline(program), profile, diags)
759
+ # Also pass 2 (oracle-confirmed the same way, `docs/instructions.md`): `Range`/`SW12`/
760
+ # `Delay`/`CS616`/`TimerInput`/`ExciteV`/`ExciteI` value-set and value-range checks.
761
+ _run_pass(instr_measure.check, program, profile, diags, env)
762
+ _run_pass(instr_measure.check_scan_delay_budget, program, profile, diags, env)
763
+ oom = next((d for d in diags.items[tier_c_start:] if d.message == "Out of memory."), None)
764
+ if oom is not None:
765
+ # A failed builder allocation ends the compile there (CSAT3, round 31): nothing from a
766
+ # later statement is printed.
767
+ at = links.positions.get((oom.file, oom.line), oom.line)
768
+ tail = diags.items[tier_c_start:]
769
+ oom_index = next(i for i, d in enumerate(tail) if d is oom)
770
+ diags.items[tier_c_start:] = [
771
+ d
772
+ for i, d in enumerate(tail)
773
+ if d.severity != "error"
774
+ or not d.line
775
+ or links.positions.get((d.file, d.line), d.line) < at
776
+ or (d.line == oom.line and i <= oom_index)
777
+ ]
778
+ ranges.items = [d for d in ranges.items if links.positions.get((d.file, d.line), d.line) < at]
779
+ bounds.items = [
780
+ d
781
+ for d in bounds.items # (a later index too, round 31 review)
782
+ if links.positions.get((d.file, d.line), d.line) < at
783
+ ]
784
+ diags.items[:tier_c_start] = [ # (and a later early warning: `Scan never reached`)
785
+ d
786
+ for d in diags.items[:tier_c_start]
787
+ if d.severity != "warning" or not d.line or links.positions.get((d.file, d.line), d.line) < at
788
+ ]
789
+ if generic_errors:
790
+ diags.items[tier_c_start:] = [
791
+ d for d in diags.items[tier_c_start:] if d.severity != "error" or not d.line or before_cutoff(d)
792
+ ]
793
+ if profile.single_pass and unknown_table_diags:
794
+ # (CR300/CR350: the unknown table ends the walk there; tier C stops with it)
795
+ end_pos = min(links.positions.get((d.file, d.line), d.line) for d in unknown_table_diags)
796
+ diags.items[tier_c_start:] = [
797
+ d
798
+ for d in diags.items[tier_c_start:]
799
+ if d.severity != "error" or not d.line or links.positions.get((d.file, d.line), d.line) <= end_pos
800
+ ]
801
+ if generic_errors and len(diags.items) > tier_c_start:
802
+ # Tier-C findings print in source order among the generic pass-2 ones (oracle, CR6/
803
+ # CR1000X, 2026-10-04: a bad channel on an earlier line precedes a later `foo bar`).
804
+ tail = diags.items[pass2_start:]
805
+ slots = [i for i, d in enumerate(tail) if d.severity == "error" and d.line]
806
+ ordered = sorted((tail[i] for i in slots), key=lambda d: links.positions.get((d.file, d.line), d.line))
807
+ for i, d in zip(slots, ordered):
808
+ tail[i] = d
809
+ diags.items[pass2_start:] = tail
810
+ if table_stop is not None and table_stop in links.positions:
811
+ late_pos = links.positions[table_stop]
812
+ diags.items[tier_c_start:] = [
813
+ d
814
+ for d in diags.items[tier_c_start:]
815
+ if d.severity != "error" or links.positions.get((d.file, d.line), late_pos) <= late_pos
816
+ ]
817
+ if profile.single_pass and not diags.errors:
818
+ # CR300/CR350: structure member/assignment errors; they do not hide the early warnings
819
+ # (oracle: `Invalid fnotch value` still printed) and are sorted in by line.
820
+ flat_program = _with_inline(program)
821
+ _run_pass(structs.check_members, program, profile, diags, flat_program)
822
+ _run_pass(structs.check_struct_assignment, program, profile, diags, flat_program)
823
+ if not diags.errors and not fields.items and not pipe.items and not early.items:
824
+ # After tier C, not before (oracle, all four loggers: an unclosed `Scan`/`If`/`For` or a
825
+ # missing `BeginProg` plus a tier-C finding reports only the tier-C finding).
826
+ _run_pass(structure.check_end_blocks, program, diags)
827
+ if (
828
+ not diags.errors
829
+ and not early.items
830
+ and not pipe.items
831
+ and not fields.items
832
+ and not bounds.items
833
+ and not ranges.items
834
+ and not leading.items
835
+ ):
836
+ # last: any other error, a missing `BeginProg` or an unclosed block included, hides it
837
+ # (oracle, CR6/CR1000X, 2026-10-03 and round 39)
838
+ _run_pass(instr_data.check_units_targets, program, profile, diags)
839
+ argument_error_first = structure.pipeline_argument_error_first_locations(program, profile)
840
+ ordinary_pipe = []
841
+ delayed_pipe = []
842
+ for diagnostic in pipe.items:
843
+ location = (diagnostic.file, diagnostic.line)
844
+ if location in argument_error_first and any(
845
+ item.severity == "error" and (item.file, item.line) == location for item in diags.items[tier_c_start:]
846
+ ):
847
+ delayed_pipe.append(diagnostic)
848
+ else:
849
+ ordinary_pipe.append(diagnostic)
850
+ diags.items[tier_c_start:tier_c_start] = ordinary_pipe
851
+ for diagnostic in delayed_pipe:
852
+ location = (diagnostic.file, diagnostic.line)
853
+ index = (
854
+ min(
855
+ i
856
+ for i, item in enumerate(diags.items)
857
+ if item.severity == "error" and (item.file, item.line) == location
858
+ )
859
+ + 1
860
+ )
861
+ diags.items.insert(index, diagnostic)
862
+
863
+ if early.items:
864
+ early.items = [d for d in early.items if before_cutoff(d)]
865
+ if table_stop is not None and table_stop in links.positions:
866
+ early.items = [
867
+ d for d in early.items if links.positions.get((d.file, d.line), 0) <= links.positions[table_stop]
868
+ ]
869
+ if bounds.items:
870
+ _place_index_bounds(bounds, diags, flat, profile.single_pass)
871
+ diags.items += bounds.items + fields.items + ranges.items + early.items
872
+ for d in reversed(soft): # before the errors of its own and later lines (oracle, round 58)
873
+ here = (links.positions.get((d.file, d.line), d.line), d.line)
874
+ at = next(
875
+ (
876
+ i
877
+ for i, o in enumerate(diags.items)
878
+ if o.severity == "error" and (links.positions.get((o.file, o.line), o.line), o.line) >= here
879
+ ),
880
+ len(diags.items),
881
+ )
882
+ diags.items.insert(at, d)
883
+
884
+ # Terminal conflicts hide nothing and print first on their line. An SDI-12 one is reported only
885
+ # when no error, another conflict included, precedes it (oracle, CR6/CR1000X, round 37).
886
+ def pos(d):
887
+ return links.positions.get((d.file, d.line), d.line)
888
+
889
+ others = [pos(d) for d in diags.items + leading.items + claims.items if d.severity == "error" and d.line]
890
+ leading.items += [
891
+ d for d in claims.items if not d.message.startswith("SDI12") or not any(p < pos(d) for p in others)
892
+ ]
893
+ last: dict[tuple, Diagnostic] = {} # (file, line) -> its latest leading finding
894
+ for d in leading.items:
895
+ # Aside like `bounds` (tier C still runs), but printed first on its own line, in their own
896
+ # order (oracle: `SPIOpen must precede SPIWrite.` precedes `SPIWrite illegally occurs
897
+ # before BeginProg.`).
898
+ key = (d.file, d.line)
899
+ if key in last:
900
+ at = next(i for i, o in enumerate(diags.items) if o is last[key]) + 1
901
+ else:
902
+ at = next((i for i, o in enumerate(diags.items) if (o.file, o.line) == key), len(diags.items))
903
+ diags.items.insert(at, d)
904
+ last[key] = d
905
+ # `Call`s naming no declared `Sub` (`structure.finalize_call_links`): a link-step message
906
+ # after every other error, so it does not gate the tier-C checks above.
907
+ _run_pass(structure.finalize_call_links, links, profile, diags, pass1_errors)
908
+
909
+ # CR300/CR350 only (a no-op on every other logger, `checker/cr3xx.py`): once any undefined-
910
+ # name-class or genuine-`Too many parameters:` finding has fired anywhere above -- tier A
911
+ # (ungated: `cr3xx.check`'s own `SerialOpen`/`ComPort` finding, `instr_rules.check_too_many`'s
912
+ # own genuine finding), tier B, or tier C -- every following two-plus-argument recognised
913
+ # instruction, to end of file, gets its own spurious `Too many parameters: %s.` in place of
914
+ # whatever it would otherwise have found (`checker/cr3xx.py`'s own module-level comment for the
915
+ # oracle evidence). Run unconditionally, after every other diagnostic-producing pass, since the
916
+ # taint can originate from a tier-A finding that would otherwise skip tier B/C entirely.
917
+ _run_pass(cr3xx.drop_block_closer_errors_after_errors, program, profile, diags)
918
+ _run_pass(cr3xx.order_names_around_arity, program, profile, diags)
919
+ _run_pass(cr3xx.order_names_before_call_arity, program, profile, diags)
920
+ _run_pass(cr3xx.order_email_destination, program, profile, diags)
921
+ _run_pass(cr3xx.drop_shortfall_with_names, program, profile, diags)
922
+ if profile.single_pass:
923
+ _run_pass(instr_data.finalize_illegal_table_names, program, profile, diags)
924
+ _run_pass(cr3xx.check_tcpopen_handle_early, program, profile, diags)
925
+ _run_pass(cr3xx.check_too_many_cascade, _with_inline(program), profile, diags)
926
+ _run_pass(cr3xx.drop_compound_target_repeat, program, profile, diags)
927
+ _run_pass(cr3xx.order_block_keyword_calls, program, profile, diags)
928
+ _run_pass(cr3xx.fix_then_operand, program, profile, diags)
929
+ _run_pass(cr3xx.order_invalid_assignments, program, profile, diags)
930
+ _run_pass(cr3xx.order_target_name_first, program, profile, diags)
931
+ _run_pass(cr3xx.order_unterminated_string, program, profile, diags)
932
+ _run_pass(cr3xx.check_tcpopen_handle, program, profile, diags)
933
+ _run_pass(cr3xx.add_string_illegal, program, profile, diags)
934
+ _run_pass(warnings.add_truncate_warnings, _with_inline(program), profile, diags)
935
+ _run_pass(cr3xx.semicolon_headers, program, profile, diags)
936
+ _run_pass(intervals.fold_timeout_expressions, _with_inline(program), profile, diags, env, pass1_items)
937
+ _run_pass(intervals.order_timeout_negative, diags, profile, program)
938
+ _run_pass(cr3xx.drop_after_alias_collision, program, profile, diags)
939
+
940
+ def is_constant(expr: S.Expr | None) -> bool:
941
+ return consteval.is_constant(expr, env)
942
+
943
+ def value_of(expr: S.Expr | None):
944
+ return consteval.value_of(expr, env)
945
+
946
+ try:
947
+ result.mode = mode.decide(program.statements, profile, is_constant, value_of)
948
+ except Exception as exc:
949
+ _record_exception(mode.decide, exc)
950
+ result.mode = None
951
+ if os.environ.get("OPENCRBASIC_STRICT") == "1":
952
+ raise
953
+
954
+ if not diags.errors:
955
+ # `When Main Sequence Scan %d is running, background calibration will be disabled.`:
956
+ # round 6, moved here from the unconditional `warnings.check_early` call above -- oracle-
957
+ # confirmed *not* ungated the way `_check_reachability`/`_check_fnotch` are, unlike this
958
+ # module's own docstring previously claimed for all of `check_early` (`checker/warnings.py`'s
959
+ # `check_background_calibration` docstring has the oracle evidence). Independent of
960
+ # `tables_hook`'s own availability, so called ahead of that guard rather than inside it.
961
+ _run_pass(warnings.check_background_calibration, program, profile, diags, env)
962
+ _run_pass(warnings.check_slow_scan_skips, program, profile, diags, env)
963
+ _run_pass(instr_measure.check_cpi_time, program, profile, diags, env)
964
+ try:
965
+ from .. import tables_hook
966
+ except ImportError:
967
+ pass
968
+ else:
969
+ _run_pass(tables_hook.attach, program, profile, result)
970
+ # `Duplicate field name %s in DataTable %s.` -- needs `result.tables`, just attached
971
+ # above (`checker/instr_data.py`, `docs/checker.md` "Duplicate field names"). Its
972
+ # errors suppress the "declared but never called" warning.
973
+ _run_pass(instr_data.check_duplicate_fields, program, result, diags)
974
+ if not diags.errors:
975
+ _run_pass(storage.check, program, profile, result, diags)
976
+ if not diags.errors:
977
+ _run_pass(warnings.check, program, result, diags)