@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.
@@ -31,6 +31,10 @@ from .model import Project
31
31
  from .xmltree import Node, escape, escape_attr, line_indent, parse
32
32
 
33
33
 
34
+ _PRED = re.compile(r"""^([A-Za-z][\w-]*)\[\s*(?:@?[\w-]*\s*=\s*)?['"]?([^'"\]]*)['"]?\s*\]$""")
35
+ _SHORT = re.compile(r"[A-Za-z][A-Za-z0-9_]*\Z")
36
+
37
+
34
38
  class ArxmlEditError(Exception):
35
39
  pass
36
40
 
@@ -102,6 +106,10 @@ class ArxmlEditor:
102
106
  self.added_tags: dict = {} # path -> tag of elements added in this call
103
107
  self.created_packages: list = []
104
108
  self.new_files: list = []
109
+ self.notes: list = []
110
+ self._tags: Optional[set] = None
111
+ self._typed: Optional[str] = None
112
+ self._idtags: set = set()
105
113
 
106
114
  # ---------- files / lookup ----------
107
115
  def tree_for(self, path: str) -> tuple:
@@ -144,15 +152,144 @@ class ArxmlEditor:
144
152
  raise ArxmlEditError("a new top-level package needs \"file\" (which ARXML file to put it in); one of: "
145
153
  + ", ".join(self.p.rel(c) for c in cands[:20]))
146
154
 
