@h2loop/autosar 0.1.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +4 -0
  2. package/agents/autosar-integrator.md +34 -2
  3. package/package.json +4 -1
  4. package/plugins/index.js +53 -23
  5. package/python/ecuc_engine/__init__.py +1 -1
  6. package/python/ecuc_engine/add_signal.py +351 -0
  7. package/python/ecuc_engine/arxml_edit.py +387 -0
  8. package/python/ecuc_engine/audit.py +372 -69
  9. package/python/ecuc_engine/cli.py +119 -10
  10. package/python/ecuc_engine/codegen/__init__.py +185 -0
  11. package/python/ecuc_engine/codegen/binding.py +210 -0
  12. package/python/ecuc_engine/codegen/bindings/sip_standin.json +111 -0
  13. package/python/ecuc_engine/codegen/cdiff.py +395 -0
  14. package/python/ecuc_engine/codegen/cwriter.py +150 -0
  15. package/python/ecuc_engine/codegen/emit.py +395 -0
  16. package/python/ecuc_engine/codegen/handles.py +89 -0
  17. package/python/ecuc_engine/codegen/mcal/tc4d.json +60 -0
  18. package/python/ecuc_engine/codegen/model.py +259 -0
  19. package/python/ecuc_engine/codegen/modules/__init__.py +7 -0
  20. package/python/ecuc_engine/codegen/modules/comstack.py +14 -0
  21. package/python/ecuc_engine/codegen/modules/dem.py +22 -0
  22. package/python/ecuc_engine/codegen/modules/mcal.py +170 -0
  23. package/python/ecuc_engine/codegen/modules/memstack.py +24 -0
  24. package/python/ecuc_engine/codegen/registry.py +133 -0
  25. package/python/ecuc_engine/codegen/schm_memmap.py +159 -0
  26. package/python/ecuc_engine/context.py +481 -0
  27. package/python/ecuc_engine/csrc.py +189 -0
  28. package/python/ecuc_engine/defs.py +30 -0
  29. package/python/ecuc_engine/diff.py +13 -5
  30. package/python/ecuc_engine/find.py +162 -0
  31. package/python/ecuc_engine/gen.py +105 -0
  32. package/python/ecuc_engine/history.py +146 -0
  33. package/python/ecuc_engine/model.py +33 -29
  34. package/python/ecuc_engine/paths.py +221 -0
  35. package/python/ecuc_engine/probe.py +229 -0
  36. package/python/ecuc_engine/rte_api.py +935 -0
  37. package/python/ecuc_engine/rules/__init__.py +5 -3
  38. package/python/ecuc_engine/rules/mem.py +84 -0
  39. package/python/ecuc_engine/rules/nvm.py +131 -0
  40. package/python/ecuc_engine/rules/pdur.py +87 -0
  41. package/python/ecuc_engine/rules/system.py +162 -0
  42. package/python/ecuc_engine/sysmodel.py +152 -0
  43. package/python/ecuc_engine/validate.py +52 -3
  44. package/python/ecuc_engine/verify_rte.py +445 -0
  45. package/python/pyproject.toml +1 -1
  46. package/skills/autosar/SKILL.md +57 -1
  47. package/skills/autosar/references/codegen.md +30 -0
  48. package/skills/autosar/references/project-layout.md +11 -2
  49. package/skills/autosar/references/runnable-code.md +50 -0
@@ -24,8 +24,23 @@ from typing import Optional
24
24
  from .model import Elem, Project
25
25
  from .values import parse_int
26
26
 
27
- C_EXT = (".c", ".h")
28
- SKIP_DIRS = {".git", "node_modules", ".hydron", "Output", "build", "Build"}
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))
29
44
 
30
45
 
31
46
  @dataclass
@@ -54,6 +69,7 @@ CALL = re.compile(r"\b(Com_(?:Send|Receive)Signal(?:Group)?|NvM_(?:ReadBlock|Wri
54
69
  r"Dem_(?:SetEventStatus|ReportErrorStatus)|PduR_\w*Transmit|Rte_(?:Write|Read)_\w+)\s*\(")
55
70
  NUM = re.compile(r"^\(?\s*(?:\(\s*\w+\s*\)\s*)?(0[xX][0-9a-fA-F]+|\d+)[uUlL]*\s*\)?$")
56
71
  SYM = re.compile(r"^(\w+?)Conf_(\w+?)_(\w+)$")
