@anik-2/autosar 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,330 @@
1
+ """ECUC parameter-definition model (StMD and vendor VSMD alike).
2
+
3
+ Schema-free: every def kind is recognised by its tag suffix, so the loader works across AUTOSAR
4
+ releases (R4.0 ... R25-11) without an XSD. A def is addressed by its AUTOSAR path, the chain of
5
+ SHORT-NAMEs from the root package, which is exactly what a value's DEFINITION-REF holds.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import os
11
+ import pickle
12
+ import xml.etree.ElementTree as ET
13
+ from dataclasses import dataclass, field
14
+ from typing import Optional
15
+
16
+ from . import ENGINE_VERSION
17
+
18
+ MODULE_TAG = "ECUC-MODULE-DEF"
19
+ CONTAINER_TAGS = {"ECUC-PARAM-CONF-CONTAINER-DEF", "ECUC-CHOICE-CONTAINER-DEF"}
20
+ CHOICE_TAG = "ECUC-CHOICE-CONTAINER-DEF"
21
+ COLLECTIONS_CONTAINER = {"CONTAINERS", "SUB-CONTAINERS", "CHOICES"}
22
+
23
+ NUMERICAL_KINDS = {"INTEGER", "FLOAT", "BOOLEAN"}
24
+ TEXTUAL_KINDS = {"ENUMERATION", "STRING", "FUNCTION-NAME", "LINKER-SYMBOL", "MULTILINE-STRING"}
25
+ ECUC_REF_KINDS = {"REFERENCE", "CHOICE-REFERENCE", "SYMBOLIC-NAME-REFERENCE"}
26
+
27
+
28
+ def _local(tag: str) -> str:
29
+ return tag.rsplit("}", 1)[-1]
30
+
31
+
32
+ def param_kind(tag: str) -> Optional[str]:
33
+ """ECUC-INTEGER-PARAM-DEF -> INTEGER; ECUC-FUNCTION-NAME-DEF -> FUNCTION-NAME; refs -> e.g. FOREIGN-REFERENCE."""
34
+ if not tag.startswith("ECUC-") or not tag.endswith("-DEF"):
35
+ return None
36
+ core = tag[5:-4]
37
+ if core.endswith("-PARAM"):
38
+ return core[:-6]
39
+ if core in ("FUNCTION-NAME", "LINKER-SYMBOL"):
40
+ return core
41
+ if core.endswith("-REFERENCE") or core == "REFERENCE":
42
+ return core
43
+ return None
44
+
45
+
46
+ def is_ref_kind(kind: str) -> bool:
47
+ return kind.endswith("REFERENCE")
48
+
49
+
50
+ @dataclass
51
+ class DefNode:
52
+ path: str
53
+ tag: str
54
+ name: str
55
+ file: str
56
+ lower: int = 1
57
+ upper: Optional[int] = 1 # None = infinite
58
+ kind: Optional[str] = None # params/refs only
59
+ min: Optional[str] = None
60
+ max: Optional[str] = None
61
+ min_open: bool = False
62
+ max_open: bool = False
63
+ literals: list = field(default_factory=list)
64
+ default: Optional[str] = None
65
+ regex: Optional[str] = None
66
+ min_len: Optional[int] = None
67
+ max_len: Optional[int] = None
68
+ dest_refs: list = field(default_factory=list)
69
+ dest_type: Optional[str] = None
70
+ with_auto: bool = False
71
+ symbolic_name: bool = False
72
+ obsolete: bool = False
73
+ trace: Optional[str] = None
74
+ containers: list = field(default_factory=list) # child container def paths (incl. choices)
75
+ params: list = field(default_factory=list) # child param def paths
76
+ refs: list = field(default_factory=list) # child ref def paths
77
+ parent: Optional[str] = None
78
+ module: Optional[str] = None # module def path
79
+
80
+ @property
81
+ def is_container(self) -> bool:
82
+ return self.tag in CONTAINER_TAGS or self.tag == MODULE_TAG
83
+
84
+ @property
85
+ def mult(self) -> str:
86
+ return f"{self.lower}..{'*' if self.upper is None else self.upper}"
87
+
88
+
89
+ def _int(s: Optional[str], default):
90
+ try:
91
+ return int(s.strip()) if s is not None else default
92
+ except ValueError:
93
+ return default
94
+
95
+
96
+ class DefIndex:
97
+ def __init__(self):
98
+ self.by_path: dict[str, DefNode] = {}
99
+ self.modules: dict[str, DefNode] = {} # module def path -> node
100
+ self.files: list[str] = []
101
+
102
+ def __bool__(self):
103
+ return bool(self.modules)
104
+
105
+ def get(self, path: Optional[str]) -> Optional[DefNode]:
106
+ return self.by_path.get(path) if path else None
107
+
108
+ def module_of(self, def_path: str) -> Optional[DefNode]:
109
+ best = None
110
+ for mp, m in self.modules.items():
111
+ if (def_path == mp or def_path.startswith(mp + "/")) and (best is None or len(mp) > len(best.path)):
112
+ best = m
113
+ return best
114
+
115
+ def rel(self, def_path: str) -> str:
116
+ """Release/vendor-neutral form: '<ModuleName>/<rest>' (so /MICROSAR/Com/X == /AUTOSAR/EcucDefs/Com/X)."""
117
+ m = self.module_of(def_path)
118
+ if m is not None:
119
+ return m.name + def_path[len(m.path):]
120
+ parts = def_path.strip("/").split("/")
121
+ # Unknown module: fall back to dropping the conventional package prefix.
122
+ return "/".join(parts[2:]) if len(parts) > 2 else def_path
123
+
124
+ def merge(self, other: "DefIndex"):
125
+ self.by_path.update(other.by_path)
126
+ self.modules.update(other.modules)
127
+ self.files.extend(other.files)
128
+
129
+
130
+ def _cache_path(path: str) -> str:
131
+ st = os.stat(path)
132
+ key = f"{ENGINE_VERSION}|{os.path.abspath(path)}|{st.st_size}|{st.st_mtime_ns}"
133
+ base = os.environ.get("XDG_CACHE_HOME") or os.path.join(os.path.expanduser("~"), ".cache")
134
+ return os.path.join(base, "hydron-autosar", "defs-" + hashlib.sha256(key.encode()).hexdigest()[:24] + ".pkl")
135
+
136
+
137
+ def load_def_file(path: str, use_cache: bool = True) -> DefIndex:
138
+ cp = None
139
+ if use_cache:
140
+ try:
141
+ cp = _cache_path(path)
142
+ with open(cp, "rb") as f:
143
+ return pickle.load(f)
144
+ except Exception:
145
+ pass
146
+ idx = _parse_def_file(path)
147
+ if cp:
148
+ try:
149
+ os.makedirs(os.path.dirname(cp), exist_ok=True)
150
+ tmp = cp + f".{os.getpid()}.tmp"
151
+ with open(tmp, "wb") as f:
152
+ pickle.dump(idx, f, protocol=pickle.HIGHEST_PROTOCOL)
153
+ os.replace(tmp, cp)
154
+ except Exception:
155
+ pass
156
+ return idx
157
+
158
+
159
+ def _parse_def_file(path: str) -> DefIndex:
160
+ idx = DefIndex()
161
+ idx.files.append(path)
162
+ root = ET.parse(path).getroot()
163
+
164
+ def short(el) -> Optional[str]:
165
+ for c in el:
166
+ if _local(c.tag) == "SHORT-NAME":
167
+ return (c.text or "").strip()
168
+ return None
169
+
170
+ skip = {"DESC", "INTRODUCTION", "ADMIN-DATA", "ANNOTATIONS", "LONG-NAME", "VALUE-CONFIG-CLASSES",
171
+ "MULTIPLICITY-CONFIG-CLASSES"}
172
+
173
+ def walk(el, prefix: str, module: Optional[DefNode], parent: Optional[DefNode]):
174
+ tag = _local(el.tag)
175
+ sn = short(el)
176
+ path = prefix
177
+ if sn is not None:
178
+ path = f"{prefix}/{sn}"
179
+ is_def = tag == MODULE_TAG or tag in CONTAINER_TAGS or (param_kind(tag) is not None and parent is not None)
180
+ if not is_def:
181
+ parent = None # some other identifiable (package, collection, value element)
182
+ else:
183
+ node = _make_def(el, tag, sn, path, path_file=path_file)
184
+ node.parent = parent.path if parent else None
185
+ if tag == MODULE_TAG:
186
+ module = node
187
+ idx.modules[path] = node
188
+ node.module = module.path if module else None
189
+ idx.by_path[path] = node
190
+ if parent is not None:
191
+ if node.is_container:
192
+ parent.containers.append(path)
193
+ elif is_ref_kind(node.kind or ""):
194
+ parent.refs.append(path)
195
+ else:
196
+ parent.params.append(path)
197
+ if node.kind: # params/refs hold no nested defs
198
+ return
199
+ parent = node
200
+ for c in el:
201
+ if _local(c.tag) not in skip:
202
+ walk(c, path, module, parent)
203
+
204
+ path_file = path
205
+ walk(root, "", None, None)
206
+ return idx
207
+
208
+
209
+ def _make_def(el, tag: str, sn: str, path: str, path_file: str) -> DefNode:
210
+ d = DefNode(path=path, tag=tag, name=sn, file=path_file)
211
+ d.kind = param_kind(tag) if tag not in CONTAINER_TAGS and tag != MODULE_TAG else None
212
+ direct = {}
213
+ for c in el:
214
+ direct.setdefault(_local(c.tag), c)
215
+ d.lower = _int(direct["LOWER-MULTIPLICITY"].text if "LOWER-MULTIPLICITY" in direct else None, 1)
216
+ inf = direct.get("UPPER-MULTIPLICITY-INFINITE")
217
+ if inf is not None and (inf.text or "").strip().lower() == "true":
218
+ d.upper = None
219
+ else:
220
+ d.upper = _int(direct["UPPER-MULTIPLICITY"].text if "UPPER-MULTIPLICITY" in direct else None, 1)
221
+ tr = direct.get("RELATED-TRACE-ITEM-REF")
222
+ if tr is not None:
223
+ d.trace = (tr.text or "").strip()
224
+ for c in el.iter():
225
+ t = _local(c.tag)
226
+ if t == "LIFE-CYCLE-INFO" or t == "LIFE-CYCLE-STATE-REF":
227
+ if "obsolete" in (c.text or "").lower():
228
+ d.obsolete = True
229
+ if d.kind is None:
230
+ return d
231
+ # Params/refs carry no nested identifiables except enum literals: a descendant scan is safe.
232
+ for c in el.iter():
233
+ t = _local(c.tag)
234
+ txt = (c.text or "").strip()
235
+ if t == "MIN" and d.min is None:
236
+ d.min, d.min_open = txt, (c.get("INTERVAL-TYPE") or "").upper() == "OPEN"
237
+ elif t == "MAX" and d.max is None:
238
+ d.max, d.max_open = txt, (c.get("INTERVAL-TYPE") or "").upper() == "OPEN"
239
+ elif t == "DEFAULT-VALUE" and d.default is None:
240
+ d.default = txt
241
+ elif t == "ECUC-ENUMERATION-LITERAL-DEF":
242
+ for s in c:
243
+ if _local(s.tag) == "SHORT-NAME":
244
+ d.literals.append((s.text or "").strip())
245
+ elif t == "REGULAR-EXPRESSION" and d.regex is None:
246
+ d.regex = txt
247
+ elif t == "MIN-LENGTH":
248
+ d.min_len = _int(txt, None)
249
+ elif t == "MAX-LENGTH":
250
+ d.max_len = _int(txt, None)
251
+ elif t in ("DESTINATION-REF",):
252
+ d.dest_refs.append(txt)
253
+ elif t == "DESTINATION-TYPE":
254
+ d.dest_type = txt
255
+ elif t == "WITH-AUTO":
256
+ d.with_auto = txt.lower() == "true"
257
+ elif t == "SYMBOLIC-NAME-VALUE":
258
+ d.symbolic_name = txt.lower() == "true"
259
+ return d
260
+
261
+
262
+ def load_defs(paths: list[str], use_cache: bool = True) -> DefIndex:
263
+ idx = DefIndex()
264
+ for p in paths:
265
+ idx.merge(load_def_file(p, use_cache=use_cache))
266
+ return idx
267
+
268
+
269
+ def describe(d: DefNode, defs: "DefIndex") -> dict:
270
+ """Compact schema view of a def, for the agent (what may be set/added under it)."""
271
+ out: dict = {"@def": d.path, "@tag": d.tag, "@mult": d.mult}
272
+ if d.trace:
273
+ out["@trace"] = d.trace
274
+ if d.tag == CHOICE_TAG:
275
+ out["@choice"] = "exactly one of the sub-containers"
276
+ if d.kind:
277
+ out.update(_param_summary(d))
278
+ return out
279
+ params = {}
280
+ for p in d.params + d.refs:
281
+ pd = defs.by_path[p]
282
+ params[pd.name] = _param_line(pd)
283
+ if params:
284
+ out["params"] = params
285
+ subs = {}
286
+ for cpath in d.containers:
287
+ cd = defs.by_path[cpath]
288
+ subs[cd.name] = f"[{cd.mult}]" + (" choice" if cd.tag == CHOICE_TAG else "")
289
+ if subs:
290
+ out["containers"] = subs
291
+ return out
292
+
293
+
294
+ def _param_summary(d: DefNode) -> dict:
295
+ s: dict = {"type": d.kind, "mult": d.mult}
296
+ if d.min is not None or d.max is not None:
297
+ s["range"] = f"{'(' if d.min_open else '['}{d.min},{d.max}{')' if d.max_open else ']'}"
298
+ if d.literals:
299
+ s["literals"] = d.literals
300
+ if d.default is not None:
301
+ s["default"] = d.default
302
+ if d.regex:
303
+ s["regex"] = d.regex
304
+ if d.dest_refs:
305
+ s["dest"] = d.dest_refs
306
+ if d.dest_type:
307
+ s["destType"] = d.dest_type
308
+ if d.symbolic_name:
309
+ s["symbolicName"] = True
310
+ if d.obsolete:
311
+ s["obsolete"] = True
312
+ return s
313
+
314
+
315
+ def _param_line(d: DefNode) -> str:
316
+ bits = [d.kind or "?", f"[{d.mult}]"]
317
+ if d.min is not None or d.max is not None:
318
+ bits.append(f"{'(' if d.min_open else '['}{d.min}..{d.max}{')' if d.max_open else ']'}")
319
+ if d.literals:
320
+ lits = d.literals if len(d.literals) <= 12 else d.literals[:12] + [f"+{len(d.literals) - 12}"]
321
+ bits.append("{" + "|".join(lits) + "}")
322
+ if d.default is not None:
323
+ bits.append(f"default={d.default}")
324
+ if d.dest_refs:
325
+ bits.append("-> " + ",".join(r.rsplit("/", 1)[-1] for r in d.dest_refs))
326
+ if d.dest_type:
327
+ bits.append("-> " + d.dest_type)
328
+ if d.obsolete:
329
+ bits.append("OBSOLETE")
330
+ return " ".join(bits)
@@ -0,0 +1,111 @@
1
+ """Semantic ARXML diff by AUTOSAR path.
2
+
3
+ Ignores UUIDs, whitespace, comments, element order and split-file layout. ECUC elements are compared
4
+ parameter by parameter (by def name); other identifiables by a canonical form of their own content
5
+ (nested identifiables are compared separately under their own paths).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from typing import Optional
11
+
12
+ from .model import MODULE_VALUES, CONTAINER_VALUE, _walk_arxml, build_file_model
13
+ from .values import parse_int, parse_float
14
+ from .xmltree import Node
15
+
16
+ IGNORED_ATTRS = {"UUID", "T", "S"}
17
+
18
+
19
+ def _norm(raw: Optional[str], numerical: bool):
20
+ if raw is None:
21
+ return None
22
+ t = raw.strip()
23
+ if numerical:
24
+ lo = t.lower()
25
+ if lo in ("true", "false"):
26
+ return lo
27
+ i = parse_int(t)
28
+ if i is not None:
29
+ return str(i)
30
+ f = parse_float(t)
31
+ if f is not None:
32
+ return repr(f)
33
+ return t
34
+
35
+
36
+ def _canon(node: Node, top: bool = True) -> str:
37
+ if not top and node.short_name is not None:
38
+ return "" # nested identifiable: diffed under its own path
39
+ attrs = ",".join(f"{k}={v}" for k, v in sorted(node.attrs.items()) if k not in IGNORED_ATTRS and not k.startswith("xmlns"))
40
+ kids = sorted(c for c in (_canon(ch, False) for ch in node.children) if c)
41
+ txt = " ".join(node.text.split()) if not node.children else ""
42
+ return f"<{node.tag}[{attrs}]{txt}{''.join(kids)}>"
43
+
44
+
45
+ def _side(target: str) -> dict:
46
+ files = [target] if os.path.isfile(target) else sorted(_walk_arxml(target))
47
+ out: dict = {}
48
+ for f in files:
49
+ with open(f, "rb") as fh:
50
+ fm = build_file_model(f, fh.read())
51
+
52
+ def visit(node: Node, prefix: str):
53
+ sn = node.short_name
54
+ p = prefix
55
+ if sn is not None:
56
+ p = f"{prefix}/{sn}"
57
+ if node.tag in (MODULE_VALUES, CONTAINER_VALUE):
58
+ ent = out.setdefault(p, {"tag": node.tag, "file": f, "line": node.line, "ecuc": True,
59
+ "def": node.child_text("DEFINITION-REF"), "values": {}})
60
+ for coll in node.children:
61
+ if coll.tag in ("PARAMETER-VALUES", "REFERENCE-VALUES"):
62
+ for pv in coll.children:
63
+ name = (pv.child_text("DEFINITION-REF") or "?").rsplit("/", 1)[-1]
64
+ vn = pv.child("VALUE") or pv.child("VALUE-REF")
65
+ v = _norm(vn.text if vn is not None else None, pv.tag == "ECUC-NUMERICAL-PARAM-VALUE")
66
+ if vn is None and pv.child("VALUE-IREF") is not None:
67
+ v = _canon(pv.child("VALUE-IREF"))
68
+ ent["values"].setdefault(name, []).append(v)
69
+ for coll in node.children:
70
+ if coll.tag in ("CONTAINERS", "SUB-CONTAINERS"):
71
+ for c in coll.children:
72
+ visit(c, p)
73
+ return
74
+ out.setdefault(p, {"tag": node.tag, "file": f, "line": node.line, "ecuc": False, "canon": _canon(node)})
75
+ for c in node.children:
76
+ visit(c, p)
77
+
78
+ visit(fm.root, "")
79
+ for ent in out.values():
80
+ if ent.get("ecuc"):
81
+ ent["values"] = {k: sorted(v, key=lambda x: (x is None, str(x))) for k, v in ent["values"].items()}
82
+ return out
83
+
84
+
85
+ def diff(a: str, b: str, rel=lambda f: f) -> dict:
86
+ A, B = _side(a), _side(b)
87
+ added = [{"path": p, "tag": B[p]["tag"], "at": f"{rel(B[p]['file'])}:{B[p]['line']}"} for p in sorted(set(B) - set(A))]
88
+ removed = [{"path": p, "tag": A[p]["tag"], "at": f"{rel(A[p]['file'])}:{A[p]['line']}"} for p in sorted(set(A) - set(B))]
89
+ changed = []
90
+ for p in sorted(set(A) & set(B)):
91
+ x, y = A[p], B[p]
92
+ ch: dict = {}
93
+ if x["tag"] != y["tag"]:
94
+ ch["tag"] = [x["tag"], y["tag"]]
95
+ if x.get("ecuc") and y.get("ecuc"):
96
+ if x["def"] != y["def"]:
97
+ ch["definitionRef"] = [x["def"], y["def"]]
98
+ vals = {}
99
+ for k in sorted(set(x["values"]) | set(y["values"])):
100
+ va, vb = x["values"].get(k), y["values"].get(k)
101
+ if va != vb:
102
+ un = lambda v: v[0] if v and len(v) == 1 else v # noqa: E731
103
+ vals[k] = [un(va), un(vb)]
104
+ if vals:
105
+ ch["values"] = vals
106
+ elif x.get("canon") != y.get("canon"):
107
+ ch["content"] = "changed"
108
+ if ch:
109
+ changed.append({"path": p, "at": f"{rel(y['file'])}:{y['line']}", **ch})
110
+ return {"added": added, "removed": removed, "changed": changed,
111
+ "summary": {"added": len(added), "removed": len(removed), "changed": len(changed)}}
@@ -0,0 +1,154 @@
1
+ """ECUC JSON: the compact view the agent reads.
2
+
3
+ Keyed by def names, not XML structure:
4
+ {"Com": {"@path": "/Pkg/Com", "@def": "/AUTOSAR/EcucDefs/Com", "@at": "Config/Com.arxml:12",
5
+ "ComConfig": {"@path": ...,
6
+ "ComIPdu:DoorStatus_Tx": {"@path": ..., "ComIPduDirection": "SEND",
7
+ "ComPduIdRef": "->/EcuC/EcucPduCollection/DoorStatus_Tx",
8
+ "ComSignal:DoorLatch": {...}}}}}
9
+ A container key is "<DefName>:<ShortName>", shortened to "<ShortName>" when the two are equal.
10
+ Multi-valued parameters are arrays. References are "->/AUTOSAR/path".
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from typing import Optional
15
+
16
+ from . import defs as D
17
+ from .model import Elem, Project
18
+ from .validate import _resolve_def
19
+ from .values import to_json
20
+
21
+
22
+ def key_of(e: Elem) -> str:
23
+ dn = (e.def_ref or "?").rsplit("/", 1)[-1]
24
+ return e.name if dn == e.name else f"{dn}:{e.name}"
25
+
26
+
27
+ def render(project: Project, group: list, depth: int, top: bool = True) -> dict:
28
+ defs = project.defs
29
+ e0 = group[0]
30
+ out: dict = {"@path": e0.path}
31
+ if top:
32
+ out["@def"] = e0.def_ref
33
+ out["@at"] = ", ".join(f"{project.rel(e.file)}:{e.line}" for e in group)
34
+ vals: dict = {}
35
+ for e in group:
36
+ for pv in e.params:
37
+ pd = _resolve_def(defs, pv.def_ref)
38
+ v = to_json(pd, pv.value, pv.is_ref, pv.tag == "ECUC-NUMERICAL-PARAM-VALUE")
39
+ name = pv.name
40
+ multi = pd is not None and (pd.upper is None or pd.upper > 1)
41
+ if name in vals:
42
+ if not isinstance(vals[name], list) or not multi:
43
+ vals[name] = vals[name] if isinstance(vals[name], list) else [vals[name]]
44
+ vals[name].append(v)
45
+ else:
46
+ vals[name] = [v] if multi else v
47
+ out.update(vals)
48
+ seen = set()
49
+ for e in group:
50
+ for c in e.children:
51
+ if c.path in seen:
52
+ continue
53
+ seen.add(c.path)
54
+ cg = project.find(c.path)
55
+ if depth <= 0:
56
+ n_p = sum(len(x.params) for x in cg)
57
+ n_c = sum(len(x.children) for x in cg)
58
+ out[key_of(c)] = f"… {n_p} values, {n_c} sub-containers (read {c.path})"
59
+ else:
60
+ out[key_of(c)] = render(project, cg, depth - 1, top=False)
61
+ return out
62
+
63
+
64
+ def read(project: Project, path: Optional[str], depth: int = 2, schema: bool = False) -> dict:
65
+ res: dict = {"defsLoaded": len(project.defs.modules)}
66
+ if project.warnings:
67
+ res["warnings"] = project.warnings
68
+ if not path:
69
+ mods: dict = {}
70
+ for m in project.modules:
71
+ g = project.find(m.path)
72
+ ent = mods.setdefault(m.path, {"module": m.name, "path": m.path, "def": m.def_ref, "files": [],
73
+ "containers": 0, "values": 0})
74
+ ent["files"].append(f"{project.rel(m.file)}:{m.line}")
75
+ ent["containers"] += sum(1 for _ in _walk(m)) - 1
76
+ ent["values"] += sum(len(x.params) for x in _walk(m))
77
+ res["modules"] = list(mods.values())
78
+ res["hint"] = "read a module or container by its AUTOSAR path, e.g. path=" + (
79
+ res["modules"][0]["path"] if res["modules"] else "/Pkg/Module")
80
+ return res
81
+ resolved = resolve(project, path)
82
+ if isinstance(resolved, dict):
83
+ res.update(resolved)
84
+ return res
85
+ if resolved != path:
86
+ res["resolved"] = f"{path} -> {resolved}"
87
+ path = resolved
88
+ group = project.find(path)
89
+ if group:
90
+ res["ecuc"] = {key_of(group[0]): render(project, group, depth)}
91
+ if schema:
92
+ d = _resolve_def(project.defs, group[0].def_ref)
93
+ res["schema"] = D.describe(d, project.defs) if d else None
94
+ return res
95
+ # a def path? describe the definition
96
+ d = project.defs.get(path)
97
+ if d is not None:
98
+ res["schema"] = D.describe(d, project.defs)
99
+ return res
100
+ # parent/param form: /.../Container/ParamName
101
+ parent, _, last = path.rstrip("/").rpartition("/")
102
+ pg = project.find(parent)
103
+ if pg:
104
+ hits = [pv for e in pg for pv in e.params if pv.name == last]
105
+ if hits:
106
+ pd = _resolve_def(project.defs, hits[0].def_ref)
107
+ res["param"] = {"@path": path, "@def": hits[0].def_ref,
108
+ "@at": [f"{project.rel(hits[0].node and pg[0].file)}:{h.node.line}" for h in hits],
109
+ "value": [to_json(pd, h.value, h.is_ref, h.tag == "ECUC-NUMERICAL-PARAM-VALUE") for h in hits]}
110
+ if pd is not None:
111
+ res["schema"] = D.describe(pd, project.defs)
112
+ return res
113
+ res["error"] = f"no ECUC element or definition at {path}"
114
+ near = [p for p in project.by_path if p.rsplit("/", 1)[-1] == last][:10]
115
+ if near:
116
+ res["didYouMean"] = near
117
+ return res
118
+
119
+
120
+ def _walk(e: Elem):
121
+ stack = [e]
122
+ while stack:
123
+ x = stack.pop()
124
+ yield x
125
+ stack.extend(x.children)
126
+
127
+
128
+ def resolve(project: Project, q: str):
129
+ """Exact path, def path, or param path pass through. Otherwise resolve a SHORT-NAME or partial
130
+ path ("ComConfig/DoorLatch", "DoorLatch", "DoorLatch/ComBitPosition") against value paths.
131
+ Returns the path, or {"error", "candidates"} when ambiguous / not found."""
132
+ q = q.strip()
133
+ if project.find(q) or project.defs.get(q):
134
+ return q
135
+ parent, _, last = q.rstrip("/").rpartition("/")
136
+ if parent.startswith("/") and project.find(parent):
137
+ return q
138
+ tail = q.strip("/")
139
+ paths = list(project.by_path)
140
+ hits = [p for p in paths if p == "/" + tail or p.endswith("/" + tail)]
141
+ param = None
142
+ if not hits and "/" in tail: # last segment may be a parameter
143
+ head, _, param = tail.rpartition("/")
144
+ hits = [p for p in paths if p.endswith("/" + head)]
145
+ hits = [p for p in hits if any(pv.name == param for e in project.find(p) for pv in e.params)]
146
+ if not hits:
147
+ low = tail.lower().rsplit("/", 1)[-1]
148
+ sugg = sorted(p for p in paths if low in p.rsplit("/", 1)[-1].lower())[:15]
149
+ return {"error": f"no ECUC element matches '{q}'", "didYouMean": sugg} if sugg else \
150
+ {"error": f"no ECUC element or definition at {q}; call ecuc_read with no path to list modules"}
151
+ if len(hits) > 1:
152
+ return {"error": f"'{q}' is ambiguous ({len(hits)} matches); pass one of these full paths",
153
+ "didYouMean": sorted(hits)[:25]}
154
+ return hits[0] + (f"/{param}" if param else "")