@h2loop/autosar 0.0.0-stage → 0.1.2

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,452 @@
1
+ """Deterministic audit of BSW API calls in integration C code against the ECUC configuration.
2
+
3
+ Finds calls that take a configuration handle (Com signals/groups, NvM blocks, Dem events, PduR
4
+ source PDUs; Rte_Write_/Rte_Read_ macros resolved through the generated RTE headers), resolves the
5
+ handle to its ECUC container via the symbolic-name rule (<Mod>Conf_<ContainerDef>_<ShortName>,
6
+ TPS_ECUC_02108) or a raw number via the module's handle-ID parameter, and checks:
7
+
8
+ H2L-API-001 raw numeric handle (use the symbolic name)
9
+ H2L-API-002 unknown handle (symbol or number matches no configured container)
10
+ H2L-API-003 direction mismatch (send on a RECEIVE I-PDU signal, receive on a SEND one)
11
+ H2L-API-004 C object width differs from the signal's ComSignalType / ComBitSize
12
+ H2L-API-005 NvM NULL_PTR without a permanent RAM block or explicit synchronization
13
+
14
+ Width is checked only when the C type is known (compound literal, or a declaration in the same
15
+ translation unit); anything else is listed under "unchecked", never guessed.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import re
21
+ from dataclasses import dataclass
22
+ from typing import Optional
23
+
24
+ from .model import Elem, Project
25
+ from .values import parse_int
26
+
27
+ C_EXT = (".c", ".h")
28
+ SKIP_DIRS = {".git", "node_modules", ".hydron", "Output", "build", "Build"}
29
+
30
+
31
+ @dataclass
32
+ class Api:
33
+ module: str
34
+ defs: tuple # container def names the handle may refer to
35
+ handle_param: str
36
+ kind: str # send | receive | nvm | dem | pdur
37
+ data: Optional[int] # index of the data pointer argument
38
+ cite: str
39
+
40
+
41
+ APIS = {
42
+ "Com_SendSignal": Api("Com", ("ComSignal", "ComGroupSignal"), "ComHandleId", "send", 1, "SWS_Com_00197"),
43
+ "Com_ReceiveSignal": Api("Com", ("ComSignal", "ComGroupSignal"), "ComHandleId", "receive", 1, "SWS_Com_00198"),
44
+ "Com_SendSignalGroup": Api("Com", ("ComSignalGroup",), "ComHandleId", "send", None, "SWS_Com_00200"),
45
+ "Com_ReceiveSignalGroup": Api("Com", ("ComSignalGroup",), "ComHandleId", "receive", None, "SWS_Com_00201"),
46
+ "NvM_ReadBlock": Api("NvM", ("NvMBlockDescriptor",), "NvMNvramBlockIdentifier", "nvm", 1, "SWS_NvM_00454"),
47
+ "NvM_WriteBlock": Api("NvM", ("NvMBlockDescriptor",), "NvMNvramBlockIdentifier", "nvm", 1, "SWS_NvM_00455"),
48
+ "NvM_RestoreBlockDefaults": Api("NvM", ("NvMBlockDescriptor",), "NvMNvramBlockIdentifier", "nvm", 1, "SWS_NvM_00456"),
49
+ "Dem_SetEventStatus": Api("Dem", ("DemEventParameter",), "DemEventId", "dem", None, "SWS_Dem_00183"),
50
+ "Dem_ReportErrorStatus": Api("Dem", ("DemEventParameter",), "DemEventId", "dem", None, "SWS_Dem (Dem_ReportErrorStatus, pre-R4.2 releases)"),
51
+ }
52
+ PDUR = Api("PduR", ("PduRSrcPdu",), "PduRSourcePduHandleId", "pdur", None, "SWS_PduR_00406")
53
+ CALL = re.compile(r"\b(Com_(?:Send|Receive)Signal(?:Group)?|NvM_(?:ReadBlock|WriteBlock|RestoreBlockDefaults)|"
54
+ r"Dem_(?:SetEventStatus|ReportErrorStatus)|PduR_\w*Transmit|Rte_(?:Write|Read)_\w+)\s*\(")
55
+ NUM = re.compile(r"^\(?\s*(?:\(\s*\w+\s*\)\s*)?(0[xX][0-9a-fA-F]+|\d+)[uUlL]*\s*\)?$")
56
+ SYM = re.compile(r"^(\w+?)Conf_(\w+?)_(\w+)$")
57
+ NULLS = {"NULL_PTR", "NULL", "0", "((void*)0)", "(void*)0"}
58
+ TYPE_BITS = {"boolean": 8, "uint8": 8, "sint8": 8, "uint16": 16, "sint16": 16, "uint32": 32, "sint32": 32,
59
+ "uint64": 64, "sint64": 64, "float32": 32, "float64": 64, "char": 8, "int8_t": 8, "uint8_t": 8,
60
+ "int16_t": 16, "uint16_t": 16, "int32_t": 32, "uint32_t": 32, "int64_t": 64, "uint64_t": 64,
61
+ "float": 32, "double": 64, "bool": 8}
62
+ SIG_BITS = {"BOOLEAN": 8, "UINT8": 8, "SINT8": 8, "UINT16": 16, "SINT16": 16, "UINT32": 32, "SINT32": 32,
63
+ "UINT64": 64, "SINT64": 64, "FLOAT32": 32, "FLOAT64": 64}
64
+
65
+
66
+ def strip_comments(src: str) -> str:
67
+ """Blank out comments and string literals, keeping offsets and newlines."""
68
+ out = list(src)
69
+ i, n = 0, len(src)
70
+ while i < n:
71
+ c = src[i]
72
+ if src.startswith("/*", i):
73
+ j = src.find("*/", i + 2)
74
+ j = n if j < 0 else j + 2
75
+ for k in range(i, j):
76
+ if out[k] != "\n":
77
+ out[k] = " "
78
+ i = j
79
+ elif src.startswith("//", i):
80
+ j = src.find("\n", i)
81
+ j = n if j < 0 else j
82
+ for k in range(i, j):
83
+ out[k] = " "
84
+ i = j
85
+ elif c in "\"'":
86
+ j = i + 1
87
+ while j < n and src[j] != c:
88
+ j += 2 if src[j] == "\\" else 1
89
+ for k in range(i + 1, min(j, n)):
90
+ if out[k] != "\n":
91
+ out[k] = " "
92
+ i = j + 1
93
+ else:
94
+ i += 1
95
+ return "".join(out)
96
+
97
+
98
+ def split_args(src: str, open_idx: int):
99
+ """Args of the call whose '(' is at open_idx -> (list of arg strings, index after ')')."""
100
+ depth, args, cur, i = 0, [], [], open_idx
101
+ while i < len(src):
102
+ c = src[i]
103
+ if c in "([{":
104
+ depth += 1
105
+ if depth > 1:
106
+ cur.append(c)
107
+ elif c in ")]}":
108
+ depth -= 1
109
+ if depth == 0:
110
+ args.append("".join(cur).strip())
111
+ return [a for a in args if a != "" or len(args) > 1], i + 1
112
+ cur.append(c)
113
+ elif c == "," and depth == 1:
114
+ args.append("".join(cur).strip())
115
+ cur = []
116
+ else:
117
+ cur.append(c)
118
+ i += 1
119
+ return None, i
120
+
121
+
122
+ def parse_defines(files: list) -> dict:
123
+ """NAME -> (params|None, body, file, line) for every #define in the given headers."""
124
+ out: dict = {}
125
+ rx = re.compile(r"^[ \t]*#[ \t]*define[ \t]+(\w+)(\([^)]*\))?[ \t]*(.*)$")
126
+ for f in files:
127
+ try:
128
+ text = strip_comments(open(f, encoding="utf-8", errors="replace").read())
129
+ except OSError:
130
+ continue
131
+ lines = text.split("\n")
132
+ i = 0
133
+ while i < len(lines):
134
+ line = lines[i]
135
+ start = i
136
+ while line.endswith("\\") and i + 1 < len(lines):
137
+ i += 1
138
+ line = line[:-1] + " " + lines[i]
139
+ m = rx.match(line)
140
+ if m:
141
+ params = [p.strip() for p in m.group(2)[1:-1].split(",")] if m.group(2) else None
142
+ out.setdefault(m.group(1), (params, m.group(3).strip(), f, start + 1))
143
+ i += 1
144
+ return out
145
+
146
+
147
+ def _files(root: str, paths: list, exts=C_EXT, skip=SKIP_DIRS) -> list:
148
+ res = []
149
+ for p in paths:
150
+ full = p if os.path.isabs(p) else os.path.join(root, p)
151
+ if os.path.isfile(full):
152
+ res.append(full)
153
+ continue
154
+ for dp, dns, fns in os.walk(full):
155
+ dns[:] = sorted(d for d in dns if d not in skip and not d.startswith("."))
156
+ res.extend(os.path.join(dp, fn) for fn in sorted(fns) if fn.endswith(exts))
157
+ return res
158
+
159
+
160
+ class Auditor:
161
+ def __init__(self, project: Project, defines: dict):
162
+ self.p = project
163
+ self.defines = defines
164
+ self.findings: list = []
165
+ self.unchecked: list = []
166
+ self.calls = 0
167
+ self._handles: dict = {}
168
+
169
+ # ---------- ECUC lookups ----------
170
+ def module(self, name: str) -> list:
171
+ return [m for m in self.p.modules if (m.def_ref or "").rsplit("/", 1)[-1] == name or m.name == name]
172
+
173
+ def containers(self, module: str, defnames: tuple) -> list:
174
+ out = []
175
+ for m in self.module(module):
176
+ stack = list(m.children)
177
+ while stack:
178
+ e = stack.pop()
179
+ stack.extend(e.children)
180
+ if (e.def_ref or "").rsplit("/", 1)[-1] in defnames:
181
+ out.append(e)
182
+ return out
183
+
184
+ def val(self, e: Elem, name: str) -> Optional[str]:
185
+ for g in self.p.find(e.path):
186
+ for pv in g.params:
187
+ if pv.name == name:
188
+ return pv.value
189
+ return None
190
+
191
+ def by_handle(self, api: Api, num: int) -> list:
192
+ key = (api.module, api.defs)
193
+ if key not in self._handles:
194
+ idx: dict = {}
195
+ for e in self.containers(api.module, api.defs):
196
+ v = parse_int(self.val(e, api.handle_param) or "")
197
+ if v is not None:
198
+ idx.setdefault(v, []).append(e)
199
+ self._handles[key] = idx
200
+ return self._handles[key].get(num, [])
201
+
202
+ def by_symbol(self, api: Api, sym: str) -> Optional[Elem]:
203
+ m = SYM.match(sym)
204
+ if not m:
205
+ return None
206
+ mod, dname, short = m.groups()
207
+ if mod != api.module:
208
+ return None
209
+ for e in self.containers(api.module, (dname,)):
210
+ if e.name == short:
211
+ return e
212
+ return None
213
+
214
+ def symbol_of(self, e: Elem, api: Api) -> str:
215
+ return f"{api.module}Conf_{(e.def_ref or '').rsplit('/', 1)[-1]}_{e.name}"
216
+
217
+ def ipdus_of(self, sig: Elem) -> list:
218
+ """I-PDUs that reference this signal (or, for a group signal, its signal group)."""
219
+ targets = {sig.path}
220
+ if (sig.def_ref or "").endswith("/ComGroupSignal") and sig.parent is not None:
221
+ targets = {sig.parent.path}
222
+ out = []
223
+ for ip in self.containers("Com", ("ComIPdu",)):
224
+ for g in self.p.find(ip.path):
225
+ if any(pv.is_ref and pv.name in ("ComIPduSignalRef", "ComIPduSignalGroupRef") and (pv.value or "").strip() in targets
226
+ for pv in g.params):
227
+ out.append(ip)
228
+ break
229
+ return out
230
+
231
+ # ---------- findings ----------
232
+ def add(self, rule, sev, f, line, call, msg, cite, evidence, fix=None):
233
+ d = {"rule": rule, "severity": sev, "file": self.p.rel(f), "line": line, "call": call,
234
+ "message": msg, "cite": cite, "evidence": evidence}
235
+ if fix:
236
+ d["fix"] = fix
237
+ self.findings.append(d)
238
+
239
+ # ---------- per call ----------
240
+ def handle_value(self, expr: str):
241
+ """-> ("num", int) | ("sym", name, resolved_int|None) | ("expr", text)."""
242
+ e = expr.strip()
243
+ m = NUM.match(e)
244
+ if m:
245
+ return ("num", int(m.group(1), 0))
246
+ while e.startswith("(") and e.endswith(")") and split_args("x" + e, 1)[1] == len(e) + 1:
247
+ e = e[1:-1].strip()
248
+ if re.fullmatch(r"\w+", e):
249
+ d = self.defines.get(e)
250
+ num = None
251
+ if d and d[0] is None:
252
+ mm = NUM.match(d[1])
253
+ num = int(mm.group(1), 0) if mm else None
254
+ return ("sym", e, num)
255
+ return ("expr", e)
256
+
257
+ def audit_call(self, f: str, line: int, name: str, args: list, unit: str, via: Optional[str] = None):
258
+ if name.startswith("Rte_"):
259
+ self.audit_rte(f, line, name, args, unit)
260
+ return
261
+ api = APIS.get(name) or (PDUR if name.startswith("PduR_") else None)
262
+ if api is None or not args:
263
+ return
264
+ self.calls += 1
265
+ call = f"{name}({', '.join(args)})" + (f" via {via}" if via else "")
266
+ h = self.handle_value(args[0])
267
+ target: Optional[Elem] = None
268
+ cite_sym = "TPS_ECUC_02108 symbolic name values"
269
+ if h[0] == "num":
270
+ hits = self.by_handle(api, h[1])
271
+ if not hits:
272
+ self.add("H2L-API-002", "error", f, line, call,
273
+ f"handle {args[0]} matches no {'/'.join(api.defs)} with {api.handle_param}={h[1]}",
274
+ f"{api.cite}; {cite_sym}", {"handleParam": api.handle_param, "value": h[1]})
275
+ return
276
+ target = hits[0]
277
+ sym = self.symbol_of(target, api)
278
+ self.add("H2L-API-001", "warning", f, line, call,
279
+ f"raw numeric handle {args[0]} = {target.path} ({api.handle_param}={h[1]}); use {sym} so the call "
280
+ "survives handle re-assignment", f"{cite_sym}; TPS_ECUConfiguration 3.4.1.1",
281
+ {"path": target.path, api.handle_param: h[1]}, fix=f"replace {args[0]} with {sym}")
282
+ elif h[0] == "sym":
283
+ target = self.by_symbol(api, h[1])
284
+ if target is None and h[2] is not None:
285
+ hits = self.by_handle(api, h[2])
286
+ target = hits[0] if hits else None
287
+ if target is None:
288
+ if SYM.match(h[1]) or h[2] is not None:
289
+ self.add("H2L-API-002", "error", f, line, call,
290
+ f"handle {h[1]} resolves to no configured {'/'.join(api.defs)} in {api.module}",
291
+ f"{api.cite}; {cite_sym}", {"symbol": h[1], "define": h[2]})
292
+ else:
293
+ self.unchecked.append({"file": self.p.rel(f), "line": line, "call": call,
294
+ "reason": f"handle {h[1]} is not a configured symbol or constant"})
295
+ return
296
+ else:
297
+ self.unchecked.append({"file": self.p.rel(f), "line": line, "call": call, "reason": "handle is an expression"})
298
+ return
299
+ if api.kind in ("send", "receive"):
300
+ self.check_direction(f, line, call, api, target)
301
+ if api.data is not None and len(args) > api.data:
302
+ self.check_width(f, line, call, target, args[api.data], unit)
303
+ if api.kind == "nvm" and api.data is not None and len(args) > api.data:
304
+ self.check_nvm_null(f, line, call, name, target, args[api.data])
305
+
306
+ def audit_rte(self, f, line, name, args, unit):
307
+ d = self.defines.get(name)
308
+ if d is None or d[0] is None:
309
+ self.unchecked.append({"file": self.p.rel(f), "line": line, "call": f"{name}(...)",
310
+ "reason": "RTE API not a macro in the generated headers (function-mapped)"})
311
+ return
312
+ params, body = d[0], d[1]
313
+ subst = body
314
+ for prm, arg in zip(params, args):
315
+ subst = re.sub(rf"\b{re.escape(prm)}\b", lambda _m, a=arg: f"({a})", subst)
316
+ clean = strip_comments(subst)
317
+ for m in CALL.finditer(clean):
318
+ inner, _ = split_args(clean, m.end() - 1)
319
+ if inner is not None and not m.group(1).startswith("Rte_"):
320
+ self.audit_call(f, line, m.group(1), inner, unit, via=f"{name} ({self.p.rel(d[2])}:{d[3]})")
321
+
322
+ def check_direction(self, f, line, call, api, sig):
323
+ ipdus = self.ipdus_of(sig)
324
+ dirs = [(ip, (self.val(ip, "ComIPduDirection") or "").strip()) for ip in ipdus]
325
+ want = "SEND" if api.kind == "send" else "RECEIVE"
326
+ bad = [(ip, d) for ip, d in dirs if d and d != want]
327
+ if bad and not any(d == want for _, d in dirs):
328
+ ip, d = bad[0]
329
+ self.add("H2L-API-003", "error", f, line, call,
330
+ f"{sig.name} belongs to I-PDU {ip.name} with ComIPduDirection={d}; {call.split('(')[0]} needs a "
331
+ f"{want} signal (Com returns COM_SERVICE_NOT_AVAILABLE / E_NOT_OK)",
332
+ f"ECUC_Com_00493 ComIPduDirection; {api.cite}",
333
+ {"signal": sig.path, "ipdu": ip.path, "ComIPduDirection": d})
334
+
335
+ def c_type(self, expr: str, unit: str):
336
+ """Type of the object a data-pointer argument points to -> (type, array_len|None), or None if unknown."""
337
+ e = expr.strip()
338
+ while e.startswith("(") and e.endswith(")") and split_args("x" + e, 1)[1] == len(e) + 1:
339
+ e = e[1:-1].strip()
340
+ lit = re.fullmatch(r"&\s*\(\s*(\w+)\s*\)\s*\{.*\}", e, re.S) # &(uint8){ x }
341
+ if lit:
342
+ return lit.group(1), None
343
+ ref = re.fullmatch(r"&\s*(\w+)", e) # &var
344
+ elem = re.fullmatch(r"&\s*(\w+)\s*\[[^\]]*\]", e) # &buf[i]
345
+ bare = re.fullmatch(r"(\w+)", e) # buf (array decays) or a pointer variable
346
+ var = (ref or elem or bare).group(1) if (ref or elem or bare) else None
347
+ if var is None:
348
+ return None
349
+ decl = re.search(rf"\b(?:(?:static|volatile|const|extern)\s+)*(\w+)\s+(?:const\s+)?{re.escape(var)}"
350
+ rf"\s*(\[\s*(\w*)\s*\])?\s*[;=,)]", unit)
351
+ if not decl or decl.group(1) in ("return", "else", "case", "goto", "sizeof", "struct", "union", "enum"):
352
+ return None
353
+ is_array = bool(decl.group(2))
354
+ if ref:
355
+ return decl.group(1), (decl.group(3) if is_array else None)
356
+ if elem or (bare and is_array):
357
+ return decl.group(1), None
358
+ return None # bare non-array variable: a pointer whose pointee is not visible here
359
+
360
+ def check_width(self, f, line, call, sig, arg, unit):
361
+ typ = (self.val(sig, "ComSignalType") or "").strip()
362
+ if typ in ("UINT8_N", "UINT8_DYN"):
363
+ self.unchecked.append({"file": self.p.rel(f), "line": line, "call": call, "reason": f"array signal type {typ}"})
364
+ return
365
+ expect = SIG_BITS.get(typ)
366
+ ct = self.c_type(arg, unit)
367
+ if expect is None or ct is None or ct[0] not in TYPE_BITS:
368
+ self.unchecked.append({"file": self.p.rel(f), "line": line, "call": call,
369
+ "reason": f"width unknown (C type {ct[0] if ct else '?'}, ComSignalType {typ or '?'})"})
370
+ return
371
+ cbits = TYPE_BITS[ct[0]]
372
+ if ct[1]:
373
+ n = parse_int(ct[1])
374
+ cbits = cbits * n if n is not None else cbits
375
+ if cbits != expect:
376
+ bs = self.val(sig, "ComBitSize")
377
+ self.add("H2L-API-004", "error", f, line, call,
378
+ f"{arg} is a {ct[0]}{'[' + ct[1] + ']' if ct[1] else ''} ({cbits} bits) but {sig.name} is "
379
+ f"ComSignalType={typ} ({expect // 8}-byte object, ComBitSize={bs}); Com accesses {expect // 8} "
380
+ f"byte(s) at that address — wrong signal or wrong variable",
381
+ "SWS_Com_00197/00198 SignalDataPtr points to an object of the ComSignalType; ECUC_Com_00127 ComSignalType",
382
+ {"signal": sig.path, "ComSignalType": typ, "ComBitSize": bs, "cType": ct[0]})
383
+
384
+ def check_nvm_null(self, f, line, call, name, blk, arg):
385
+ if arg.replace(" ", "") not in NULLS:
386
+ return
387
+ ram = (self.val(blk, "NvMRamBlockDataAddress") or "").strip()
388
+ sync = (self.val(blk, "NvMBlockUseSyncMechanism") or "").strip().lower() in ("true", "1")
389
+ if ram or sync:
390
+ return # valid: SWS_NvM_00898/00900 (permanent RAM) or 00899/00901 (explicit sync)
391
+ self.add("H2L-API-005", "error", f, line, call,
392
+ f"{name} with NULL_PTR on {blk.name}, which has no permanent RAM block (NvMRamBlockDataAddress unset) and "
393
+ "no explicit synchronization; NvM rejects the request (DET parameter error)",
394
+ "SWS_NvM_00898/00899 (ReadBlock), SWS_NvM_00900/00901 (WriteBlock), SWS_NvM_00278/00280",
395
+ {"block": blk.path, "NvMRamBlockDataAddress": None, "NvMBlockUseSyncMechanism": sync})
396
+
397
+
398
+ def audit(project: Project, paths: Optional[list] = None, headers: Optional[list] = None) -> dict:
399
+ root = project.root
400
+ cfg = {}
401
+ try:
402
+ import json
403
+ with open(os.path.join(root, ".hydron", "autosar.json")) as fh:
404
+ raw = json.load(fh).get("audit")
405
+ cfg = raw if isinstance(raw, dict) else ({"paths": raw} if isinstance(raw, list) else {})
406
+ except (OSError, ValueError):
407
+ pass
408
+ paths = paths or cfg.get("paths") or (["Source"] if os.path.isdir(os.path.join(root, "Source")) else ["."])
409
+ headers = headers or cfg.get("headers") or [d for d in ("Output",) if os.path.isdir(os.path.join(root, d))]
410
+ sources = [f for f in _files(root, paths) if f.endswith(".c")]
411
+ hdrs = _files(root, headers, exts=(".h",), skip={".git", "node_modules"}) + [f for f in _files(root, paths) if f.endswith(".h")]
412
+ a = Auditor(project, parse_defines(hdrs))
413
+ for f in sources:
414
+ try:
415
+ unit = strip_comments(open(f, encoding="utf-8", errors="replace").read())
416
+ except OSError:
417
+ continue
418
+ for m in CALL.finditer(unit):
419
+ ls = unit.rfind("\n", 0, m.start()) + 1
420
+ if unit[ls:m.start()].lstrip().startswith("#"):
421
+ continue # macro definition, not a call
422
+ before = unit[ls:m.start()].rstrip()
423
+ prev = re.search(r"(\w+)\s*$", before)
424
+ if prev and prev.group(1) not in ("return", "void") and not before.endswith(")"):
425
+ continue # a declaration/prototype ("Std_ReturnType Com_SendSignal(")
426
+ args, _ = split_args(unit, m.end() - 1)
427
+ if args is None:
428
+ continue
429
+ a.audit_call(f, unit.count("\n", 0, m.start()) + 1, m.group(1), args, unit)
430
+ a.findings.sort(key=lambda d: (d["file"], d["line"], d["rule"]))
431
+ summary: dict = {}
432
+ for d in a.findings:
433
+ summary[d["severity"]] = summary.get(d["severity"], 0) + 1
434
+ return {"scanned": {"sources": [project.rel(f) for f in sources], "headers": len(hdrs), "calls": a.calls},
435
+ "summary": summary, "findings": a.findings, "unchecked": a.unchecked}
436
+
437
+
438
+ def signal_owners(project: Project, paths: list) -> dict:
439
+ """AUTOSAR path of a ComSignal / ComGroupSignal -> [{ipdu, direction, pdu}] from the local ECUC."""
440
+ a = Auditor(project, {})
441
+ out: dict = {}
442
+ for p in paths:
443
+ g = project.find(p)
444
+ if not g:
445
+ out[p] = None # not in the local checkout
446
+ continue
447
+ sig = g[0]
448
+ if not (sig.def_ref or "").endswith(("/ComSignal", "/ComGroupSignal", "/ComSignalGroup")):
449
+ continue
450
+ out[p] = [{"ipdu": ip.path, "direction": (a.val(ip, "ComIPduDirection") or "").strip() or None,
451
+ "pdu": (a.val(ip, "ComPduIdRef") or "").strip() or None} for ip in a.ipdus_of(sig)]
452
+ return out
@@ -0,0 +1,125 @@
1
+ """CLI: python -m ecuc_engine [--root DIR] <info|read|edit|validate|diff> ... --json
2
+
3
+ Always prints one JSON object on stdout. {"ok": false, "error": ...} on failure (exit code 0 for
4
+ handled errors, so callers parse stdout rather than the status).
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import json
10
+ import os
11
+ import sys
12
+ import time
13
+
14
+ from . import ENGINE_VERSION
15
+
16
+
17
+ def _ops(arg: str):
18
+ if arg.startswith("@"):
19
+ with open(arg[1:]) as f:
20
+ arg = f.read()
21
+ if arg == "-":
22
+ arg = sys.stdin.read()
23
+ ops = json.loads(arg)
24
+ for _ in range(3): # tolerate JSON that was encoded more than once ("[{...}]" as a string)
25
+ if isinstance(ops, str):
26
+ ops = json.loads(ops)
27
+ if isinstance(ops, dict) and isinstance(ops.get("ops"), (list, str)):
28
+ return _ops(json.dumps(ops["ops"]))
29
+ return ops if isinstance(ops, list) else [ops]
30
+
31
+
32
+ def main(argv=None) -> int:
33
+ ap = argparse.ArgumentParser(prog="ecuc_engine")
34
+ ap.add_argument("--root", default=os.getcwd(), help="project root (default: cwd)")
35
+ ap.add_argument("--defs", action="append", help="param-def file/dir/glob (overrides discovery)")
36
+ ap.add_argument("--config", action="append", help="ECUC value file/dir/glob (overrides discovery)")
37
+ ap.add_argument("--json", action="store_true", help="JSON output (always on; accepted for clarity)")
38
+ sub = ap.add_subparsers(dest="cmd", required=True)
39
+ sub.add_parser("info")
40
+ r = sub.add_parser("read")
41
+ r.add_argument("path", nargs="?")
42
+ r.add_argument("--depth", type=int, default=2)
43
+ r.add_argument("--schema", action="store_true")
44
+ e = sub.add_parser("edit")
45
+ e.add_argument("--ops", required=True, help="JSON op or list, @file, or - for stdin")
46
+ e.add_argument("--dry-run", action="store_true")
47
+ e.add_argument("--force", action="store_true")
48
+ e.add_argument("--no-validate", action="store_true")
49
+ v = sub.add_parser("validate")
50
+ v.add_argument("--file", action="append")
51
+ v.add_argument("--module")
52
+ v.add_argument("--max", type=int, default=0, help="cap the number of diagnostics returned (0 = all)")
53
+ au = sub.add_parser("audit")
54
+ au.add_argument("paths", nargs="*", help="C files/dirs to scan (default: audit.paths in .hydron/autosar.json, else Source/)")
55
+ au.add_argument("--headers", action="append", help="generated header dirs for symbols and RTE macros (default Output/)")
56
+ so = sub.add_parser("signal-owners")
57
+ so.add_argument("paths", nargs="+")
58
+ d = sub.add_parser("diff")
59
+ d.add_argument("a")
60
+ d.add_argument("b")
61
+ for p in (r, e, v, d, au, so): # allow --json after the subcommand too
62
+ p.add_argument("--json", action="store_true", help=argparse.SUPPRESS)
63
+ a = ap.parse_args(argv)
64
+ t0 = time.time()
65
+ try:
66
+ res = run(a)
67
+ except Exception as ex: # noqa: BLE001
68
+ res = {"ok": False, "error": f"{type(ex).__name__}: {ex}"}
69
+ res.setdefault("ok", "error" not in res)
70
+ res["engine"] = ENGINE_VERSION
71
+ res["ms"] = int((time.time() - t0) * 1000)
72
+ sys.stdout.write(json.dumps(res, ensure_ascii=False, indent=None if a.json else 1, default=str) + "\n")
73
+ return 0
74
+
75
+
76
+ def run(a) -> dict:
77
+ if a.cmd == "edit":
78
+ try:
79
+ a.ops = _ops(a.ops)
80
+ except (ValueError, OSError) as ex:
81
+ return {"error": f"ops is not valid JSON: {ex}"}
82
+ if a.cmd == "diff":
83
+ from .diff import diff
84
+ for x in (a.a, a.b):
85
+ if not os.path.exists(x):
86
+ return {"error": f"{x} does not exist"}
87
+ root = os.path.abspath(a.root)
88
+ rel = lambda f: os.path.relpath(f, root) if not os.path.relpath(f, root).startswith("..") else f # noqa: E731
89
+ return diff(a.a, a.b, rel)
90
+ from .model import Project
91
+ project = Project(a.root, config_files=a.config, def_files=a.defs, load_input=a.cmd in ("validate", "edit"))
92
+ if a.cmd == "signal-owners":
93
+ from .audit import signal_owners
94
+ return {"owners": signal_owners(project, a.paths)}
95
+ if a.cmd == "audit":
96
+ from .audit import audit
97
+ res = audit(project, paths=a.paths or None, headers=a.headers)
98
+ if project.warnings:
99
+ res["warnings"] = project.warnings
100
+ return res
101
+ if a.cmd == "info":
102
+ s = project.sources
103
+ return {"root": s.root, "origin": s.origin, "config": [project.rel(f) for f in s.config],
104
+ "paramDefs": [project.rel(f) for f in s.defs], "input": [project.rel(f) for f in s.input][:200],
105
+ "defModules": len(project.defs.modules), "modules": sorted({m.name for m in project.modules}),
106
+ "warnings": project.warnings}
107
+ if a.cmd == "read":
108
+ from .ecucjson import read
109
+ return read(project, a.path, depth=a.depth, schema=a.schema)
110
+ if a.cmd == "edit":
111
+ from .edit import edit
112
+ res = edit(project, a.ops, dry_run=a.dry_run, force=a.force, run_validate=not a.no_validate)
113
+ if project.warnings:
114
+ res["warnings"] = project.warnings
115
+ return res
116
+ if a.cmd == "validate":
117
+ from .validate import validate
118
+ res = validate(project, files=a.file, module=a.module)
119
+ if a.max and len(res["diagnostics"]) > a.max:
120
+ res["truncated"] = len(res["diagnostics"]) - a.max
121
+ res["diagnostics"] = res["diagnostics"][: a.max]
122
+ if project.warnings:
123
+ res["warnings"] = project.warnings
124
+ return res
125
+ return {"error": f"unknown command {a.cmd}"}