72
+ INSTANCE = re.compile(r"^(?:[A-Z]{1,3}\d{0,2}|\d{1,3}|[A-Za-z]?\d{1,3})$") # FL, RR, L, 2, 02, R1
57
73
  NULLS = {"NULL_PTR", "NULL", "0", "((void*)0)", "(void*)0"}
58
74
  TYPE_BITS = {"boolean": 8, "uint8": 8, "sint8": 8, "uint16": 16, "sint16": 16, "uint32": 32, "sint32": 32,
59
75
  "uint64": 64, "sint64": 64, "float32": 32, "float64": 64, "char": 8, "int8_t": 8, "uint8_t": 8,
@@ -119,6 +135,23 @@ def split_args(src: str, open_idx: int):
119
135
  return None, i
120
136
 
121
137
 
138
+ def iter_calls(unit: str, rx: re.Pattern = CALL):
139
+ """The audit's call scanner: (name, offset, line, args) for each call matched by ``rx`` (group 1 = callee,
140
+ match ends at '(') in comment-stripped C, skipping #define bodies and declarations/prototypes."""
141
+ for m in rx.finditer(unit):
142
+ ls = unit.rfind("\n", 0, m.start()) + 1
143
+ if unit[ls:m.start()].lstrip().startswith("#"):
144
+ continue # macro definition, not a call
145
+ before = unit[ls:m.start()].rstrip()
146
+ prev = re.search(r"(\w+)\s*$", before)
147
+ if prev and prev.group(1) not in ("return", "void", "else", "do") and not before.endswith(")"):
148
+ continue # a declaration/prototype ("Std_ReturnType Com_SendSignal(")
149
+ args, _ = split_args(unit, m.end() - 1)
150
+ if args is None:
151
+ continue
152
+ yield m.group(1), m.start(), unit.count("\n", 0, m.start()) + 1, args
153
+
154
+
122
155
  def parse_defines(files: list) -> dict:
123
156
  """NAME -> (params|None, body, file, line) for every #define in the given headers."""
124
157
  out: dict = {}
@@ -144,19 +177,6 @@ def parse_defines(files: list) -> dict:
144
177
  return out
145
178
 
146
179
 
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
180
  class Auditor:
161
181
  def __init__(self, project: Project, defines: dict):
162
182
  self.p = project
@@ -165,21 +185,32 @@ class Auditor:
165
185
  self.unchecked: list = []
166
186
  self.calls = 0
167
187
  self._handles: dict = {}
188
+ self.referenced: set = set()
189
+ self.sibling_candidates: list = []
190
+ self.rte_written: dict = {}
191
+ self.tu_defines: dict = {}
192
+ self._aliases: set = set()
193
+ self._struct_cache: dict = {}
194
+ self.header_text = ""
168
195
 
169
196
  # ---------- ECUC lookups ----------
170
197
  def module(self, name: str) -> list:
171
198
  return [m for m in self.p.modules if (m.def_ref or "").rsplit("/", 1)[-1] == name or m.name == name]
172
199
 
173
200
  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
201
+ key = (module, tuple(defnames))
202
+ cache = self.__dict__.setdefault("_cont_cache", {})
203
+ if key not in cache:
204
+ out = []
205
+ for m in self.module(module):
206
+ stack = list(m.children)
207
+ while stack:
208
+ e = stack.pop()
209
+ stack.extend(e.children)
210
+ if (e.def_ref or "").rsplit("/", 1)[-1] in defnames:
211
+ out.append(e)
212
+ cache[key] = out
213
+ return cache[key]
183
214
 
184
215
  def val(self, e: Elem, name: str) -> Optional[str]:
185
216
  for g in self.p.find(e.path):
@@ -206,27 +237,28 @@ class Auditor:
206
237
  mod, dname, short = m.groups()
207
238
  if mod != api.module:
208
239
  return None
209
- for e in self.containers(api.module, (dname,)):
210
- if e.name == short:
211
- return e
212
- return None
240
+ idx = self.__dict__.setdefault("_sym_cache", {})
241
+ if (mod, dname) not in idx:
242
+ idx[(mod, dname)] = {e.name: e for e in self.containers(api.module, (dname,))}
243
+ return idx[(mod, dname)].get(short)
213
244
 
214
245
  def symbol_of(self, e: Elem, api: Api) -> str:
215
246
  return f"{api.module}Conf_{(e.def_ref or '').rsplit('/', 1)[-1]}_{e.name}"
216
247
 
217
248
  def ipdus_of(self, sig: Elem) -> list:
218
249
  """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
250
+ if "_owners" not in self.__dict__:
251
+ own: dict = {}
252
+ for ip in self.containers("Com", ("ComIPdu",)):
253
+ for g in self.p.find(ip.path):
254
+ for pv in g.params:
255
+ if pv.is_ref and pv.name in ("ComIPduSignalRef", "ComIPduSignalGroupRef") and pv.value:
256
+ lst = own.setdefault(pv.value.strip(), [])
257
+ if ip not in lst:
258
+ lst.append(ip)
259
+ self._owners = own
260
+ key = sig.parent.path if (sig.def_ref or "").endswith("/ComGroupSignal") and sig.parent is not None else sig.path
261
+ return list(self._owners.get(key, []))
230
262
 
231
263
  # ---------- findings ----------
232
264
  def add(self, rule, sev, f, line, call, msg, cite, evidence, fix=None):
@@ -246,7 +278,7 @@ class Auditor:
246
278
  while e.startswith("(") and e.endswith(")") and split_args("x" + e, 1)[1] == len(e) + 1:
247
279
  e = e[1:-1].strip()
248
280
  if re.fullmatch(r"\w+", e):
249
- d = self.defines.get(e)
281
+ d = self.tu_defines.get(e) or self.defines.get(e)
250
282
  num = None
251
283
  if d and d[0] is None:
252
284
  mm = NUM.match(d[1])
@@ -280,10 +312,29 @@ class Auditor:
280
312
  "survives handle re-assignment", f"{cite_sym}; TPS_ECUConfiguration 3.4.1.1",
281
313
  {"path": target.path, api.handle_param: h[1]}, fix=f"replace {args[0]} with {sym}")
282
314
  elif h[0] == "sym":
315
+ m = SYM.match(h[1])
316
+ if m and m.group(1) != api.module and self.module(m.group(1)):
317
+ other = next((e for e in self.containers(m.group(1), (m.group(2),)) if e.name == m.group(3)), None)
318
+ self.add("H2L-API-009", "error", f, line, call,
319
+ f"{h[1]} is a {m.group(1)} handle ({other.path if other else m.group(2)}) but {name} takes a "
320
+ f"{api.module} {'/'.join(api.defs)} handle ({api.handle_param}); the numeric value addresses an "
321
+ f"unrelated {api.module} PDU/object", f"{api.cite}; TPS_ECUC_02108 symbolic names are per module",
322
+ {"symbol": h[1], "belongsTo": other.path if other else None, "expects": f"{api.module}Conf_{api.defs[0]}_<name>"})
323
+ return
283
324
  target = self.by_symbol(api, h[1])
325
+ if target is None and m and m.group(1) == api.module:
326
+ d = self.tu_defines.get(h[1]) or self.defines.get(h[1])
327
+ self.add("H2L-API-002", "error", f, line, call,
328
+ f"{h[1]} names no configured {m.group(2)} '{m.group(3)}' in {api.module}"
329
+ + (f"; the generated define ({self.p.rel(d[2])}:{d[3]} = {h[2]}) is stale — regenerate and fix "
330
+ "the configuration or the call" if d else ""),
331
+ f"{api.cite}; TPS_ECUC_02108 symbolic name values",
332
+ {"symbol": h[1], "define": h[2], "defineAt": f"{self.p.rel(d[2])}:{d[3]}" if d else None})
333
+ return
284
334
  if target is None and h[2] is not None:
285
335
  hits = self.by_handle(api, h[2])
286
336
  target = hits[0] if hits else None
337
+ self.check_alias(api, h[1], h[2], target)
287
338
  if target is None:
288
339
  if SYM.match(h[1]) or h[2] is not None:
289
340
  self.add("H2L-API-002", "error", f, line, call,
@@ -296,12 +347,23 @@ class Auditor:
296
347
  else:
297
348
  self.unchecked.append({"file": self.p.rel(f), "line": line, "call": call, "reason": "handle is an expression"})
298
349
  return
350
+ self.referenced.add(target.path)
351
+ if h[0] == "sym":
352
+ self.sibling_candidates.append((f, line, call, api, target, h[1], args, unit))
353
+ if api.kind == "send" and via is None and target.path in self.rte_written:
354
+ mac = self.rte_written[target.path]
355
+ self.add("H2L-API-010", "warning", f, line, call,
356
+ f"{target.name} is written directly through Com here, but it is also the RTE-mapped signal of "
357
+ f"{mac} (generated RTE): two writers for one signal, bypassing the RTE",
358
+ "SWS_Rte inter-ECU sender-receiver communication (one sender per data element); TPS_SystemTemplate "
359
+ "SenderReceiverToSignalMapping", {"signal": target.path, "rteApi": mac})
299
360
  if api.kind in ("send", "receive"):
300
361
  self.check_direction(f, line, call, api, target)
301
362
  if api.data is not None and len(args) > api.data:
302
363
  self.check_width(f, line, call, target, args[api.data], unit)
303
364
  if api.kind == "nvm" and api.data is not None and len(args) > api.data:
304
365
  self.check_nvm_null(f, line, call, name, target, args[api.data])
366
+ self.check_nvm_ram(f, line, call, name, target, args[api.data])
305
367
 
306
368
  def audit_rte(self, f, line, name, args, unit):
307
369
  d = self.defines.get(name)
@@ -332,29 +394,63 @@ class Auditor:
332
394
  f"ECUC_Com_00493 ComIPduDirection; {api.cite}",
333
395
  {"signal": sig.path, "ipdu": ip.path, "ComIPduDirection": d})
334
396
 
397
+ def struct_member(self, type_name: str, member: str, unit: str) -> Optional[tuple]:
398
+ key = (type_name, member)
399
+ if key in self._struct_cache:
400
+ return self._struct_cache[key]
401
+ res = None
402
+ for text in (unit, self.header_text):
403
+ m = re.search(rf"typedef\s+struct\s*\w*\s*\{{([^{{}}]*)\}}\s*{re.escape(type_name)}\s*;", text) or \
404
+ re.search(rf"struct\s+{re.escape(type_name)}\s*\{{([^{{}}]*)\}}", text)
405
+ if m:
406
+ mm = re.search(rf"\b(\w+)\s+{re.escape(member)}\s*(\[\s*(\w*)\s*\])?\s*;", m.group(1))
407
+ if mm:
408
+ res = (mm.group(1), mm.group(3) if mm.group(2) else None)
409
+ break
410
+ self._struct_cache[key] = res
411
+ return res
412
+
413
+ def decl(self, var: str, unit: str):
414
+ m = re.search(rf"\b(?:(?:static|volatile|const|extern)\s+)*(\w+)\s*(\*?)\s*(?:const\s+)?{re.escape(var)}"
415
+ rf"\s*(\[\s*(\w*)\s*\])?\s*[;=,)]", unit)
416
+ if not m or m.group(1) in ("return", "else", "case", "goto", "sizeof", "struct", "union", "enum"):
417
+ return None
418
+ return m.group(1), bool(m.group(2)), bool(m.group(3)), m.group(4), unit.count("\n", 0, m.start()) + 1
419
+
335
420
  def c_type(self, expr: str, unit: str):
336
421
  """Type of the object a data-pointer argument points to -> (type, array_len|None), or None if unknown."""
422
+ self.last_decl_line = None
337
423
  e = expr.strip()
338
424
  while e.startswith("(") and e.endswith(")") and split_args("x" + e, 1)[1] == len(e) + 1:
339
425
  e = e[1:-1].strip()
340
426
  lit = re.fullmatch(r"&\s*\(\s*(\w+)\s*\)\s*\{.*\}", e, re.S) # &(uint8){ x }
341
427
  if lit:
342
428
  return lit.group(1), None
429
+ mem = re.fullmatch(r"&\s*\(?\s*(\w+)\s*(\.|->)\s*(\w+)\s*\)?", e) # &var.member / &ptr->member
430
+ if mem:
431
+ d = self.decl(mem.group(1), unit)
432
+ if d is None or (mem.group(2) == "->") != d[1]:
433
+ return None
434
+ t = self.struct_member(d[0], mem.group(3), unit)
435
+ if t is None:
436
+ return None
437
+ self.last_decl_line = d[4]
438
+ return t
343
439
  ref = re.fullmatch(r"&\s*(\w+)", e) # &var
344
440
  elem = re.fullmatch(r"&\s*(\w+)\s*\[[^\]]*\]", e) # &buf[i]
345
441
  bare = re.fullmatch(r"(\w+)", e) # buf (array decays) or a pointer variable
346
442
  var = (ref or elem or bare).group(1) if (ref or elem or bare) else None
347
443
  if var is None:
348
444
  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"):
445
+ d = self.decl(var, unit)
446
+ if d is None or d[1]:
352
447
  return None
353
- is_array = bool(decl.group(2))
448
+ typ, _ptr, is_array, alen, dline = d
449
+ self.last_decl_line = dline
354
450
  if ref:
355
- return decl.group(1), (decl.group(3) if is_array else None)
451
+ return typ, (alen if is_array else None)
356
452
  if elem or (bare and is_array):
357
- return decl.group(1), None
453
+ return typ, None
358
454
  return None # bare non-array variable: a pointer whose pointee is not visible here
359
455
 
360
456
  def check_width(self, f, line, call, sig, arg, unit):
@@ -377,9 +473,136 @@ class Auditor:
377
473
  self.add("H2L-API-004", "error", f, line, call,
378
474
  f"{arg} is a {ct[0]}{'[' + ct[1] + ']' if ct[1] else ''} ({cbits} bits) but {sig.name} is "
379
475
  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",
476
+ f"byte(s) at that address — " + ("Com writes past the object (overflow)" if "Receive" in call else
477
+ "wrong signal or wrong variable") + (f"; object declared at line {self.last_decl_line}" if self.last_decl_line else ""),
381
478
  "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]})
479
+ {"signal": sig.path, "ComSignalType": typ, "ComBitSize": bs, "cType": ct[0],
480
+ "declLine": self.last_decl_line})
481
+
482
+ def check_alias(self, api, sym, value, target):
483
+ """A hand-written #define standing in for a generated handle (H2L-API-007), reported at the #define."""
484
+ d = self.tu_defines.get(sym) or self.defines.get(sym)
485
+ if d is None or sym in self._aliases:
486
+ return
487
+ self._aliases.add(sym)
488
+ dfile, dline = d[2], d[3]
489
+ try:
490
+ raw = open(dfile, encoding="utf-8", errors="replace").read().split("\n")[dline - 1]
491
+ except (OSError, IndexError):
492
+ raw = ""
493
+ named = re.search(r"\b(\w+Conf_\w+?_\w+)\b", raw.split("define", 1)[-1].split(sym, 1)[-1])
494
+ call = f"#define {sym} {d[1]}"
495
+ if named:
496
+ meant = self.by_symbol(api, named.group(1))
497
+ mv = parse_int(self.val(meant, api.handle_param) or "") if meant is not None else None
498
+ if meant is not None and mv != value:
499
+ self.add("H2L-API-007", "error", dfile, dline, call,
500
+ f"stale handle alias: {sym} = {value} is commented as {named.group(1)}, whose {api.handle_param} is "
501
+ f"{mv}; {value} addresses {target.path if target else 'no configured element'}",
502
+ "TPS_ECUC_02108 symbolic name values; TPS_ECUConfiguration 3.4.1.1",
503
+ {"alias": sym, "value": value, "meant": meant.path, api.handle_param: mv,
504
+ "actual": target.path if target else None}, fix=f"use {named.group(1)} directly")
505
+ return
506
+ self.add("H2L-API-007", "warning", dfile, dline, call,
507
+ f"hand-coded handle macro {sym} = {value} ({target.path if target else 'matches nothing'}); it breaks "
508
+ "silently when the generator re-assigns handles",
509
+ "TPS_ECUC_02108 symbolic name values", {"alias": sym, "value": value, "path": target.path if target else None},
510
+ fix=f"use {self.symbol_of(target, api)}" if target else None)
511
+
512
+ def check_nvm_ram(self, f, line, call, name, blk, arg):
513
+ """Data pointer is the permanent RAM block of a *different* NvM block (H2L-API-008)."""
514
+ m = re.fullmatch(r"\(?\s*&?\s*(\w+)\s*\)?", arg.strip())
515
+ if not m or arg.replace(" ", "") in NULLS:
516
+ return
517
+ sym = m.group(1)
518
+ own = (self.val(blk, "NvMRamBlockDataAddress") or "").strip()
519
+ if re.search(rf"\b{re.escape(sym)}\b", own):
520
+ return
521
+ for other in self.containers("NvM", ("NvMBlockDescriptor",)):
522
+ if other.path == blk.path:
523
+ continue
524
+ addr = (self.val(other, "NvMRamBlockDataAddress") or "").strip()
525
+ if addr and re.search(rf"(?:^|[^\w]){re.escape(sym)}\b", addr):
526
+ self.add("H2L-API-008", "error", f, line, call,
527
+ f"{name} on {blk.name} is given {arg}, the permanent RAM block of {other.name} "
528
+ f"(NvMRamBlockDataAddress={addr}); {blk.name}'s own RAM block is {own or '(none)'} — wrong data is "
529
+ f"stored/overwritten", "SWS_NvM_00454/00455 NvM_DstPtr/NvM_SrcPtr; ECUC_NvM_00482 NvMRamBlockDataAddress",
530
+ {"block": blk.path, "ownRam": own or None, "givenRamOf": other.path,
531
+ "NvMNvBlockLength": self.val(blk, "NvMNvBlockLength"),
532
+ "otherLength": self.val(other, "NvMNvBlockLength")},
533
+ fix=f"pass {own}" if own else None)
534
+ return
535
+
536
+ def check_siblings(self):
537
+ """Handle whose instance token (FL/FR/2/...) disagrees with the call's context, while the sibling with the
538
+ context's token is configured and never referenced (H2L-API-006)."""
539
+ uses_by: dict = {}
540
+ for c in self.sibling_candidates:
541
+ uses_by[(c[0], c[4].path)] = uses_by.get((c[0], c[4].path), 0) + 1
542
+ sib_index: dict = {}
543
+ for f, line, call, api, target, sym, args, unit in self.sibling_candidates:
544
+ if uses_by[(f, target.path)] < 2:
545
+ continue
546
+ dname = (target.def_ref or "").rsplit("/", 1)[-1]
547
+ mine = target.name.split("_")
548
+ key = (api.module, dname)
549
+ if key not in sib_index: # pattern with one part blanked -> containers
550
+ idx: dict = {}
551
+ for e in self.containers(api.module, (dname,)):
552
+ parts = e.name.split("_")
553
+ for i in range(len(parts)):
554
+ idx.setdefault((len(parts), i, tuple(parts[:i] + parts[i + 1:])), []).append((e, parts[i]))
555
+ sib_index[key] = idx
556
+ sibs = []
557
+ for i in range(len(mine)):
558
+ for e, tok in sib_index[key].get((len(mine), i, tuple(mine[:i] + mine[i + 1:])), []):
559
+ if e.path != target.path:
560
+ sibs.append((e, mine[i], tok))
561
+ if not sibs:
562
+ continue
563
+ ctx_text = " ".join(args[1:])
564
+ fn = self._function_at(unit, line)
565
+ tokens = set(re.split(r"[^A-Za-z0-9]+", ctx_text + " " + fn)) | \
566
+ {t for ident in re.findall(r"\w+", ctx_text + " " + fn) for t in ident.split("_")}
567
+ uses = uses_by[(f, target.path)] # copy-paste signature: the same handle is used again in this file
568
+ for e, my_tok, sib_tok in sibs:
569
+ if not (INSTANCE.match(my_tok) and INSTANCE.match(sib_tok)):
570
+ continue
571
+ if sib_tok in tokens and my_tok not in tokens and e.path not in self.referenced:
572
+ self.add("H2L-API-006", "warning", f, line, call,
573
+ f"{sym} uses instance '{my_tok}' (and is used {uses}x in this file) but this call's context "
574
+ f"refers to '{sib_tok}' "
575
+ f"({ctx_text[:80].strip() or fn}); {e.name} (the '{sib_tok}' sibling) is configured but never "
576
+ "referenced in the code — likely copy-paste of the wrong handle",
577
+ "heuristic: sibling handle mismatch (instance token)",
578
+ {"handle": target.path, "sibling": e.path, "contextToken": sib_tok, "siblingReferenced": False},
579
+ fix=f"use {self.symbol_of(e, api)}")
580
+ break
581
+
582
+ @staticmethod
583
+ def _function_at(unit: str, line: int) -> str:
584
+ head = "\n".join(unit.split("\n")[:line])
585
+ m = list(re.finditer(r"^\s*(?:static\s+)?[\w\s\*]+?\b(\w+)\s*\([^;{]*\)\s*\{", head, re.M))
586
+ return m[-1].group(1) if m else ""
587
+
588
+ def unreferenced(self) -> dict:
589
+ out = {}
590
+ dem = [e for e in self.containers("Dem", ("DemEventParameter",)) if e.path not in self.referenced
591
+ and any(pv.is_ref and pv.name == "DemDTCRef" for g in self.p.find(e.path) for pv in g.params)]
592
+ tx = []
593
+ for ip in self.containers("Com", ("ComIPdu",)):
594
+ if (self.val(ip, "ComIPduDirection") or "").strip() != "SEND":
595
+ continue
596
+ for g in self.p.find(ip.path):
597
+ for pv in g.params:
598
+ if pv.name == "ComIPduSignalRef" and pv.value and pv.value.strip() not in self.referenced:
599
+ tx.append(pv.value.strip())
600
+ nvm = [e for e in self.containers("NvM", ("NvMBlockDescriptor",)) if e.path not in self.referenced]
601
+ for k, items in (("demEventsWithDtc", [e.path for e in dem]), ("txSignals", sorted(set(tx))),
602
+ ("nvmBlocks", [e.path for e in nvm])):
603
+ if items:
604
+ out[k] = {"count": len(items), "sample": items[:10]}
605
+ return out
383
606
 
384
607
  def check_nvm_null(self, f, line, call, name, blk, arg):
385
608
  if arg.replace(" ", "") not in NULLS:
@@ -395,7 +618,7 @@ class Auditor:
395
618
  {"block": blk.path, "NvMRamBlockDataAddress": None, "NvMBlockUseSyncMechanism": sync})
