@h2loop/autosar 0.0.0-stage → 1.0.1

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.
@@ -0,0 +1,107 @@
1
+ """COM I-PDU packing rules: signals inside the I-PDU, no overlapping signals."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Optional
5
+
6
+ from . import rule
7
+ from ..values import parse_int
8
+
9
+ TYPE_BITS = {"BOOLEAN": 1, "UINT8": 8, "SINT8": 8, "UINT16": 16, "SINT16": 16, "UINT32": 32, "SINT32": 32,
10
+ "UINT64": 64, "SINT64": 64, "FLOAT32": 32, "FLOAT64": 64}
11
+ CITE_BOUNDS = "ECUC_Com_00259 ComBitPosition (position within the I-PDU), ECUC_Com_00158 ComBitSize, ECUC_EcuC_00003 PduLength"
12
+ CITE_OVERLAP = "SWS_Com_CONSTR_00102 Signals Separated in I-PDU"
13
+
14
+
15
+ def _size(ctx, s) -> Optional[int]:
16
+ bs = parse_int(ctx.text(s, "ComBitSize") or "")
17
+ if bs is not None:
18
+ return bs
19
+ typ = (ctx.text(s, "ComSignalType") or "").strip()
20
+ if typ in ("UINT8_N", "UINT8_DYN"):
21
+ n = parse_int(ctx.text(s, "ComSignalLength") or "")
22
+ return n * 8 if n is not None else None
23
+ return TYPE_BITS.get(typ)
24
+
25
+
26
+ def bits(pos: int, size: int, endian: str) -> list:
27
+ """Occupied bit indices (byte*8 + bit). ComBitPosition is the LSB. BIG_ENDIAN (Motorola) walks
28
+ toward the MSB by going up within a byte, then to bit 0 of the previous byte."""
29
+ if endian == "BIG_ENDIAN":
30
+ out, b = [], pos
31
+ for _ in range(size):
32
+ out.append(b)
33
+ b = b + 1 if b % 8 != 7 else b - 15
34
+ return out
35
+ return list(range(pos, pos + size)) # LITTLE_ENDIAN and OPAQUE (bit0 of first byte, little-endian order)
36
+
37
+
38
+ def _ipdus(ctx):
39
+ """(ipdu, length_bytes|None, [(member, kind)]) for every ComIPdu."""
40
+ for ipdu in ctx.containers("Com/ComConfig/ComIPdu"):
41
+ pdu = ctx.target(ctx.value(ipdu, "ComPduIdRef"))
42
+ length = parse_int(ctx.text(pdu, "PduLength") or "") if pdu is not None else None
43
+ members = []
44
+ for ref in ctx.values(ipdu, "ComIPduSignalRef"):
45
+ s = ctx.target(ref)
46
+ if s is not None:
47
+ members.append(s)
48
+ for ref in ctx.values(ipdu, "ComIPduSignalGroupRef"):
49
+ grp = ctx.target(ref)
50
+ if grp is None:
51
+ continue
52
+ members.extend(c for g in ctx.p.find(grp.path) for c in g.children
53
+ if ctx.rel(c.def_ref).endswith("/ComGroupSignal"))
54
+ members.append(grp) # contributes only its update bit
55
+ yield ipdu, length, pdu, members
56
+
57
+
58
+ def _layout(ctx, s):
59
+ """[(label, bitset, line)] a signal/group occupies: its value bits and its update bit."""
60
+ out = []
61
+ pos = parse_int(ctx.text(s, "ComBitPosition") or "")
62
+ size = _size(ctx, s)
63
+ if pos is not None and size:
64
+ endian = (ctx.text(s, "ComSignalEndianness") or "LITTLE_ENDIAN").strip()
65
+ out.append((s.name, bits(pos, size, endian), ctx.value(s, "ComBitPosition").node.line, pos, size, endian))
66
+ ub = parse_int(ctx.text(s, "ComUpdateBitPosition") or "")
67
+ if ub is not None:
68
+ out.append((f"{s.name}.updateBit", [ub], ctx.value(s, "ComUpdateBitPosition").node.line, ub, 1, "LITTLE_ENDIAN"))
69
+ return out
70
+
71
+
72
+ @rule("H2L-COM-001", "error", CITE_BOUNDS, "signal bits lie inside its I-PDU (PduLength * 8)")
73
+ def signal_in_pdu(ctx):
74
+ for ipdu, length, pdu, members in _ipdus(ctx):
75
+ if length is None:
76
+ continue
77
+ limit = length * 8
78
+ for s in members:
79
+ for label, bs, line, pos, size, endian in _layout(ctx, s):
80
+ lo, hi = min(bs), max(bs)
81
+ if lo < 0 or hi >= limit:
82
+ yield s, line, (f"{label}: ComBitPosition={pos} ComBitSize={size} ({endian}) occupies bits {lo}..{hi}, "
83
+ f"outside I-PDU {ipdu.name} ({pdu.path} PduLength={length} bytes = bits 0..{limit - 1})"), \
84
+ (ctx.value(s, "ComBitPosition") or ctx.value(s, "ComUpdateBitPosition")).def_ref
85
+
86
+
87
+ @rule("H2L-COM-002", "error", CITE_OVERLAP, "signals of one I-PDU do not overlap")
88
+ def no_overlap(ctx):
89
+ for ipdu, length, pdu, members in _ipdus(ctx):
90
+ owner: dict = {}
91
+ reported = set()
92
+ for s in members:
93
+ for label, bs, line, pos, size, endian in _layout(ctx, s):
94
+ for b in bs:
95
+ prev = owner.get(b)
96
+ if prev is None:
97
+ owner[b] = (label, s)
98
+ continue
99
+ if prev[0] == label:
100
+ continue
101
+ key = tuple(sorted((prev[0], label)))
102
+ if key in reported:
103
+ continue
104
+ reported.add(key)
105
+ yield s, line, (f"{label} overlaps {prev[0]} at bit {b} in I-PDU {ipdu.name} "
106
+ f"({label}: pos {pos} size {size} {endian})"), \
107
+ (ctx.value(s, "ComBitPosition") or ctx.value(s, "ComUpdateBitPosition")).def_ref
@@ -0,0 +1,42 @@
1
+ """Handle-ID uniqueness (TPS_ECUConfiguration 3.4.1.1 "Handle ID concept")."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+
6
+ from . import rule
7
+ from ..values import parse_int
8
+
9
+ # Handle-ID params by name when no defs say so; with defs, any SYMBOLIC-NAME-VALUE integer counts.
10
+ NAME = re.compile(r"(HandleId|^CanIfTxPduId|^CanIfRxPduId)$")
11
+
12
+
13
+ @rule("H2L-HID-001", "error",
14
+ "TPS_ECUConfiguration 3.4.1.1 Handle ID concept; ECUC_Com_00165/00175, ECUC_PduR_00322, ECUC_CanIf_00591/00597",
15
+ "handle IDs are unique per module and handle-ID parameter")
16
+ def unique_handle_ids(ctx):
17
+ seen: dict = {} # (module path, rel def) -> {value: [(elem, pv)]}
18
+ for path, group in ctx.p.by_path.items():
19
+ e = group[0]
20
+ if e.kind != "container":
21
+ continue
22
+ mod = ctx.module_of(e)
23
+ for g in group:
24
+ for pv in g.params:
25
+ if pv.is_ref or pv.value is None:
26
+ continue
27
+ d = ctx.defs.get(pv.def_ref)
28
+ is_handle = (d.kind == "INTEGER" and d.symbolic_name) if d is not None else False
29
+ if not is_handle and not NAME.search(pv.name):
30
+ continue
31
+ v = parse_int(pv.value)
32
+ if v is None:
33
+ continue
34
+ key = (mod.path, ctx.rel(pv.def_ref))
35
+ seen.setdefault(key, {}).setdefault(v, []).append((g, pv))
36
+ for (mod, rel), by_val in seen.items():
37
+ for v, hits in by_val.items():
38
+ if len(hits) < 2:
39
+ continue
40
+ names = ", ".join(sorted({h[0].name for h in hits}))
41
+ for g, pv in hits:
42
+ yield g, pv.node.line, f"{pv.name}={v} is used by {len(hits)} containers in {mod} ({names}); handle IDs must be unique", pv.def_ref
@@ -0,0 +1,263 @@
1
+ """Mechanical ECUC checks, derived from the param defs only (no hand-written module rules).
2
+
3
+ Diagnostics are LSP-like: {file, line, path, rule, severity, message, defRef}.
4
+ Rules:
5
+ ECUC-NO-DEF module's definition is not among the loaded param defs (module skipped)
6
+ ECUC-UNKNOWN-DEF container / parameter / reference whose DEFINITION-REF is not in the module def
7
+ ECUC-WRONG-PARENT container's def is not a child of its parent's def
8
+ ECUC-DEST-MISMATCH DEFINITION-REF DEST attribute differs from the def element type
9
+ ECUC-VALUE-TYPE NUMERICAL vs TEXTUAL value element does not match the def kind
10
+ ECUC-MULT-CONTAINER sub-container count outside the def multiplicity (incl. missing mandatory)
11
+ ECUC-CHOICE choice container does not hold exactly one choice
12
+ ECUC-MULT-PARAM parameter / reference count outside the def multiplicity
13
+ ECUC-MISSING-PARAM mandatory parameter / reference absent
14
+ ECUC-TYPE / ECUC-RANGE / ECUC-ENUM / ECUC-STRING / ECUC-EMPTY-VALUE value checks
15
+ ECUC-REF-DANGLING reference target does not exist
16
+ ECUC-REF-DEST reference target is of the wrong definition / metaclass
17
+ ECUC-REF-UNRESOLVED foreign reference target not in any loaded file (warning)
18
+ ECUC-DUP-NAME two containers with the same AUTOSAR path
19
+ ECUC-OBSOLETE value for an obsolete def (warning)
20
+ H2L-FILTER-NOMATCH a files/module filter matched nothing that was loaded (error, never a silent "clean")
21
+ H2L-DISCOVERY discovery notes: hidden dir included, symlink cycle skipped, walk cap (info)
22
+ Hand-written semantic rules (ecuc_engine/rules, ids H2L-*) run afterwards, with or without defs.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ from typing import Optional
27
+
28
+ from . import defs as D
29
+ from . import paths
30
+ from .model import Project
31
+ from .values import check_value, expected_value_tag
32
+
33
+ GENERIC_CONTAINER_DESTS = {"ECUC-CONTAINER-DEF"}
34
+ SEV_ORDER = {"error": 0, "warning": 1, "info": 2, "hint": 3}
35
+
36
+
37
+ class Diags:
38
+ def __init__(self, project: Project):
39
+ self.p = project
40
+ self.items: list = []
41
+
42
+ def add(self, file, line, path, rule, severity, message, def_ref=None):
43
+ self.items.append({"file": self.p.rel(file), "line": line, "path": path, "rule": rule,
44
+ "severity": severity, "message": message, "defRef": def_ref})
45
+
46
+
47
+ def _same_def(defs: D.DefIndex, a: Optional[str], b: Optional[str]) -> bool:
48
+ if not a or not b:
49
+ return False
50
+ return a == b or defs.rel(a) == defs.rel(b)
51
+
52
+
53
+ def _resolve_def(defs: D.DefIndex, ref: Optional[str]) -> Optional[D.DefNode]:
54
+ if not ref:
55
+ return None
56
+ d = defs.get(ref)
57
+ if d is not None:
58
+ return d
59
+ # vendor-relocated path (/Vendor/Com/... vs /AUTOSAR/EcucDefs/Com/...): match by module-relative path
60
+ rel = defs.rel(ref)
61
+ for m in defs.modules.values():
62
+ if rel.split("/", 1)[0] == m.name:
63
+ return defs.get(m.path + rel[len(m.name):])
64
+ return None
65
+
66
+
67
+ def validate(project: Project, files: Optional[list] = None, module: Optional[str] = None) -> dict:
68
+ defs = project.defs
69
+ dg = Diags(project)
70
+ warned_modules = set()
71
+ visited = set()
72
+
73
+ def fmult(lo, up):
74
+ return f"{lo}..{'*' if up is None else up}"
75
+
76
+ def check_elem_group(path: str):
77
+ if path in visited:
78
+ return
79
+ visited.add(path)
80
+ group = project.find(path)
81
+ e0 = group[0]
82
+ if e0.kind == "container" and len(group) > 1:
83
+ for e in group[1:]:
84
+ dg.add(e.file, e.line, path, "ECUC-DUP-NAME", "error",
85
+ f"duplicate container path {path} (also at {project.rel(e0.file)}:{e0.line})", e.def_ref)
86
+ d = _resolve_def(defs, e0.def_ref)
87
+ if d is None:
88
+ if e0.kind == "module":
89
+ key = e0.def_ref or path
90
+ if key not in warned_modules:
91
+ warned_modules.add(key)
92
+ dg.add(e0.file, e0.line, path, "ECUC-NO-DEF", "warning",
93
+ f"no param def loaded for {e0.def_ref}; module not validated", e0.def_ref)
94
+ else:
95
+ dg.add(e0.file, e0.line, path, "ECUC-UNKNOWN-DEF", "error",
96
+ f"container definition {e0.def_ref} does not exist in the loaded param defs", e0.def_ref)
97
+ return
98
+ if e0.def_dest and e0.def_dest != d.tag and not (e0.def_dest in GENERIC_CONTAINER_DESTS and d.is_container):
99
+ dg.add(e0.file, e0.line, path, "ECUC-DEST-MISMATCH", "warning",
100
+ f"DEFINITION-REF DEST={e0.def_dest} but the def is {d.tag}", d.path)
101
+ if e0.kind == "container" and e0.parent is not None:
102
+ pd = _resolve_def(defs, e0.parent.def_ref)
103
+ if pd is not None and d.parent != pd.path:
104
+ dg.add(e0.file, e0.line, path, "ECUC-WRONG-PARENT", "error",
105
+ f"{d.name} is not a sub-container of {pd.name} (allowed: "
106
+ f"{', '.join(defs.by_path[c].name for c in pd.containers) or 'none'})", d.path)
107
+ if d.obsolete:
108
+ dg.add(e0.file, e0.line, path, "ECUC-OBSOLETE", "warning", f"{d.name} is obsolete", d.path)
109
+
110
+ # children (merged across split fragments)
111
+ children = [c for e in group for c in e.children]
112
+ params = [(e, pv) for e in group for pv in e.params]
113
+ counts: dict = {}
114
+ seen_child_paths = set()
115
+ for c in children:
116
+ if c.path in seen_child_paths:
117
+ continue
118
+ seen_child_paths.add(c.path)
119
+ cd = _resolve_def(defs, c.def_ref)
120
+ if cd is not None:
121
+ counts[cd.path] = counts.get(cd.path, 0) + 1
122
+ if d.tag == D.CHOICE_TAG:
123
+ n = sum(counts.get(c, 0) for c in d.containers)
124
+ if n != 1:
125
+ dg.add(e0.file, e0.line, path, "ECUC-CHOICE", "error",
126
+ f"choice container {d.name} holds {n} choices, needs exactly 1 of "
127
+ f"{', '.join(defs.by_path[c].name for c in d.containers)}", d.path)
128
+ else:
129
+ for cpath in d.containers:
130
+ cd = defs.by_path[cpath]
131
+ n = counts.get(cpath, 0)
132
+ if n < cd.lower or (cd.upper is not None and n > cd.upper):
133
+ dg.add(e0.file, e0.line, path, "ECUC-MULT-CONTAINER", "error",
134
+ f"{d.name} '{e0.name}': {cd.name} has {n} instance(s), def requires {fmult(cd.lower, cd.upper)}",
135
+ cd.path)
136
+ pcounts: dict = {}
137
+ for e, pv in params:
138
+ pd = _resolve_def(defs, pv.def_ref)
139
+ line = pv.node.line
140
+ if pd is None or pd.parent != d.path:
141
+ dg.add(e.file, line, path, "ECUC-UNKNOWN-DEF", "error",
142
+ f"parameter/reference {pv.def_ref} is not defined for {d.name}", pv.def_ref)
143
+ continue
144
+ pcounts[pd.path] = pcounts.get(pd.path, 0) + 1
145
+ if pd.obsolete:
146
+ dg.add(e.file, line, path, "ECUC-OBSOLETE", "warning", f"{pd.name} is obsolete", pd.path)
147
+ if pv.def_dest and pv.def_dest != pd.tag:
148
+ dg.add(e.file, line, path, "ECUC-DEST-MISMATCH", "warning",
149
+ f"{pd.name}: DEFINITION-REF DEST={pv.def_dest} but the def is {pd.tag}", pd.path)
150
+ exp = expected_value_tag(pd)
151
+ if exp and pv.tag != exp:
152
+ dg.add(e.file, line, path, "ECUC-VALUE-TYPE", "warning",
153
+ f"{pd.name} is a {pd.kind} def; value element should be {exp}, found {pv.tag}", pd.path)
154
+ if D.is_ref_kind(pd.kind or ""):
155
+ check_ref(e, pv, pd, path, line)
156
+ elif pd.kind != "ADD-INFO" and not pv.is_auto:
157
+ for rule, msg in check_value(pd, pv.value):
158
+ sev = "warning" if rule == "ECUC-EMPTY-VALUE" else "error"
159
+ dg.add(e.file, (pv.value_node or pv.node).line, path, rule, sev, f"{d.name} '{e0.name}': {msg}", pd.path)
160
+ for ppath in d.params + d.refs:
161
+ pd = defs.by_path[ppath]
162
+ n = pcounts.get(ppath, 0)
163
+ if n == 0 and pd.lower > 0:
164
+ dg.add(e0.file, e0.line, path, "ECUC-MISSING-PARAM", "error",
165
+ f"{d.name} '{e0.name}': mandatory {pd.name} ({pd.kind}, {pd.mult}) is missing", pd.path)
166
+ elif n < pd.lower or (pd.upper is not None and n > pd.upper):
167
+ dg.add(e0.file, e0.line, path, "ECUC-MULT-PARAM", "error",
168
+ f"{d.name} '{e0.name}': {pd.name} has {n} value(s), def requires {pd.mult}", pd.path)
169
+ for c in children:
170
+ check_elem_group(c.path)
171
+
172
+ def check_ref(e, pv, pd, path, line):
173
+ tgt = (pv.value or "").strip()
174
+ if pd.kind in ("INSTANCE-REFERENCE", "URI-REFERENCE"):
175
+ return
176
+ if not tgt:
177
+ dg.add(e.file, line, path, "ECUC-EMPTY-VALUE", "error", f"{pd.name}: empty reference", pd.path)
178
+ return
179
+ if pd.kind == "FOREIGN-REFERENCE":
180
+ hit = project.identifiables.get(tgt)
181
+ if hit is None:
182
+ if project.input_loaded:
183
+ dg.add(e.file, line, path, "ECUC-REF-DANGLING", "error",
184
+ f"{pd.name} -> {tgt}: target not found in any loaded ARXML", pd.path)
185
+ else:
186
+ dg.add(e.file, line, path, "ECUC-REF-UNRESOLVED", "warning",
187
+ f"{pd.name} -> {tgt}: target not in loaded files (add the System/SWC ARXML to "
188
+ f"'input' in .hydron/autosar.json)", pd.path)
189
+ return
190
+ # DESTINATION-TYPE is often an abstract metaclass (RTE-EVENT for TIMING-EVENT), which needs the
191
+ # metamodel to check; the VALUE-REF DEST attribute must name the target's concrete type exactly.
192
+ if pv.dest and pv.dest != hit[0]:
193
+ dg.add(e.file, line, path, "ECUC-REF-DEST", "error",
194
+ f"{pd.name} -> {tgt}: VALUE-REF DEST={pv.dest} but the target is a {hit[0]}", pd.path)
195
+ return
196
+ targets = project.find(tgt)
197
+ if not targets:
198
+ dg.add(e.file, line, path, "ECUC-REF-DANGLING", "error",
199
+ f"{pd.name} -> {tgt}: no ECUC container with that path", pd.path)
200
+ return
201
+ t = targets[0]
202
+ if pd.dest_refs and not any(_same_def(defs, t.def_ref, dr) for dr in pd.dest_refs):
203
+ dg.add(e.file, line, path, "ECUC-REF-DEST", "error",
204
+ f"{pd.name} -> {tgt} is a {t.def_ref.rsplit('/', 1)[-1] if t.def_ref else '?'}, def requires "
205
+ f"{' or '.join(r.rsplit('/', 1)[-1] for r in pd.dest_refs)}", pd.path)
206
+
207
+ for m in project.modules:
208
+ if module and m.name != module and (m.def_ref or "").rsplit("/", 1)[-1] != module:
209
+ continue
210
+ check_elem_group(m.path)
211
+
212
+ from .rules import run_rules
213
+ dg.items.extend(run_rules(project))
214
+
215
+ items = dg.items
216
+ loaded = sorted(project.files)
217
+ by_file: dict = {}
218
+ for m in project.modules:
219
+ by_file.setdefault(project.rel(m.file), set()).add(m.name)
220
+ sample = ", ".join(project.rel(f) for f in loaded[:8]) + (f", … (+{len(loaded) - 8})" if len(loaded) > 8 else "")
221
+ scope: dict = {"root": project.root, "configFiles": len(loaded), "paramDefFiles": len(project.sources.defs),
222
+ "inputFiles": len(project.sources.input)}
223
+ nomatch = []
224
+ if files:
225
+ matched, empty = paths.select(project.root, files, loaded)
226
+ for raw in empty:
227
+ nomatch.append(_nomatch(project, f"files filter {raw!r} matched none of the {len(loaded)} loaded config "
228
+ f"file(s) [{sample or 'none'}]; NOTHING was validated for it. Pass a file, "
229
+ f"directory or glob relative to {project.root}, or omit 'files'."))
230
+ want = {project.rel(f) for f in matched}
231
+ items = [i for i in items if i["file"] in want]
232
+ scope["filter"] = {"files": list(files), "matched": sorted(want)[:200], "matchedCount": len(want)}
233
+ if module:
234
+ names = {m.name for m in project.modules} | {(m.def_ref or "").rsplit("/", 1)[-1] for m in project.modules}
235
+ if module not in names:
236
+ nomatch.append(_nomatch(project, f"module filter {module!r} matches no loaded module "
237
+ f"({', '.join(sorted(names - {''})) or 'none loaded'}); NOTHING was validated."))
238
+ scope["module"] = module
239
+ info = [{"file": "", "line": 0, "path": "", "rule": "H2L-DISCOVERY", "severity": "info", "message": n, "defRef": None}
240
+ for n in project.sources.notes]
241
+ items = nomatch + items + info
242
+ shown = sorted(by_file.items())
243
+ scope["files"] = [{"file": f, "modules": sorted(ms)} for f, ms in shown[:100]]
244
+ if len(shown) > 100:
245
+ scope["filesTruncated"] = len(shown) - 100
246
+ items.sort(key=lambda i: (i["file"], i["line"], SEV_ORDER.get(i["severity"], 9), i["rule"]))
247
+ summary: dict = {}
248
+ for i in items:
249
+ summary[i["severity"]] = summary.get(i["severity"], 0) + 1
250
+ return {
251
+ "validated": bool(defs),
252
+ "rules": "semantic rule packs ran (H2L-*)",
253
+ "defsLoaded": len(defs.modules),
254
+ "modules": sorted({m.name for m in project.modules}),
255
+ "summary": summary,
256
+ "scope": scope,
257
+ "diagnostics": items,
258
+ }
259
+
260
+
261
+ def _nomatch(project: Project, msg: str) -> dict:
262
+ return {"file": "", "line": 0, "path": project.root, "rule": "H2L-FILTER-NOMATCH", "severity": "error",
263
+ "message": msg, "defRef": None}
@@ -0,0 +1,146 @@
1
+ """Typed value checks and ECUC-JSON value rendering, driven by the param def."""
2
+ from __future__ import annotations
3
+
4
+ import math
5
+ import re
6
+ from typing import Optional
7
+
8
+ from .defs import DefNode
9
+
10
+ C_IDENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
11
+ BOOL_TRUE = {"true", "1"}
12
+ BOOL_FALSE = {"false", "0"}
13
+ JS_SAFE = 2 ** 53 - 1
14
+
15
+
16
+ def parse_int(s: str) -> Optional[int]:
17
+ s = s.strip()
18
+ try:
19
+ return int(s, 0)
20
+ except ValueError:
21
+ try:
22
+ return int(s) # leading zeros, e.g. "007"
23
+ except ValueError:
24
+ return None
25
+
26
+
27
+ def parse_float(s: str) -> Optional[float]:
28
+ t = s.strip()
29
+ if t.upper() in ("INF", "+INF"):
30
+ return math.inf
31
+ if t.upper() == "-INF":
32
+ return -math.inf
33
+ try:
34
+ return float(t)
35
+ except ValueError:
36
+ iv = parse_int(t)
37
+ return float(iv) if iv is not None else None
38
+
39
+
40
+ def _bound(s: Optional[str], f):
41
+ if s is None or s == "":
42
+ return None
43
+ return f(s)
44
+
45
+
46
+ def check_value(d: DefNode, raw: Optional[str]) -> list:
47
+ """Return [(rule, message)] problems with ``raw`` against ``d``. Empty list = OK."""
48
+ if raw is None or raw == "":
49
+ return [("ECUC-EMPTY-VALUE", f"{d.name}: empty value")]
50
+ k = d.kind
51
+ out = []
52
+ if k == "INTEGER":
53
+ v = parse_int(raw)
54
+ if v is None:
55
+ return [("ECUC-TYPE", f"{d.name}: '{raw}' is not an integer")]
56
+ lo, hi = _bound(d.min, parse_int), _bound(d.max, parse_int)
57
+ if (lo is not None and v < lo) or (hi is not None and v > hi):
58
+ out.append(("ECUC-RANGE", f"{d.name}={raw} outside [{d.min}..{d.max}]"))
59
+ elif k == "FLOAT":
60
+ v = parse_float(raw)
61
+ if v is None or math.isnan(v):
62
+ return [("ECUC-TYPE", f"{d.name}: '{raw}' is not a float")]
63
+ lo, hi = _bound(d.min, parse_float), _bound(d.max, parse_float)
64
+ bad = (lo is not None and (v < lo or (d.min_open and v == lo))) or \
65
+ (hi is not None and (v > hi or (d.max_open and v == hi)))
66
+ if bad:
67
+ out.append(("ECUC-RANGE", f"{d.name}={raw} outside {'(' if d.min_open else '['}{d.min}..{d.max}{')' if d.max_open else ']'}"))
68
+ elif k == "BOOLEAN":
69
+ if raw.strip().lower() not in BOOL_TRUE | BOOL_FALSE:
70
+ out.append(("ECUC-TYPE", f"{d.name}: '{raw}' is not a boolean (true/false/1/0)"))
71
+ elif k == "ENUMERATION":
72
+ if d.literals and raw.strip() not in d.literals:
73
+ lits = ", ".join(d.literals[:20]) + (" …" if len(d.literals) > 20 else "")
74
+ out.append(("ECUC-ENUM", f"{d.name}: '{raw}' is not a literal of the def ({lits})"))
75
+ elif k in ("STRING", "FUNCTION-NAME", "LINKER-SYMBOL", "MULTILINE-STRING"):
76
+ if d.min_len is not None and len(raw) < d.min_len:
77
+ out.append(("ECUC-STRING", f"{d.name}: length {len(raw)} < min {d.min_len}"))
78
+ if d.max_len is not None and len(raw) > d.max_len:
79
+ out.append(("ECUC-STRING", f"{d.name}: length {len(raw)} > max {d.max_len}"))
80
+ if k in ("FUNCTION-NAME", "LINKER-SYMBOL") and not C_IDENT.match(raw.strip()):
81
+ out.append(("ECUC-STRING", f"{d.name}: '{raw}' is not a valid C identifier"))
82
+ if d.regex:
83
+ try:
84
+ if not re.fullmatch(d.regex, raw):
85
+ out.append(("ECUC-STRING", f"{d.name}: '{raw}' does not match /{d.regex}/"))
86
+ except re.error:
87
+ pass
88
+ return out
89
+
90
+
91
+ def expected_value_tag(d: DefNode) -> Optional[str]:
92
+ k = d.kind or ""
93
+ if k in ("INTEGER", "FLOAT", "BOOLEAN"):
94
+ return "ECUC-NUMERICAL-PARAM-VALUE"
95
+ if k in ("ENUMERATION", "STRING", "FUNCTION-NAME", "LINKER-SYMBOL", "MULTILINE-STRING"):
96
+ return "ECUC-TEXTUAL-PARAM-VALUE"
97
+ if k == "ADD-INFO":
98
+ return "ECUC-ADD-INFO-PARAM-VALUE"
99
+ if k == "INSTANCE-REFERENCE":
100
+ return "ECUC-INSTANCE-REFERENCE-VALUE"
101
+ if k.endswith("REFERENCE"):
102
+ return "ECUC-REFERENCE-VALUE"
103
+ return None
104
+
105
+
106
+ def to_json(d: Optional[DefNode], raw: Optional[str], is_ref: bool, numerical: bool):
107
+ """Raw ARXML text -> compact JSON value (int/float/bool/str, refs as '->/path')."""
108
+ if raw is None:
109
+ return None
110
+ if is_ref:
111
+ return "->" + raw if raw.startswith("/") else raw
112
+ k = d.kind if d is not None else ("NUMBER" if numerical else None)
113
+ t = raw.strip()
114
+ if k == "BOOLEAN" or (k == "NUMBER" and t.lower() in ("true", "false")):
115
+ lo = t.lower()
116
+ return True if lo in BOOL_TRUE else False if lo in BOOL_FALSE else raw
117
+ if k in ("INTEGER", "NUMBER"):
118
+ v = parse_int(t)
119
+ if v is not None:
120
+ return v if abs(v) <= JS_SAFE and t == str(v) else t # keep hex / huge as text
121
+ if k == "INTEGER":
122
+ return raw
123
+ if k in ("FLOAT", "NUMBER"):
124
+ v = parse_float(t)
125
+ if v is not None and math.isfinite(v):
126
+ return v
127
+ return raw
128
+ return raw
129
+
130
+
131
+ def from_json(d: Optional[DefNode], v) -> str:
132
+ """JSON value from an edit op -> ARXML text."""
133
+ if isinstance(v, bool):
134
+ return "true" if v else "false"
135
+ if isinstance(v, (int, float)):
136
+ if isinstance(v, float) and v.is_integer() and d is not None and d.kind == "FLOAT":
137
+ return repr(v)
138
+ return str(v)
139
+ if v is None:
140
+ raise ValueError("null is not a value; use op 'remove' to delete a parameter")
141
+ s = str(v)
142
+ if s.startswith("->"):
143
+ s = s[2:]
144
+ if d is not None and d.kind == "BOOLEAN" and s.lower() in ("true", "false"):
145
+ s = s.lower()
146
+ return s