@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.
Files changed (49) hide show
  1. package/README.md +4 -0
  2. package/agents/autosar-integrator.md +34 -2
  3. package/package.json +4 -1
  4. package/plugins/index.js +53 -23
  5. package/python/ecuc_engine/__init__.py +1 -1
  6. package/python/ecuc_engine/add_signal.py +351 -0
  7. package/python/ecuc_engine/arxml_edit.py +387 -0
  8. package/python/ecuc_engine/audit.py +372 -69
  9. package/python/ecuc_engine/cli.py +119 -10
  10. package/python/ecuc_engine/codegen/__init__.py +185 -0
  11. package/python/ecuc_engine/codegen/binding.py +210 -0
  12. package/python/ecuc_engine/codegen/bindings/sip_standin.json +111 -0
  13. package/python/ecuc_engine/codegen/cdiff.py +395 -0
  14. package/python/ecuc_engine/codegen/cwriter.py +150 -0
  15. package/python/ecuc_engine/codegen/emit.py +395 -0
  16. package/python/ecuc_engine/codegen/handles.py +89 -0
  17. package/python/ecuc_engine/codegen/mcal/tc4d.json +60 -0
  18. package/python/ecuc_engine/codegen/model.py +259 -0
  19. package/python/ecuc_engine/codegen/modules/__init__.py +7 -0
  20. package/python/ecuc_engine/codegen/modules/comstack.py +14 -0
  21. package/python/ecuc_engine/codegen/modules/dem.py +22 -0
  22. package/python/ecuc_engine/codegen/modules/mcal.py +170 -0
  23. package/python/ecuc_engine/codegen/modules/memstack.py +24 -0
  24. package/python/ecuc_engine/codegen/registry.py +133 -0
  25. package/python/ecuc_engine/codegen/schm_memmap.py +159 -0
  26. package/python/ecuc_engine/context.py +481 -0
  27. package/python/ecuc_engine/csrc.py +189 -0
  28. package/python/ecuc_engine/defs.py +30 -0
  29. package/python/ecuc_engine/diff.py +13 -5
  30. package/python/ecuc_engine/find.py +162 -0
  31. package/python/ecuc_engine/gen.py +105 -0
  32. package/python/ecuc_engine/history.py +146 -0
  33. package/python/ecuc_engine/model.py +33 -29
  34. package/python/ecuc_engine/paths.py +221 -0
  35. package/python/ecuc_engine/probe.py +229 -0
  36. package/python/ecuc_engine/rte_api.py +935 -0
  37. package/python/ecuc_engine/rules/__init__.py +5 -3
  38. package/python/ecuc_engine/rules/mem.py +84 -0
  39. package/python/ecuc_engine/rules/nvm.py +131 -0
  40. package/python/ecuc_engine/rules/pdur.py +87 -0
  41. package/python/ecuc_engine/rules/system.py +162 -0
  42. package/python/ecuc_engine/sysmodel.py +152 -0
  43. package/python/ecuc_engine/validate.py +52 -3
  44. package/python/ecuc_engine/verify_rte.py +445 -0
  45. package/python/pyproject.toml +1 -1
  46. package/skills/autosar/SKILL.md +57 -1
  47. package/skills/autosar/references/codegen.md +30 -0
  48. package/skills/autosar/references/project-layout.md +11 -2
  49. 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 = {".git", "node_modules", ".hydron", ".venv", "__pycache__", ".cache", ".idea", ".vscode"}
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
- # Sorted walk: os.walk order is filesystem-dependent (macOS and Linux differ),
107
- # and load order decides which definition of a path wins.
108
- for dp, dns, fns in os.walk(root):
109
- dns[:] = sorted(d for d in dns if d not in SKIP_DIRS and not d.startswith("."))
110
- for fn in sorted(fns):
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 or []:
118
- p = os.path.expanduser(p)
119
- full = p if os.path.isabs(p) else os.path.join(root, p)
120
- if os.path.isdir(full):
121
- out.extend(_walk_arxml(full))
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 a not in seen and os.path.isfile(a):
128
- seen.add(a)
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.foreign.update(index_identifiables(f))
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