@h2loop/autosar 0.0.0-stage → 1.0.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,333 @@
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 . import paths
20
+ from .xmltree import Node, parse
21
+
22
+ SKIP_DIRS = paths.TOOLING_DIRS # kept for importers; discovery lives in paths.py
23
+ PROJECT_CONFIG = os.path.join(".hydron", "autosar.json")
24
+
25
+ MODULE_VALUES = "ECUC-MODULE-CONFIGURATION-VALUES"
26
+ CONTAINER_VALUE = "ECUC-CONTAINER-VALUE"
27
+ PARAM_VALUE_TAGS = {"ECUC-NUMERICAL-PARAM-VALUE", "ECUC-TEXTUAL-PARAM-VALUE", "ECUC-ADD-INFO-PARAM-VALUE"}
28
+ REF_VALUE_TAGS = {"ECUC-REFERENCE-VALUE", "ECUC-INSTANCE-REFERENCE-VALUE"}
29
+
30
+
31
+ @dataclass
32
+ class ParamVal:
33
+ node: Node
34
+ def_ref: Optional[str]
35
+ def_dest: Optional[str]
36
+ tag: str
37
+ value: Optional[str] # VALUE text / VALUE-REF text / None
38
+ value_node: Optional[Node]
39
+ dest: Optional[str] = None # VALUE-REF DEST
40
+ is_auto: bool = False
41
+
42
+ @property
43
+ def name(self) -> str:
44
+ return (self.def_ref or "?").rsplit("/", 1)[-1]
45
+
46
+ @property
47
+ def is_ref(self) -> bool:
48
+ return self.tag in REF_VALUE_TAGS
49
+
50
+
51
+ @dataclass
52
+ class Elem:
53
+ """A module configuration (ECUC-MODULE-CONFIGURATION-VALUES) or a container value."""
54
+ node: Node
55
+ file: str
56
+ path: str
57
+ name: str
58
+ kind: str # "module" | "container"
59
+ def_ref: Optional[str]
60
+ def_dest: Optional[str]
61
+ params: list = field(default_factory=list) # ParamVal (params and refs)
62
+ children: list = field(default_factory=list) # Elem
63
+ parent: Optional["Elem"] = None
64
+
65
+ @property
66
+ def line(self) -> int:
67
+ return self.node.line
68
+
69
+
70
+ @dataclass
71
+ class FileModel:
72
+ path: str
73
+ data: bytes
74
+ root: Node
75
+ elems: list # top-level module Elems
76
+ identifiables: dict # path -> (tag, line)
77
+ newline: bytes = b"\n"
78
+
79
+
80
+ _MARKERS = {
81
+ "defs": re.compile(rb"<(?:[\w.-]+:)?ECUC-MODULE-DEF[\s>]"),
82
+ "config": re.compile(rb"<(?:[\w.-]+:)?ECUC-MODULE-CONFIGURATION-VALUES[\s>]"),
83
+ }
84
+
85
+
86
+ def _scan_markers(path: str) -> set:
87
+ """Which element kinds a file holds (element starts only; DEST="ECUC-MODULE-DEF" text does not count)."""
88
+ found = set()
89
+ tail = b""
90
+ try:
91
+ with open(path, "rb") as f:
92
+ while len(found) < len(_MARKERS):
93
+ chunk = f.read(1 << 20)
94
+ if not chunk:
95
+ break
96
+ buf = tail + chunk
97
+ for k, rx in _MARKERS.items():
98
+ if k not in found and rx.search(buf):
99
+ found.add(k)
100
+ tail = buf[-96:]
101
+ except OSError:
102
+ pass
103
+ return found
104
+
105
+
106
+ def _walk_arxml(root: str, notes: Optional[list] = None):
107
+ """Every ARXML file under root (any depth/folder name/extension case); sorted, deterministic."""
108
+ w = paths.walk(root, paths.is_arxml)
109
+ if notes is not None:
110
+ notes.extend(w.notes(root))
111
+ return w.files
112
+
113
+
114
+ def _globs(root: str, pats, warnings: Optional[list] = None, what: str = "") -> list:
115
+ """Files/dirs/globs (relative or absolute, trailing slash optional) -> ARXML files on disk."""
116
+ pats = [pats] if isinstance(pats, str) else list(pats or [])
117
+ out = []
118
+ for p in paths.patterns(root, pats):
119
+ got = [f for f in p.expand(paths.is_arxml) if paths.is_arxml(f)]
120
+ if not got and warnings is not None:
121
+ warnings.append(f"{what or 'path'} {p.raw!r} matched no .arxml file under {root}")
122
+ out.extend(got)
123
+ seen, res = set(), []
124
+ for f in out:
125
+ a, real = os.path.abspath(f), os.path.realpath(f)
126
+ if real not in seen and os.path.isfile(a):
127
+ seen.add(real)
128
+ res.append(a)
129
+ return res
130
+
131
+
132
+ @dataclass
133
+ class Sources:
134
+ root: str
135
+ config: list
136
+ defs: list
137
+ input: list
138
+ warnings: list
139
+ origin: dict # which mechanism found each set
140
+ notes: list = field(default_factory=list) # info: hidden dirs included, cycles, caps
141
+
142
+
143
+ def discover(root: str, config_files: Optional[list] = None, def_files: Optional[list] = None) -> Sources:
144
+ root = os.path.abspath(root)
145
+ cfg = {}
146
+ cfg_path = os.path.join(root, PROJECT_CONFIG)
147
+ warnings: list = []
148
+ if os.path.isfile(cfg_path):
149
+ try:
150
+ with open(cfg_path) as f:
151
+ cfg = json.load(f)
152
+ except Exception as e:
153
+ warnings.append(f"{PROJECT_CONFIG} is not valid JSON ({e}); ignoring it")
154
+ origin = {}
155
+ scanned = None
156
+ notes: list = []
157
+
158
+ def scan():
159
+ nonlocal scanned
160
+ if scanned is None:
161
+ scanned = {f: _scan_markers(f) for f in _walk_arxml(root, notes)}
162
+ return scanned
163
+
164
+ if def_files:
165
+ dfiles, origin["defs"] = _globs(root, def_files, warnings, "--defs"), "argument"
166
+ elif cfg.get("paramDefs"):
167
+ dfiles, origin["defs"] = _globs(root, cfg["paramDefs"], warnings, "paramDefs"), PROJECT_CONFIG
168
+ else:
169
+ dfiles, origin["defs"] = [f for f, m in scan().items() if "defs" in m], "auto-discovered"
170
+ if not dfiles and os.environ.get("AUTOSAR_PARAMDEFS"):
171
+ dfiles = _globs(root, os.environ["AUTOSAR_PARAMDEFS"].split(os.pathsep), warnings, "AUTOSAR_PARAMDEFS")
172
+ origin["defs"] = "AUTOSAR_PARAMDEFS"
173
+ if config_files:
174
+ cfiles, origin["config"] = _globs(root, config_files, warnings, "--config"), "argument"
175
+ elif cfg.get("config"):
176
+ cfiles, origin["config"] = _globs(root, cfg["config"], warnings, "config"), PROJECT_CONFIG
177
+ else:
178
+ cfiles, origin["config"] = [f for f, m in scan().items() if "config" in m], "auto-discovered"
179
+ if cfg.get("input"):
180
+ ifiles, origin["input"] = _globs(root, cfg["input"], warnings, "input"), PROJECT_CONFIG
181
+ else:
182
+ known = set(cfiles) | set(dfiles)
183
+ ifiles, origin["input"] = [f for f in scan() if f not in known], "auto-discovered"
184
+ if not dfiles:
185
+ warnings.append(
186
+ "No ECUC param definitions found (no ECUC-MODULE-DEF in the project, no paramDefs in "
187
+ f"{PROJECT_CONFIG}, AUTOSAR_PARAMDEFS unset). Values are readable but NOT validated, and edits "
188
+ "cannot add new elements. Point paramDefs at the AUTOSAR / vendor BSWMD param-def ARXML.")
189
+ return Sources(root, cfiles, dfiles, ifiles, warnings, origin, notes)
190
+
191
+
192
+ def _dest(n: Optional[Node]) -> Optional[str]:
193
+ return n.attrs.get("DEST") if n is not None else None
194
+
195
+
196
+ def build_file_model(path: str, data: bytes) -> FileModel:
197
+ root = parse(data)
198
+ elems: list = []
199
+ idents: dict = {}
200
+
201
+ def visit(node: Node, prefix: str, parent: Optional[Elem]):
202
+ sn = node.short_name
203
+ path_here = prefix
204
+ if sn is not None:
205
+ path_here = f"{prefix}/{sn}"
206
+ idents.setdefault(path_here, (node.tag, node.line))
207
+ if node.tag in (MODULE_VALUES, CONTAINER_VALUE) and sn is not None:
208
+ dr = node.child("DEFINITION-REF")
209
+ e = Elem(node=node, file=path, path=path_here, name=sn,
210
+ kind="module" if node.tag == MODULE_VALUES else "container",
211
+ def_ref=dr.text.strip() if dr is not None else None, def_dest=_dest(dr), parent=parent)
212
+ for coll in node.children:
213
+ if coll.tag in ("PARAMETER-VALUES", "REFERENCE-VALUES"):
214
+ for pv in coll.children:
215
+ e.params.append(_param(pv))
216
+ if parent is not None:
217
+ parent.children.append(e)
218
+ else:
219
+ elems.append(e)
220
+ for coll in node.children:
221
+ if coll.tag in ("CONTAINERS", "SUB-CONTAINERS"):
222
+ for c in coll.children:
223
+ visit(c, path_here, e)
224
+ return
225
+ for c in node.children:
226
+ if c.tag in ("SHORT-NAME", "DESC", "ADMIN-DATA", "INTRODUCTION", "ANNOTATIONS"):
227
+ continue
228
+ visit(c, path_here, parent if sn is None else None)
229
+
230
+ visit(root, "", None)
231
+ nl = b"\r\n" if b"\r\n" in data[:4096] else b"\n"
232
+ return FileModel(path, data, root, elems, idents, nl)
233
+
234
+
235
+ def _param(pv: Node) -> ParamVal:
236
+ dr = pv.child("DEFINITION-REF")
237
+ vn = pv.child("VALUE") if pv.tag in PARAM_VALUE_TAGS else pv.child("VALUE-REF") or pv.child("VALUE-IREF")
238
+ val = None
239
+ if vn is not None:
240
+ val = vn.text.strip() if vn.tag != "VALUE-IREF" else "(instance-ref)"
241
+ if pv.tag == "ECUC-ADD-INFO-PARAM-VALUE":
242
+ val = " ".join(vn.text.split())
243
+ auto = (pv.child_text("IS-AUTO-VALUE") or "").lower() == "true"
244
+ return ParamVal(node=pv, def_ref=dr.text.strip() if dr is not None else None, def_dest=_dest(dr), tag=pv.tag,
245
+ value=val, value_node=vn, dest=_dest(vn) if vn is not None and vn.tag == "VALUE-REF" else None,
246
+ is_auto=auto)
247
+
248
+
249
+ def index_identifiables(path: str) -> dict:
250
+ """Fast path -> (tag, line=0) index of a non-config ARXML (System, SWC, BSWMD) for foreign refs."""
251
+ out: dict = {}
252
+ stack: list = [] # (short_name or None)
253
+ pending: list = []
254
+ try:
255
+ for ev, el in ET.iterparse(path, events=("start", "end")):
256
+ tag = el.tag.rsplit("}", 1)[-1]
257
+ if ev == "start":
258
+ stack.append(None)
259
+ pending.append(tag)
260
+ else:
261
+ if tag == "SHORT-NAME" and len(stack) >= 2:
262
+ stack[-2] = (el.text or "").strip()
263
+ p = "/" + "/".join(s for s in stack[:-1] if s)
264
+ out.setdefault(p, (pending[-2], 0))
265
+ stack.pop()
266
+ pending.pop()
267
+ el.clear()
268
+ except ET.ParseError:
269
+ pass
270
+ return out
271
+
272
+
273
+ class Project:
274
+ def __init__(self, root: str, config_files=None, def_files=None, load_input: bool = True):
275
+ self.sources = discover(root, config_files, def_files)
276
+ self.root = self.sources.root
277
+ self.warnings = list(self.sources.warnings)
278
+ self.defs = D.load_defs(self.sources.defs) if self.sources.defs else D.DefIndex()
279
+ if self.sources.defs and not self.defs:
280
+ self.warnings.append("param-def files were found but contain no ECUC-MODULE-DEF")
281
+ self.files: dict[str, FileModel] = {}
282
+ for f in self.sources.config:
283
+ self._load(f)
284
+ self.foreign: dict = {}
285
+ self.input_loaded = False
286
+ if load_input:
287
+ for f in self.sources.input:
288
+ self.foreign.update(index_identifiables(f))
289
+ self.input_loaded = bool(self.sources.input)
290
+ self.reindex()
291
+
292
+ def _load(self, f: str, data: Optional[bytes] = None):
293
+ if data is None:
294
+ with open(f, "rb") as fh:
295
+ data = fh.read()
296
+ try:
297
+ self.files[f] = build_file_model(f, data)
298
+ except Exception as e:
299
+ self.warnings.append(f"{self.rel(f)}: XML parse error: {e}")
300
+
301
+ def reload(self, f: str, data: bytes):
302
+ self._load(f, data)
303
+ self.reindex()
304
+
305
+ def reindex(self):
306
+ self.by_path: dict[str, list] = {}
307
+ self.modules: list = []
308
+ self.identifiables: dict = dict(self.foreign)
309
+ for fm in self.files.values():
310
+ for p, v in fm.identifiables.items():
311
+ self.identifiables.setdefault(p, v + (fm.path,))
312
+ for m in fm.elems:
313
+ self.modules.append(m)
314
+ for e in _iter_elems(m):
315
+ self.by_path.setdefault(e.path, []).append(e)
316
+
317
+ def rel(self, f: str) -> str:
318
+ try:
319
+ r = os.path.relpath(f, self.root)
320
+ return f if r.startswith("..") else r
321
+ except ValueError:
322
+ return f
323
+
324
+ def find(self, path: str) -> list:
325
+ return self.by_path.get(path.rstrip("/"), [])
326
+
327
+
328
+ def _iter_elems(e: Elem):
329
+ stack = [e]
330
+ while stack:
331
+ x = stack.pop()
332
+ yield x
333
+ stack.extend(reversed(x.children))
@@ -0,0 +1,221 @@
1
+ """Layout-agnostic file discovery and path filters.
2
+
3
+ Nothing here assumes Config/, Input/, Source/ or Output/:
4
+ - walk() finds files at any depth, skips only tooling dirs, includes other hidden dirs (and says so),
5
+ follows symlinked dirs once with cycle protection, and stops at a cap with a clear message.
6
+ - Pattern() matches a user path argument against files: an exact file, a directory (= path prefix,
7
+ with or without trailing slash), a glob with ** (relative to the root, or anywhere when it has no
8
+ slash), relative or absolute.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import fnmatch
13
+ import os
14
+ import re
15
+ from dataclasses import dataclass, field
16
+ from typing import Callable, Iterable, Optional
17
+
18
+ TOOLING_DIRS = {".git", ".hg", ".svn", ".hydron", ".venv", "venv", "node_modules", "__pycache__", ".cache",
19
+ ".idea", ".vscode", ".pytest_cache", ".mypy_cache", ".tox"}
20
+ MAX_FILES = int(os.environ.get("AUTOSAR_MAX_FILES", "200000"))
21
+ GLOB_CHARS = set("*?[")
22
+
23
+
24
+ def is_arxml(name: str) -> bool:
25
+ return name.lower().endswith(".arxml") # .arxml, .ARXML, .ecuc.arxml, ...
26
+
27
+
28
+ def is_c(name: str) -> bool:
29
+ return name.lower().endswith((".c", ".h"))
30
+
31
+
32
+ @dataclass
33
+ class Walk:
34
+ files: list = field(default_factory=list)
35
+ hidden: list = field(default_factory=list) # hidden dirs that contributed files
36
+ cycles: list = field(default_factory=list) # symlinked dirs skipped as already visited
37
+ truncated: bool = False
38
+ visited_dirs: int = 0
39
+
40
+ def notes(self, root: str) -> list:
41
+ out = []
42
+ for d in self.hidden:
43
+ out.append(f"included hidden directory {os.path.relpath(d, root)}/ (it contains matching files)")
44
+ for d in self.cycles[:5]:
45
+ out.append(f"symlinked directory {os.path.relpath(d, root)} skipped: already visited (cycle)")
46
+ if self.truncated:
47
+ out.append(f"file walk stopped at {MAX_FILES} files; narrow the scan with .hydron/autosar.json globs "
48
+ "or raise AUTOSAR_MAX_FILES")
49
+ return out
50
+
51
+
52
+ def walk(root: str, want: Callable[[str], bool], cap: int = 0) -> Walk:
53
+ """All files under ``root`` (any depth) for which ``want(name)`` is true, sorted per directory."""
54
+ cap = cap or MAX_FILES
55
+ res = Walk()
56
+ seen: set = set()
57
+ hidden_hits: dict = {}
58
+
59
+ def visit(d: str, hidden_anc: Optional[str]):
60
+ real = os.path.realpath(d)
61
+ if real in seen:
62
+ res.cycles.append(d)
63
+ return
64
+ seen.add(real)
65
+ res.visited_dirs += 1
66
+ try:
67
+ entries = sorted(os.scandir(d), key=lambda e: e.name)
68
+ except OSError:
69
+ return
70
+ subdirs = []
71
+ for e in entries:
72
+ if res.truncated:
73
+ return
74
+ try:
75
+ if e.is_dir(follow_symlinks=True):
76
+ if e.name not in TOOLING_DIRS:
77
+ subdirs.append(e)
78
+ elif e.is_file(follow_symlinks=True) and want(e.name):
79
+ res.files.append(e.path)
80
+ if hidden_anc:
81
+ hidden_hits.setdefault(hidden_anc, True)
82
+ if len(res.files) >= cap:
83
+ res.truncated = True
84
+ return
85
+ except OSError:
86
+ continue
87
+ for e in subdirs:
88
+ anc = hidden_anc or (e.path if e.name.startswith(".") else None)
89
+ visit(e.path, anc)
90
+
91
+ if os.path.isdir(root):
92
+ visit(root, None)
93
+ res.hidden = sorted(hidden_hits)
94
+ return res
95
+
96
+
97
+ def _glob_regex(pat: str) -> re.Pattern:
98
+ """Glob with ** (any number of dirs), * and ? (within a segment), [..] classes."""
99
+ out, i = [], 0
100
+ while i < len(pat):
101
+ if pat.startswith("**/", i):
102
+ out.append("(?:.*/)?")
103
+ i += 3
104
+ elif pat.startswith("**", i):
105
+ out.append(".*")
106
+ i += 2
107
+ elif pat[i] == "*":
108
+ out.append("[^/]*")
109
+ i += 1
110
+ elif pat[i] == "?":
111
+ out.append("[^/]")
112
+ i += 1
113
+ elif pat[i] == "[":
114
+ j = pat.find("]", i)
115
+ if j < 0:
116
+ out.append(re.escape(pat[i]))
117
+ i += 1
118
+ else:
119
+ out.append(fnmatch.translate(pat[i:j + 1])[4:-3])
120
+ i = j + 1
121
+ else:
122
+ out.append(re.escape(pat[i]))
123
+ i += 1
124
+ return re.compile("".join(out) + r"\Z", re.IGNORECASE) # *.ARXML == *.arxml
125
+
126
+
127
+ def _norm(p: str) -> str:
128
+ return p.replace("\\", "/")
129
+
130
+
131
+ class Pattern:
132
+ """One user-supplied path argument, resolved against ``root``."""
133
+
134
+ def __init__(self, root: str, raw: str):
135
+ self.raw = raw
136
+ p = os.path.expanduser(str(raw).strip())
137
+ stripped = p.rstrip("/\\") or p
138
+ self.is_glob = any(ch in GLOB_CHARS for ch in stripped)
139
+ full = stripped if os.path.isabs(stripped) else os.path.join(root, stripped)
140
+ self.abs = os.path.normpath(full)
141
+ self.real = os.path.realpath(self.abs)
142
+ self.root = os.path.realpath(root)
143
+ if self.is_glob:
144
+ self.kind = "glob"
145
+ self.rx = _glob_regex(_norm(stripped))
146
+ self.anywhere = "/" not in _norm(stripped).lstrip("./") # "*.arxml" or "Com*.arxml": match basenames
147
+ elif os.path.isdir(self.abs):
148
+ self.kind = "dir"
149
+ elif os.path.isfile(self.abs):
150
+ self.kind = "file"
151
+ else:
152
+ self.kind = "missing"
153
+
154
+ def matches(self, path: str) -> bool:
155
+ a = os.path.normpath(os.path.abspath(path))
156
+ r = os.path.realpath(a)
157
+ if self.kind == "file":
158
+ return a == self.abs or r == self.real
159
+ if self.kind == "dir":
160
+ return any(x.startswith(base.rstrip(os.sep) + os.sep) for x in (a, r) for base in (self.abs, self.real))
161
+ if self.kind == "glob":
162
+ rel = _norm(os.path.relpath(r, self.root))
163
+ cands = [rel, _norm(a), _norm(r), os.path.basename(a)] if self.anywhere else [rel, _norm(a), _norm(r)]
164
+ return any(self.rx.match(c) for c in cands) or (self.anywhere and any(self.rx.match(c.split("/")[-1]) for c in cands))
165
+ # missing on disk: maybe a path relative to root with odd separators, or a basename
166
+ name = _norm(str(self.raw)).strip("/")
167
+ rel = _norm(os.path.relpath(r, self.root))
168
+ return rel == name or rel.startswith(name + "/") or os.path.basename(a) == name
169
+
170
+ def expand(self, want: Callable[[str], bool]) -> list:
171
+ """Files on disk this pattern names (dirs walked recursively)."""
172
+ if self.kind == "file":
173
+ return [self.abs]
174
+ if self.kind == "dir":
175
+ return walk(self.abs, want).files
176
+ if self.kind == "glob":
177
+ # Walk (cycle-safe, tooling dirs skipped) from the glob's fixed prefix instead of glob.glob,
178
+ # which follows symlink loops and is case-sensitive.
179
+ stripped = _norm(os.path.expanduser(str(self.raw).strip())).rstrip("/")
180
+ fixed = []
181
+ for seg in stripped.split("/"):
182
+ if any(ch in GLOB_CHARS for ch in seg):
183
+ break
184
+ fixed.append(seg)
185
+ prefix = "/".join(fixed)
186
+ base = prefix if os.path.isabs(stripped) else os.path.join(self.root, prefix) if prefix else self.root
187
+ if self.anywhere:
188
+ base = self.root
189
+ if not os.path.isdir(base):
190
+ return []
191
+ return [f for f in walk(base, want).files if self.matches(f)]
192
+ return []
193
+
194
+
195
+ def patterns(root: str, raws: Optional[Iterable]) -> list:
196
+ return [Pattern(root, r) for r in (raws or []) if str(r).strip()]
197
+
198
+
199
+ def expand(root: str, raws: Optional[Iterable], want: Callable[[str], bool]) -> list:
200
+ """Files named by the patterns, each real file once (symlinked routes collapse)."""
201
+ seen, out = set(), []
202
+ for p in patterns(root, raws):
203
+ for f in p.expand(want):
204
+ real = os.path.realpath(f)
205
+ if real not in seen:
206
+ seen.add(real)
207
+ out.append(os.path.abspath(f))
208
+ return out
209
+
210
+
211
+ def select(root: str, raws: Optional[Iterable], files: Iterable[str]) -> tuple:
212
+ """(matched files, patterns that matched nothing) for a filter over already-loaded files."""
213
+ pats = patterns(root, raws)
214
+ files = list(files)
215
+ hit, empty = [], []
216
+ for p in pats:
217
+ m = [f for f in files if p.matches(f)]
218
+ if not m:
219
+ empty.append(p.raw)
220
+ hit.extend(m)
221
+ return sorted(set(hit)), empty
@@ -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}