396
619
 
397
620
 
398
- def audit(project: Project, paths: Optional[list] = None, headers: Optional[list] = None) -> dict:
621
+ def audit(project: Project, paths_arg: Optional[list] = None, headers_arg: Optional[list] = None) -> dict:
399
622
  root = project.root
400
623
  cfg = {}
401
624
  try:
@@ -405,34 +628,109 @@ def audit(project: Project, paths: Optional[list] = None, headers: Optional[list
405
628
  cfg = raw if isinstance(raw, dict) else ({"paths": raw} if isinstance(raw, list) else {})
406
629
  except (OSError, ValueError):
407
630
  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")]
631
+ explicit = paths_arg or cfg.get("paths")
632
+ notes: list = []
633
+ tree = paths.walk(root, paths.is_c)
634
+ notes.extend(tree.notes(root))
635
+ if explicit:
636
+ scan = paths.expand(root, explicit, paths.is_c)
637
+ for p in paths.patterns(root, explicit):
638
+ if not p.expand(paths.is_c):
639
+ notes.append(f"audit path {p.raw!r} matched no C file under {root}")
640
+ sources = [f for f in scan if f.lower().endswith(".c")]
641
+ generated = []
642
+ else: # whole tree, minus generator output (recognised by the file, not the folder)
643
+ cfiles = [f for f in tree.files if f.lower().endswith(".c")]
644
+ generated = [f for f in cfiles if is_generated(f)]
645
+ gen = set(generated)
646
+ sources = [f for f in cfiles if f not in gen]
647
+ extra = paths.expand(root, headers_arg or cfg.get("headers") or [], paths.is_c)
648
+ hdrs = sorted({f for f in tree.files + extra if f.lower().endswith(".h")})
412
649
  a = Auditor(project, parse_defines(hdrs))
650
+ try:
651
+ a.header_text = strip_comments("\n".join(open(h, encoding="utf-8", errors="replace").read() for h in hdrs[:2000]))
652
+ except OSError:
653
+ a.header_text = ""
654
+ for mac, (params, body, _f, _l) in a.defines.items():
655
+ if mac.startswith("Rte_Write_") and params is not None:
656
+ mm = re.search(r"Com_SendSignal\s*\(\s*(\w+)", body)
657
+ if mm:
658
+ t = a.by_symbol(APIS["Com_SendSignal"], mm.group(1))
659
+ if t is not None:
660
+ a.rte_written.setdefault(t.path, mac)
413
661
  for f in sources:
414
662
  try:
415
663
  unit = strip_comments(open(f, encoding="utf-8", errors="replace").read())
416
664
  except OSError:
417
665
  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)
666
+ a.tu_defines = parse_defines([f])
667
+ for name, start, line, args in iter_calls(unit, CALL):
668
+ a.audit_call(f, line, name, args, unit)
669
+ a.check_siblings()
430
670
  a.findings.sort(key=lambda d: (d["file"], d["line"], d["rule"]))
431
671
  summary: dict = {}
432
672
  for d in a.findings:
433
673
  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}