155
+ # ---------- path dialects ----------
156
+ # Agents write XML/XPath-style paths ("/AR-PACKAGE/AR-PACKAGE[2]/APPLICATION-SW-COMPONENT-TYPE[SHORT-NAME='X']/
157
+ # PORTS", "/PT/Swc/TqLim/PORTS", ".../I-SIGNAL-TO-I-PDU-MAPPING/m/TRANSFER-PROPERTY"). 124 arxml_edit errors in 30
158
+ # held-out Qwen runs were mostly these. An AUTOSAR path is SHORT-NAMEs only: tag segments are dropped, a
159
+ # [SHORT-NAME='x'] predicate gives x, and a trailing collection tag is kept as the collection to add into.
160
+ def known_tags(self) -> set:
161
+ if self._tags is None:
162
+ tags = {"AUTOSAR", "ELEMENT"}
163
+ for f in self.p.sources.input + self.p.sources.config:
164
+ try:
165
+ ft = self.load(f)
166
+ except Exception: # noqa: BLE001
167
+ continue
168
+ for n in ft.root.iter():
169
+ tags.add(n.tag.upper())
170
+ if n.short_name is not None:
171
+ self._idtags.add(n.tag.upper())
172
+ self._tags = tags
173
+ return self._tags
174
+
175
+ def normalize(self, raw: str) -> tuple:
176
+ """(SHORT-NAME path, trailing collection tag or None, changed?) for an XML/XPath-style path."""
177
+ out, trailing, changed = [], None, False
178
+ self._typed = None
179
+ tags = self.known_tags()
180
+ for seg in [x for x in raw.strip().strip("/").split("/") if x]:
181
+ m = _PRED.match(seg)
182
+ if m:
183
+ inner = m.group(2).strip()
184
+ changed = True
185
+ if not inner or inner.isdigit():
186
+ raise ArxmlEditError(f"path segment {seg!r} selects by position; name the element by its SHORT-NAME "
187
+ "instead (e.g. /PT/Swc/CruiseCtl)")
188
+ out.append(inner)
189
+ trailing = None
190
+ continue
191
+ if seg.upper() in tags or "-" in seg:
192
+ changed = True
193
+ if seg.upper() in self._idtags: # an element type (APPLICATION-SW-COMPONENT-TYPE): not a collection
194
+ self._typed, trailing = seg.upper(), None
195
+ else: # a collection (PORTS) or wrapper: add into it, if it fits
196
+ trailing = seg.upper() if (out or self._typed) else None
197
+ continue
198
+ out.append(seg)
199
+ trailing = None
200
+ return "/" + "/".join(out), trailing, changed
201
+
202
+ def suggest(self, path: str, k: int = 5) -> list:
203
+ names = [x for x in path.strip("/").split("/") if x and _SHORT.match(x)]
204
+ if not names:
205
+ return []
206
+ known = set(self.p.identifiables) | {q for ft in self.files.values() for q in ft.by_path}
207
+ last = names[-1]
208
+ hits = [q for q in known if q.rsplit("/", 1)[-1] == last] or \
209
+ [q for q in known if q.rsplit("/", 1)[-1].lower() == last.lower()]
210
+ hits.sort(key=lambda q: (-sum(n in q.split("/") for n in names), len(q)))
211
+ return hits[:k]
212
+
213
+ def not_found(self, raw: str, norm: Optional[str]) -> str:
214
+ msg = f"no ARXML element at {raw}"
215
+ if norm and norm != raw:
216
+ msg += f" (read as {norm}, which does not exist either)"
217
+ msg += (". An AUTOSAR path is the SHORT-NAMEs joined by '/', e.g. /PT/Swc/TqLim: no XML tags (AR-PACKAGE, "
218
+ "ELEMENTS, PORTS, APPLICATION-SW-COMPONENT-TYPE), no [SHORT-NAME='x'] predicates.")
219
+ sugg = self.suggest(norm or raw)
220
+ if sugg:
221
+ msg += " Did you mean: " + ", ".join(sugg)
222
+ return msg
223
+
224
+ def by_type(self, tag: str, file: Optional[str]) -> list:
225
+ trees = []
226
+ if file:
227
+ f = os.path.normpath(os.path.join(self.p.root, file))
228
+ for cand in self.p.sources.input + self.p.sources.config + list(self.files):
229
+ if os.path.realpath(cand) == os.path.realpath(f):
230
+ trees = [self.load(cand)]
231
+ break
232
+ else:
233
+ trees = [self.load(f) for f in self.p.sources.input]
234
+ return sorted({q for ft in trees for q, n in ft.by_path.items() if n.tag == tag})
235
+
236
+ def resolve(self, raw: str, what: str, file: Optional[str] = None) -> tuple:
237
+ """(FileTree, node, SHORT-NAME path, collection hint) for a path in any of the dialects above."""
238
+ p = raw.rstrip("/")
239
+ try:
240
+ ft, node = self.tree_for(p)
241
+ return ft, node, p, None
242
+ except ArxmlEditError:
243
+ pass
244
+ norm, trailing, changed = self.normalize(p)
245
+ if changed and norm != "/":
246
+ try:
247
+ ft, node = self.tree_for(norm)
248
+ self.notes.append(f"{what} {raw!r} read as {norm!r}" + (f" (collection {trailing})" if trailing else ""))
249
+ return ft, node, norm, trailing
250
+ except ArxmlEditError:
251
+ pass
252
+ # only tags ("/AR-PACKAGES/AR-PACKAGE/ELEMENTS/APPLICATION-SW-COMPONENT-TYPE/PORTS"): the one element of that type
253
+ if changed and norm == "/" and self._typed:
254
+ hits = self.by_type(self._typed, file)
255
+ if len(hits) == 1:
256
+ ft, node = self.tree_for(hits[0])
257
+ self.notes.append(f"{what} {raw!r} read as {hits[0]!r} (the only {self._typed}"
258
+ + (f" in {file}" if file else "") + ")" + (f" (collection {trailing})" if trailing else ""))
259
+ return ft, node, hits[0], trailing
260
+ if hits:
261
+ raise ArxmlEditError(f"{raw} names no element, and there are several {self._typed}: {', '.join(hits[:8])}; "
262
+ "give the SHORT-NAME path of the one you mean")
263
+ # package names left out ("/ExtLightCtrl/PpLightStatus"): accept the one element whose path ends that way
264
+ tail = "/" + norm.strip("/") if changed else "/" + p.strip("/")
265
+ if tail != "/":
266
+ known = set(self.p.identifiables) | {q for ft in self.files.values() for q in ft.by_path}
267
+ hits = sorted(q for q in known if q == tail or q.endswith(tail))
268
+ if len(hits) == 1:
269
+ ft, node = self.tree_for(hits[0])
270
+ self.notes.append(f"{what} {raw!r} read as {hits[0]!r} (the only element whose path ends with {tail})"
271
+ + (f" (collection {trailing})" if trailing else ""))
272
+ return ft, node, hits[0], trailing
273
+ if len(hits) > 1:
274
+ raise ArxmlEditError(f"{raw} is ambiguous: several elements end with {tail}: {', '.join(hits[:8])}; give the full path")
275
+ raise ArxmlEditError(self.not_found(raw, norm if changed else None))
276
+
147
277
  def parent_for(self, parent_path: str, file: Optional[str]) -> tuple:
148
- """(FileTree, node) of parent_path, creating missing AR-PACKAGEs below the deepest existing package."""
278
+ """(FileTree, node, path, collection hint) of parent_path; creates missing AR-PACKAGEs below the deepest
279
+ existing package, but only for a plain SHORT-NAME path (never from a tag-style or mistyped path)."""
149
280
  pp = parent_path.rstrip("/")
281
+ if pp and pp != "/" and self.normalize(pp)[0] == "/" and not self._typed: # "/AUTOSAR/AR-PACKAGES": the root
282
+ self.notes.append(f"parent_path {parent_path!r} read as the file's top level")
283
+ pp = ""
150
284
  if pp == "":
151
- return self.root_of(file)
285
+ ft, node = self.root_of(file)
286
+ return ft, node, "", None
152
287
  try:
153
- return self.tree_for(pp)
154
- except ArxmlEditError:
155
- pass
288
+ return self.resolve(pp, "parent_path", file)
289
+ except ArxmlEditError as miss:
290
+ segs = [x for x in pp.split("/") if x]
291
+ if not segs or not all(_SHORT.match(x) for x in segs) or segs[0].upper() == "AUTOSAR":
292
+ raise miss
156
293
  segs = [s for s in pp.split("/") if s]
157
294
  anc, k = "", 0
158
295
  for i in range(len(segs) - 1, 0, -1):
@@ -170,7 +307,8 @@ class ArxmlEditor:
170
307
  self.op_add({"parent_path": anc or "/", "element": "AR-PACKAGE", "short_name": s, "file": file})
171
308
  anc = f"{anc}/{s}"
172
309
  self.created_packages.append(anc)
173
- return self.tree_for(pp)
310
+ ft, node = self.tree_for(pp)
311
+ return ft, node, pp, None
174
312
 
175
313
  def load(self, f: str) -> FileTree:
176
314
  if f not in self.files:
@@ -218,8 +356,14 @@ class ArxmlEditor:
218
356
  for k in keys:
219
357
  v = values[k]
220
358
  for one in (v if isinstance(v, list) else [v]):
221
- sub = etype.find_sub_element(k, version) if etype is not None else None
222
- if isinstance(one, dict) and "ref" not in one:
359
+ try:
360
+ sub = etype.find_sub_element(k, version) if etype is not None else None
361
+ except Exception: # noqa: BLE001 -- autosar-data rejects a name that is no AUTOSAR tag at all
362
+ raise ArxmlEditError(f"{k!r} is not an AUTOSAR element name (sub-elements of {tag}: "
363
+ f"{', '.join(o for o in order if o != 'SHORT-NAME')[:500]})") from None
364
+ is_ref = k.endswith(("-REF", "-TREF", "-IREF")) and isinstance(one, dict) and \
365
+ any(x in one for x in ("ref", "$ref", "REF", "DEST", "dest"))
366
+ if isinstance(one, dict) and not is_ref:
223
367
  sn = one.get("SHORT-NAME")
224
368
  lines.extend(self.render(k, sn, one, sub, version, depth + 1))
225
369
  else:
@@ -228,6 +372,10 @@ class ArxmlEditor:
228
372
  return lines
229
373
 
230
374
  def leaf(self, tag: str, v) -> str:
375
+ if isinstance(v, dict) and not ("ref" in v) and any(k in v for k in ("$ref", "REF", "path", "#text", "value")) \
376
+ and (tag.endswith("-REF") or tag.endswith("-TREF") or tag.endswith("-IREF")):
377
+ ref = v.get("$ref") or v.get("REF") or v.get("path") or v.get("#text") or v.get("value")
378
+ v = {"ref": str(ref).lstrip("->").strip(), "dest": v.get("dest") or v.get("DEST")}
231
379
  if isinstance(v, dict) and "ref" in v:
232
380
  target, dest = str(v["ref"]), v.get("dest") or self.dest_of(str(v["ref"]))
233
381
  if not dest:
@@ -267,10 +415,22 @@ class ArxmlEditor:
267
415
  if not parent_path or not tag:
268
416
  raise ArxmlEditError("add needs parent_path (the package or element to add under) and element (the XML "
269
417
  "tag), e.g. " + ADD_EXAMPLE)
270
- ft, parent = self.parent_for(parent_path, op.get("file"))
418
+ if tag.upper() == "SHORT-NAME":
419
+ raise ArxmlEditError("SHORT-NAME is not an element to add: give short_name on the add of the element itself; "
420
+ "to rename an element use {op:'set', path:<element path>, element:'SHORT-NAME', value:<new>}")
421
+ ft, parent, parent_path, coll_hint = self.parent_for(parent_path, op.get("file"))
422
+ if tag.upper().startswith("ECUC-") or parent.tag.startswith("ECUC-"):
423
+ raise ArxmlEditError(
424
+ "this is ECUC configuration: use ecuc_edit, not arxml_edit, e.g. {op:'add', path:'" + (parent_path or "/<Pkg>/<Module>")
425
+ + "', container:'<ContainerDef>:<ShortName>', values:{<ParamDef>: <value>}} (ecuc_read schema=true lists "
426
+ "the containers and params that may be added there)")
271
427
  version = _version(ft.data)
272
428
  ptype = _etype(ft.chain(parent), version)
273
429
  coll = op.get("collection")
430
+ if coll is None and coll_hint and ptype is not None:
431
+ hold = ptype.find_sub_element(coll_hint, version)
432
+ if hold is not None and hold.find_sub_element(tag, version) is not None:
433
+ coll = coll_hint
274
434
  if coll is None and ptype is not None:
275
435
  if ptype.find_sub_element(tag, version) is None:
276
436
  for c in _order(ptype):
@@ -278,6 +438,21 @@ class ArxmlEditor:
278
438
  if ct is not None and ct.find_sub_element(tag, version) is not None:
279
439
  coll = c
280
440
  break
441
+ if coll is None and parent_path:
442
+ holders = []
443
+ for q, n in ft.by_path.items():
444
+ if not q.startswith(parent_path + "/") or n is parent:
445
+ continue
446
+ nt = _etype(ft.chain(n), version)
447
+ if nt is None:
448
+ continue
449
+ if nt.find_sub_element(tag, version) is not None or any(
450
+ (ct := nt.find_sub_element(c, version)) is not None and ct.find_sub_element(tag, version) is not None
451
+ for c in _order(nt)):
452
+ holders.append(q)
453
+ if len(holders) == 1:
454
+ self.notes.append(f"{tag} cannot go directly under {parent_path}; added under {holders[0]}")
455
+ return self.op_add(dict(op, parent_path=holders[0], collection=None))
281
456
  if coll is None:
282
457
  raise ArxmlEditError(f"{tag} cannot be placed under {parent.tag} {parent_path} in this schema; "
283
458
  f"{parent.tag} takes: {', '.join(_placeable(ptype, version))[:600]}")
@@ -360,8 +535,9 @@ class ArxmlEditor:
360
535
  def op_set(self, op: dict):
361
536
  path, tag = op.get("path"), op.get("element")
362
537
  if not path or not tag or "value" not in op:
363
- raise ArxmlEditError("set needs path, element and value")
364
- ft, node = self.tree_for(path.rstrip("/"))
538
+ raise ArxmlEditError("set needs path (the element that holds the value), element (the XML tag of the value) "
539
+ "and value, e.g. {op:'set', path:'/PT/Com/ISignals/ENG_TEMP', element:'LENGTH', value:8}")
540
+ ft, node, _, _ = self.resolve(path, "path")
365
541
  leaf = self.own_child(node, tag)
366
542
  text = self.leaf(tag, op["value"]).encode("utf-8")
367
543
  if leaf is None:
@@ -385,7 +561,9 @@ class ArxmlEditor:
385
561
 
386
562
  def op_remove(self, op: dict):
387
563
  path = (op.get("path") or "").rstrip("/")
388
- ft, node = self.tree_for(path)
564
+ if not path:
565
+ raise ArxmlEditError("remove needs path (the element to remove)")
566
+ ft, node, _, _ = self.resolve(path, "path")
389
567
  start = node.start
390
568
  if line_indent(ft.data, start) is not None:
391
569
  nl = ft.data.rfind(b"\n", 0, start)
@@ -444,16 +622,28 @@ def _placeable(ptype, version) -> list:
444
622
  return out
445
623
 
446
624
 
625
+ _TAG_ANYCASE = re.compile(r"[A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)+\Z|[A-Za-z]+\Z")
626
+
627
+
628
+ def _upper_keys(v):
629
+ """Upper-case element-name keys of a values tree ("Init-Value" -> INIT-VALUE); '@'/'$' keys and ref dicts stay."""
630
+ if isinstance(v, list):
631
+ return [_upper_keys(x) for x in v]
632
+ if not isinstance(v, dict) or "ref" in v or "$ref" in v:
633
+ return v
634
+ return {(k.upper() if _TAG_ANYCASE.match(k) and k not in ("ref", "dest") else k): _upper_keys(x) for k, x in v.items()}
635
+
636
+
447
637
  def normalize(op: dict, notes: list, i: int) -> dict:
448
- """Accept the shapes agents mix up (ecuc_edit's path/container, name/tag) for add; note each rewrite."""
449
- if op.get("op") != "add":
638
+ """Accept the shapes agents mix up (ecuc_edit's path/container/param, name/tag, any-case tags); note each rewrite."""
639
+ if op.get("op") not in ("add", "set"):
450
640
  return op
451
641
  op = dict(op)
452
- if not op.get("parent_path") and op.get("path"):
642
+ if op.get("op") == "add" and not op.get("parent_path") and op.get("path"):
453
643
  op["parent_path"] = op.pop("path")
454
644
  notes.append(f"op {i}: 'path' read as parent_path")
455
645
  cont = op.get("container")
456
- if not op.get("element") and isinstance(cont, str):
646
+ if op.get("op") == "add" and not op.get("element") and isinstance(cont, str):
457
647
  tag, _, sn = cont.partition(":")
458
648
  if _TAG.match(tag):
459
649
  op.pop("container")
@@ -465,6 +655,15 @@ def normalize(op: dict, notes: list, i: int) -> dict:
465
655
  if alias in op and not op.get(key):
466
656
  op[key] = op.pop(alias)
467
657
  notes.append(f"op {i}: {alias!r} read as {key!r}")
658
+ if op.get("op") == "set" and not op.get("element") and op.get("param"):
659
+ op["element"] = op.pop("param")
660
+ notes.append(f"op {i}: 'param' read as 'element'")
661
+ el = op.get("element")
662
+ if isinstance(el, str) and _TAG_ANYCASE.match(el) and el != el.upper():
663
+ op["element"] = el.upper()
664
+ notes.append(f"op {i}: element {el!r} read as {el.upper()!r} (AUTOSAR tags are upper case)")
665
+ if isinstance(op.get("values"), dict):
666
+ op["values"] = _upper_keys(op["values"])
468
667
  vals = op.get("values")
469
668
  if isinstance(vals, dict) and "SHORT-NAME" in vals and not op.get("short_name"):
470
669
  vals = dict(vals)
@@ -483,6 +682,7 @@ def arxml_edit(project: Project, ops, dry_run: bool = False) -> dict:
483
682
  created = []
484
683
  notes: list = []
485
684
  ops = [normalize(o, notes, i) for i, o in enumerate(ops)]
685
+ ed.notes = notes
486
686
  try:
487
687
  for i, op in enumerate(ops):
488
688
  kind = op.get("op")
@@ -499,6 +699,9 @@ def arxml_edit(project: Project, ops, dry_run: bool = False) -> dict:
499
699
  raise ArxmlEditError(f"unknown op {kind!r} (new_file/add/set/remove)")
500
700
  except ArxmlEditError as e:
501
701
  raise ArxmlEditError(f"op {i} ({kind} {op.get('path') or op.get('parent_path') or op.get('file')}): {e}") from None
702
+ except Exception as e: # noqa: BLE001 -- never let a library error escape as a bare engine failure
703
+ raise ArxmlEditError(f"op {i} ({kind} {op.get('path') or op.get('parent_path') or op.get('file')}): "
704
+ f"{type(e).__name__}: {e}") from None
502
705
  except ArxmlEditError as e:
503
706
  return {"ok": False, "error": str(e), "written": False, "notes": notes}
504
707
  problems = ed.check()
@@ -8,6 +8,7 @@ from __future__ import annotations
8
8
 
9
9
  import argparse
10
10
  import json
11
+ import re
11
12
  import os
12
13
  import sys
13
14
  import time
@@ -15,16 +16,44 @@ import time
15
16
  from . import ENGINE_VERSION
16
17
 
17
18
 
19
+ REPAIRED: list = []
20
+
21
+
22
+ def _loads(text: str):
23
+ """json.loads, then the slips models make in long hand-written JSON: trailing commas, missing commas between
24
+ objects/values, Python literals (True/None, single quotes). The first error is re-raised if nothing parses."""
25
+ try:
26
+ return json.loads(text)
27
+ except json.JSONDecodeError as first:
28
+ fixed = re.sub(r",\s*([}\]])", r"\1", text)
29
+ fixed = re.sub(r"([}\]\"])\s*\n?\s*([{\"])", lambda m: m.group(1) + ("," if m.group(2) in "{\"" else "") + m.group(2), fixed) \
30
+ if fixed.count('"') % 2 == 0 else fixed
31
+ for cand in (fixed, ):
32
+ try:
33
+ v = json.loads(cand)
34
+ REPAIRED.append(f"ops JSON had a syntax error ({first.msg} at char {first.pos}) and was repaired; check the result")
35
+ return v
36
+ except json.JSONDecodeError:
37
+ pass
38
+ try:
39
+ import ast
40
+ v = ast.literal_eval(text.replace("true", "True").replace("false", "False").replace("null", "None"))
41
+ REPAIRED.append(f"ops were not strict JSON ({first.msg} at char {first.pos}) and were read as a literal")
42
+ return v
43
+ except Exception: # noqa: BLE001
44
+ raise first from None
45
+
46
+
18
47
  def _ops(arg: str):
19
48
  if arg.startswith("@"):
20
49
  with open(arg[1:]) as f:
21
50
  arg = f.read()
22
51
  if arg == "-":
23
52
  arg = sys.stdin.read()
24
- ops = json.loads(arg)
53
+ ops = _loads(arg)
25
54
  for _ in range(3): # tolerate JSON that was encoded more than once ("[{...}]" as a string)
26
55
  if isinstance(ops, str):
27
- ops = json.loads(ops)
56
+ ops = _loads(ops)
28
57
  if isinstance(ops, dict) and isinstance(ops.get("ops"), (list, str)):
29
58
  return _ops(json.dumps(ops["ops"]))
30
59
  return ops if isinstance(ops, list) else [ops]
@@ -78,6 +107,14 @@ def main(argv=None) -> int:
78
107
  an.add_argument("--dry-run", action="store_true")
79
108
  ac = sub.add_parser("arxml-check", help="verify non-ECUC ARXML: strict schema + every reference and its DEST")
80
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")
81
118
  fd = sub.add_parser("find")
82
119
  fd.add_argument("--module")
83
120
  fd.add_argument("--container-def", dest="container_def")
@@ -123,7 +160,7 @@ def main(argv=None) -> int:
123
160
  bc.add_argument("--task", required=True, help="task text, @file, or - for stdin")
124
161
  bc.add_argument("--budget-tokens", dest="budget_tokens", type=int, default=24000)
125
162
  bc.add_argument("--no-history", dest="history", action="store_false")
126
- 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
127
164
  p.add_argument("--json", action="store_true", help=argparse.SUPPRESS)
128
165
  a = ap.parse_args(argv)
129
166
  t0 = time.time()
@@ -193,7 +230,7 @@ def run(a) -> dict:
193
230
  from .specids import check_ids
