@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
+ """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,82 @@
1
+ """Hand-written semantic rule packs (the "H" checks the param defs cannot express).
2
+
3
+ A rule is a function registered with @rule(...). It receives a RuleContext and yields
4
+ (elem, line, message, def_ref) tuples; the registry adds the rule id, severity and spec citation.
5
+ Rules are keyed by module-relative DEFINITION-REFs ("Com/ComConfig/ComSignal/ComBitPosition"), so
6
+ they work for /AUTOSAR/EcucDefs/... and vendor-relocated defs alike, and run with or without the
7
+ param defs loaded. To add a pack: create a module here with @rule functions and import it below.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from typing import Callable, Iterable, Optional
13
+
14
+ from ..model import Elem, ParamVal, Project
15
+
16
+
17
+ @dataclass
18
+ class Rule:
19
+ id: str
20
+ severity: str
21
+ cite: str
22
+ title: str
23
+ fn: Callable
24
+
25
+
26
+ RULES: list = []
27
+
28
+
29
+ def rule(id: str, severity: str, cite: str, title: str):
30
+ def deco(fn):
31
+ RULES.append(Rule(id, severity, cite, title, fn))
32
+ return fn
33
+ return deco
34
+
35
+
36
+ class RuleContext:
37
+ def __init__(self, project: Project):
38
+ self.p = project
39
+ self.defs = project.defs
40
+ self._by_rel: Optional[dict] = None
41
+
42
+ def rel(self, def_ref: Optional[str]) -> str:
43
+ return self.defs.rel(def_ref) if def_ref else ""
44
+
45
+ def containers(self, rel_def: str) -> list:
46
+ """All container values (first fragment per path) whose def is ``rel_def`` (module-relative)."""
47
+ if self._by_rel is None:
48
+ self._by_rel = {}
49
+ for path, group in self.p.by_path.items():
50
+ self._by_rel.setdefault(self.rel(group[0].def_ref), []).append(group[0])
51
+ return self._by_rel.get(rel_def, [])
52
+
53
+ def values(self, e: Elem, name: str) -> list:
54
+ return [pv for g in self.p.find(e.path) for pv in g.params if pv.name == name]
55
+
56
+ def value(self, e: Elem, name: str) -> Optional[ParamVal]:
57
+ v = self.values(e, name)
58
+ return v[0] if v else None
59
+
60
+ def text(self, e: Elem, name: str) -> Optional[str]:
61
+ v = self.value(e, name)
62
+ return v.value if v is not None else None
63
+
64
+ def target(self, pv: Optional[ParamVal]) -> Optional[Elem]:
65
+ if pv is None or not pv.value:
66
+ return None
67
+ hit = self.p.find(pv.value.strip())
68
+ return hit[0] if hit else None
69
+
70
+ def module_of(self, e: Elem) -> Elem:
71
+ while e.parent is not None:
72
+ e = e.parent
73
+ return e
74
+
75
+
76
+ def run_rules(project: Project) -> Iterable[dict]:
77
+ from . import com, handles # noqa: F401 (registers the packs)
78
+ ctx = RuleContext(project)
79
+ for r in RULES:
80
+ for e, line, msg, def_ref in r.fn(ctx):
81
+ yield {"file": project.rel(e.file), "line": line, "path": e.path, "rule": r.id,
82
+ "severity": r.severity, "message": f"{msg} [{r.cite}]", "defRef": def_ref}
@@ -0,0 +1,107 @@
1
+ """COM I-PDU packing rules: signals inside the I-PDU, no overlapping signals."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Optional
5
+
6
+ from . import rule
7
+ from ..values import parse_int
8
+
9
+ TYPE_BITS = {"BOOLEAN": 1, "UINT8": 8, "SINT8": 8, "UINT16": 16, "SINT16": 16, "UINT32": 32, "SINT32": 32,
10
+ "UINT64": 64, "SINT64": 64, "FLOAT32": 32, "FLOAT64": 64}
11
+ CITE_BOUNDS = "ECUC_Com_00259 ComBitPosition (position within the I-PDU), ECUC_Com_00158 ComBitSize, ECUC_EcuC_00003 PduLength"
12
+ CITE_OVERLAP = "SWS_Com_CONSTR_00102 Signals Separated in I-PDU"
13
+
14
+
15
+ def _size(ctx, s) -> Optional[int]:
16
+ bs = parse_int(ctx.text(s, "ComBitSize") or "")
17
+ if bs is not None:
18
+ return bs
19
+ typ = (ctx.text(s, "ComSignalType") or "").strip()
20
+ if typ in ("UINT8_N", "UINT8_DYN"):
21
+ n = parse_int(ctx.text(s, "ComSignalLength") or "")
22
+ return n * 8 if n is not None else None
23
+ return TYPE_BITS.get(typ)
24
+
25
+
26
+ def bits(pos: int, size: int, endian: str) -> list:
27
+ """Occupied bit indices (byte*8 + bit). ComBitPosition is the LSB. BIG_ENDIAN (Motorola) walks
28
+ toward the MSB by going up within a byte, then to bit 0 of the previous byte."""
29
+ if endian == "BIG_ENDIAN":
30
+ out, b = [], pos
31
+ for _ in range(size):
32
+ out.append(b)
33
+ b = b + 1 if b % 8 != 7 else b - 15
34
+ return out
35
+ return list(range(pos, pos + size)) # LITTLE_ENDIAN and OPAQUE (bit0 of first byte, little-endian order)
36
+
37
+
38
+ def _ipdus(ctx):
39
+ """(ipdu, length_bytes|None, [(member, kind)]) for every ComIPdu."""
40
+ for ipdu in ctx.containers("Com/ComConfig/ComIPdu"):
41
+ pdu = ctx.target(ctx.value(ipdu, "ComPduIdRef"))
42
+ length = parse_int(ctx.text(pdu, "PduLength") or "") if pdu is not None else None
43
+ members = []
44
+ for ref in ctx.values(ipdu, "ComIPduSignalRef"):
45
+ s = ctx.target(ref)
46
+ if s is not None:
47
+ members.append(s)
48
+ for ref in ctx.values(ipdu, "ComIPduSignalGroupRef"):
49
+ grp = ctx.target(ref)
50
+ if grp is None:
51
+ continue
52
+ members.extend(c for g in ctx.p.find(grp.path) for c in g.children
53
+ if ctx.rel(c.def_ref).endswith("/ComGroupSignal"))
54
+ members.append(grp) # contributes only its update bit
55
+ yield ipdu, length, pdu, members
56
+
57
+
58
+ def _layout(ctx, s):
59
+ """[(label, bitset, line)] a signal/group occupies: its value bits and its update bit."""
60
+ out = []
61
+ pos = parse_int(ctx.text(s, "ComBitPosition") or "")
62
+ size = _size(ctx, s)
63
+ if pos is not None and size:
64
+ endian = (ctx.text(s, "ComSignalEndianness") or "LITTLE_ENDIAN").strip()
65
+ out.append((s.name, bits(pos, size, endian), ctx.value(s, "ComBitPosition").node.line, pos, size, endian))
66
+ ub = parse_int(ctx.text(s, "ComUpdateBitPosition") or "")
67
+ if ub is not None:
68
+ out.append((f"{s.name}.updateBit", [ub], ctx.value(s, "ComUpdateBitPosition").node.line, ub, 1, "LITTLE_ENDIAN"))
69
+ return out
70
+
71
+
72
+ @rule("H2L-COM-001", "error", CITE_BOUNDS, "signal bits lie inside its I-PDU (PduLength * 8)")
73
+ def signal_in_pdu(ctx):
74
+ for ipdu, length, pdu, members in _ipdus(ctx):
75
+ if length is None:
76
+ continue
77
+ limit = length * 8
78
+ for s in members:
79
+ for label, bs, line, pos, size, endian in _layout(ctx, s):
80
+ lo, hi = min(bs), max(bs)
81
+ if lo < 0 or hi >= limit:
82
+ yield s, line, (f"{label}: ComBitPosition={pos} ComBitSize={size} ({endian}) occupies bits {lo}..{hi}, "
83
+ f"outside I-PDU {ipdu.name} ({pdu.path} PduLength={length} bytes = bits 0..{limit - 1})"), \
84
+ (ctx.value(s, "ComBitPosition") or ctx.value(s, "ComUpdateBitPosition")).def_ref
85
+
86
+
87
+ @rule("H2L-COM-002", "error", CITE_OVERLAP, "signals of one I-PDU do not overlap")
88
+ def no_overlap(ctx):
89
+ for ipdu, length, pdu, members in _ipdus(ctx):
90
+ owner: dict = {}
91
+ reported = set()
92
+ for s in members:
93
+ for label, bs, line, pos, size, endian in _layout(ctx, s):
94
+ for b in bs:
95
+ prev = owner.get(b)
96
+ if prev is None:
97
+ owner[b] = (label, s)
98
+ continue
99
+ if prev[0] == label:
100
+ continue
101
+ key = tuple(sorted((prev[0], label)))
102
+ if key in reported:
103
+ continue
104
+ reported.add(key)
105
+ yield s, line, (f"{label} overlaps {prev[0]} at bit {b} in I-PDU {ipdu.name} "
106
+ f"({label}: pos {pos} size {size} {endian})"), \
107
+ (ctx.value(s, "ComBitPosition") or ctx.value(s, "ComUpdateBitPosition")).def_ref
@@ -0,0 +1,42 @@
1
+ """Handle-ID uniqueness (TPS_ECUConfiguration 3.4.1.1 "Handle ID concept")."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+
6
+ from . import rule
7
+ from ..values import parse_int
8
+
9
+ # Handle-ID params by name when no defs say so; with defs, any SYMBOLIC-NAME-VALUE integer counts.
10
+ NAME = re.compile(r"(HandleId|^CanIfTxPduId|^CanIfRxPduId)$")
11
+
12
+
13
+ @rule("H2L-HID-001", "error",
14
+ "TPS_ECUConfiguration 3.4.1.1 Handle ID concept; ECUC_Com_00165/00175, ECUC_PduR_00322, ECUC_CanIf_00591/00597",
15
+ "handle IDs are unique per module and handle-ID parameter")
16
+ def unique_handle_ids(ctx):
17
+ seen: dict = {} # (module path, rel def) -> {value: [(elem, pv)]}
18
+ for path, group in ctx.p.by_path.items():
19
+ e = group[0]
20
+ if e.kind != "container":
21
+ continue
22
+ mod = ctx.module_of(e)
23
+ for g in group:
24
+ for pv in g.params:
25
+ if pv.is_ref or pv.value is None:
26
+ continue
27
+ d = ctx.defs.get(pv.def_ref)
28
+ is_handle = (d.kind == "INTEGER" and d.symbolic_name) if d is not None else False
29
+ if not is_handle and not NAME.search(pv.name):
30
+ continue
31
+ v = parse_int(pv.value)
32
+ if v is None:
33
+ continue
34
+ key = (mod.path, ctx.rel(pv.def_ref))
35
+ seen.setdefault(key, {}).setdefault(v, []).append((g, pv))
36
+ for (mod, rel), by_val in seen.items():
37
+ for v, hits in by_val.items():
38
+ if len(hits) < 2:
39
+ continue
40
+ names = ", ".join(sorted({h[0].name for h in hits}))
41
+ for g, pv in hits:
42
+ yield g, pv.node.line, f"{pv.name}={v} is used by {len(hits)} containers in {mod} ({names}); handle IDs must be unique", pv.def_ref