674
+ res = {"scanned": {"sources": [project.rel(f) for f in sources], "headers": len(hdrs), "calls": a.calls,
675
+ "generatedSkipped": len(generated)},
676
+ "summary": summary, "findings": a.findings, "unchecked": a.unchecked,
677
+ "unreferenced": a.unreferenced()}
678
+ if notes:
679
+ res["notes"] = notes
680
+ if explicit and not sources:
681
+ res["error"] = (f"audit paths {list(explicit)} matched no .c file under {root}; nothing was audited. "
682
+ "Pass files, directories or globs relative to the project root, or omit paths.")
683
+ return res
684
+
685
+
686
+ def can_info(project: Project, pdu_path: Optional[str]) -> list:
687
+ """EcuC Pdu of a Com I-PDU -> CanIf Tx/Rx PDU(s) with CAN ID, controller and network.
688
+
689
+ Linked generically: the CanIf PDU's EcuC Pdu is the same Pdu, or the other end of a PduR routing path,
690
+ or stands for the same system I-PDU in the extract.
691
+ """
692
+ if not pdu_path:
693
+ return []
694
+ from .rules import RuleContext
695
+ from .rules.system import channels, system_ipdus
696
+ ctx = RuleContext(project)
697
+ pdu = project.find(pdu_path)
698
+ if not pdu:
699
+ return []
700
+ pdu = pdu[0]
701
+ linked = {pdu.path}
702
+ for rp in ctx.containers("PduR/PduRRoutingPaths/PduRRoutingPath"):
703
+ ends = [ctx.target(ctx.value(rp, "PduRSrcPduRRef"))] + [ctx.target(pv) for pv in ctx.values(rp, "PduRDestPduRRef")]
704
+ pdus = {ctx.target(ctx.value(e, n)).path for e in ends if e is not None
705
+ for n in ("PduRSrcPduRef", "PduRDestPduRef") if ctx.target(ctx.value(e, n)) is not None}
706
+ if pdu.path in pdus:
707
+ linked |= pdus
708
+ sys_ip = set(system_ipdus(ctx, pdu))
709
+ out = []
710
+ for kind, ref, cid, typ in (("Tx", "CanIfTxPduRef", "CanIfTxPduCanId", "CanIfTxPduCanIdType"),
711
+ ("Rx", "CanIfRxPduRef", "CanIfRxPduCanId", "CanIfRxPduCanIdType")):
712
+ for cp in ctx.containers(f"CanIf/CanIfInitCfg/CanIf{kind}PduCfg"):
713
+ tgt = ctx.target(ctx.value(cp, ref))
714
+ if tgt is None:
715
+ continue
716
+ if tgt.path not in linked and not (sys_ip and sys_ip & set(system_ipdus(ctx, tgt))):
717
+ continue
718
+ can_id = parse_int(ctx.text(cp, cid) or "")
719
+ hoh = None
720
+ if kind == "Tx":
721
+ buf = ctx.target(ctx.value(cp, "CanIfTxPduBufferRef"))
722
+ hoh = ctx.target(ctx.value(buf, "CanIfBufferHthRef")) if buf is not None else None
723
+ ctrl = ctx.target(ctx.value(hoh, "CanIfHthCanCtrlIdRef")) if hoh is not None else None
724
+ else:
725
+ hoh = ctx.target(ctx.value(cp, "CanIfRxPduHrhIdRef"))
726
+ ctrl = ctx.target(ctx.value(hoh, "CanIfHrhCanCtrlIdRef")) if hoh is not None else None
727
+ out.append({"canIfPdu": cp.path, "direction": kind, "canId": can_id,
728
+ "canIdHex": hex(can_id) if can_id is not None else None,
729
+ "canIdType": ctx.text(cp, typ), "at": f"{project.rel(cp.file)}:{cp.line}",
730
+ "controller": ctrl.path if ctrl is not None else None,
731
+ "canController": (ctx.text(ctrl, "CanIfCtrlCanCtrlRef") or None) if ctrl is not None else None,
732
+ "network": channels(ctx, tgt) or channels(ctx, pdu) or None})
733
+ return out
436
734
 
437
735
 
438
736
  def signal_owners(project: Project, paths: list) -> dict:
@@ -445,8 +743,13 @@ def signal_owners(project: Project, paths: list) -> dict:
445
743
  out[p] = None # not in the local checkout
446
744
  continue
447
745
  sig = g[0]
448
- if not (sig.def_ref or "").endswith(("/ComSignal", "/ComGroupSignal", "/ComSignalGroup")):
746
+ if (sig.def_ref or "").endswith("/ComIPdu"):
747
+ ipdus = [sig]
748
+ elif (sig.def_ref or "").endswith(("/ComSignal", "/ComGroupSignal", "/ComSignalGroup")):
749
+ ipdus = a.ipdus_of(sig)
750
+ else:
449
751
  continue
450
752
  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)]
753
+ "pdu": (a.val(ip, "ComPduIdRef") or "").strip() or None,
754
+ "can": can_info(project, (a.val(ip, "ComPduIdRef") or "").strip() or None)} for ip in ipdus]
452
755
  return out