@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,475 @@
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
+ from . import paths
28
+
29
+ GENERATED_MARK = re.compile(r"generated by|automatically generated|auto-generated|this file (?:is|was) generated|do not edit",
30
+ re.IGNORECASE)
31
+
32
+
33
+ def is_generated(path: str) -> bool:
34
+ """Generator output, recognised by the file itself (name pattern or header banner), not its folder."""
35
+ name = os.path.basename(path)
36
+ if re.search(r"(?:_(?:Cfg|PBcfg|Lcfg)\.[ch]|^Rte\w*\.c)$", name, re.IGNORECASE):
37
+ return True
38
+ try:
39
+ with open(path, "rb") as fh:
40
+ head = fh.read(2048).decode("utf-8", "replace")
41
+ except OSError:
42
+ return False
43
+ return bool(GENERATED_MARK.search(head))
44
+
45
+
46
+ @dataclass
47
+ class Api:
48
+ module: str
49
+ defs: tuple # container def names the handle may refer to
50
+ handle_param: str
51
+ kind: str # send | receive | nvm | dem | pdur
52
+ data: Optional[int] # index of the data pointer argument
53
+ cite: str
54
+
55
+
56
+ APIS = {
57
+ "Com_SendSignal": Api("Com", ("ComSignal", "ComGroupSignal"), "ComHandleId", "send", 1, "SWS_Com_00197"),
58
+ "Com_ReceiveSignal": Api("Com", ("ComSignal", "ComGroupSignal"), "ComHandleId", "receive", 1, "SWS_Com_00198"),
59
+ "Com_SendSignalGroup": Api("Com", ("ComSignalGroup",), "ComHandleId", "send", None, "SWS_Com_00200"),
60
+ "Com_ReceiveSignalGroup": Api("Com", ("ComSignalGroup",), "ComHandleId", "receive", None, "SWS_Com_00201"),
61
+ "NvM_ReadBlock": Api("NvM", ("NvMBlockDescriptor",), "NvMNvramBlockIdentifier", "nvm", 1, "SWS_NvM_00454"),
62
+ "NvM_WriteBlock": Api("NvM", ("NvMBlockDescriptor",), "NvMNvramBlockIdentifier", "nvm", 1, "SWS_NvM_00455"),
63
+ "NvM_RestoreBlockDefaults": Api("NvM", ("NvMBlockDescriptor",), "NvMNvramBlockIdentifier", "nvm", 1, "SWS_NvM_00456"),
64
+ "Dem_SetEventStatus": Api("Dem", ("DemEventParameter",), "DemEventId", "dem", None, "SWS_Dem_00183"),
65
+ "Dem_ReportErrorStatus": Api("Dem", ("DemEventParameter",), "DemEventId", "dem", None, "SWS_Dem (Dem_ReportErrorStatus, pre-R4.2 releases)"),
66
+ }
67
+ PDUR = Api("PduR", ("PduRSrcPdu",), "PduRSourcePduHandleId", "pdur", None, "SWS_PduR_00406")
68
+ CALL = re.compile(r"\b(Com_(?:Send|Receive)Signal(?:Group)?|NvM_(?:ReadBlock|WriteBlock|RestoreBlockDefaults)|"
69
+ r"Dem_(?:SetEventStatus|ReportErrorStatus)|PduR_\w*Transmit|Rte_(?:Write|Read)_\w+)\s*\(")
70
+ NUM = re.compile(r"^\(?\s*(?:\(\s*\w+\s*\)\s*)?(0[xX][0-9a-fA-F]+|\d+)[uUlL]*\s*\)?$")
71
+ SYM = re.compile(r"^(\w+?)Conf_(\w+?)_(\w+)$")
72
+ NULLS = {"NULL_PTR", "NULL", "0", "((void*)0)", "(void*)0"}
73
+ TYPE_BITS = {"boolean": 8, "uint8": 8, "sint8": 8, "uint16": 16, "sint16": 16, "uint32": 32, "sint32": 32,
74
+ "uint64": 64, "sint64": 64, "float32": 32, "float64": 64, "char": 8, "int8_t": 8, "uint8_t": 8,
75
+ "int16_t": 16, "uint16_t": 16, "int32_t": 32, "uint32_t": 32, "int64_t": 64, "uint64_t": 64,
76
+ "float": 32, "double": 64, "bool": 8}
77
+ SIG_BITS = {"BOOLEAN": 8, "UINT8": 8, "SINT8": 8, "UINT16": 16, "SINT16": 16, "UINT32": 32, "SINT32": 32,
78
+ "UINT64": 64, "SINT64": 64, "FLOAT32": 32, "FLOAT64": 64}
79
+
80
+
81
+ def strip_comments(src: str) -> str:
82
+ """Blank out comments and string literals, keeping offsets and newlines."""
83
+ out = list(src)
84
+ i, n = 0, len(src)
85
+ while i < n:
86
+ c = src[i]
87
+ if src.startswith("/*", i):
88
+ j = src.find("*/", i + 2)
89
+ j = n if j < 0 else j + 2
90
+ for k in range(i, j):
91
+ if out[k] != "\n":
92
+ out[k] = " "
93
+ i = j
94
+ elif src.startswith("//", i):
95
+ j = src.find("\n", i)
96
+ j = n if j < 0 else j
97
+ for k in range(i, j):
98
+ out[k] = " "
99
+ i = j
100
+ elif c in "\"'":
101
+ j = i + 1
102
+ while j < n and src[j] != c:
103
+ j += 2 if src[j] == "\\" else 1
104
+ for k in range(i + 1, min(j, n)):
105
+ if out[k] != "\n":
106
+ out[k] = " "
107
+ i = j + 1
108
+ else:
109
+ i += 1
110
+ return "".join(out)
111
+
112
+
113
+ def split_args(src: str, open_idx: int):
114
+ """Args of the call whose '(' is at open_idx -> (list of arg strings, index after ')')."""
115
+ depth, args, cur, i = 0, [], [], open_idx
116
+ while i < len(src):
117
+ c = src[i]
118
+ if c in "([{":
119
+ depth += 1
120
+ if depth > 1:
121
+ cur.append(c)
122
+ elif c in ")]}":
123
+ depth -= 1
124
+ if depth == 0:
125
+ args.append("".join(cur).strip())
126
+ return [a for a in args if a != "" or len(args) > 1], i + 1
127
+ cur.append(c)
128
+ elif c == "," and depth == 1:
129
+ args.append("".join(cur).strip())
130
+ cur = []
131
+ else:
132
+ cur.append(c)
133
+ i += 1
134
+ return None, i
135
+
136
+
137
+ def parse_defines(files: list) -> dict:
138
+ """NAME -> (params|None, body, file, line) for every #define in the given headers."""
139
+ out: dict = {}
140
+ rx = re.compile(r"^[ \t]*#[ \t]*define[ \t]+(\w+)(\([^)]*\))?[ \t]*(.*)$")
141
+ for f in files:
142
+ try:
143
+ text = strip_comments(open(f, encoding="utf-8", errors="replace").read())
144
+ except OSError:
145
+ continue
146
+ lines = text.split("\n")
147
+ i = 0
148
+ while i < len(lines):
149
+ line = lines[i]
150
+ start = i
151
+ while line.endswith("\\") and i + 1 < len(lines):
152
+ i += 1
153
+ line = line[:-1] + " " + lines[i]
154
+ m = rx.match(line)
155
+ if m:
156
+ params = [p.strip() for p in m.group(2)[1:-1].split(",")] if m.group(2) else None
157
+ out.setdefault(m.group(1), (params, m.group(3).strip(), f, start + 1))
158
+ i += 1
159
+ return out
160
+
161
+
162
+ class Auditor:
163
+ def __init__(self, project: Project, defines: dict):
164
+ self.p = project
165
+ self.defines = defines
166
+ self.findings: list = []
167
+ self.unchecked: list = []
168
+ self.calls = 0
169
+ self._handles: dict = {}
170
+
171
+ # ---------- ECUC lookups ----------
172
+ def module(self, name: str) -> list:
173
+ return [m for m in self.p.modules if (m.def_ref or "").rsplit("/", 1)[-1] == name or m.name == name]
174
+
175
+ def containers(self, module: str, defnames: tuple) -> list:
176
+ out = []
177
+ for m in self.module(module):
178
+ stack = list(m.children)
179
+ while stack:
180
+ e = stack.pop()
181
+ stack.extend(e.children)
182
+ if (e.def_ref or "").rsplit("/", 1)[-1] in defnames:
183
+ out.append(e)
184
+ return out
185
+
186
+ def val(self, e: Elem, name: str) -> Optional[str]:
187
+ for g in self.p.find(e.path):
188
+ for pv in g.params:
189
+ if pv.name == name:
190
+ return pv.value
191
+ return None
192
+
193
+ def by_handle(self, api: Api, num: int) -> list:
194
+ key = (api.module, api.defs)
195
+ if key not in self._handles:
196
+ idx: dict = {}
197
+ for e in self.containers(api.module, api.defs):
198
+ v = parse_int(self.val(e, api.handle_param) or "")
199
+ if v is not None:
200
+ idx.setdefault(v, []).append(e)
201
+ self._handles[key] = idx
202
+ return self._handles[key].get(num, [])
203
+
204
+ def by_symbol(self, api: Api, sym: str) -> Optional[Elem]:
205
+ m = SYM.match(sym)
206
+ if not m:
207
+ return None
208
+ mod, dname, short = m.groups()
209
+ if mod != api.module:
210
+ return None
211
+ for e in self.containers(api.module, (dname,)):
212
+ if e.name == short:
213
+ return e
214
+ return None
215
+
216
+ def symbol_of(self, e: Elem, api: Api) -> str:
217
+ return f"{api.module}Conf_{(e.def_ref or '').rsplit('/', 1)[-1]}_{e.name}"
218
+
219
+ def ipdus_of(self, sig: Elem) -> list:
220
+ """I-PDUs that reference this signal (or, for a group signal, its signal group)."""
221
+ targets = {sig.path}
222
+ if (sig.def_ref or "").endswith("/ComGroupSignal") and sig.parent is not None:
223
+ targets = {sig.parent.path}
224
+ out = []
225
+ for ip in self.containers("Com", ("ComIPdu",)):
226
+ for g in self.p.find(ip.path):
227
+ if any(pv.is_ref and pv.name in ("ComIPduSignalRef", "ComIPduSignalGroupRef") and (pv.value or "").strip() in targets
228
+ for pv in g.params):
229
+ out.append(ip)
230
+ break
231
+ return out
232
+
233
+ # ---------- findings ----------
234
+ def add(self, rule, sev, f, line, call, msg, cite, evidence, fix=None):
235
+ d = {"rule": rule, "severity": sev, "file": self.p.rel(f), "line": line, "call": call,
236
+ "message": msg, "cite": cite, "evidence": evidence}
237
+ if fix:
238
+ d["fix"] = fix
239
+ self.findings.append(d)
240
+
241
+ # ---------- per call ----------
242
+ def handle_value(self, expr: str):
243
+ """-> ("num", int) | ("sym", name, resolved_int|None) | ("expr", text)."""
244
+ e = expr.strip()
245
+ m = NUM.match(e)
246
+ if m:
247
+ return ("num", int(m.group(1), 0))
248
+ while e.startswith("(") and e.endswith(")") and split_args("x" + e, 1)[1] == len(e) + 1:
249
+ e = e[1:-1].strip()
250
+ if re.fullmatch(r"\w+", e):
251
+ d = self.defines.get(e)
252
+ num = None
253
+ if d and d[0] is None:
254
+ mm = NUM.match(d[1])
255
+ num = int(mm.group(1), 0) if mm else None
256
+ return ("sym", e, num)
257
+ return ("expr", e)
258
+
259
+ def audit_call(self, f: str, line: int, name: str, args: list, unit: str, via: Optional[str] = None):
260
+ if name.startswith("Rte_"):
261
+ self.audit_rte(f, line, name, args, unit)
262
+ return
263
+ api = APIS.get(name) or (PDUR if name.startswith("PduR_") else None)
264
+ if api is None or not args:
265
+ return
266
+ self.calls += 1
267
+ call = f"{name}({', '.join(args)})" + (f" via {via}" if via else "")
268
+ h = self.handle_value(args[0])
269
+ target: Optional[Elem] = None
270
+ cite_sym = "TPS_ECUC_02108 symbolic name values"
271
+ if h[0] == "num":
272
+ hits = self.by_handle(api, h[1])
273
+ if not hits:
274
+ self.add("H2L-API-002", "error", f, line, call,
275
+ f"handle {args[0]} matches no {'/'.join(api.defs)} with {api.handle_param}={h[1]}",
276
+ f"{api.cite}; {cite_sym}", {"handleParam": api.handle_param, "value": h[1]})
277
+ return
278
+ target = hits[0]
279
+ sym = self.symbol_of(target, api)
280
+ self.add("H2L-API-001", "warning", f, line, call,
281
+ f"raw numeric handle {args[0]} = {target.path} ({api.handle_param}={h[1]}); use {sym} so the call "
282
+ "survives handle re-assignment", f"{cite_sym}; TPS_ECUConfiguration 3.4.1.1",
283
+ {"path": target.path, api.handle_param: h[1]}, fix=f"replace {args[0]} with {sym}")
284
+ elif h[0] == "sym":
285
+ target = self.by_symbol(api, h[1])
286
+ if target is None and h[2] is not None:
287
+ hits = self.by_handle(api, h[2])
288
+ target = hits[0] if hits else None
289
+ if target is None:
290
+ if SYM.match(h[1]) or h[2] is not None:
291
+ self.add("H2L-API-002", "error", f, line, call,
292
+ f"handle {h[1]} resolves to no configured {'/'.join(api.defs)} in {api.module}",
293
+ f"{api.cite}; {cite_sym}", {"symbol": h[1], "define": h[2]})
294
+ else:
295
+ self.unchecked.append({"file": self.p.rel(f), "line": line, "call": call,
296
+ "reason": f"handle {h[1]} is not a configured symbol or constant"})
297
+ return
298
+ else:
299
+ self.unchecked.append({"file": self.p.rel(f), "line": line, "call": call, "reason": "handle is an expression"})
300
+ return
301
+ if api.kind in ("send", "receive"):
302
+ self.check_direction(f, line, call, api, target)
303
+ if api.data is not None and len(args) > api.data:
304
+ self.check_width(f, line, call, target, args[api.data], unit)
305
+ if api.kind == "nvm" and api.data is not None and len(args) > api.data:
306
+ self.check_nvm_null(f, line, call, name, target, args[api.data])
307
+
308
+ def audit_rte(self, f, line, name, args, unit):
309
+ d = self.defines.get(name)
310
+ if d is None or d[0] is None:
311
+ self.unchecked.append({"file": self.p.rel(f), "line": line, "call": f"{name}(...)",
312
+ "reason": "RTE API not a macro in the generated headers (function-mapped)"})
313
+ return
314
+ params, body = d[0], d[1]
315
+ subst = body
316
+ for prm, arg in zip(params, args):
317
+ subst = re.sub(rf"\b{re.escape(prm)}\b", lambda _m, a=arg: f"({a})", subst)
318
+ clean = strip_comments(subst)
319
+ for m in CALL.finditer(clean):
320
+ inner, _ = split_args(clean, m.end() - 1)
321
+ if inner is not None and not m.group(1).startswith("Rte_"):
322
+ self.audit_call(f, line, m.group(1), inner, unit, via=f"{name} ({self.p.rel(d[2])}:{d[3]})")
323
+
324
+ def check_direction(self, f, line, call, api, sig):
325
+ ipdus = self.ipdus_of(sig)
326
+ dirs = [(ip, (self.val(ip, "ComIPduDirection") or "").strip()) for ip in ipdus]
327
+ want = "SEND" if api.kind == "send" else "RECEIVE"
328
+ bad = [(ip, d) for ip, d in dirs if d and d != want]
329
+ if bad and not any(d == want for _, d in dirs):
330
+ ip, d = bad[0]
331
+ self.add("H2L-API-003", "error", f, line, call,
332
+ f"{sig.name} belongs to I-PDU {ip.name} with ComIPduDirection={d}; {call.split('(')[0]} needs a "
333
+ f"{want} signal (Com returns COM_SERVICE_NOT_AVAILABLE / E_NOT_OK)",
334
+ f"ECUC_Com_00493 ComIPduDirection; {api.cite}",
335
+ {"signal": sig.path, "ipdu": ip.path, "ComIPduDirection": d})
336
+
337
+ def c_type(self, expr: str, unit: str):
338
+ """Type of the object a data-pointer argument points to -> (type, array_len|None), or None if unknown."""
339
+ e = expr.strip()
340
+ while e.startswith("(") and e.endswith(")") and split_args("x" + e, 1)[1] == len(e) + 1:
341
+ e = e[1:-1].strip()
342
+ lit = re.fullmatch(r"&\s*\(\s*(\w+)\s*\)\s*\{.*\}", e, re.S) # &(uint8){ x }
343
+ if lit:
344
+ return lit.group(1), None
345
+ ref = re.fullmatch(r"&\s*(\w+)", e) # &var
346
+ elem = re.fullmatch(r"&\s*(\w+)\s*\[[^\]]*\]", e) # &buf[i]
347
+ bare = re.fullmatch(r"(\w+)", e) # buf (array decays) or a pointer variable
348
+ var = (ref or elem or bare).group(1) if (ref or elem or bare) else None
349
+ if var is None:
350
+ return None
351
+ decl = re.search(rf"\b(?:(?:static|volatile|const|extern)\s+)*(\w+)\s+(?:const\s+)?{re.escape(var)}"
352
+ rf"\s*(\[\s*(\w*)\s*\])?\s*[;=,)]", unit)
353
+ if not decl or decl.group(1) in ("return", "else", "case", "goto", "sizeof", "struct", "union", "enum"):
354
+ return None
355
+ is_array = bool(decl.group(2))
356
+ if ref:
357
+ return decl.group(1), (decl.group(3) if is_array else None)
358
+ if elem or (bare and is_array):
359
+ return decl.group(1), None
360
+ return None # bare non-array variable: a pointer whose pointee is not visible here
361
+
362
+ def check_width(self, f, line, call, sig, arg, unit):
363
+ typ = (self.val(sig, "ComSignalType") or "").strip()
364
+ if typ in ("UINT8_N", "UINT8_DYN"):
365
+ self.unchecked.append({"file": self.p.rel(f), "line": line, "call": call, "reason": f"array signal type {typ}"})
366
+ return
367
+ expect = SIG_BITS.get(typ)
368
+ ct = self.c_type(arg, unit)
369
+ if expect is None or ct is None or ct[0] not in TYPE_BITS:
370
+ self.unchecked.append({"file": self.p.rel(f), "line": line, "call": call,
371
+ "reason": f"width unknown (C type {ct[0] if ct else '?'}, ComSignalType {typ or '?'})"})
372
+ return
373
+ cbits = TYPE_BITS[ct[0]]
374
+ if ct[1]:
375
+ n = parse_int(ct[1])
376
+ cbits = cbits * n if n is not None else cbits
377
+ if cbits != expect:
378
+ bs = self.val(sig, "ComBitSize")
379
+ self.add("H2L-API-004", "error", f, line, call,
380
+ f"{arg} is a {ct[0]}{'[' + ct[1] + ']' if ct[1] else ''} ({cbits} bits) but {sig.name} is "
381
+ f"ComSignalType={typ} ({expect // 8}-byte object, ComBitSize={bs}); Com accesses {expect // 8} "
382
+ f"byte(s) at that address — wrong signal or wrong variable",
383
+ "SWS_Com_00197/00198 SignalDataPtr points to an object of the ComSignalType; ECUC_Com_00127 ComSignalType",
384
+ {"signal": sig.path, "ComSignalType": typ, "ComBitSize": bs, "cType": ct[0]})
385
+
386
+ def check_nvm_null(self, f, line, call, name, blk, arg):
387
+ if arg.replace(" ", "") not in NULLS:
388
+ return
389
+ ram = (self.val(blk, "NvMRamBlockDataAddress") or "").strip()
390
+ sync = (self.val(blk, "NvMBlockUseSyncMechanism") or "").strip().lower() in ("true", "1")
391
+ if ram or sync:
392
+ return # valid: SWS_NvM_00898/00900 (permanent RAM) or 00899/00901 (explicit sync)
393
+ self.add("H2L-API-005", "error", f, line, call,
394
+ f"{name} with NULL_PTR on {blk.name}, which has no permanent RAM block (NvMRamBlockDataAddress unset) and "
395
+ "no explicit synchronization; NvM rejects the request (DET parameter error)",
396
+ "SWS_NvM_00898/00899 (ReadBlock), SWS_NvM_00900/00901 (WriteBlock), SWS_NvM_00278/00280",
397
+ {"block": blk.path, "NvMRamBlockDataAddress": None, "NvMBlockUseSyncMechanism": sync})
398
+
399
+
400
+ def audit(project: Project, paths_arg: Optional[list] = None, headers_arg: Optional[list] = None) -> dict:
401
+ root = project.root
402
+ cfg = {}
403
+ try:
404
+ import json
405
+ with open(os.path.join(root, ".hydron", "autosar.json")) as fh:
406
+ raw = json.load(fh).get("audit")
407
+ cfg = raw if isinstance(raw, dict) else ({"paths": raw} if isinstance(raw, list) else {})
408
+ except (OSError, ValueError):
409
+ pass
410
+ explicit = paths_arg or cfg.get("paths")
411
+ notes: list = []
412
+ tree = paths.walk(root, paths.is_c)
413
+ notes.extend(tree.notes(root))
414
+ if explicit:
415
+ scan = paths.expand(root, explicit, paths.is_c)
416
+ for p in paths.patterns(root, explicit):
417
+ if not p.expand(paths.is_c):
418
+ notes.append(f"audit path {p.raw!r} matched no C file under {root}")
419
+ sources = [f for f in scan if f.lower().endswith(".c")]
420
+ generated = []
421
+ else: # whole tree, minus generator output (recognised by the file, not the folder)
422
+ cfiles = [f for f in tree.files if f.lower().endswith(".c")]
423
+ generated = [f for f in cfiles if is_generated(f)]
424
+ gen = set(generated)
425
+ sources = [f for f in cfiles if f not in gen]
426
+ extra = paths.expand(root, headers_arg or cfg.get("headers") or [], paths.is_c)
427
+ hdrs = sorted({f for f in tree.files + extra if f.lower().endswith(".h")})
428
+ a = Auditor(project, parse_defines(hdrs))
429
+ for f in sources:
430
+ try:
431
+ unit = strip_comments(open(f, encoding="utf-8", errors="replace").read())
432
+ except OSError:
433
+ continue
434
+ for m in CALL.finditer(unit):
435
+ ls = unit.rfind("\n", 0, m.start()) + 1
436
+ if unit[ls:m.start()].lstrip().startswith("#"):
437
+ continue # macro definition, not a call
438
+ before = unit[ls:m.start()].rstrip()
439
+ prev = re.search(r"(\w+)\s*$", before)
440
+ if prev and prev.group(1) not in ("return", "void") and not before.endswith(")"):
441
+ continue # a declaration/prototype ("Std_ReturnType Com_SendSignal(")
442
+ args, _ = split_args(unit, m.end() - 1)
443
+ if args is None:
444
+ continue
445
+ a.audit_call(f, unit.count("\n", 0, m.start()) + 1, m.group(1), args, unit)
446
+ a.findings.sort(key=lambda d: (d["file"], d["line"], d["rule"]))
447
+ summary: dict = {}
448
+ for d in a.findings:
449
+ summary[d["severity"]] = summary.get(d["severity"], 0) + 1
450
+ res = {"scanned": {"sources": [project.rel(f) for f in sources], "headers": len(hdrs), "calls": a.calls,
451
+ "generatedSkipped": len(generated)},
452
+ "summary": summary, "findings": a.findings, "unchecked": a.unchecked}
453
+ if notes:
454
+ res["notes"] = notes
455
+ if explicit and not sources:
456
+ res["error"] = (f"audit paths {list(explicit)} matched no .c file under {root}; nothing was audited. "
457
+ "Pass files, directories or globs relative to the project root, or omit paths.")
458
+ return res
459
+
460
+
461
+ def signal_owners(project: Project, paths: list) -> dict:
462
+ """AUTOSAR path of a ComSignal / ComGroupSignal -> [{ipdu, direction, pdu}] from the local ECUC."""
463
+ a = Auditor(project, {})
464
+ out: dict = {}
465
+ for p in paths:
466
+ g = project.find(p)
467
+ if not g:
468
+ out[p] = None # not in the local checkout
469
+ continue
470
+ sig = g[0]
471
+ if not (sig.def_ref or "").endswith(("/ComSignal", "/ComGroupSignal", "/ComSignalGroup")):
472
+ continue
473
+ out[p] = [{"ipdu": ip.path, "direction": (a.val(ip, "ComIPduDirection") or "").strip() or None,
474
+ "pdu": (a.val(ip, "ComPduIdRef") or "").strip() or None} for ip in a.ipdus_of(sig)]
475
+ 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
+ root = os.path.abspath(a.root)
85
+ rel = lambda f: os.path.relpath(f, root) if not os.path.relpath(f, root).startswith("..") else f # noqa: E731
86
+ try:
87
+ return diff(a.a, a.b, rel, root)
88
+ except FileNotFoundError as ex:
89
+ return {"error": str(ex)}
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_arg=a.paths or None, headers_arg=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}"}