@h2loop/autosar 0.1.2 → 1.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.
- package/README.md +4 -0
- package/agents/autosar-integrator.md +34 -2
- package/package.json +4 -1
- package/plugins/index.js +53 -23
- package/python/ecuc_engine/__init__.py +1 -1
- package/python/ecuc_engine/add_signal.py +351 -0
- package/python/ecuc_engine/arxml_edit.py +387 -0
- package/python/ecuc_engine/audit.py +372 -69
- package/python/ecuc_engine/cli.py +119 -10
- package/python/ecuc_engine/codegen/__init__.py +185 -0
- package/python/ecuc_engine/codegen/binding.py +210 -0
- package/python/ecuc_engine/codegen/bindings/sip_standin.json +111 -0
- package/python/ecuc_engine/codegen/cdiff.py +395 -0
- package/python/ecuc_engine/codegen/cwriter.py +150 -0
- package/python/ecuc_engine/codegen/emit.py +395 -0
- package/python/ecuc_engine/codegen/handles.py +89 -0
- package/python/ecuc_engine/codegen/mcal/tc4d.json +60 -0
- package/python/ecuc_engine/codegen/model.py +259 -0
- package/python/ecuc_engine/codegen/modules/__init__.py +7 -0
- package/python/ecuc_engine/codegen/modules/comstack.py +14 -0
- package/python/ecuc_engine/codegen/modules/dem.py +22 -0
- package/python/ecuc_engine/codegen/modules/mcal.py +170 -0
- package/python/ecuc_engine/codegen/modules/memstack.py +24 -0
- package/python/ecuc_engine/codegen/registry.py +133 -0
- package/python/ecuc_engine/codegen/schm_memmap.py +159 -0
- package/python/ecuc_engine/context.py +481 -0
- package/python/ecuc_engine/csrc.py +189 -0
- package/python/ecuc_engine/defs.py +30 -0
- package/python/ecuc_engine/diff.py +13 -5
- package/python/ecuc_engine/find.py +162 -0
- package/python/ecuc_engine/gen.py +105 -0
- package/python/ecuc_engine/history.py +146 -0
- package/python/ecuc_engine/model.py +33 -29
- package/python/ecuc_engine/paths.py +221 -0
- package/python/ecuc_engine/probe.py +229 -0
- package/python/ecuc_engine/rte_api.py +935 -0
- package/python/ecuc_engine/rules/__init__.py +5 -3
- package/python/ecuc_engine/rules/mem.py +84 -0
- package/python/ecuc_engine/rules/nvm.py +131 -0
- package/python/ecuc_engine/rules/pdur.py +87 -0
- package/python/ecuc_engine/rules/system.py +162 -0
- package/python/ecuc_engine/sysmodel.py +152 -0
- package/python/ecuc_engine/validate.py +52 -3
- package/python/ecuc_engine/verify_rte.py +445 -0
- package/python/pyproject.toml +1 -1
- package/skills/autosar/SKILL.md +57 -1
- package/skills/autosar/references/codegen.md +30 -0
- package/skills/autosar/references/project-layout.md +11 -2
- package/skills/autosar/references/runnable-code.md +50 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Git-history-lite: "similar past changes" for a SW-C, without embeddings.
|
|
2
|
+
|
|
3
|
+
Takes ``git log`` (a bounded number of commits) touching the SW-C's ARXML and C files and those of similar
|
|
4
|
+
SW-Cs, summarises each commit as an ARXML delta (the semantic ``arxml_diff`` of the before/after blobs) plus the
|
|
5
|
+
C diff hunks, and ranks by overlap with the task's terms (ports, data elements, words of the task) and recency.
|
|
6
|
+
Everything is bounded: commits scanned, hunks per commit, characters per summary, results returned.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import subprocess
|
|
13
|
+
import tempfile
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
MAX_COMMITS = 60
|
|
17
|
+
MAX_HUNK_CHARS = 1500
|
|
18
|
+
MAX_DELTA_ITEMS = 12
|
|
19
|
+
MAX_COMMIT_CHARS = 4000 # all C hunks of one commit together
|
|
20
|
+
MAX_FILES_PER_COMMIT = 6
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _git(root: str, *args, timeout: int = 60) -> Optional[str]:
|
|
24
|
+
try:
|
|
25
|
+
r = subprocess.run(["git", "-C", root, *args], capture_output=True, timeout=timeout)
|
|
26
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
27
|
+
return None
|
|
28
|
+
return r.stdout.decode("utf-8", "replace") if r.returncode == 0 else None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def is_repo(root: str) -> bool:
|
|
32
|
+
return (_git(root, "rev-parse", "--is-inside-work-tree") or "").strip() == "true"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def terms_of(text: str) -> set:
|
|
36
|
+
"""Identifier-ish terms, with CamelCase / snake_case parts, lower-cased; short noise words dropped."""
|
|
37
|
+
out = set()
|
|
38
|
+
for tok in re.findall(r"[A-Za-z_][A-Za-z0-9_]{2,}", text or ""):
|
|
39
|
+
out.add(tok.lower())
|
|
40
|
+
for part in re.findall(r"[A-Z]+(?![a-z])|[A-Z]?[a-z]+|\d+", tok):
|
|
41
|
+
if len(part) >= 3:
|
|
42
|
+
out.add(part.lower())
|
|
43
|
+
return out - STOP
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
STOP = {"the", "and", "for", "with", "from", "this", "that", "into", "when", "add", "use", "new", "void", "rte",
|
|
47
|
+
"std", "returntype", "uint8", "uint16", "uint32", "boolean", "return", "data", "static", "const", "then",
|
|
48
|
+
"should", "must", "set", "get", "true", "false", "null", "ptr", "null_ptr", "not", "are", "has", "its"}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _blob(root: str, rev: str, rel: str) -> Optional[bytes]:
|
|
52
|
+
try:
|
|
53
|
+
r = subprocess.run(["git", "-C", root, "show", f"{rev}:./{rel}"], capture_output=True, timeout=60)
|
|
54
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
55
|
+
return None
|
|
56
|
+
return r.stdout if r.returncode == 0 else None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def arxml_delta(root: str, commit: str, rel: str) -> dict:
|
|
60
|
+
"""Semantic delta of one ARXML file across ``commit`` (parent -> commit), via diff.diff on temp copies."""
|
|
61
|
+
from .diff import diff
|
|
62
|
+
before, after = _blob(root, f"{commit}^", rel), _blob(root, commit, rel)
|
|
63
|
+
if before is None and after is None:
|
|
64
|
+
return {"file": rel, "error": "blob not readable"}
|
|
65
|
+
if before is None or after is None:
|
|
66
|
+
return {"file": rel, "status": "added" if before is None else "deleted"}
|
|
67
|
+
with tempfile.TemporaryDirectory(prefix="h2l-hist-") as tmp:
|
|
68
|
+
a, b = os.path.join(tmp, "a.arxml"), os.path.join(tmp, "b.arxml")
|
|
69
|
+
open(a, "wb").write(before)
|
|
70
|
+
open(b, "wb").write(after)
|
|
71
|
+
try:
|
|
72
|
+
d = diff(a, b, root=tmp)
|
|
73
|
+
except Exception as ex: # noqa: BLE001
|
|
74
|
+
return {"file": rel, "error": f"arxml_diff failed: {ex}"}
|
|
75
|
+
items = [f"+ {x['path']} ({x['tag']})" for x in d["added"]] + [f"- {x['path']} ({x['tag']})" for x in d["removed"]]
|
|
76
|
+
for x in d["changed"]:
|
|
77
|
+
vals = x.get("values")
|
|
78
|
+
items.append(f"~ {x['path']}" + (": " + ", ".join(f"{k} {v[0]}->{v[1]}" for k, v in sorted(vals.items()))
|
|
79
|
+
if vals else " (content)"))
|
|
80
|
+
return {"file": rel, "summary": d["summary"], "items": items[:MAX_DELTA_ITEMS],
|
|
81
|
+
"more": max(0, len(items) - MAX_DELTA_ITEMS)}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def c_hunks(root: str, commit: str, rel: str) -> str:
|
|
85
|
+
out = _git(root, "show", "--format=", "--no-color", "--no-ext-diff", "-U2", commit, "--", rel) or ""
|
|
86
|
+
body = "\n".join(ln for ln in out.splitlines() if not ln.startswith(("diff --git", "index ", "--- ", "+++ ")))
|
|
87
|
+
return body if len(body) <= MAX_HUNK_CHARS else body[:MAX_HUNK_CHARS] + "\n… (hunk truncated)"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def similar_changes(root: str, files: list, related: list, task_terms: set, limit: int = 5,
|
|
91
|
+
max_commits: int = MAX_COMMITS) -> dict:
|
|
92
|
+
"""files: the SW-C's own ARXML/C files (abs); related: those of similar SW-Cs. -> {changes, scanned, notes}."""
|
|
93
|
+
if not is_repo(root):
|
|
94
|
+
return {"changes": [], "scanned": 0, "notes": ["not a git repository; no history"]}
|
|
95
|
+
rels = sorted({os.path.relpath(f, root) for f in files + related if f and os.path.exists(f)})
|
|
96
|
+
own = {os.path.relpath(f, root) for f in files if f}
|
|
97
|
+
if not rels:
|
|
98
|
+
return {"changes": [], "scanned": 0, "notes": ["no files to look up in history"]}
|
|
99
|
+
log = _git(root, "log", f"-n{max_commits}", "--format=%x1e%H%x1f%ct%x1f%s", "--name-only", "--", *rels) or ""
|
|
100
|
+
commits = []
|
|
101
|
+
for chunk in log.split("\x1e"):
|
|
102
|
+
if not chunk.strip():
|
|
103
|
+
continue
|
|
104
|
+
head, *names = chunk.strip("\n").split("\n")
|
|
105
|
+
h, ct, subj = (head.split("\x1f") + ["", ""])[:3]
|
|
106
|
+
commits.append((h, int(ct or 0), subj, [n for n in names if n.strip() in rels]))
|
|
107
|
+
scored = []
|
|
108
|
+
for i, (h, ct, subj, touched) in enumerate(commits):
|
|
109
|
+
text = subj + "\n" + "\n".join(touched)
|
|
110
|
+
overlap = terms_of(text) & task_terms
|
|
111
|
+
scored.append((i, h, ct, subj, touched, overlap))
|
|
112
|
+
# cheap first pass by subject/file overlap + recency; expensive deltas only for the best candidates
|
|
113
|
+
pre = sorted(scored, key=lambda x: (-(len(x[5]) * 2 + (1 if set(x[4]) & own else 0)), x[0]))[:limit * 3]
|
|
114
|
+
out = []
|
|
115
|
+
for i, h, ct, subj, touched, _ov in pre:
|
|
116
|
+
deltas, hunks = [], []
|
|
117
|
+
budget = MAX_COMMIT_CHARS
|
|
118
|
+
# the SW-C's own files first, then the rest, at most MAX_FILES_PER_COMMIT
|
|
119
|
+
touched = sorted(touched, key=lambda r: (r not in own, r))[:MAX_FILES_PER_COMMIT]
|
|
120
|
+
for rel in touched:
|
|
121
|
+
if rel.lower().endswith(".arxml"):
|
|
122
|
+
deltas.append(arxml_delta(root, h, rel))
|
|
123
|
+
elif rel.lower().endswith((".c", ".h")) and budget > 0:
|
|
124
|
+
d = c_hunks(root, h, rel)
|
|
125
|
+
if len(d) > budget:
|
|
126
|
+
d = d[:budget] + "\n… (commit diff truncated)"
|
|
127
|
+
budget -= len(d)
|
|
128
|
+
hunks.append({"file": rel, "diff": d})
|
|
129
|
+
full = subj + "\n" + "\n".join(json_lines(deltas)) + "\n" + "\n".join(x["diff"] for x in hunks)
|
|
130
|
+
overlap = sorted(terms_of(full) & task_terms)
|
|
131
|
+
recency = 1.0 / (1 + i)
|
|
132
|
+
score = round(len(overlap) + recency + (0.5 if set(touched) & own else 0.0), 4)
|
|
133
|
+
out.append({"commit": h[:12], "subject": subj, "score": score, "overlap": overlap[:20],
|
|
134
|
+
"ownFiles": sorted(set(touched) & own), "arxml": deltas, "c": hunks, "_order": i})
|
|
135
|
+
out.sort(key=lambda x: (-x["score"], x["_order"]))
|
|
136
|
+
for x in out:
|
|
137
|
+
x.pop("_order")
|
|
138
|
+
return {"changes": out[:limit], "scanned": len(commits), "notes": []}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def json_lines(deltas: list) -> list:
|
|
142
|
+
lines = []
|
|
143
|
+
for d in deltas:
|
|
144
|
+
lines.append(d.get("file", ""))
|
|
145
|
+
lines.extend(d.get("items", []))
|
|
146
|
+
return lines
|
|
@@ -16,9 +16,10 @@ from dataclasses import dataclass, field
|
|
|
16
16
|
from typing import Optional
|
|
17
17
|
|
|
18
18
|
from . import defs as D
|
|
19
|
+
from . import paths
|
|
19
20
|
from .xmltree import Node, parse
|
|
20
21
|
|
|
21
|
-
SKIP_DIRS =
|
|
22
|
+
SKIP_DIRS = paths.TOOLING_DIRS # kept for importers; discovery lives in paths.py
|
|
22
23
|
PROJECT_CONFIG = os.path.join(".hydron", "autosar.json")
|
|
23
24
|
|
|
24
25
|
MODULE_VALUES = "ECUC-MODULE-CONFIGURATION-VALUES"
|
|
@@ -102,30 +103,28 @@ def _scan_markers(path: str) -> set:
|
|
|
102
103
|
return found
|
|
103
104
|
|
|
104
105
|
|
|
105
|
-
def _walk_arxml(root: str):
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if fn.lower().endswith(".arxml"):
|
|
112
|
-
yield os.path.join(dp, fn)
|
|
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
|
|
113
112
|
|
|
114
113
|
|
|
115
|
-
def _globs(root: str, pats) -> list:
|
|
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 [])
|
|
116
117
|
out = []
|
|
117
|
-
for p in pats
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
else:
|
|
123
|
-
out.extend(sorted(glob.glob(full, recursive=True)))
|
|
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)
|
|
124
123
|
seen, res = set(), []
|
|
125
124
|
for f in out:
|
|
126
|
-
a = os.path.abspath(f)
|
|
127
|
-
if
|
|
128
|
-
seen.add(
|
|
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)
|
|
129
128
|
res.append(a)
|
|
130
129
|
return res
|
|
131
130
|
|
|
@@ -138,6 +137,7 @@ class Sources:
|
|
|
138
137
|
input: list
|
|
139
138
|
warnings: list
|
|
140
139
|
origin: dict # which mechanism found each set
|
|
140
|
+
notes: list = field(default_factory=list) # info: hidden dirs included, cycles, caps
|
|
141
141
|
|
|
142
142
|
|
|
143
143
|
def discover(root: str, config_files: Optional[list] = None, def_files: Optional[list] = None) -> Sources:
|
|
@@ -153,30 +153,31 @@ def discover(root: str, config_files: Optional[list] = None, def_files: Optional
|
|
|
153
153
|
warnings.append(f"{PROJECT_CONFIG} is not valid JSON ({e}); ignoring it")
|
|
154
154
|
origin = {}
|
|
155
155
|
scanned = None
|
|
156
|
+
notes: list = []
|
|
156
157
|
|
|
157
158
|
def scan():
|
|
158
159
|
nonlocal scanned
|
|
159
160
|
if scanned is None:
|
|
160
|
-
scanned = {f: _scan_markers(f) for f in _walk_arxml(root)}
|
|
161
|
+
scanned = {f: _scan_markers(f) for f in _walk_arxml(root, notes)}
|
|
161
162
|
return scanned
|
|
162
163
|
|
|
163
164
|
if def_files:
|
|
164
|
-
dfiles, origin["defs"] = _globs(root, def_files), "argument"
|
|
165
|
+
dfiles, origin["defs"] = _globs(root, def_files, warnings, "--defs"), "argument"
|
|
165
166
|
elif cfg.get("paramDefs"):
|
|
166
|
-
dfiles, origin["defs"] = _globs(root, cfg["paramDefs"]), PROJECT_CONFIG
|
|
167
|
+
dfiles, origin["defs"] = _globs(root, cfg["paramDefs"], warnings, "paramDefs"), PROJECT_CONFIG
|
|
167
168
|
else:
|
|
168
169
|
dfiles, origin["defs"] = [f for f, m in scan().items() if "defs" in m], "auto-discovered"
|
|
169
170
|
if not dfiles and os.environ.get("AUTOSAR_PARAMDEFS"):
|
|
170
|
-
dfiles = _globs(root, os.environ["AUTOSAR_PARAMDEFS"].split(os.pathsep))
|
|
171
|
+
dfiles = _globs(root, os.environ["AUTOSAR_PARAMDEFS"].split(os.pathsep), warnings, "AUTOSAR_PARAMDEFS")
|
|
171
172
|
origin["defs"] = "AUTOSAR_PARAMDEFS"
|
|
172
173
|
if config_files:
|
|
173
|
-
cfiles, origin["config"] = _globs(root, config_files), "argument"
|
|
174
|
+
cfiles, origin["config"] = _globs(root, config_files, warnings, "--config"), "argument"
|
|
174
175
|
elif cfg.get("config"):
|
|
175
|
-
cfiles, origin["config"] = _globs(root, cfg["config"]), PROJECT_CONFIG
|
|
176
|
+
cfiles, origin["config"] = _globs(root, cfg["config"], warnings, "config"), PROJECT_CONFIG
|
|
176
177
|
else:
|
|
177
178
|
cfiles, origin["config"] = [f for f, m in scan().items() if "config" in m], "auto-discovered"
|
|
178
179
|
if cfg.get("input"):
|
|
179
|
-
ifiles, origin["input"] = _globs(root, cfg["input"]), PROJECT_CONFIG
|
|
180
|
+
ifiles, origin["input"] = _globs(root, cfg["input"], warnings, "input"), PROJECT_CONFIG
|
|
180
181
|
else:
|
|
181
182
|
known = set(cfiles) | set(dfiles)
|
|
182
183
|
ifiles, origin["input"] = [f for f in scan() if f not in known], "auto-discovered"
|
|
@@ -185,7 +186,7 @@ def discover(root: str, config_files: Optional[list] = None, def_files: Optional
|
|
|
185
186
|
"No ECUC param definitions found (no ECUC-MODULE-DEF in the project, no paramDefs in "
|
|
186
187
|
f"{PROJECT_CONFIG}, AUTOSAR_PARAMDEFS unset). Values are readable but NOT validated, and edits "
|
|
187
188
|
"cannot add new elements. Point paramDefs at the AUTOSAR / vendor BSWMD param-def ARXML.")
|
|
188
|
-
return Sources(root, cfiles, dfiles, ifiles, warnings, origin)
|
|
189
|
+
return Sources(root, cfiles, dfiles, ifiles, warnings, origin, notes)
|
|
189
190
|
|
|
190
191
|
|
|
191
192
|
def _dest(n: Optional[Node]) -> Optional[str]:
|
|
@@ -282,9 +283,12 @@ class Project:
|
|
|
282
283
|
self._load(f)
|
|
283
284
|
self.foreign: dict = {}
|
|
284
285
|
self.input_loaded = False
|
|
286
|
+
from .sysmodel import SystemIndex
|
|
287
|
+
self.system = SystemIndex()
|
|
285
288
|
if load_input:
|
|
286
289
|
for f in self.sources.input:
|
|
287
|
-
self.
|
|
290
|
+
self.system.add_file(f)
|
|
291
|
+
self.foreign = self.system.foreign()
|
|
288
292
|
self.input_loaded = bool(self.sources.input)
|
|
289
293
|
self.reindex()
|
|
290
294
|
|
|
@@ -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
|