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
opencrbasic/checks.py ADDED
@@ -0,0 +1,491 @@
1
+ """opencrbasic checks: likely defects the reference compiler accepts (`PLAN.md` "Three diagnostic
2
+ channels"). Findings never enter `Diagnostics`; `crbc --strict` prints them on stderr."""
3
+
4
+ import functools
5
+ import json
6
+ import os
7
+ import re
8
+ from dataclasses import dataclass, replace
9
+ from pathlib import Path
10
+
11
+ from . import checkconfig, consteval
12
+ from . import syntax as S
13
+ from .checker import symbols, var_types
14
+
15
+ # Built-in default severity per check code; also the code set the configuration validates against.
16
+ DEFAULT_SEVERITY = {
17
+ "OCB001": "error",
18
+ "OCB002": "warning",
19
+ "OCB003": "warning",
20
+ "OCB004": "warning",
21
+ "OCB005": "info",
22
+ "OCB006": "info",
23
+ "OCB007": "warning",
24
+ }
25
+ SCHEMA_VERSION = 2 # 2: `notices` (M4)
26
+
27
+
28
+ @dataclass
29
+ class Finding:
30
+ code: str
31
+ severity: str # "error" | "warning" | "info"
32
+ line: int
33
+ file: str
34
+ message: str
35
+ reason: str = "" # the suppression's reason, on `CompileResult.suppressed` only
36
+
37
+ def text(self, program: str) -> str:
38
+ return f"{self.file or program} line {self.line}: {self.severity} {self.code}: {self.message}"
39
+
40
+
41
+ # Names the reference accepts as an `Alias` name although CRBasic already uses them (oracle,
42
+ # 2026-10-11 `alias_predefined_name.json` and 2026-10-24 `r33_geus_false_rejects.json`, all four
43
+ # loggers). Predefined constants (`Sec`, `mV5000`, `FP2`, `String`) are reference errors instead.
44
+ _SYSTEM_TABLES = {"PUBLIC", "STATUS", "SETTINGS"}
45
+ _KEYWORDS = {"THEN", "ELSE", "TO", "STEP", "AS", "ENDIF", "NEXT", "DIM", "AND", "OR", "NOT", "XOR", "MOD"}
46
+ _SPECIAL_VALUES = {"NAN", "INF"}
47
+
48
+
49
+ def _reserved_kind(name: str, profile) -> str | None:
50
+ upper = name.upper()
51
+ if symbols.is_reserved_predefined_name(name, profile):
52
+ return None
53
+ if upper in _SYSTEM_TABLES:
54
+ return f"the {name} system table"
55
+ if upper in _KEYWORDS:
56
+ return "a CRBasic keyword"
57
+ if upper in profile.declarable_predefined_names:
58
+ return "a data type"
59
+ if upper in _SPECIAL_VALUES:
60
+ return "a special value"
61
+ if upper in profile.functions:
62
+ return "a built-in function"
63
+ if upper in profile.instructions:
64
+ return "an instruction"
65
+ return None
66
+
67
+
68
+ def _alias_keyword(program: S.Program, profile) -> list[Finding]:
69
+ """OCB001: an `Alias` named after a CRBasic name (`Alias D(3)=Status`). The reference accepts
70
+ it, but the name then shadows the original: a bare `Status` becomes the alias while
71
+ `Status.Battery` still reads the table (`docs/checker.md` "Aliases named Status, Public or
72
+ Settings"), which is easy to misread. Human request, 2026-10-24."""
73
+ out = []
74
+ for st in program.statements:
75
+ if not (isinstance(st, S.Instruction) and st.keyword.upper() == "ALIAS"):
76
+ continue
77
+ for arg in st.args[1:]:
78
+ if isinstance(arg, (S.Name, S.Index)) and (kind := _reserved_kind(arg.name, profile)):
79
+ out.append(
80
+ Finding(
81
+ "OCB001",
82
+ "error",
83
+ st.line,
84
+ st.file,
85
+ f"Alias name {arg.name} is also {kind}; choose another name.",
86
+ )
87
+ )
88
+ return out
89
+
90
+
91
+ _NUMERIC_TYPES = {"FLOAT", "LONG", "BOOLEAN", "DOUBLE"}
92
+
93
+
94
+ def _statements(program: S.Program):
95
+ """Every statement, a one-line If's own statements included."""
96
+ for st in program.statements:
97
+ yield st
98
+ if isinstance(st, S.If):
99
+ yield from (st.inline or []) + (st.inline_else or [])
100
+
101
+
102
+ def _declared_names(program: S.Program) -> list[str]:
103
+ """Every declared variable and routine parameter name, upper case, once per declaration."""
104
+ names = [
105
+ v.name.upper()
106
+ for st in program.statements
107
+ if isinstance(st, S.Declaration) and st.kind in ("PUBLIC", "DIM", "STATIC")
108
+ for v in st.vars
109
+ ]
110
+ return names + [p.name.upper() for st in program.statements if isinstance(st, S.SubDef) for p in st.params]
111
+
112
+
113
+ def _const_env(program: S.Program, profile) -> consteval.Env:
114
+ """The program's constants without those a variable or parameter of the same name can shadow
115
+ (`Sub P(Z As Long)` with a later `Const Z = 0`, `docs/checker.md` "Sub names shadowing an
116
+ earlier Const")."""
117
+ shadowed = set(_declared_names(program))
118
+ return consteval.Env(profile, {k: v for k, v in program.consts.items() if k.upper() not in shadowed})
119
+
120
+
121
+ def _names_in(expr) -> set[str]:
122
+ """Upper-case names an expression refers to, at any depth."""
123
+ if isinstance(expr, S.Name):
124
+ return {expr.name.upper()}
125
+ if isinstance(expr, S.Index):
126
+ return {expr.name.upper()}.union(*(_names_in(a) for a in expr.args))
127
+ if isinstance(expr, S.Member):
128
+ return _names_in(expr.base).union(*(_names_in(a) for a in expr.args or []))
129
+ if isinstance(expr, S.Binary):
130
+ return _names_in(expr.left) | _names_in(expr.right)
131
+ if isinstance(expr, S.Unary):
132
+ return _names_in(expr.operand)
133
+ return set()
134
+
135
+
136
+ def _ambiguous_names(program: S.Program) -> set[str]:
137
+ """Names whose declaration a check cannot pick without scope resolution: declared more than once
138
+ (a `Dim` in two routines, a global and a parameter) or used in an `Alias`."""
139
+ names = _declared_names(program)
140
+ out = {n for n in names if names.count(n) > 1}
141
+ for st in program.statements:
142
+ if isinstance(st, S.Instruction) and st.keyword.upper() == "ALIAS":
143
+ out |= {a.name.upper() for a in st.args if isinstance(a, (S.Name, S.Index))}
144
+ return out
145
+
146
+
147
+ def _numeric_concatenation(program: S.Program, profile) -> list[Finding]:
148
+ """OCB002: a String `+` (or `&`) assigned to a numeric variable (`L = 2000 + Mid(S,5,2)`). The
149
+ reference accepts it, but `+` with a String operand concatenates, and the variable receives
150
+ the converted text: a GPS clock set this way read 2057 (`knowledge/crbasic/lessons.md` "`+` with
151
+ a string operand concatenates", hardware 2026-10-22). Only a chain mixing numeric and String
152
+ operands is reported: a String alone (`X = Mid(...)`) is a plain conversion, and Strings joined
153
+ into a number's text (`X = HexStr(6) + "." + HexStr(7)`, GEUS programs) are deliberate."""
154
+ typer, out, ambiguous = var_types._Typer(program), [], _ambiguous_names(program)
155
+ for st in _statements(program):
156
+ if not isinstance(st, S.Assignment) or not isinstance(st.value, S.Binary) or st.value.op not in ("+", "&"):
157
+ continue
158
+ if isinstance(st.target, (S.Name, S.Index)) and st.target.name.upper() in ambiguous:
159
+ continue
160
+ var = typer.variable(st.target)
161
+ if var is None or typer.types[var] not in _NUMERIC_TYPES:
162
+ continue
163
+ operands, stack = [], [st.value]
164
+ while stack:
165
+ node = stack.pop()
166
+ if isinstance(node, S.Binary) and node.op in ("+", "&"):
167
+ stack += [node.left, node.right]
168
+ else:
169
+ operands.append("SCOPED" if _names_in(node) & ambiguous else typer.of(node))
170
+ if "SCOPED" in operands:
171
+ continue # an operand's type needs scope resolution
172
+ if "STRING" not in operands or not any(t in _NUMERIC_TYPES for t in operands):
173
+ continue # `HexStr(6) + "." + HexStr(7)` builds a number's text on purpose (GEUS)
174
+ name = st.target.name if isinstance(st.target, (S.Name, S.Index)) else var
175
+ out.append(
176
+ Finding(
177
+ "OCB002",
178
+ "warning",
179
+ st.line,
180
+ st.file,
181
+ f"`{st.value.op}` with a String operand concatenates; {name} "
182
+ "receives the converted text. Convert the String to a number first.",
183
+ )
184
+ )
185
+ return out
186
+
187
+
188
+ # microseconds per `Delay`/`DataInterval` unit code (uSec, mSec, Sec, Min, Hr, Day)
189
+ _US_PER_UNIT = {0: 1, 1: 1_000, 2: 1_000_000, 3: 60_000_000, 4: 3_600_000_000, 5: 86_400_000_000}
190
+
191
+
192
+ def _negative_delays(program: S.Program, profile) -> list[Finding]:
193
+ """OCB003: a constant negative `Delay` interval or `ExciteV` delay that the reference accepts
194
+ (`Delay(1,-5,Sec)`, `Delay(2,-1,Sec)`; `ExciteV(Vx1,0,-10)` on CR300/CR350 only; most negative
195
+ `Delay(0,...)` and every `ExciteV` delay of `-1` or less on CR6/CR1000X are reference errors). The
196
+ compiler keeps the delay as an unsigned 32-bit count, so it is not the written one; the runtime
197
+ effect is not observed (`docs/checker.md` "Task-memory overflow of long delays (round 57)",
198
+ human proposal 2026-10-28). A zero delay is legitimate."""
199
+ env, out = _const_env(program, profile), []
200
+ for st in _statements(program):
201
+ if not isinstance(st, S.Instruction):
202
+ continue
203
+ kw = st.keyword.upper()
204
+ pos = {"DELAY": 1, "EXCITEV": 2}.get(kw)
205
+ if pos is None or len(st.args) <= 2 or st.args[2] is None:
206
+ continue
207
+ try:
208
+ value = consteval.value_of(st.args[pos], env)
209
+ unit = 0 if kw == "EXCITEV" else consteval.value_of(st.args[2], env)
210
+ except consteval.NotConstant:
211
+ continue
212
+ # at least one whole microsecond below zero: `ExciteV(U1,0,-0.9)` compiles on CR6 where
213
+ # `-1` is a reference error, so a fraction is not shown to wrap (oracle, 2026-10-30)
214
+ if (
215
+ isinstance(value, (int, float))
216
+ and not isinstance(value, bool)
217
+ and unit in _US_PER_UNIT
218
+ and value * _US_PER_UNIT[unit] <= -1
219
+ ):
220
+ out.append(
221
+ Finding(
222
+ "OCB003",
223
+ "warning",
224
+ st.line,
225
+ st.file,
226
+ f"{st.keyword} delay {value:g} is negative; the logger does not wait the written time.",
227
+ )
228
+ )
229
+ return out
230
+
231
+
232
+ def _zero_divisors(program: S.Program, profile) -> list[Finding]:
233
+ """OCB004: a division by a constant zero (`Y / 0`, `Y \\ 0`, `Y MOD 0`, `Y / Z` with
234
+ `Const Z = 0`), which the reference compiles on all four loggers (oracle, round 65). The
235
+ result is undefined for every dividend (`datatypes.htm`: undefined mathematical results give
236
+ NAN or INF); its runtime value is not observed."""
237
+ env, out = _const_env(program, profile), []
238
+
239
+ def walk(expr):
240
+ if isinstance(expr, S.Binary):
241
+ yield expr
242
+ yield from walk(expr.left)
243
+ yield from walk(expr.right)
244
+ elif isinstance(expr, S.Unary):
245
+ yield from walk(expr.operand)
246
+ elif isinstance(expr, (S.Index, S.Member)):
247
+ for a in expr.args or []:
248
+ yield from walk(a)
249
+
250
+ for st in _statements(program):
251
+ for top in var_types._statement_expressions(st):
252
+ for node in walk(top):
253
+ if node.op not in ("/", "\\", "MOD", "INTDV"):
254
+ continue
255
+ try:
256
+ divisor = consteval.value_of(node.right, env)
257
+ except consteval.NotConstant:
258
+ continue
259
+ if isinstance(divisor, (int, float)) and not isinstance(divisor, bool) and divisor == 0:
260
+ out.append(Finding("OCB004", "warning", st.line, st.file, "Division by a constant zero."))
261
+ return out
262
+
263
+
264
+ def _unsized_string_parameters(program: S.Program, profile) -> list[Finding]:
265
+ """OCB005: a `Sub`/`Function` String parameter without `* size`. It holds 23 characters (24
266
+ bytes with the terminator) and a longer argument is cut (hardware, `knowledge/crbasic/
267
+ lessons.md` "Default string parameter size"). Information only: a short argument is fine."""
268
+ return [
269
+ Finding(
270
+ "OCB005",
271
+ "info",
272
+ st.line,
273
+ st.file,
274
+ f"String parameter {p.name} of {st.name} has no size and holds 23 characters; "
275
+ "give it a size (As String * n) if a longer value can be passed.",
276
+ )
277
+ for st in program.statements
278
+ if isinstance(st, S.SubDef)
279
+ for p in st.params
280
+ if (p.type or "").upper() == "STRING" and p.string_size is None
281
+ ]
282
+
283
+
284
+ def _unaligned_intervals(program: S.Program, profile) -> list[Finding]:
285
+ """OCB006: a constant `DataInterval` that divides neither an hour nor a day (`7 Min`, `25 Hr`).
286
+ Its records are phased from the start hour and may not align with midnight (hardware,
287
+ `knowledge/crbasic/lessons.md` "`DataInterval` phase"). Information only; an interval of 0
288
+ (every call) is not reported."""
289
+ env, out = _const_env(program, profile), []
290
+ for st in _statements(program):
291
+ if not (isinstance(st, S.Instruction) and st.keyword.upper() == "DATAINTERVAL") or len(st.args) < 3:
292
+ continue
293
+ try:
294
+ value, unit = consteval.value_of(st.args[1], env), consteval.value_of(st.args[2], env)
295
+ except consteval.NotConstant:
296
+ continue
297
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or unit not in _US_PER_UNIT:
298
+ continue
299
+ us = value * _US_PER_UNIT[unit]
300
+ if us <= 0 or us != int(us) or 3_600_000_000 % int(us) == 0 or 86_400_000_000 % int(us) == 0:
301
+ continue
302
+ out.append(
303
+ Finding(
304
+ "OCB006",
305
+ "info",
306
+ st.line,
307
+ st.file,
308
+ "DataInterval divides neither an hour nor a day; its records are phased "
309
+ "from the start hour and may not align with midnight.",
310
+ )
311
+ )
312
+ return out
313
+
314
+
315
+ def _long_string_literals(program: S.Program, profile) -> list[Finding]:
316
+ """OCB007: a String literal assigned to a String variable that cannot hold it (`S = "..."` with
317
+ 36 characters on `S As String * 32`). The size is the most characters the variable holds
318
+ (`dim.htm`, `public.htm`); the reference accepts the assignment on all four loggers (oracle,
319
+ 2026-10-30). The capacity is the larger reading: the round-29 storage slot `4*ceil(max(4,
320
+ L+1)/4) - 1` (`docs/checker.md` "String alias storage (round 29)"), except `String * 1`, which
321
+ holds one character (`dim.htm`). Names declared more than once or used in an `Alias`, and
322
+ character indices, are not checked."""
323
+ env, decls, out = _const_env(program, profile), {}, []
324
+ ambiguous = _ambiguous_names(program)
325
+ for st in program.statements:
326
+ if not (isinstance(st, S.Declaration) and st.kind in ("PUBLIC", "DIM", "STATIC")):
327
+ continue
328
+ for v in st.vars:
329
+ if (v.type or "").upper() != "STRING" or v.paren_dim or len(v.dims) > 2 or v.name.upper() in ambiguous:
330
+ continue
331
+ try:
332
+ size = 23 if v.string_size is None else consteval.value_of(v.string_size, env)
333
+ except consteval.NotConstant:
334
+ continue
335
+ if isinstance(size, bool) or not isinstance(size, (int, float)) or not 1 <= size < 2**31:
336
+ continue
337
+ size = int(size)
338
+ decls[v.name.upper()] = (1 if size == 1 else 4 * -(-max(4, size + 1) // 4) - 1, len(v.dims))
339
+ for st in _statements(program):
340
+ if not (
341
+ isinstance(st, S.Assignment) and isinstance(st.value, S.String) and isinstance(st.target, (S.Name, S.Index))
342
+ ):
343
+ continue
344
+ capacity, rank = decls.get(st.target.name.upper(), (None, 0))
345
+ if capacity is None or (isinstance(st.target, S.Index) and len(st.target.args) > rank):
346
+ continue
347
+ if len(st.value.value) > capacity:
348
+ out.append(
349
+ Finding(
350
+ "OCB007",
351
+ "warning",
352
+ st.line,
353
+ st.file,
354
+ f"String literal of {len(st.value.value)} characters is longer than "
355
+ f"{st.target.name} holds ({capacity}); it is cut.",
356
+ )
357
+ )
358
+ return out
359
+
360
+
361
+ def run(program: S.Program | None, profile) -> list[Finding]:
362
+ if program is None:
363
+ return []
364
+ return [
365
+ f
366
+ for check in (
367
+ _alias_keyword,
368
+ _numeric_concatenation,
369
+ _negative_delays,
370
+ _zero_divisors,
371
+ _unsized_string_parameters,
372
+ _unaligned_intervals,
373
+ _long_string_literals,
374
+ )
375
+ for f in check(program, profile)
376
+ ]
377
+
378
+
379
+ def analyze(
380
+ result,
381
+ entry: Path,
382
+ config: checkconfig.Config | None = None,
383
+ cli: dict[str, str] | None = None,
384
+ lsp: dict[str, str] | None = None,
385
+ ) -> None:
386
+ """Run the checks on a compiled `result` and store them, after severity and suppression
387
+ configuration, in `result.findings` / `result.suppressed` (never in `result.diagnostics`, so
388
+ `result.ok` is unchanged). `cli` and `lsp` are severity maps (`checkconfig.parse_overrides`)."""
389
+ cfg = config or checkconfig.Config()
390
+ entry = entry.resolve()
391
+ root = cfg.root or entry.parent
392
+
393
+ def rel(p: Path) -> str:
394
+ try:
395
+ return Path(os.path.relpath(p, root)).as_posix().lower()
396
+ except ValueError: # another Windows drive: no relative path, so no entry or glob matches
397
+ return p.as_posix().lower()
398
+
399
+ for f in run(result.program_ast, result.profile):
400
+ sev = checkconfig.severity(
401
+ cfg, f.code, DEFAULT_SEVERITY[f.code], result.profile.name, rel(entry), cli or {}, lsp or {}
402
+ )
403
+ if sev == "off":
404
+ continue
405
+ f = replace(f, severity=sev)
406
+ # an included file is named as written in the `Include` (drive prefix and all), relative
407
+ # to the entry file's directory; the entry itself by its name
408
+ file = (
409
+ entry
410
+ if f.file in ("", entry.name)
411
+ else entry.parent / re.sub(r"^[A-Za-z]+:", "", f.file).replace("\\", "/")
412
+ )
413
+ if s := checkconfig.suppression(cfg, f.code, rel(file), f.line):
414
+ result.suppressed.append(replace(f, reason=s.reason))
415
+ else:
416
+ result.findings.append(f)
417
+
418
+
419
+ # Where each check's evidence is (`AnalysisFinding.link`); repository-relative.
420
+ EVIDENCE = {
421
+ "OCB001": "docs/checker.md#alias-names-that-are-crbasic-names-round-33",
422
+ **{f"OCB00{n}": "docs/checker.md#opencrbasic-checks-ocb002-ocb007-m3" for n in range(2, 8)},
423
+ "notice": "docs/inventory.md#coverage-notices",
424
+ }
425
+
426
+
427
+ @functools.cache
428
+ def _missing_rules(logger: str) -> dict[str, list[str]]:
429
+ path = Path(__file__).resolve().parent / "data" / f"notices_{logger}.json"
430
+ return json.loads(path.read_text(encoding="utf-8"))["missing"] if path.exists() else {}
431
+
432
+
433
+ def notices(program: S.Program | None, profile) -> list[dict]:
434
+ """Coverage notices: at the first use of each instruction, the reference rules of its builder
435
+ that opencrbasic does not model, from the evidence inventory (`docs/inventory.md`). CR6 only;
436
+ information, never a verdict."""
437
+ missing, out, seen = _missing_rules(profile.name), [], set()
438
+ for st in _statements(program) if program is not None else ():
439
+ if isinstance(st, S.Instruction) and (kw := st.keyword.upper()) in missing and kw not in seen:
440
+ seen.add(kw)
441
+ out.append({"file": st.file, "line": st.line, "instruction": st.keyword, "rules": missing[kw]})
442
+ return out
443
+
444
+
445
+ def report(result, program: str, strict: bool, config_file: Path | None, config_error: str | None) -> dict:
446
+ """The `crbc --report` document (`docs/checker.md` "Check configuration and report file")."""
447
+
448
+ def item(f: Finding) -> dict:
449
+ return {"code": f.code, "severity": f.severity, "file": f.file or program, "line": f.line, "message": f.message}
450
+
451
+ return {
452
+ "schema_version": SCHEMA_VERSION,
453
+ "program": program,
454
+ "logger": result.profile.name,
455
+ "reference": {
456
+ "ok": result.ok,
457
+ "mode": result.mode,
458
+ "diagnostics": [
459
+ {"severity": d.severity, "file": d.file or program, "line": d.line, "message": d.message}
460
+ for d in result.diagnostics.items
461
+ ],
462
+ },
463
+ "strict": strict,
464
+ "config": {"file": str(config_file) if config_file else None, "error": config_error},
465
+ "findings": [item(f) for f in result.findings],
466
+ "suppressed": [{**item(f), "reason": f.reason} for f in result.suppressed],
467
+ "notices": [
468
+ {**n, "file": n["file"] or program}
469
+ for n in (notices(result.program_ast, result.profile) if result.ok else [])
470
+ ],
471
+ }
472
+
473
+
474
+ def missing_program_report(program: str, logger: str, strict: bool) -> dict:
475
+ """The `crbc --report` document when the entry file does not exist: the reference's `No Program
476
+ File` failure, no checks run."""
477
+ return {
478
+ "schema_version": SCHEMA_VERSION,
479
+ "program": program,
480
+ "logger": logger,
481
+ "reference": {
482
+ "ok": False,
483
+ "mode": None,
484
+ "diagnostics": [{"severity": "error", "file": program, "line": None, "message": "No Program File"}],
485
+ },
486
+ "strict": strict,
487
+ "config": {"file": None, "error": None},
488
+ "findings": [],
489
+ "suppressed": [],
490
+ "notices": [],
491
+ }