@h2loop/autosar 1.5.4 → 1.7.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,475 @@
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_all = [d for d in after["diagnostics"] if d["severity"] in ("error", "warning") and _dkey(d) not in before]
310
+ # completeness warnings (H2L-CHAIN-*: e.g. the new signal is not mapped to a port YET) are follow-up work, not a
311
+ # reason to undo the step; errors (a duplicated CAN id, a broken route) still roll everything back
312
+ fresh = [d for d in fresh_all if d["severity"] == "error" or not d["rule"].startswith("H2L-CHAIN")]
313
+ todo = [d for d in fresh_all if d not in fresh]
314
+ if fresh:
315
+ for f, b in originals.items():
316
+ with open(f, "wb") as fh:
317
+ fh.write(b)
318
+ return {**res, "ok": False, "written": False, "error": "validation found new problems; all changes rolled back",
319
+ "diagnostics": fresh[:20]}
320
+ return {**res, "ok": True, "written": True, "validation": after["summary"],
321
+ "todo": [f"{d['rule']} {d['path']}: {d['message'][:300]}" for d in todo],
322
+ "next": "check each created element against the request (ecuc_read / the diff), set what is still the "
323
+ "template's (ecuc_edit / arxml_edit), add the new signals (autosar_add_signal), then regenerate "
324
+ "and build"}
325
+
326
+
327
+ def _rn(path: str, pairs, new: Optional[str] = None) -> str:
328
+ if isinstance(pairs, str):
329
+ pairs = [(pairs, new)]
330
+ out = []
331
+ for seg in path.split("/"):
332
+ for a, b in pairs:
333
+ if a in seg:
334
+ seg = seg.replace(a, b)
335
+ break
336
+ out.append(seg)
337
+ return "/".join(out)
338
+
339
+
340
+ def _refs_below(node) -> list:
341
+ return [d.text.strip() for d in node.iter() if _is_ref(d) and d.text.strip().startswith("/")]
342
+
343
+
344
+ def _line_start(data: bytes, pos: int) -> int:
345
+ if line_indent(data, pos) is not None:
346
+ nl = data.rfind(b"\n", 0, pos)
347
+ return nl if nl >= 0 else pos
348
+ return pos
349
+
350
+
351
+ def _excise(data: bytes, start: int, end: int, cuts: list) -> bytes:
352
+ out, at = [], start
353
+ for a, b in sorted(c for c in cuts if start <= c[0] and c[1] <= end):
354
+ if a < at:
355
+ continue
356
+ out.append(data[at:a])
357
+ at = b
358
+ out.append(data[at:end])
359
+ return b"".join(out)
360
+
361
+
362
+ def _retext(text: str, pairs: list, in_chain) -> str:
363
+ """Rename the stems in SHORT-NAMEs and in references into the chain; fresh UUIDs."""
364
+ text = re.sub(r"(<SHORT-NAME>)([^<]*)(</SHORT-NAME>)", lambda m: m.group(1) + _rn(m.group(2), pairs) + m.group(3), text)
365
+
366
+ def ref(m):
367
+ t = m.group(2).strip()
368
+ return m.group(1) + (_rn(t, pairs) if in_chain(t) else m.group(2)) + m.group(3)
369
+ text = re.sub(r"(<[A-Z0-9-]*(?:-REF|-TREF|-IREF|VALUE-REF)\b[^>]*>)([^<]*)(</)", ref, text)
370
+ return re.sub(r'\sUUID="[^"]*"', lambda _m: f' UUID="{uuid.uuid4()}"', text)
371
+
372
+
373
+ def _num_values(ft: FileTree, root):
374
+ """(DEFINITION-REF, def short name, VALUE node) for each ECUC numerical value below root."""
375
+ for n in root.iter():
376
+ if n.tag == "ECUC-NUMERICAL-PARAM-VALUE":
377
+ d = n.child("DEFINITION-REF")
378
+ v = n.child("VALUE")
379
+ if d is not None and v is not None:
380
+ yield d.text.strip(), d.text.strip().rsplit("/", 1)[-1], v
381
+
382
+
383
+ def _is_handle(project: Project, defref: str, name: str) -> bool:
384
+ d = project.defs.get(defref) if project.defs else None
385
+ if d is not None:
386
+ return d.kind == "INTEGER" and bool(d.symbolic_name)
387
+ return bool(HANDLE_NAME.search(name))
388
+
389
+
390
+ def _range(project: Project, defref: str) -> tuple:
391
+ d = project.defs.get(defref) if project.defs else None
392
+ lo, hi = 0, (1 << 16) - 1
393
+ try:
394
+ if d is not None and d.min not in (None, ""):
395
+ lo = int(str(d.min), 0) + (1 if d.min_open else 0)
396
+ if d is not None and d.max not in (None, ""):
397
+ hi = min(hi, int(str(d.max), 0) - (1 if d.max_open else 0))
398
+ except ValueError:
399
+ pass
400
+ return max(lo, 0), hi
401
+
402
+
403
+ def _assign_ids(ed: ArxmlEditor, created: list) -> list:
404
+ out = []
405
+ for p in created:
406
+ ft, node = ed.tree_for(p)
407
+ todo = [(dr, dn) for dr, dn, _ in _num_values(ft, node) if _is_handle(ed.p, dr, dn)]
408
+ for k, (dr, dn) in enumerate(todo):
409
+ ft, node = ed.tree_for(p) # positions move after each patch
410
+ mine = [v for r, _, v in _num_values(ft, node) if r == dr]
411
+ target = mine[min(k - sum(1 for x in todo[:k] if x[0] != dr), len(mine) - 1)] if mine else None
412
+ if target is None:
413
+ continue
414
+ used = set()
415
+ for ft2 in ed.files.values():
416
+ for r2, _, v2 in _num_values(ft2, ft2.root):
417
+ if r2 == dr and not (ft2 is ft and v2.start == target.start):
418
+ try:
419
+ used.add(int(v2.text.strip(), 0))
420
+ except ValueError:
421
+ pass
422
+ lo, hi = _range(ed.p, dr)
423
+ nid = next((i for i in range(lo, hi + 1) if i not in used), None)
424
+ if nid is None:
425
+ raise AddLikeError(f"no free {dn} value in [{lo}..{hi}]")
426
+ ed.patch(ft, target.start, target.end, f"<VALUE>{nid}</VALUE>".encode())
427
+ out.append(f"{p}/{dn} = {nid}")
428
+ return out
429
+
430
+
431
+ def _apply_set(ed: ArxmlEditor, created: list, values: dict) -> list:
432
+ applied = []
433
+ for key, value in values.items():
434
+ owner, _, k = str(key).rpartition(":")
435
+ hits = 0
436
+ for p in created:
437
+ done = 0
438
+ while True:
439
+ ft, node = ed.tree_for(p) # re-read: every patch moves the byte offsets
440
+ cands = []
441
+ for c in _value_nodes(ft, node, k):
442
+ h = _holder(c) or node
443
+ hp = next((q for q, n in ft.by_path.items() if n is h), p)
444
+ if not owner or owner in hp.rsplit("/", 1)[-1] or owner.upper() == h.tag:
445
+ cands.append((c, hp))
446
+ if done >= len(cands):
447
+ break
448
+ c, hp = cands[done]
449
+ ed.patch(ft, c.start, c.end, f"<{c.tag}>{escape(str(value))}</{c.tag}>".encode())
450
+ applied.append(f"{hp}/{k} = {value}")
451
+ done += 1
452
+ hits += 1
453
+ if not hits:
454
+ raise AddLikeError(f"set {key!r}: no ECUC parameter or element {k!r} in the cloned elements "
455
+ f"({', '.join(created[:6])}); use the parameter's definition name or the XML tag")
456
+ return applied
457
+
458
+
459
+ def _value_nodes(ft: FileTree, root, key: str) -> list:
460
+ """VALUE nodes of ECUC params named `key` and leaf elements tagged `key` below root."""
461
+ out = []
462
+ for n in root.iter():
463
+ if n.tag in ("ECUC-NUMERICAL-PARAM-VALUE", "ECUC-TEXTUAL-PARAM-VALUE"):
464
+ d, v = n.child("DEFINITION-REF"), n.child("VALUE")
465
+ if d is not None and v is not None and d.text.strip().rsplit("/", 1)[-1] == key:
466
+ out.append(v)
467
+ elif n.tag == key.upper() and not n.children and n.tag != "SHORT-NAME":
468
+ out.append(n)
469
+ return out
470
+
471
+
472
+ def _dkey(d: dict) -> tuple:
473
+ """A finding without line numbers (file.arxml:123), so one that only moved because text was inserted above it
474
+ is not taken for a new one."""
475
+ 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
 
