opencrbasic 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- opencrbasic/__init__.py +3 -0
- opencrbasic/__main__.py +5 -0
- opencrbasic/analysis.py +184 -0
- opencrbasic/bindings.py +1034 -0
- opencrbasic/catalog.py +173 -0
- opencrbasic/checkconfig.py +144 -0
- opencrbasic/checker/__init__.py +977 -0
- opencrbasic/checker/cr3xx.py +3118 -0
- opencrbasic/checker/ess.py +81 -0
- opencrbasic/checker/expressions.py +3581 -0
- opencrbasic/checker/instr_data.py +1802 -0
- opencrbasic/checker/instr_measure.py +1547 -0
- opencrbasic/checker/instr_rules.py +3540 -0
- opencrbasic/checker/instructions.py +17 -0
- opencrbasic/checker/intervals.py +678 -0
- opencrbasic/checker/longconv.py +133 -0
- opencrbasic/checker/scan_time.py +580 -0
- opencrbasic/checker/storage.py +456 -0
- opencrbasic/checker/structs.py +240 -0
- opencrbasic/checker/structure.py +6213 -0
- opencrbasic/checker/symbols.py +609 -0
- opencrbasic/checker/terminals.py +249 -0
- opencrbasic/checker/unused.py +186 -0
- opencrbasic/checker/var_types.py +564 -0
- opencrbasic/checker/warnings.py +661 -0
- opencrbasic/checks.py +491 -0
- opencrbasic/cli.py +391 -0
- opencrbasic/commands.py +128 -0
- opencrbasic/compiler.py +320 -0
- opencrbasic/consteval.py +327 -0
- opencrbasic/data/background_calibration.json +4 -0
- opencrbasic/data/before_beginprog.json +99 -0
- opencrbasic/data/cr1000x/background_calibration.json +4 -0
- opencrbasic/data/cr1000x/before_beginprog.json +138 -0
- opencrbasic/data/cr1000x/ess_variables.json +1087 -0
- opencrbasic/data/cr1000x/fnotch.json +29 -0
- opencrbasic/data/cr1000x/functions.json +1337 -0
- opencrbasic/data/cr1000x/instructions.json +4836 -0
- opencrbasic/data/cr1000x/measure.json +121 -0
- opencrbasic/data/cr1000x/mode.json +111 -0
- opencrbasic/data/cr1000x/pipeline_tasks.json +88 -0
- opencrbasic/data/cr1000x/settings_fields.json +321 -0
- opencrbasic/data/cr1000x/status_members.json +318 -0
- opencrbasic/data/cr1000x/symbols.json +872 -0
- opencrbasic/data/cr1000x/terminals.json +225 -0
- opencrbasic/data/cr300/before_beginprog.json +42 -0
- opencrbasic/data/cr300/ess_variables.json +1087 -0
- opencrbasic/data/cr300/fnotch.json +22 -0
- opencrbasic/data/cr300/functions.json +1261 -0
- opencrbasic/data/cr300/instructions.json +2837 -0
- opencrbasic/data/cr300/measure.json +33 -0
- opencrbasic/data/cr300/mode.json +121 -0
- opencrbasic/data/cr300/settings_fields.json +259 -0
- opencrbasic/data/cr300/status_members.json +303 -0
- opencrbasic/data/cr300/symbols.json +584 -0
- opencrbasic/data/cr300/terminals.json +193 -0
- opencrbasic/data/cr350/before_beginprog.json +44 -0
- opencrbasic/data/cr350/ess_variables.json +1087 -0
- opencrbasic/data/cr350/fnotch.json +22 -0
- opencrbasic/data/cr350/functions.json +1262 -0
- opencrbasic/data/cr350/instructions.json +2848 -0
- opencrbasic/data/cr350/measure.json +35 -0
- opencrbasic/data/cr350/mode.json +121 -0
- opencrbasic/data/cr350/settings_fields.json +292 -0
- opencrbasic/data/cr350/status_members.json +303 -0
- opencrbasic/data/cr350/symbols.json +572 -0
- opencrbasic/data/cr350/terminals.json +190 -0
- opencrbasic/data/ess_variables.json +1087 -0
- opencrbasic/data/fnotch.json +34 -0
- opencrbasic/data/functions.json +1337 -0
- opencrbasic/data/instructions.json +4965 -0
- opencrbasic/data/measure.json +123 -0
- opencrbasic/data/mode.json +121 -0
- opencrbasic/data/notices_cr6.json +890 -0
- opencrbasic/data/pipeline_tasks.json +84 -0
- opencrbasic/data/settings_fields.json +322 -0
- opencrbasic/data/status_members.json +319 -0
- opencrbasic/data/status_table.json +684 -0
- opencrbasic/data/symbols.json +902 -0
- opencrbasic/data/terminals.json +195 -0
- opencrbasic/data/type_codes.json +249 -0
- opencrbasic/diagnostics.py +196 -0
- opencrbasic/editing.py +827 -0
- opencrbasic/formatting.py +310 -0
- opencrbasic/helpdocs.py +677 -0
- opencrbasic/hover.py +667 -0
- opencrbasic/inlay.py +169 -0
- opencrbasic/lexer.py +183 -0
- opencrbasic/lsp/__init__.py +5 -0
- opencrbasic/lsp/__main__.py +7 -0
- opencrbasic/lsp/config.py +202 -0
- opencrbasic/lsp/features.py +602 -0
- opencrbasic/lsp/server.py +741 -0
- opencrbasic/lsp/session.py +1189 -0
- opencrbasic/lsp/worker.py +128 -0
- opencrbasic/mode.py +143 -0
- opencrbasic/navigation.py +401 -0
- opencrbasic/parser.py +3264 -0
- opencrbasic/positions.py +274 -0
- opencrbasic/preprocess.py +269 -0
- opencrbasic/profiles.py +266 -0
- opencrbasic/provenance.py +17 -0
- opencrbasic/quickfix.py +331 -0
- opencrbasic/rename.py +662 -0
- opencrbasic/signature.py +33 -0
- opencrbasic/source.py +600 -0
- opencrbasic/sources.py +125 -0
- opencrbasic/syntax.py +337 -0
- opencrbasic/tables.py +2173 -0
- opencrbasic/tables_hook.py +14 -0
- opencrbasic/tdf.py +142 -0
- opencrbasic/timing.py +565 -0
- opencrbasic-1.0.0.dist-info/METADATA +223 -0
- opencrbasic-1.0.0.dist-info/RECORD +117 -0
- opencrbasic-1.0.0.dist-info/WHEEL +4 -0
- opencrbasic-1.0.0.dist-info/entry_points.txt +3 -0
- opencrbasic-1.0.0.dist-info/licenses/LICENSE +21 -0
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
|
+
}
|