@anik-2/autosar 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,330 @@
1
+ """Project discovery + the ECUC value model.
2
+
3
+ Files are classified by content, not by name or folder: a file holding ECUC-MODULE-DEF is a
4
+ param-def source, one holding ECUC-MODULE-CONFIGURATION-VALUES is configuration, any other .arxml
5
+ is "input" (System extract, SWCs, BSWMD) and only indexed for foreign-reference resolution.
6
+ `.hydron/autosar.json` can pin each set with globs: {"paramDefs": [...], "config": [...], "input": [...]}.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import glob
11
+ import json
12
+ import os
13
+ import re
14
+ import xml.etree.ElementTree as ET
15
+ from dataclasses import dataclass, field
16
+ from typing import Optional
17
+
18
+ from . import defs as D
19
+ from .xmltree import Node, parse
20
+
21
+ SKIP_DIRS = {".git", "node_modules", ".hydron", ".venv", "__pycache__", ".cache", ".idea", ".vscode"}
22
+ PROJECT_CONFIG = os.path.join(".hydron", "autosar.json")
23
+
24
+ MODULE_VALUES = "ECUC-MODULE-CONFIGURATION-VALUES"
25
+ CONTAINER_VALUE = "ECUC-CONTAINER-VALUE"
26
+ PARAM_VALUE_TAGS = {"ECUC-NUMERICAL-PARAM-VALUE", "ECUC-TEXTUAL-PARAM-VALUE", "ECUC-ADD-INFO-PARAM-VALUE"}
27
+ REF_VALUE_TAGS = {"ECUC-REFERENCE-VALUE", "ECUC-INSTANCE-REFERENCE-VALUE"}
28
+
29
+
30
+ @dataclass
31
+ class ParamVal:
32
+ node: Node
33
+ def_ref: Optional[str]
34
+ def_dest: Optional[str]
35
+ tag: str
36
+ value: Optional[str] # VALUE text / VALUE-REF text / None
37
+ value_node: Optional[Node]
38
+ dest: Optional[str] = None # VALUE-REF DEST
39
+ is_auto: bool = False
40
+
41
+ @property
42
+ def name(self) -> str:
43
+ return (self.def_ref or "?").rsplit("/", 1)[-1]
44
+
45
+ @property
46
+ def is_ref(self) -> bool:
47
+ return self.tag in REF_VALUE_TAGS
48
+
49
+
50
+ @dataclass
51
+ class Elem:
52
+ """A module configuration (ECUC-MODULE-CONFIGURATION-VALUES) or a container value."""
53
+ node: Node
54
+ file: str
55
+ path: str
56
+ name: str
57
+ kind: str # "module" | "container"
58
+ def_ref: Optional[str]
59
+ def_dest: Optional[str]
60
+ params: list = field(default_factory=list) # ParamVal (params and refs)
61
+ children: list = field(default_factory=list) # Elem
62
+ parent: Optional["Elem"] = None
63
+
64
+ @property
65
+ def line(self) -> int:
66
+ return self.node.line
67
+
68
+
69
+ @dataclass
70
+ class FileModel:
71
+ path: str
72
+ data: bytes
73
+ root: Node
74
+ elems: list # top-level module Elems
75
+ identifiables: dict # path -> (tag, line)
76
+ newline: bytes = b"\n"
77
+
78
+
79
+ _MARKERS = {
80
+ "defs": re.compile(rb"<(?:[\w.-]+:)?ECUC-MODULE-DEF[\s>]"),
81
+ "config": re.compile(rb"<(?:[\w.-]+:)?ECUC-MODULE-CONFIGURATION-VALUES[\s>]"),
82
+ }
83
+
84
+
85
+ def _scan_markers(path: str) -> set:
86
+ """Which element kinds a file holds (element starts only; DEST="ECUC-MODULE-DEF" text does not count)."""
87
+ found = set()
88
+ tail = b""
89
+ try:
90
+ with open(path, "rb") as f:
91
+ while len(found) < len(_MARKERS):
92
+ chunk = f.read(1 << 20)
93
+ if not chunk:
94
+ break
95
+ buf = tail + chunk
96
+ for k, rx in _MARKERS.items():
97
+ if k not in found and rx.search(buf):
98
+ found.add(k)
99
+ tail = buf[-96:]
100
+ except OSError:
101
+ pass
102
+ return found
103
+
104
+
105
+ def _walk_arxml(root: str):
106
+ for dp, dns, fns in os.walk(root):
107
+ dns[:] = [d for d in dns if d not in SKIP_DIRS and not d.startswith(".")]
108
+ for fn in fns:
109
+ if fn.lower().endswith(".arxml"):
110
+ yield os.path.join(dp, fn)
111
+
112
+
113
+ def _globs(root: str, pats) -> list:
114
+ out = []
115
+ for p in pats or []:
116
+ p = os.path.expanduser(p)
117
+ full = p if os.path.isabs(p) else os.path.join(root, p)
118
+ if os.path.isdir(full):
119
+ out.extend(_walk_arxml(full))
120
+ else:
121
+ out.extend(sorted(glob.glob(full, recursive=True)))
122
+ seen, res = set(), []
123
+ for f in out:
124
+ a = os.path.abspath(f)
125
+ if a not in seen and os.path.isfile(a):
126
+ seen.add(a)
127
+ res.append(a)
128
+ return res
129
+
130
+
131
+ @dataclass
132
+ class Sources:
133
+ root: str
134
+ config: list
135
+ defs: list
136
+ input: list
137
+ warnings: list
138
+ origin: dict # which mechanism found each set
139
+
140
+
141
+ def discover(root: str, config_files: Optional[list] = None, def_files: Optional[list] = None) -> Sources:
142
+ root = os.path.abspath(root)
143
+ cfg = {}
144
+ cfg_path = os.path.join(root, PROJECT_CONFIG)
145
+ warnings: list = []
146
+ if os.path.isfile(cfg_path):
147
+ try:
148
+ with open(cfg_path) as f:
149
+ cfg = json.load(f)
150
+ except Exception as e:
151
+ warnings.append(f"{PROJECT_CONFIG} is not valid JSON ({e}); ignoring it")
152
+ origin = {}
153
+ scanned = None
154
+
155
+ def scan():
156
+ nonlocal scanned
157
+ if scanned is None:
158
+ scanned = {f: _scan_markers(f) for f in _walk_arxml(root)}
159
+ return scanned
160
+
161
+ if def_files:
162
+ dfiles, origin["defs"] = _globs(root, def_files), "argument"
163
+ elif cfg.get("paramDefs"):
164
+ dfiles, origin["defs"] = _globs(root, cfg["paramDefs"]), PROJECT_CONFIG
165
+ else:
166
+ dfiles, origin["defs"] = [f for f, m in scan().items() if "defs" in m], "auto-discovered"
167
+ if not dfiles and os.environ.get("AUTOSAR_PARAMDEFS"):
168
+ dfiles = _globs(root, os.environ["AUTOSAR_PARAMDEFS"].split(os.pathsep))
169
+ origin["defs"] = "AUTOSAR_PARAMDEFS"
170
+ if config_files:
171
+ cfiles, origin["config"] = _globs(root, config_files), "argument"
172
+ elif cfg.get("config"):
173
+ cfiles, origin["config"] = _globs(root, cfg["config"]), PROJECT_CONFIG
174
+ else:
175
+ cfiles, origin["config"] = [f for f, m in scan().items() if "config" in m], "auto-discovered"
176
+ if cfg.get("input"):
177
+ ifiles, origin["input"] = _globs(root, cfg["input"]), PROJECT_CONFIG
178
+ else:
179
+ known = set(cfiles) | set(dfiles)
180
+ ifiles, origin["input"] = [f for f in scan() if f not in known], "auto-discovered"
181
+ if not dfiles:
182
+ warnings.append(
183
+ "No ECUC param definitions found (no ECUC-MODULE-DEF in the project, no paramDefs in "
184
+ f"{PROJECT_CONFIG}, AUTOSAR_PARAMDEFS unset). Values are readable but NOT validated, and edits "
185
+ "cannot add new elements. Point paramDefs at the AUTOSAR / vendor BSWMD param-def ARXML.")
186
+ return Sources(root, cfiles, dfiles, ifiles, warnings, origin)
187
+
188
+
189
+ def _dest(n: Optional[Node]) -> Optional[str]:
190
+ return n.attrs.get("DEST") if n is not None else None
191
+
192
+
193
+ def build_file_model(path: str, data: bytes) -> FileModel:
194
+ root = parse(data)
195
+ elems: list = []
196
+ idents: dict = {}
197
+
198
+ def visit(node: Node, prefix: str, parent: Optional[Elem]):
199
+ sn = node.short_name
200
+ path_here = prefix
201
+ if sn is not None:
202
+ path_here = f"{prefix}/{sn}"
203
+ idents.setdefault(path_here, (node.tag, node.line))
204
+ if node.tag in (MODULE_VALUES, CONTAINER_VALUE) and sn is not None:
205
+ dr = node.child("DEFINITION-REF")
206
+ e = Elem(node=node, file=path, path=path_here, name=sn,
207
+ kind="module" if node.tag == MODULE_VALUES else "container",
208
+ def_ref=dr.text.strip() if dr is not None else None, def_dest=_dest(dr), parent=parent)
209
+ for coll in node.children:
210
+ if coll.tag in ("PARAMETER-VALUES", "REFERENCE-VALUES"):
211
+ for pv in coll.children:
212
+ e.params.append(_param(pv))
213
+ if parent is not None:
214
+ parent.children.append(e)
215
+ else:
216
+ elems.append(e)
217
+ for coll in node.children:
218
+ if coll.tag in ("CONTAINERS", "SUB-CONTAINERS"):
219
+ for c in coll.children:
220
+ visit(c, path_here, e)
221
+ return
222
+ for c in node.children:
223
+ if c.tag in ("SHORT-NAME", "DESC", "ADMIN-DATA", "INTRODUCTION", "ANNOTATIONS"):
224
+ continue
225
+ visit(c, path_here, parent if sn is None else None)
226
+
227
+ visit(root, "", None)
228
+ nl = b"\r\n" if b"\r\n" in data[:4096] else b"\n"
229
+ return FileModel(path, data, root, elems, idents, nl)
230
+
231
+
232
+ def _param(pv: Node) -> ParamVal:
233
+ dr = pv.child("DEFINITION-REF")
234
+ vn = pv.child("VALUE") if pv.tag in PARAM_VALUE_TAGS else pv.child("VALUE-REF") or pv.child("VALUE-IREF")
235
+ val = None
236
+ if vn is not None:
237
+ val = vn.text.strip() if vn.tag != "VALUE-IREF" else "(instance-ref)"
238
+ if pv.tag == "ECUC-ADD-INFO-PARAM-VALUE":
239
+ val = " ".join(vn.text.split())
240
+ auto = (pv.child_text("IS-AUTO-VALUE") or "").lower() == "true"
241
+ return ParamVal(node=pv, def_ref=dr.text.strip() if dr is not None else None, def_dest=_dest(dr), tag=pv.tag,
242
+ value=val, value_node=vn, dest=_dest(vn) if vn is not None and vn.tag == "VALUE-REF" else None,
243
+ is_auto=auto)
244
+
245
+
246
+ def index_identifiables(path: str) -> dict:
247
+ """Fast path -> (tag, line=0) index of a non-config ARXML (System, SWC, BSWMD) for foreign refs."""
248
+ out: dict = {}
249
+ stack: list = [] # (short_name or None)
250
+ pending: list = []
251
+ try:
252
+ for ev, el in ET.iterparse(path, events=("start", "end")):
253
+ tag = el.tag.rsplit("}", 1)[-1]
254
+ if ev == "start":
255
+ stack.append(None)
256
+ pending.append(tag)
257
+ else:
258
+ if tag == "SHORT-NAME" and len(stack) >= 2:
259
+ stack[-2] = (el.text or "").strip()
260
+ p = "/" + "/".join(s for s in stack[:-1] if s)
261
+ out.setdefault(p, (pending[-2], 0))
262
+ stack.pop()
263
+ pending.pop()
264
+ el.clear()
265
+ except ET.ParseError:
266
+ pass
267
+ return out
268
+
269
+
270
+ class Project:
271
+ def __init__(self, root: str, config_files=None, def_files=None, load_input: bool = True):
272
+ self.sources = discover(root, config_files, def_files)
273
+ self.root = self.sources.root
274
+ self.warnings = list(self.sources.warnings)
275
+ self.defs = D.load_defs(self.sources.defs) if self.sources.defs else D.DefIndex()
276
+ if self.sources.defs and not self.defs:
277
+ self.warnings.append("param-def files were found but contain no ECUC-MODULE-DEF")
278
+ self.files: dict[str, FileModel] = {}
279
+ for f in self.sources.config:
280
+ self._load(f)
281
+ self.foreign: dict = {}
282
+ self.input_loaded = False
283
+ if load_input:
284
+ for f in self.sources.input:
285
+ self.foreign.update(index_identifiables(f))
286
+ self.input_loaded = bool(self.sources.input)
287
+ self.reindex()
288
+
289
+ def _load(self, f: str, data: Optional[bytes] = None):
290
+ if data is None:
291
+ with open(f, "rb") as fh:
292
+ data = fh.read()
293
+ try:
294
+ self.files[f] = build_file_model(f, data)
295
+ except Exception as e:
296
+ self.warnings.append(f"{self.rel(f)}: XML parse error: {e}")
297
+
298
+ def reload(self, f: str, data: bytes):
299
+ self._load(f, data)
300
+ self.reindex()
301
+
302
+ def reindex(self):
303
+ self.by_path: dict[str, list] = {}
304
+ self.modules: list = []
305
+ self.identifiables: dict = dict(self.foreign)
306
+ for fm in self.files.values():
307
+ for p, v in fm.identifiables.items():
308
+ self.identifiables.setdefault(p, v + (fm.path,))
309
+ for m in fm.elems:
310
+ self.modules.append(m)
311
+ for e in _iter_elems(m):
312
+ self.by_path.setdefault(e.path, []).append(e)
313
+
314
+ def rel(self, f: str) -> str:
315
+ try:
316
+ r = os.path.relpath(f, self.root)
317
+ return f if r.startswith("..") else r
318
+ except ValueError:
319
+ return f
320
+
321
+ def find(self, path: str) -> list:
322
+ return self.by_path.get(path.rstrip("/"), [])
323
+
324
+
325
+ def _iter_elems(e: Elem):
326
+ stack = [e]
327
+ while stack:
328
+ x = stack.pop()
329
+ yield x
330
+ stack.extend(reversed(x.children))
@@ -0,0 +1,222 @@
1
+ """Mechanical ECUC checks, derived from the param defs only (no hand-written module rules).
2
+
3
+ Diagnostics are LSP-like: {file, line, path, rule, severity, message, defRef}.
4
+ Rules:
5
+ ECUC-NO-DEF module's definition is not among the loaded param defs (module skipped)
6
+ ECUC-UNKNOWN-DEF container / parameter / reference whose DEFINITION-REF is not in the module def
7
+ ECUC-WRONG-PARENT container's def is not a child of its parent's def
8
+ ECUC-DEST-MISMATCH DEFINITION-REF DEST attribute differs from the def element type
9
+ ECUC-VALUE-TYPE NUMERICAL vs TEXTUAL value element does not match the def kind
10
+ ECUC-MULT-CONTAINER sub-container count outside the def multiplicity (incl. missing mandatory)
11
+ ECUC-CHOICE choice container does not hold exactly one choice
12
+ ECUC-MULT-PARAM parameter / reference count outside the def multiplicity
13
+ ECUC-MISSING-PARAM mandatory parameter / reference absent
14
+ ECUC-TYPE / ECUC-RANGE / ECUC-ENUM / ECUC-STRING / ECUC-EMPTY-VALUE value checks
15
+ ECUC-REF-DANGLING reference target does not exist
16
+ ECUC-REF-DEST reference target is of the wrong definition / metaclass
17
+ ECUC-REF-UNRESOLVED foreign reference target not in any loaded file (warning)
18
+ ECUC-DUP-NAME two containers with the same AUTOSAR path
19
+ ECUC-OBSOLETE value for an obsolete def (warning)
20
+ """
21
+ from __future__ import annotations
22
+
23
+ from typing import Optional
24
+
25
+ from . import defs as D
26
+ from .model import Project
27
+ from .values import check_value, expected_value_tag
28
+
29
+ GENERIC_CONTAINER_DESTS = {"ECUC-CONTAINER-DEF"}
30
+ SEV_ORDER = {"error": 0, "warning": 1, "info": 2, "hint": 3}
31
+
32
+
33
+ class Diags:
34
+ def __init__(self, project: Project):
35
+ self.p = project
36
+ self.items: list = []
37
+
38
+ def add(self, file, line, path, rule, severity, message, def_ref=None):
39
+ self.items.append({"file": self.p.rel(file), "line": line, "path": path, "rule": rule,
40
+ "severity": severity, "message": message, "defRef": def_ref})
41
+
42
+
43
+ def _same_def(defs: D.DefIndex, a: Optional[str], b: Optional[str]) -> bool:
44
+ if not a or not b:
45
+ return False
46
+ return a == b or defs.rel(a) == defs.rel(b)
47
+
48
+
49
+ def _resolve_def(defs: D.DefIndex, ref: Optional[str]) -> Optional[D.DefNode]:
50
+ if not ref:
51
+ return None
52
+ d = defs.get(ref)
53
+ if d is not None:
54
+ return d
55
+ # vendor-relocated path (/Vendor/Com/... vs /AUTOSAR/EcucDefs/Com/...): match by module-relative path
56
+ rel = defs.rel(ref)
57
+ for m in defs.modules.values():
58
+ if rel.split("/", 1)[0] == m.name:
59
+ return defs.get(m.path + rel[len(m.name):])
60
+ return None
61
+
62
+
63
+ def validate(project: Project, files: Optional[list] = None, module: Optional[str] = None) -> dict:
64
+ defs = project.defs
65
+ dg = Diags(project)
66
+ warned_modules = set()
67
+ visited = set()
68
+
69
+ def fmult(lo, up):
70
+ return f"{lo}..{'*' if up is None else up}"
71
+
72
+ def check_elem_group(path: str):
73
+ if path in visited:
74
+ return
75
+ visited.add(path)
76
+ group = project.find(path)
77
+ e0 = group[0]
78
+ if e0.kind == "container" and len(group) > 1:
79
+ for e in group[1:]:
80
+ dg.add(e.file, e.line, path, "ECUC-DUP-NAME", "error",
81
+ f"duplicate container path {path} (also at {project.rel(e0.file)}:{e0.line})", e.def_ref)
82
+ d = _resolve_def(defs, e0.def_ref)
83
+ if d is None:
84
+ if e0.kind == "module":
85
+ key = e0.def_ref or path
86
+ if key not in warned_modules:
87
+ warned_modules.add(key)
88
+ dg.add(e0.file, e0.line, path, "ECUC-NO-DEF", "warning",
89
+ f"no param def loaded for {e0.def_ref}; module not validated", e0.def_ref)
90
+ else:
91
+ dg.add(e0.file, e0.line, path, "ECUC-UNKNOWN-DEF", "error",
92
+ f"container definition {e0.def_ref} does not exist in the loaded param defs", e0.def_ref)
93
+ return
94
+ if e0.def_dest and e0.def_dest != d.tag and not (e0.def_dest in GENERIC_CONTAINER_DESTS and d.is_container):
95
+ dg.add(e0.file, e0.line, path, "ECUC-DEST-MISMATCH", "warning",
96
+ f"DEFINITION-REF DEST={e0.def_dest} but the def is {d.tag}", d.path)
97
+ if e0.kind == "container" and e0.parent is not None:
98
+ pd = _resolve_def(defs, e0.parent.def_ref)
99
+ if pd is not None and d.parent != pd.path:
100
+ dg.add(e0.file, e0.line, path, "ECUC-WRONG-PARENT", "error",
101
+ f"{d.name} is not a sub-container of {pd.name} (allowed: "
102
+ f"{', '.join(defs.by_path[c].name for c in pd.containers) or 'none'})", d.path)
103
+ if d.obsolete:
104
+ dg.add(e0.file, e0.line, path, "ECUC-OBSOLETE", "warning", f"{d.name} is obsolete", d.path)
105
+
106
+ # children (merged across split fragments)
107
+ children = [c for e in group for c in e.children]
108
+ params = [(e, pv) for e in group for pv in e.params]
109
+ counts: dict = {}
110
+ seen_child_paths = set()
111
+ for c in children:
112
+ if c.path in seen_child_paths:
113
+ continue
114
+ seen_child_paths.add(c.path)
115
+ cd = _resolve_def(defs, c.def_ref)
116
+ if cd is not None:
117
+ counts[cd.path] = counts.get(cd.path, 0) + 1
118
+ if d.tag == D.CHOICE_TAG:
119
+ n = sum(counts.get(c, 0) for c in d.containers)
120
+ if n != 1:
121
+ dg.add(e0.file, e0.line, path, "ECUC-CHOICE", "error",
122
+ f"choice container {d.name} holds {n} choices, needs exactly 1 of "
123
+ f"{', '.join(defs.by_path[c].name for c in d.containers)}", d.path)
124
+ else:
125
+ for cpath in d.containers:
126
+ cd = defs.by_path[cpath]
127
+ n = counts.get(cpath, 0)
128
+ if n < cd.lower or (cd.upper is not None and n > cd.upper):
129
+ dg.add(e0.file, e0.line, path, "ECUC-MULT-CONTAINER", "error",
130
+ f"{d.name} '{e0.name}': {cd.name} has {n} instance(s), def requires {fmult(cd.lower, cd.upper)}",
131
+ cd.path)
132
+ pcounts: dict = {}
133
+ for e, pv in params:
134
+ pd = _resolve_def(defs, pv.def_ref)
135
+ line = pv.node.line
136
+ if pd is None or pd.parent != d.path:
137
+ dg.add(e.file, line, path, "ECUC-UNKNOWN-DEF", "error",
138
+ f"parameter/reference {pv.def_ref} is not defined for {d.name}", pv.def_ref)
139
+ continue
140
+ pcounts[pd.path] = pcounts.get(pd.path, 0) + 1
141
+ if pd.obsolete:
142
+ dg.add(e.file, line, path, "ECUC-OBSOLETE", "warning", f"{pd.name} is obsolete", pd.path)
143
+ if pv.def_dest and pv.def_dest != pd.tag:
144
+ dg.add(e.file, line, path, "ECUC-DEST-MISMATCH", "warning",
145
+ f"{pd.name}: DEFINITION-REF DEST={pv.def_dest} but the def is {pd.tag}", pd.path)
146
+ exp = expected_value_tag(pd)
147
+ if exp and pv.tag != exp:
148
+ dg.add(e.file, line, path, "ECUC-VALUE-TYPE", "warning",
149
+ f"{pd.name} is a {pd.kind} def; value element should be {exp}, found {pv.tag}", pd.path)
150
+ if D.is_ref_kind(pd.kind or ""):
151
+ check_ref(e, pv, pd, path, line)
152
+ elif pd.kind != "ADD-INFO" and not pv.is_auto:
153
+ for rule, msg in check_value(pd, pv.value):
154
+ sev = "warning" if rule == "ECUC-EMPTY-VALUE" else "error"
155
+ dg.add(e.file, (pv.value_node or pv.node).line, path, rule, sev, f"{d.name} '{e0.name}': {msg}", pd.path)
156
+ for ppath in d.params + d.refs:
157
+ pd = defs.by_path[ppath]
158
+ n = pcounts.get(ppath, 0)
159
+ if n == 0 and pd.lower > 0:
160
+ dg.add(e0.file, e0.line, path, "ECUC-MISSING-PARAM", "error",
161
+ f"{d.name} '{e0.name}': mandatory {pd.name} ({pd.kind}, {pd.mult}) is missing", pd.path)
162
+ elif n < pd.lower or (pd.upper is not None and n > pd.upper):
163
+ dg.add(e0.file, e0.line, path, "ECUC-MULT-PARAM", "error",
164
+ f"{d.name} '{e0.name}': {pd.name} has {n} value(s), def requires {pd.mult}", pd.path)
165
+ for c in children:
166
+ check_elem_group(c.path)
167
+
168
+ def check_ref(e, pv, pd, path, line):
169
+ tgt = (pv.value or "").strip()
170
+ if pd.kind in ("INSTANCE-REFERENCE", "URI-REFERENCE"):
171
+ return
172
+ if not tgt:
173
+ dg.add(e.file, line, path, "ECUC-EMPTY-VALUE", "error", f"{pd.name}: empty reference", pd.path)
174
+ return
175
+ if pd.kind == "FOREIGN-REFERENCE":
176
+ hit = project.identifiables.get(tgt)
177
+ if hit is None:
178
+ if project.input_loaded:
179
+ dg.add(e.file, line, path, "ECUC-REF-DANGLING", "error",
180
+ f"{pd.name} -> {tgt}: target not found in any loaded ARXML", pd.path)
181
+ else:
182
+ dg.add(e.file, line, path, "ECUC-REF-UNRESOLVED", "warning",
183
+ f"{pd.name} -> {tgt}: target not in loaded files (add the System/SWC ARXML to "
184
+ f"'input' in .hydron/autosar.json)", pd.path)
185
+ return
186
+ # DESTINATION-TYPE is often an abstract metaclass (RTE-EVENT for TIMING-EVENT), which needs the
187
+ # metamodel to check; the VALUE-REF DEST attribute must name the target's concrete type exactly.
188
+ if pv.dest and pv.dest != hit[0]:
189
+ dg.add(e.file, line, path, "ECUC-REF-DEST", "error",
190
+ f"{pd.name} -> {tgt}: VALUE-REF DEST={pv.dest} but the target is a {hit[0]}", pd.path)
191
+ return
192
+ targets = project.find(tgt)
193
+ if not targets:
194
+ dg.add(e.file, line, path, "ECUC-REF-DANGLING", "error",
195
+ f"{pd.name} -> {tgt}: no ECUC container with that path", pd.path)
196
+ return
197
+ t = targets[0]
198
+ if pd.dest_refs and not any(_same_def(defs, t.def_ref, dr) for dr in pd.dest_refs):
199
+ dg.add(e.file, line, path, "ECUC-REF-DEST", "error",
200
+ f"{pd.name} -> {tgt} is a {t.def_ref.rsplit('/', 1)[-1] if t.def_ref else '?'}, def requires "
201
+ f"{' or '.join(r.rsplit('/', 1)[-1] for r in pd.dest_refs)}", pd.path)
202
+
203
+ for m in project.modules:
204
+ if module and m.name != module and (m.def_ref or "").rsplit("/", 1)[-1] != module:
205
+ continue
206
+ check_elem_group(m.path)
207
+
208
+ items = dg.items
209
+ if files:
210
+ want = {project.rel(f) for f in files} | set(files)
211
+ items = [i for i in items if i["file"] in want]
212
+ items.sort(key=lambda i: (i["file"], i["line"], SEV_ORDER.get(i["severity"], 9), i["rule"]))
213
+ summary: dict = {}
214
+ for i in items:
215
+ summary[i["severity"]] = summary.get(i["severity"], 0) + 1
216
+ return {
217
+ "validated": bool(defs),
218
+ "defsLoaded": len(defs.modules),
219
+ "modules": sorted({m.name for m in project.modules}),
220
+ "summary": summary,
221
+ "diagnostics": items,
222
+ }