194
231
  return check_ids(os.path.abspath(a.root), a.ids, docs=a.docs)
195
232
  from .model import Project
196
- 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"))
197
234
  if a.cmd == "add-signal":
198
235
  from .add_signal import AddSignalError, add_signal
199
236
  try:
@@ -202,12 +239,27 @@ def run(a) -> dict:
202
239
  transfer_property=a.transfer_property, dry_run=a.dry_run)
203
240
  except AddSignalError as ex:
204
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)}
205
254
  if a.cmd == "arxml-check":
206
255
  from .arxml_edit import arxml_check
207
256
  return arxml_check(project, a.file or None)
208
257
  if a.cmd == "arxml-edit":
209
258
  from .arxml_edit import arxml_edit
210
- return arxml_edit(project, a.ops, dry_run=a.dry_run)
259
+ res = arxml_edit(project, a.ops, dry_run=a.dry_run)
260
+ if REPAIRED:
261
+ res.setdefault("notes", []).extend(REPAIRED)
262
+ return res
211
263
  if a.cmd == "find":
212
264
  from .find import find
213
265
  where = a.where
@@ -71,6 +71,7 @@ grounding, no tools, no exploring the project.
71
71
  | Verify ALL ARXML (ECUC rules + extract/SWC schema, references, DEST) | `ecuc_validate` (unfiltered) |
72
72
  | Edit System extract / SWC / interface XML | `arxml_edit` (DEST inferred, schema-ordered, refs checked) |
73
73
  | Add a signal end to end (ECUC + extract + DBC) | `autosar_add_signal` |
74
+ | Add a new message / port / runnable+event / Dem event / NvM block / interface **like an existing one**, in every file at once | `autosar_add_like like=<existing stem> name=<new stem> also={member: new} set={...}` |
74
75
  | What may be set/added here (types, ranges, literals, multiplicity) | `ecuc_read path=… schema=true`, or a def path `/AUTOSAR/EcucDefs/<Mod>/…` |
75
76
  | Change config | `ecuc_edit ops=[…]` (`dry_run` first for large edits) |
76
77
  | Check config | `ecuc_validate` |
@@ -123,6 +124,20 @@ Do not decide what to change from memory or from the prompt alone. Each change g
123
124
  (unvalidated edits, failing validation, unverified C, changed elements not yet checked against the documents)
124
125
  until it is resolved.
125
126
 
127
+ ## A new thing that resembles an existing one: autosar_add_like (preferred)
128
+ Most changes add something the project already has one of: a message, a port, a runnable with its timing event, a
129
+ Dem event with its DTC, an NvM block with its Fee block. Do not build those op by op. Pick the existing one most like
130
+ the new one (same direction, bus, kind), then:
131
+ 1. `autosar_add_like like=<its stem> name=<new stem> dry_run=true`: read the Created list (every file it touches)
132
+ and the "Not copied" / "single-valued" lists.
133
+ 2. Name the members you want too: `also={"DrvDoorOpenSts": "SettingsAckCounter"}` gives the new message one signal
134
+ cloned from that one.
135
+ 3. Set the values the request gives with `set` (ECUC param name, XML tag, or `Owner:key` when several clones have
136
+ it): CAN id (`IDENTIFIER`, `CanIfTxPduCanId`), lengths (`PduLength`, `FRAME-LENGTH`, `I-SIGNAL-I-PDU:LENGTH`),
137
+ period (`ComTxModeTimePeriod`), signal layout (`<sig>:ComBitPosition`, `<sig>:ComBitSize`, `START-POSITION`) ...
138
+ 4. Run it without dry_run. It validates and rolls back on any new finding. Then fix what is listed as single-valued
139
+ (e.g. the S/R-to-signal mapping of a new port) with arxml_edit, and verify as below.
140
+
126
141
  ## New ARXML file (SWC, interface, data types, extract) from nothing
127
142
  1. ONE `arxml_edit` call. Its first op creates the file:
128
143
  `{op:"new_file", file:"Input/SWC_X.arxml", packages:["/DataTypes", "/Interfaces", "/Swcs"]}` (R25-11 header and empty