@@ -208,8 +208,11 @@ def add_signal(project: Project, name: str, ipdu: str, bit_size: Optional[int] =
208
208
  fh.write(b)
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
- new = [d for d in after["diagnostics"] if d["severity"] in ("error", "warning")
212
- and (d["rule"], d["path"], d["message"]) not in before]
211
+ new_all = [d for d in after["diagnostics"] if d["severity"] in ("error", "warning") and _dkey(d) not in before]
212
+ # completeness warnings (H2L-CHAIN-*: e.g. the new signal is not mapped to a port YET) are follow-up work, not a
213
+ # reason to undo the step; errors (a duplicated CAN id, a broken route) still roll everything back
214
+ new = [d for d in new_all if d["severity"] == "error" or not d["rule"].startswith("H2L-CHAIN")]
215
+ todo = [d for d in new_all if d not in new]
213
216
  if new:
214
217
  for f, b in originals.items():
215
218
  with open(f, "wb") as fh:
@@ -217,6 +220,7 @@ def add_signal(project: Project, name: str, ipdu: str, bit_size: Optional[int] =
217
220
  return {**res, "ok": False, "written": False, "error": "validation found new problems; all changes rolled back",
218
221
  "diagnostics": new[:20]}
219
222
  return {**res, "ok": True, "written": True, "validation": after["summary"],
223
+ "todo": [f"{d['rule']} {d['path']}: {d['message'][:300]}" for d in todo],
220
224
  "next": "regenerate (autosar_generate), use the generated symbol "
221
225
  f"ComConf_ComSignal_{name} in the C code, build (autosar_build)"}
222
226
 
@@ -397,3 +401,9 @@ def _dbc_line(project: Project, pdu, name, pos, size, endian, typ, notes, member
397
401
  out[f] = (text[:block_end] + line + text[block_end:]).encode()
398
402
  notes.append(f"added SG_ {name} to BO_ {m.group(1)} {m.group(2)} in {project.rel(f)}")
399
403
  return out
404
+
405
+
406
+ def _dkey(d: dict) -> tuple:
407
+ """A finding without line numbers (file.arxml:123), so one that only moved because text was inserted above it
408
+ is not taken for a new one."""
409
+ return d["rule"], d["path"], re.sub(r"(\.arxml|\.ARXML|\.dbc):\d+", r"\1", d["message"])
@@ -107,6 +107,14 @@ def main(argv=None) -> int:
107
107
  an.add_argument("--dry-run", action="store_true")
108
108
  ac = sub.add_parser("arxml-check", help="verify non-ECUC ARXML: strict schema + every reference and its DEST")
109
109
  ac.add_argument("--file", action="append", default=[], help="limit to files whose path contains this")
110
+ al = sub.add_parser("add-like", help="add a new X like an existing Y: clone its chain across all ARXML, renamed")
111
+ al.add_argument("--like", required=True, help="name stem of the template (e.g. BCM_DoorStatus)")
112
+ al.add_argument("--name", required=True, help="the new stem (e.g. BCM_SettingsAck)")
113
+ al.add_argument("--seed", help="one element of the template chain, when several chains carry the stem")
114
+ al.add_argument("--also", help='JSON {"template member stem": "new stem"}, e.g. {"DrvDoorOpenSts": "SettingsAckCounter"}')
115
+ al.add_argument("--set", dest="set_values", help='JSON {"ParamDef"|"XML-TAG"|"Owner:key": value}')
116
+ al.add_argument("--keep-members", action="store_true")
117
+ al.add_argument("--dry-run", action="store_true")
110
118
  fd = sub.add_parser("find")
111
119
  fd.add_argument("--module")
112
120
  fd.add_argument("--container-def", dest="container_def")
@@ -152,7 +160,7 @@ def main(argv=None) -> int:
152
160
  bc.add_argument("--task", required=True, help="task text, @file, or - for stdin")
153
161
  bc.add_argument("--budget-tokens", dest="budget_tokens", type=int, default=24000)
154
162
  bc.add_argument("--no-history", dest="history", action="store_false")
155
- for p in (r, e, v, d, au, so, vs, si, fd, cx, ae, an, ac, asg, cg, cd, ra, vr, bc): # allow --json after the subcommand too
163
+ for p in (r, e, v, d, au, so, vs, si, fd, cx, ae, an, ac, al, asg, cg, cd, ra, vr, bc): # allow --json after the subcommand too
156
164
  p.add_argument("--json", action="store_true", help=argparse.SUPPRESS)
157
165
  a = ap.parse_args(argv)
158
166
  t0 = time.time()
@@ -222,7 +230,7 @@ def run(a) -> dict:
222
230
  from .specids import check_ids
223
231
  return check_ids(os.path.abspath(a.root), a.ids, docs=a.docs)
224
232
  from .model import Project
225
- project = Project(a.root, config_files=a.config, def_files=a.defs, load_input=a.cmd in ("validate", "edit", "find", "crosscheck", "signal-owners", "arxml-edit", "add-signal", "arxml-check"))
233
+ project = Project(a.root, config_files=a.config, def_files=a.defs, load_input=a.cmd in ("validate", "edit", "find", "crosscheck", "signal-owners", "arxml-edit", "add-signal", "arxml-check", "add-like"))
226
234
  if a.cmd == "add-signal":
227
235
  from .add_signal import AddSignalError, add_signal
228
236
  try:
@@ -231,6 +239,18 @@ def run(a) -> dict:
231
239
  transfer_property=a.transfer_property, dry_run=a.dry_run)
232
240
  except AddSignalError as ex:
233
241
  return {"error": str(ex), "written": False}
242
+ if a.cmd == "add-like":
243
+ from .add_like import AddLikeError, add_like
244
+ try:
245
+ also = _loads(a.also) if a.also else None
246
+ sv = _loads(a.set_values) if a.set_values else None
247
+ except json.JSONDecodeError as ex:
248
+ return {"ok": False, "error": f"also/set is not valid JSON: {ex}"}
249
+ try:
250
+ return add_like(project, a.like, a.name, seed=a.seed, set_values=sv, keep_members=a.keep_members,
251
+ dry_run=a.dry_run, also=also)
252
+ except AddLikeError as ex:
253
+ return {"ok": False, "error": str(ex)}
234
254
  if a.cmd == "arxml-check":
235
255
  from .arxml_edit import arxml_check
236
256
  return arxml_check(project, a.file or None)
@@ -74,7 +74,7 @@ class RuleContext:
74
74
 
75
75
 
76
76
  def run_rules(project: Project) -> Iterable[dict]:
77
- from . import com, handles, mem, nvm, pdur, system # noqa: F401 (registers the packs)
77
+ from . import chain, com, handles, mem, nvm, pdur, system # noqa: F401 (registers the packs)
78
78
  ctx = RuleContext(project)
79
79
  for r in RULES:
80
80
  for item in r.fn(ctx):