@h2loop/autosar 1.5.3 → 1.6.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.
@@ -0,0 +1,471 @@
1
+ """autosar_add_like: add a new X like an existing Y, end to end, across all ARXML (ECUC + System/SWC), or refuse.
2
+
3
+ Projects name the elements of one thing after it: a message BCM_DoorStatus is the frame BCM_DoorStatus_CanFrame,
4
+ the I-PDU, the PDU/frame triggerings, the EcuC Pdu, the CanIf TX PDU, the PduR routing path and the Com I-PDU, all
5
+ linked by references. Cloning that chain, renamed, gives a new message that is consistent with the project's own
6
+ conventions, in every file at once. The same works for a port, a runnable with its event, a Dem event, an NvM
7
+ block, a calibration parameter: whatever the template's chain is.
8
+
9
+ chain elements whose SHORT-NAME contains `like` and that are linked to each other by references
10
+ (`seed` picks the chain when several unconnected ones carry the stem)
11
+ members inside a cloned element, sub-elements whose name lacks the stem and that refer to elements outside the
12
+ chain (a message's signal mappings, an I-PDU's ComIPduSignalRefs) are the template's own content: they
13
+ are not copied (`keep_members=true` copies them)
14
+ links a list outside the chain that refers to a chain element (ECU-extract FIBEX elements, ...) gets an entry
15
+ for the clone; a single-valued slot is reported, not changed
16
+ ids ECUC *HandleId / *PduId values get the lowest unused value of that parameter
17
+ set {"ParamDefName" | "XML-TAG" | "Owner:key": value} applied to the clones (Owner = part of a clone's
18
+ SHORT-NAME or its tag); every key must match at least one value
19
+ Everything is checked (schema, every reference + DEST) and validated before/after; any new finding rolls back.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import collections
24
+ import os
25
+ import re
26
+ import uuid
27
+ from typing import Optional
28
+
29
+ from .arxml_edit import ArxmlEditor, FileTree, _ad
30
+ from .model import Project
31
+ from .xmltree import escape, line_indent
32
+
33
+ REF_TAG = re.compile(r"(?:-REF|-TREF|-IREF|^VALUE-REF)$")
34
+ REGISTRY_MIN = 12 # a holder with this many outgoing references is a list of everything, not an owner
35
+ OWNER_DEST = re.compile(r"(?:PORT-PROTOTYPE|RUNNABLE-ENTITY|COMPONENT-PROTOTYPE|SW-COMPONENT-TYPE|INTERNAL-BEHAVIOR)$")
36
+ # Handle IDs exactly as validation sees them (rules/handles.py): with param defs, an integer with SYMBOLIC-NAME-VALUE;
37
+ # without defs, by name.
38
+ from .rules.handles import NAME as HANDLE_NAME # noqa: E402
39
+
40
+
41
+ class AddLikeError(Exception):
42
+ pass
43
+
44
+
45
+ def _is_ref(node) -> bool:
46
+ """A reference to another configured/modelled element (not DEFINITION-REF, which points at the param def)."""
47
+ return bool(REF_TAG.search(node.tag)) and not node.children and node.tag != "DEFINITION-REF"
48
+
49
+
50
+ def _holder(node):
51
+ """Nearest ancestor (or self) that has a SHORT-NAME: the identifiable that owns a reference."""
52
+ n = node.parent
53
+ while n is not None and n.short_name is None:
54
+ n = n.parent
55
+ return n
56
+
57
+
58
+ class Graph:
59
+ def __init__(self, ed: ArxmlEditor, files: list):
60
+ self.ed = ed
61
+ self.trees = {}
62
+ self.where = {} # path -> (file, node)
63
+ self.path_of = {} # id(node) -> path
64
+ self.refs = [] # (file, ref node, holder path, target)
65
+ for f in files:
66
+ try:
67
+ ft = ed.load(f)
68
+ except Exception: # noqa: BLE001 -- unparseable files are left alone
69
+ continue
70
+ self.trees[f] = ft
71
+ for p, n in ft.by_path.items():
72
+ self.where.setdefault(p, (f, n))
73
+ self.path_of[id(n)] = p
74
+ for f, ft in self.trees.items():
75
+ for n in ft.root.iter():
76
+ if _is_ref(n):
77
+ t = n.text.strip()
78
+ h = _holder(n)
79
+ hp = self.path_of.get(id(h)) if h is not None else None
80
+ if t.startswith("/") and hp:
81
+ self.refs.append((f, n, hp, t))
82
+ self.referrers = collections.defaultdict(list)
83
+ self.out = collections.defaultdict(list)
84
+ for f, n, hp, t in self.refs:
85
+ self.referrers[t].append((f, n, hp))
86
+ self.out[hp].append((t, n.attrs.get("DEST", "")))
87
+
88
+ @staticmethod
89
+ def name(path: str) -> str:
90
+ return path.rsplit("/", 1)[-1]
91
+
92
+ def under(self, path: str, roots) -> bool:
93
+ return any(path == r or path.startswith(r + "/") for r in roots)
94
+
95
+
96
+ def _chain(g: Graph, pairs: list, seed: Optional[str]) -> list:
97
+ stems = [a for a, _ in pairs]
98
+ stem = pairs[-1][0] if len(pairs) == 1 else " / ".join(stems)
99
+ cands = {p for p in g.where if any(a in Graph.name(p) for a in stems)}
100
+ if not cands:
101
+ raise AddLikeError(f"no element's SHORT-NAME contains {stem!r}")
102
+ for a in stems:
103
+ if not any(a in Graph.name(p) for p in cands):
104
+ raise AddLikeError(f"no element's SHORT-NAME contains {a!r}")
105
+ adj = collections.defaultdict(set)
106
+ for f, n, hp, t in g.refs:
107
+ a = next((x for x in _ancestors(hp) if x in cands), None)
108
+ b = next((x for x in _ancestors(t) if x in cands), None)
109
+ if a and b and a != b:
110
+ adj[a].add(b)
111
+ adj[b].add(a)
112
+ for p in cands: # containment links a named child to a named parent
113
+ par = next((x for x in _ancestors(p)[1:] if x in cands), None)
114
+ if par:
115
+ adj[p].add(par)
116
+ adj[par].add(p)
117
+ comps, seen = [], set()
118
+ for p in sorted(cands):
119
+ if p in seen:
120
+ continue
121
+ comp, todo = set(), [p]
122
+ while todo:
123
+ x = todo.pop()
124
+ if x in comp:
125
+ continue
126
+ comp.add(x)
127
+ todo.extend(adj[x] - comp)
128
+ seen |= comp
129
+ comps.append(comp)
130
+ if seed:
131
+ comps = [c for c in comps if any(seed == x or x.endswith("/" + seed.strip("/")) or seed.strip("/") == Graph.name(x)
132
+ for x in c)]
133
+ if not comps:
134
+ raise AddLikeError(f"seed {seed!r} is not an element whose SHORT-NAME contains {stem!r}")
135
+ if len(stems) > 1: # the template and its named members must form ONE chain
136
+ comps = [c for c in comps if all(any(a in Graph.name(x) for x in c) for a in stems)] or comps
137
+ linked = [c for c in comps if len(c) > 1] or comps
138
+ if len(linked) > 1:
139
+ heads = ["; ".join(sorted(c)[:3]) for c in sorted(linked, key=len, reverse=True)[:6]]
140
+ raise AddLikeError(f"{len(linked)} separate chains carry {stem!r}; pass seed = one element of the one you mean: "
141
+ + " | ".join(heads))
142
+ comp = set(linked[0])
143
+ # A member of the template (named after an `also` stem, not the main one) that points at an outside element
144
+ # OWNING something (a port, runnable, component, behavior) belongs to that owner, not to the template: e.g. the
145
+ # write access of the OLD port to a data element of the cloned interface. Leave it out.
146
+ main = pairs[0][0] if len(pairs) == 1 else next(a for a, _ in pairs if a == g.main)
147
+ named = lambda x: any(a in Graph.name(x) for a in stems) # noqa: E731
148
+ mains = {p for p in comp if main in Graph.name(p)}
149
+ for p in sorted(comp):
150
+ if p in mains or any(a in mains for a in _ancestors(p)[1:]):
151
+ continue
152
+ outs = [(t, d) for hp, lst in g.out.items() if hp == p or hp.startswith(p + "/") for t, d in lst]
153
+ if any(OWNER_DEST.search(d or "") and not named(t) and not any(t == c or t.startswith(c + "/") for c in comp)
154
+ for t, d in outs):
155
+ comp.discard(p)
156
+ return sorted(p for p in comp if not any(a in comp for a in _ancestors(p)[1:])) # top-most only
157
+
158
+
159
+ def _ancestors(path: str) -> list:
160
+ segs = path.strip("/").split("/")
161
+ return ["/" + "/".join(segs[:i]) for i in range(len(segs), 0, -1)]
162
+
163
+
164
+ def _multi(project: Project, ref, wrapper) -> bool:
165
+ """Whether a reference sits in a multi-valued slot (a list the clone can leave out of)."""
166
+ if wrapper.tag.endswith("REFERENCE-VALUE"):
167
+ dr = wrapper.child("DEFINITION-REF")
168
+ d = project.defs.get(dr.text.strip()) if (dr is not None and project.defs) else None
169
+ if d is not None:
170
+ return d.upper is None or d.upper > 1
171
+ return False # without the def, assume single and keep it
172
+ return wrapper.parent is not None and wrapper.parent.tag.endswith("S")
173
+
174
+
175
+ def _wrapper(node, holder):
176
+ """The smallest element around a reference that can be repeated on its own (ECUC-REFERENCE-VALUE, *-CONDITIONAL)."""
177
+ if node.parent is not None and node.parent.tag.endswith("REFERENCE-VALUE") and \
178
+ all(c is node or c.tag == "DEFINITION-REF" for c in node.parent.children):
179
+ return node.parent # ECUC: the whole ECUC-(INSTANCE-)REFERENCE-VALUE entry
180
+ w = node
181
+ while w.parent is not None and w.parent is not holder and len(w.parent.children) == 1 and not w.parent.tag.endswith("S"):
182
+ w = w.parent
183
+ return w
184
+
185
+
186
+ def add_like(project: Project, like: str, name: str, seed: Optional[str] = None, set_values: Optional[dict] = None,
187
+ keep_members: bool = False, dry_run: bool = False, also: Optional[dict] = None) -> dict:
188
+ """also = {template member stem: new stem}, e.g. {"DrvDoorOpenSts": "SettingsAckCounter"}: those members of the
189
+ template (a signal of the message, a data element of the interface, ...) are cloned and renamed too."""
190
+ from .validate import validate
191
+ stem, new = (like or "").strip(), (name or "").strip()
192
+ if not stem or not new:
193
+ raise AddLikeError("like (the name stem of an existing element, e.g. BCM_DoorStatus) and name (the new stem) "
194
+ "are both required")
195
+ pairs = [(stem, new)] + [(str(a).strip(), str(b).strip()) for a, b in (also or {}).items()]
196
+ for a, b in pairs:
197
+ if not a or not re.fullmatch(r"[A-Za-z_]\w*", b):
198
+ raise AddLikeError(f"{a!r} -> {b!r}: the new name must be a valid SHORT-NAME")
199
+ pairs.sort(key=lambda x: len(x[0]), reverse=True) # longer stems first, so one does not eat another
200
+ ed = ArxmlEditor(project)
201
+ files = list(project.sources.config) + list(project.sources.input)
202
+ g = Graph(ed, files)
203
+ g.main = stem
204
+ chain = _chain(g, pairs, seed)
205
+ newpaths = {p: _rn(p, pairs) for p in chain}
206
+ clash = [q for q in newpaths.values() if q in g.where]
207
+ if clash:
208
+ raise AddLikeError(f"already exists: {', '.join(clash[:5])}")
209
+ chain_roots = set(chain)
210
+ in_chain = lambda t: g.under(t, chain_roots) # noqa: E731
211
+ # The template's OWN content: targets referenced only from the chain, or only from other such targets
212
+ # (a message's I-SIGNAL is referenced by its mapping and by its I-SIGNAL-TRIGGERING, which only the message's
213
+ # PDU triggering references). Shared things (a debounce class used by every Dem event) are never "own".
214
+ # Registries (an ECU extract's FIBEX-ELEMENTS, an ECUC value collection: one holder listing many elements) say
215
+ # nothing about ownership and are ignored here; they are still linked for the clones below.
216
+ registry = {hp for hp, lst in g.out.items() if len(lst) >= REGISTRY_MIN}
217
+ targets = {t for _, _, _, t in g.refs if not in_chain(t)}
218
+ exclusive: set = set()
219
+ grew = True
220
+ while grew:
221
+ grew = False
222
+ for t in targets - exclusive:
223
+ owners = [hp for _, _, hp in g.referrers[t] if hp not in registry]
224
+ if owners and all(g.under(hp, chain_roots) or g.under(hp, exclusive) for hp in owners):
225
+ exclusive.add(t)
226
+ grew = True
227
+ named = lambda p: any(a in Graph.name(p) for a, _ in pairs) # noqa: E731
228
+ notes, dropped, linked, single = [], [], [], []
229
+ before = {_dkey(d) for d in validate(project, check_generated=False)["diagnostics"]
230
+ if d["severity"] in ("error", "warning")}
231
+
232
+ # ---------- clone every chain element next to its template ----------
233
+ inserts = collections.defaultdict(list) # file -> [(end offset, bytes)]
234
+ for p in chain:
235
+ f, node = g.where[p]
236
+ ft = g.trees[f]
237
+ data = ft.data
238
+ cuts = [] # (start, end) byte spans inside the element to leave out
239
+ if not keep_members:
240
+ for d in node.iter():
241
+ if d is node:
242
+ continue
243
+ dp = g.path_of.get(id(d))
244
+ if dp and not named(dp):
245
+ outside = [t for t in _refs_below(d) if t in exclusive and not named(t)]
246
+ if outside and not any(c[0] <= d.start < c[1] for c in cuts):
247
+ cuts.append((_line_start(data, d.start), d.end))
248
+ dropped.append(f"{_rn(dp, pairs)} (the template's {d.tag} {Graph.name(dp)})")
249
+ elif _is_ref(d) and not any(c[0] <= d.start < c[1] for c in cuts):
250
+ t = d.text.strip()
251
+ if t in exclusive and not named(t):
252
+ w = _wrapper(d, _holder(d))
253
+ if not _multi(project, d, w): # a single slot (CanIfTxPduBufferRef): keep, it is shared now
254
+ notes.append(f"{_rn(g.path_of.get(id(_holder(d)), ''), pairs)}: {d.tag} still -> {t} "
255
+ "(the template's; single-valued, so the clone shares it; change it if it must not)")
256
+ elif w is not d or d.parent.tag.endswith("S"):
257
+ if not any(c[0] <= w.start < c[1] for c in cuts):
258
+ cuts.append((_line_start(data, w.start), w.end))
259
+ dropped.append(f"{d.tag} -> {t} (the template's own member)")
260
+ text = _excise(data, node.start, node.end, cuts).decode("utf-8")
261
+ text = _retext(text, pairs, in_chain)
262
+ ind = line_indent(data, node.start) or b""
263
+ inserts[f].append((node.end, ft.nl + ind + text.encode("utf-8")))
264
+
265
+ # ---------- link the clones where the template is listed ----------
266
+ for p in chain:
267
+ for f, n, hp in g.referrers.get(p, []):
268
+ if g.under(hp, chain_roots):
269
+ continue
270
+ ft = g.trees[f]
271
+ holder = _holder(n)
272
+ w = _wrapper(n, holder)
273
+ repeatable = w.parent is not None and (w.parent.tag.endswith("S") or w is not n)
274
+ if not repeatable:
275
+ single.append(f"{hp}: {n.tag} -> {p} (single-valued; not changed)")
276
+ continue
277
+ t = ft.data[w.start:w.end].decode("utf-8").replace(f">{p}<", f">{newpaths[p]}<")
278
+ t = re.sub(r'\sUUID="[^"]*"', lambda _m: f' UUID="{uuid.uuid4()}"', t)
279
+ ind = line_indent(ft.data, w.start) or b""
280
+ inserts[f].append((w.end, ft.nl + ind + t.encode("utf-8")))
281
+ linked.append(f"{hp}: {n.tag} -> {newpaths[p]}")
282
+
283
+ for f, ins in inserts.items():
284
+ data = g.trees[f].data
285
+ for off, b in sorted(ins, key=lambda x: x[0], reverse=True):
286
+ data = data[:off] + b + data[off:]
287
+ ed.files[f] = FileTree(f, data)
288
+
289
+ # ---------- handle ids + requested values ----------
290
+ created = sorted(newpaths.values())
291
+ ids = _assign_ids(ed, created)
292
+ applied = _apply_set(ed, created, set_values or {})
293
+
294
+ probs = ed.check()
295
+ changes = {f: ft.data for f, ft in ed.files.items() if ft.data != ed.original.get(f)}
296
+ res = {"like": stem, "name": new, "created": created, "linked": linked, "notCopied": dropped,
297
+ "singleValuedNotLinked": single, "ids": ids, "set": applied, "files": sorted(project.rel(f) for f in changes),
298
+ "notes": notes}
299
+ if probs:
300
+ return {**res, "ok": False, "written": False, "error": "the clone would leave broken ARXML; nothing written",
301
+ "problems": probs[:20]}
302
+ if dry_run:
303
+ return {**res, "ok": True, "written": False}
304
+ originals = {f: ed.original[f] for f in changes}
305
+ for f, b in changes.items():
306
+ with open(f, "wb") as fh:
307
+ fh.write(b)
308
+ after = validate(Project(project.root, project.sources.config, project.sources.defs), check_generated=False)
309
+ fresh = [d for d in after["diagnostics"] if d["severity"] in ("error", "warning")
310
+ and _dkey(d) not in before]
311
+ if fresh:
312
+ for f, b in originals.items():
313
+ with open(f, "wb") as fh:
314
+ fh.write(b)
315
+ return {**res, "ok": False, "written": False, "error": "validation found new problems; all changes rolled back",
316
+ "diagnostics": fresh[:20]}
317
+ return {**res, "ok": True, "written": True, "validation": after["summary"],
318
+ "next": "check each created element against the request (ecuc_read / the diff), set what is still the "
319
+ "template's (ecuc_edit / arxml_edit), add the new signals (autosar_add_signal), then regenerate "
320
+ "and build"}
321
+
322
+
323
+ def _rn(path: str, pairs, new: Optional[str] = None) -> str:
324
+ if isinstance(pairs, str):
325
+ pairs = [(pairs, new)]
326
+ out = []
327
+ for seg in path.split("/"):
328
+ for a, b in pairs:
329
+ if a in seg:
330
+ seg = seg.replace(a, b)
331
+ break
332
+ out.append(seg)
333
+ return "/".join(out)
334
+
335
+
336
+ def _refs_below(node) -> list:
337
+ return [d.text.strip() for d in node.iter() if _is_ref(d) and d.text.strip().startswith("/")]
338
+
339
+
340
+ def _line_start(data: bytes, pos: int) -> int:
341
+ if line_indent(data, pos) is not None:
342
+ nl = data.rfind(b"\n", 0, pos)
343
+ return nl if nl >= 0 else pos
344
+ return pos
345
+
346
+
347
+ def _excise(data: bytes, start: int, end: int, cuts: list) -> bytes:
348
+ out, at = [], start
349
+ for a, b in sorted(c for c in cuts if start <= c[0] and c[1] <= end):
350
+ if a < at:
351
+ continue
352
+ out.append(data[at:a])
353
+ at = b
354
+ out.append(data[at:end])
355
+ return b"".join(out)
356
+
357
+
358
+ def _retext(text: str, pairs: list, in_chain) -> str:
359
+ """Rename the stems in SHORT-NAMEs and in references into the chain; fresh UUIDs."""
360
+ text = re.sub(r"(<SHORT-NAME>)([^<]*)(</SHORT-NAME>)", lambda m: m.group(1) + _rn(m.group(2), pairs) + m.group(3), text)
361
+
362
+ def ref(m):
363
+ t = m.group(2).strip()
364
+ return m.group(1) + (_rn(t, pairs) if in_chain(t) else m.group(2)) + m.group(3)
365
+ text = re.sub(r"(<[A-Z0-9-]*(?:-REF|-TREF|-IREF|VALUE-REF)\b[^>]*>)([^<]*)(</)", ref, text)
366
+ return re.sub(r'\sUUID="[^"]*"', lambda _m: f' UUID="{uuid.uuid4()}"', text)
367
+
368
+
369
+ def _num_values(ft: FileTree, root):
370
+ """(DEFINITION-REF, def short name, VALUE node) for each ECUC numerical value below root."""
371
+ for n in root.iter():
372
+ if n.tag == "ECUC-NUMERICAL-PARAM-VALUE":
373
+ d = n.child("DEFINITION-REF")
374
+ v = n.child("VALUE")
375
+ if d is not None and v is not None:
376
+ yield d.text.strip(), d.text.strip().rsplit("/", 1)[-1], v
377
+
378
+
379
+ def _is_handle(project: Project, defref: str, name: str) -> bool:
380
+ d = project.defs.get(defref) if project.defs else None
381
+ if d is not None:
382
+ return d.kind == "INTEGER" and bool(d.symbolic_name)
383
+ return bool(HANDLE_NAME.search(name))
384
+
385
+
386
+ def _range(project: Project, defref: str) -> tuple:
387
+ d = project.defs.get(defref) if project.defs else None
388
+ lo, hi = 0, (1 << 16) - 1
389
+ try:
390
+ if d is not None and d.min not in (None, ""):
391
+ lo = int(str(d.min), 0) + (1 if d.min_open else 0)
392
+ if d is not None and d.max not in (None, ""):
393
+ hi = min(hi, int(str(d.max), 0) - (1 if d.max_open else 0))
394
+ except ValueError:
395
+ pass
396
+ return max(lo, 0), hi
397
+
398
+
399
+ def _assign_ids(ed: ArxmlEditor, created: list) -> list:
400
+ out = []
401
+ for p in created:
402
+ ft, node = ed.tree_for(p)
403
+ todo = [(dr, dn) for dr, dn, _ in _num_values(ft, node) if _is_handle(ed.p, dr, dn)]
404
+ for k, (dr, dn) in enumerate(todo):
405
+ ft, node = ed.tree_for(p) # positions move after each patch
406
+ mine = [v for r, _, v in _num_values(ft, node) if r == dr]
407
+ target = mine[min(k - sum(1 for x in todo[:k] if x[0] != dr), len(mine) - 1)] if mine else None
408
+ if target is None:
409
+ continue
410
+ used = set()
411
+ for ft2 in ed.files.values():
412
+ for r2, _, v2 in _num_values(ft2, ft2.root):
413
+ if r2 == dr and not (ft2 is ft and v2.start == target.start):
414
+ try:
415
+ used.add(int(v2.text.strip(), 0))
416
+ except ValueError:
417
+ pass
418
+ lo, hi = _range(ed.p, dr)
419
+ nid = next((i for i in range(lo, hi + 1) if i not in used), None)
420
+ if nid is None:
421
+ raise AddLikeError(f"no free {dn} value in [{lo}..{hi}]")
422
+ ed.patch(ft, target.start, target.end, f"<VALUE>{nid}</VALUE>".encode())
423
+ out.append(f"{p}/{dn} = {nid}")
424
+ return out
425
+
426
+
427
+ def _apply_set(ed: ArxmlEditor, created: list, values: dict) -> list:
428
+ applied = []
429
+ for key, value in values.items():
430
+ owner, _, k = str(key).rpartition(":")
431
+ hits = 0
432
+ for p in created:
433
+ done = 0
434
+ while True:
435
+ ft, node = ed.tree_for(p) # re-read: every patch moves the byte offsets
436
+ cands = []
437
+ for c in _value_nodes(ft, node, k):
438
+ h = _holder(c) or node
439
+ hp = next((q for q, n in ft.by_path.items() if n is h), p)
440
+ if not owner or owner in hp.rsplit("/", 1)[-1] or owner.upper() == h.tag:
441
+ cands.append((c, hp))
442
+ if done >= len(cands):
443
+ break
444
+ c, hp = cands[done]
445
+ ed.patch(ft, c.start, c.end, f"<{c.tag}>{escape(str(value))}</{c.tag}>".encode())
446
+ applied.append(f"{hp}/{k} = {value}")
447
+ done += 1
448
+ hits += 1
449
+ if not hits:
450
+ raise AddLikeError(f"set {key!r}: no ECUC parameter or element {k!r} in the cloned elements "
451
+ f"({', '.join(created[:6])}); use the parameter's definition name or the XML tag")
452
+ return applied
453
+
454
+
455
+ def _value_nodes(ft: FileTree, root, key: str) -> list:
456
+ """VALUE nodes of ECUC params named `key` and leaf elements tagged `key` below root."""
457
+ out = []
458
+ for n in root.iter():
459
+ if n.tag in ("ECUC-NUMERICAL-PARAM-VALUE", "ECUC-TEXTUAL-PARAM-VALUE"):
460
+ d, v = n.child("DEFINITION-REF"), n.child("VALUE")
461
+ if d is not None and v is not None and d.text.strip().rsplit("/", 1)[-1] == key:
462
+ out.append(v)
463
+ elif n.tag == key.upper() and not n.children and n.tag != "SHORT-NAME":
464
+ out.append(n)
465
+ return out
466
+
467
+
468
+ def _dkey(d: dict) -> tuple:
469
+ """A finding without line numbers (file.arxml:123), so one that only moved because text was inserted above it
470
+ is not taken for a new one."""
471
+ return d["rule"], d["path"], re.sub(r"(\.arxml|\.ARXML|\.dbc):\d+", r"\1", d["message"])
@@ -95,7 +95,7 @@ def add_signal(project: Project, name: str, ipdu: str, bit_size: Optional[int] =
95
95
  sib = next((m for m in members if m is not None and (m.def_ref or "").endswith("/ComSignal")), None)
96
96
  if sib is None:
97
97
  raise AddSignalError(f"{ip.name} has no ComSignal to use as template")
98
- before = {(d["rule"], d["path"], d["message"]) for d in validate(project, check_generated=False)["diagnostics"]
98
+ before = {_dkey(d) for d in validate(project, check_generated=False)["diagnostics"]
99
99
  if d["severity"] in ("error", "warning")}
100
100
  notes: list = []
101
101
 
@@ -209,7 +209,7 @@ def add_signal(project: Project, name: str, ipdu: str, bit_size: Optional[int] =
209
209
  # same inputs as `before` (explicit --defs/--config included), so only the edit's own findings count as new
210
210
  after = validate(Project(project.root, project.sources.config, project.sources.defs), check_generated=False)
211
211
  new = [d for d in after["diagnostics"] if d["severity"] in ("error", "warning")
212
- and (d["rule"], d["path"], d["message"]) not in before]
212
+ and _dkey(d) not in before]
213
213
  if new:
214
214
  for f, b in originals.items():
215
215
  with open(f, "wb") as fh:
@@ -397,3 +397,9 @@ def _dbc_line(project: Project, pdu, name, pos, size, endian, typ, notes, member
397
397
  out[f] = (text[:block_end] + line + text[block_end:]).encode()
398
398
  notes.append(f"added SG_ {name} to BO_ {m.group(1)} {m.group(2)} in {project.rel(f)}")
399
399
  return out
400
+
401
+
402
+ def _dkey(d: dict) -> tuple:
403
+ """A finding without line numbers (file.arxml:123), so one that only moved because text was inserted above it
404
+ is not taken for a new one."""
405
+ return d["rule"], d["path"], re.sub(r"(\.arxml|\.ARXML|\.dbc):\d+", r"\1", d["message"])