@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.
- package/.hydron-plugin/plugin.json +3 -0
- package/README.md +53 -2
- package/agents/autosar-integrator.md +44 -0
- package/commands/autosar-trace.md +11 -0
- package/commands/autosar-validate.md +12 -0
- package/package.json +44 -4
- package/plugins/index.js +26 -0
- package/python/ecuc_engine/__init__.py +2 -0
- package/python/ecuc_engine/__main__.py +5 -0
- package/python/ecuc_engine/audit.py +475 -0
- package/python/ecuc_engine/cli.py +125 -0
- package/python/ecuc_engine/defs.py +330 -0
- package/python/ecuc_engine/diff.py +119 -0
- package/python/ecuc_engine/ecucjson.py +154 -0
- package/python/ecuc_engine/edit.py +429 -0
- package/python/ecuc_engine/model.py +333 -0
- package/python/ecuc_engine/paths.py +221 -0
- package/python/ecuc_engine/rules/__init__.py +82 -0
- package/python/ecuc_engine/rules/com.py +107 -0
- package/python/ecuc_engine/rules/handles.py +42 -0
- package/python/ecuc_engine/validate.py +263 -0
- package/python/ecuc_engine/values.py +146 -0
- package/python/ecuc_engine/xmltree.py +141 -0
- package/python/pyproject.toml +13 -0
- package/skills/autosar/SKILL.md +103 -0
- package/skills/autosar/references/config-classes.md +23 -0
- package/skills/autosar/references/ecuc-model.md +42 -0
- package/skills/autosar/references/project-layout.md +32 -0
- package/skills/autosar/references/symbolic-names.md +25 -0
- package/skills/autosar/references/workflow.md +29 -0
|
@@ -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}
|