@h2loop/autosar 1.0.1 → 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 (47) 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 +328 -48
  9. package/python/ecuc_engine/cli.py +114 -5
  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/find.py +162 -0
  30. package/python/ecuc_engine/gen.py +105 -0
  31. package/python/ecuc_engine/history.py +146 -0
  32. package/python/ecuc_engine/model.py +4 -1
  33. package/python/ecuc_engine/probe.py +229 -0
  34. package/python/ecuc_engine/rte_api.py +935 -0
  35. package/python/ecuc_engine/rules/__init__.py +5 -3
  36. package/python/ecuc_engine/rules/mem.py +84 -0
  37. package/python/ecuc_engine/rules/nvm.py +131 -0
  38. package/python/ecuc_engine/rules/pdur.py +87 -0
  39. package/python/ecuc_engine/rules/system.py +162 -0
  40. package/python/ecuc_engine/sysmodel.py +152 -0
  41. package/python/ecuc_engine/validate.py +15 -2
  42. package/python/ecuc_engine/verify_rte.py +445 -0
  43. package/python/pyproject.toml +1 -1
  44. package/skills/autosar/SKILL.md +47 -1
  45. package/skills/autosar/references/codegen.md +30 -0
  46. package/skills/autosar/references/project-layout.md +7 -1
  47. package/skills/autosar/references/runnable-code.md +50 -0
@@ -0,0 +1,189 @@
1
+ """Local, dependency-free C function index: definitions, bodies, callees, Rte_* calls and the call graph.
2
+
3
+ Function definitions are found at brace depth 0 in comment/string-stripped text (audit.strip_comments, which
4
+ keeps offsets): a '{' preceded by ')' whose matching '(' follows an identifier is a definition of that
5
+ identifier. Works with AUTOSAR compiler-abstraction macros (FUNC(void, X_CODE) Name(void)) because the name is
6
+ the identifier directly before the parameter list. Preprocessor lines are ignored.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import re
12
+ from dataclasses import dataclass, field
13
+ from typing import Optional
14
+
15
+ from . import paths
16
+ from .audit import is_generated, iter_calls, strip_comments
17
+
18
+ KEYWORDS = {"if", "for", "while", "switch", "return", "sizeof", "defined", "else", "do", "case", "goto", "typeof",
19
+ "_Alignof", "__attribute__", "__asm__", "asm", "offsetof"}
20
+ RTE_CALL = re.compile(r"\b(Rte_\w+)\s*\(")
21
+ ANY_CALL = re.compile(r"\b([A-Za-z_]\w*)\s*\(")
22
+
23
+
24
+ @dataclass
25
+ class Func:
26
+ name: str
27
+ file: str
28
+ line: int
29
+ end_line: int
30
+ start: int # offset of the declaration start
31
+ body_start: int # offset of '{'
32
+ end: int # offset after '}'
33
+ static: bool
34
+ signature: str
35
+ calls: list = field(default_factory=list) # callee names in order of first use
36
+ rte: list = field(default_factory=list) # [(name, line, args)]
37
+
38
+ def text(self, src: str) -> str:
39
+ return src[self.start:self.end]
40
+
41
+
42
+ def _blank_preproc(text: str) -> str:
43
+ out = []
44
+ cont = False
45
+ for ln in text.split("\n"):
46
+ if cont or ln.lstrip().startswith("#"):
47
+ cont = ln.rstrip().endswith("\\")
48
+ out.append(" " * len(ln))
49
+ else:
50
+ out.append(ln)
51
+ return "\n".join(out)
52
+
53
+
54
+ def _match_open(s: str, close: int) -> int:
55
+ depth = 0
56
+ for i in range(close, -1, -1):
57
+ if s[i] == ")":
58
+ depth += 1
59
+ elif s[i] == "(":
60
+ depth -= 1
61
+ if depth == 0:
62
+ return i
63
+ return -1
64
+
65
+
66
+ def functions(path: str, raw: Optional[str] = None) -> tuple:
67
+ """-> (list of Func, raw source)."""
68
+ if raw is None:
69
+ try:
70
+ raw = open(path, encoding="utf-8", errors="replace").read()
71
+ except OSError:
72
+ return [], ""
73
+ s = _blank_preproc(strip_comments(raw))
74
+ out = []
75
+ depth = 0
76
+ i, n = 0, len(s)
77
+ last_end = 0 # end of the previous top-level construct (for the declaration start)
78
+ while i < n:
79
+ c = s[i]
80
+ if c == "{":
81
+ if depth == 0:
82
+ j = i - 1
83
+ while j >= 0 and s[j].isspace():
84
+ j -= 1
85
+ fn = None
86
+ if j >= 0 and s[j] == ")":
87
+ o = _match_open(s, j)
88
+ k = o - 1
89
+ while k >= 0 and s[k].isspace():
90
+ k -= 1
91
+ m = re.search(r"([A-Za-z_]\w*)$", s[max(0, k - 200):k + 1]) if k >= 0 else None
92
+ if m and m.group(1) not in KEYWORDS:
93
+ fn = m.group(1)
94
+ if fn:
95
+ # matching close brace
96
+ d, e = 0, i
97
+ while e < n:
98
+ if s[e] == "{":
99
+ d += 1
100
+ elif s[e] == "}":
101
+ d -= 1
102
+ if d == 0:
103
+ break
104
+ e += 1
105
+ head_start = max(s.rfind(";", 0, i), s.rfind("}", 0, i), last_end - 1) + 1
106
+ while head_start < i and s[head_start].isspace():
107
+ head_start += 1
108
+ sig = " ".join(s[head_start:i].split())
109
+ f = Func(fn, path, s.count("\n", 0, head_start) + 1, s.count("\n", 0, e) + 1, head_start, i,
110
+ e + 1, bool(re.match(r"(?:\w+\s+)*?static\b", sig)), sig)
111
+ body = s[i:e + 1]
112
+ seen = []
113
+ for mm in ANY_CALL.finditer(body):
114
+ name = mm.group(1)
115
+ if name in KEYWORDS or name == fn:
116
+ continue
117
+ if name not in seen:
118
+ seen.append(name)
119
+ f.calls = seen
120
+ for name, off, _line, args in iter_calls(body, RTE_CALL):
121
+ f.rte.append((name, f.line + body.count("\n", 0, off) + (s.count("\n", head_start, i)), args))
122
+ out.append(f)
123
+ i = e + 1
124
+ last_end = i
125
+ continue
126
+ depth += 1
127
+ elif c == "}":
128
+ depth = max(0, depth - 1)
129
+ if depth == 0:
130
+ last_end = i + 1
131
+ elif c == ";" and depth == 0:
132
+ last_end = i + 1
133
+ i += 1
134
+ return out, raw
135
+
136
+
137
+ class CIndex:
138
+ """All function definitions under the given C files (in-house first; generated files kept separately)."""
139
+
140
+ def __init__(self, root: str, files: Optional[list] = None, include_generated: bool = False):
141
+ self.root = root
142
+ all_c = files if files is not None else paths.walk(root, paths.is_c).files
143
+ self.cfiles = sorted(f for f in all_c if f.lower().endswith(".c"))
144
+ self.generated = {f for f in self.cfiles if is_generated(f)}
145
+ self.funcs: dict = {} # name -> [Func]
146
+ self.src: dict = {}
147
+ self.includes: dict = {}
148
+ for f in self.cfiles:
149
+ if f in self.generated and not include_generated:
150
+ continue
151
+ fs, raw = functions(f)
152
+ self.src[f] = raw
153
+ self.includes[f] = re.findall(r'^\s*#\s*include\s*[<"]([^>"]+)[>"]', raw, re.M)
154
+ for fn in fs:
155
+ self.funcs.setdefault(fn.name, []).append(fn)
156
+
157
+ def get(self, name: str, prefer_file: Optional[str] = None) -> Optional[Func]:
158
+ lst = self.funcs.get(name) or []
159
+ if prefer_file:
160
+ for f in lst:
161
+ if f.file == prefer_file:
162
+ return f
163
+ nonstatic = [f for f in lst if not f.static]
164
+ return (nonstatic or lst or [None])[0]
165
+
166
+ def callee(self, caller: Func, name: str) -> Optional[Func]:
167
+ """Resolve a call from ``caller``: a static function in the same file wins, else a global one."""
168
+ lst = self.funcs.get(name) or []
169
+ same = [f for f in lst if f.file == caller.file]
170
+ if same:
171
+ return same[0]
172
+ glob = [f for f in lst if not f.static]
173
+ return glob[0] if glob else None
174
+
175
+ def reachable(self, start: Func, limit: int = 2000) -> list:
176
+ seen, order, stack = {id(start)}, [start], [start]
177
+ while stack and len(order) < limit:
178
+ f = stack.pop()
179
+ for c in f.calls:
180
+ g = self.callee(f, c)
181
+ if g is not None and id(g) not in seen:
182
+ seen.add(id(g))
183
+ order.append(g)
184
+ stack.append(g)
185
+ return order
186
+
187
+ def rel(self, f: str) -> str:
188
+ r = os.path.relpath(f, self.root)
189
+ return f if r.startswith("..") else r
@@ -76,6 +76,11 @@ class DefNode:
76
76
  refs: list = field(default_factory=list) # child ref def paths
77
77
  parent: Optional[str] = None
78
78
  module: Optional[str] = None # module def path
79
+ # Config classes per IMPLEMENTATION-CONFIG-VARIANT ({"VARIANT-PRE-COMPILE": "PRE-COMPILE", ...}).
80
+ config_classes: Optional[dict] = None # VALUE-CONFIG-CLASSES (params/refs)
81
+ mult_classes: Optional[dict] = None # MULTIPLICITY-CONFIG-CLASSES (containers, params)
82
+ requires_symbolic: bool = False # REQUIRES-SYMBOLIC-NAME-VALUE (refs)
83
+ variants: Optional[list] = None # SUPPORTED-CONFIG-VARIANTS (module defs)
79
84
 
80
85
  @property
81
86
  def is_container(self) -> bool:
@@ -218,6 +223,13 @@ def _make_def(el, tag: str, sn: str, path: str, path_file: str) -> DefNode:
218
223
  d.upper = None
219
224
  else:
220
225
  d.upper = _int(direct["UPPER-MULTIPLICITY"].text if "UPPER-MULTIPLICITY" in direct else None, 1)
226
+ for wrap, attr in (("VALUE-CONFIG-CLASSES", "config_classes"), ("MULTIPLICITY-CONFIG-CLASSES", "mult_classes")):
227
+ w = direct.get(wrap)
228
+ if w is not None:
229
+ setattr(d, attr, _config_classes(w))
230
+ sv = direct.get("SUPPORTED-CONFIG-VARIANTS")
231
+ if sv is not None:
232
+ d.variants = [(c.text or "").strip() for c in sv if _local(c.tag) == "SUPPORTED-CONFIG-VARIANT"]
221
233
  tr = direct.get("RELATED-TRACE-ITEM-REF")
222
234
  if tr is not None:
223
235
  d.trace = (tr.text or "").strip()
@@ -256,9 +268,27 @@ def _make_def(el, tag: str, sn: str, path: str, path_file: str) -> DefNode:
256
268
  d.with_auto = txt.lower() == "true"
257
269
  elif t == "SYMBOLIC-NAME-VALUE":
258
270
  d.symbolic_name = txt.lower() == "true"
271
+ elif t == "REQUIRES-SYMBOLIC-NAME-VALUE":
272
+ d.requires_symbolic = txt.lower() == "true"
259
273
  return d
260
274
 
261
275
 
276
+ def _config_classes(wrapper) -> dict:
277
+ """<VALUE-CONFIG-CLASSES>/<MULTIPLICITY-CONFIG-CLASSES> -> {CONFIG-VARIANT: CONFIG-CLASS}."""
278
+ out = {}
279
+ for c in wrapper:
280
+ cls = var = None
281
+ for f in c:
282
+ t = _local(f.tag)
283
+ if t == "CONFIG-CLASS":
284
+ cls = (f.text or "").strip()
285
+ elif t == "CONFIG-VARIANT":
286
+ var = (f.text or "").strip()
287
+ if cls and var:
288
+ out[var] = cls
289
+ return out
290
+
291
+
262
292
  def load_defs(paths: list[str], use_cache: bool = True) -> DefIndex:
263
293
  idx = DefIndex()
264
294
  for p in paths:
@@ -0,0 +1,162 @@
1
+ """Structured ECUC queries (ecuc_find) and per-module cross-check tables (ecuc_crosscheck)."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from typing import Optional
6
+
7
+ from .model import Project
8
+ from .values import parse_int
9
+
10
+ import operator
11
+
12
+ CMP = {">": operator.gt, "<": operator.lt, ">=": operator.ge, "<=": operator.le}
13
+ OPS = re.compile(r"^\s*([A-Za-z_]\w*)\s*(!=|>=|<=|=|>|<|~)\s*(.*?)\s*$")
14
+
15
+
16
+ def _where(where) -> list:
17
+ if not where:
18
+ return []
19
+ if isinstance(where, dict):
20
+ return [(k, "=", v) for k, v in where.items()]
21
+ out = []
22
+ for part in re.split(r"\s*(?:,|&&|\band\b)\s*", str(where)):
23
+ m = OPS.match(part)
24
+ if m:
25
+ out.append((m.group(1), m.group(2), m.group(3)))
26
+ return out
27
+
28
+
29
+ def _cmp(vals: list, op: str, want) -> bool:
30
+ w = str(want)
31
+ w = w[2:] if w.startswith("->") else w
32
+ for v in vals:
33
+ s = (v or "").strip()
34
+ if op == "=" and (s == w or s.lower() == w.lower()):
35
+ return True
36
+ if op == "!=" and s != w:
37
+ return True
38
+ if op == "~" and re.search(w, s):
39
+ return True
40
+ if op in (">", "<", ">=", "<="):
41
+ a, b = parse_int(s), parse_int(w)
42
+ if a is not None and b is not None and CMP[op](a, b):
43
+ return True
44
+ return op == "!=" and not vals
45
+
46
+
47
+ def _own(project: Project, e) -> dict:
48
+ vals: dict = {}
49
+ for g in project.find(e.path):
50
+ for pv in g.params:
51
+ vals.setdefault(pv.name, []).append(pv.value)
52
+ return vals
53
+
54
+
55
+ def _refs(project: Project, e, depth: int) -> set:
56
+ """Reference targets of e, following references up to ``depth`` hops."""
57
+ out, frontier = set(), [e]
58
+ for _ in range(depth):
59
+ nxt = []
60
+ for x in frontier:
61
+ for g in project.find(x.path):
62
+ for pv in g.params:
63
+ if pv.is_ref and pv.value:
64
+ t = pv.value.strip()
65
+ if t not in out:
66
+ out.add(t)
67
+ hit = project.find(t)
68
+ if hit:
69
+ nxt.append(hit[0])
70
+ frontier = nxt
71
+ return out
72
+
73
+
74
+ def find(project: Project, module: Optional[str] = None, container_def: Optional[str] = None, where=None,
75
+ ref_to: Optional[str] = None, referenced_by: Optional[str] = None, limit: int = 50, depth: int = 2) -> dict:
76
+ from .ecucjson import resolve
77
+ conds = _where(where)
78
+ targets = None
79
+ if ref_to:
80
+ r = resolve(project, ref_to)
81
+ if isinstance(r, dict):
82
+ sys = project.system.get(ref_to)
83
+ if sys is None:
84
+ return {"error": r["error"], "didYouMean": r.get("didYouMean", [])}
85
+ r = ref_to
86
+ targets = r
87
+ owner_refs = None
88
+ if referenced_by:
89
+ r = resolve(project, referenced_by)
90
+ if isinstance(r, dict):
91
+ return {"error": r["error"], "didYouMean": r.get("didYouMean", [])}
92
+ owner_refs = _refs(project, project.find(r)[0], 1)
93
+ hits = []
94
+ total = 0
95
+ for path, group in project.by_path.items():
96
+ e = group[0]
97
+ if e.kind != "container":
98
+ continue
99
+ dname = (e.def_ref or "").rsplit("/", 1)[-1]
100
+ if container_def and not (dname == container_def or (e.def_ref or "").endswith("/" + container_def.strip("/"))):
101
+ continue
102
+ if module:
103
+ m = e
104
+ while m.parent is not None:
105
+ m = m.parent
106
+ if module not in (m.name, (m.def_ref or "").rsplit("/", 1)[-1]):
107
+ continue
108
+ if owner_refs is not None and path not in owner_refs:
109
+ continue
110
+ own = _own(project, e)
111
+ if any(not _cmp(own.get(k, []), op, v) for k, op, v in conds):
112
+ continue
113
+ if targets is not None:
114
+ refs = _refs(project, e, depth)
115
+ if not any(t == targets or t.startswith(targets + "/") for t in refs):
116
+ continue
117
+ total += 1
118
+ if len(hits) < limit:
119
+ hits.append({"path": path, "def": dname, "at": f"{project.rel(e.file)}:{e.line}",
120
+ "values": {k: (v[0] if len(v) == 1 else v) for k, v in own.items()}})
121
+ return {"count": total, "returned": len(hits), "matches": hits}
122
+
123
+
124
+ ELEMENTS = {
125
+ "Com": ["Com/ComConfig/ComSignal", "Com/ComConfig/ComIPdu"],
126
+ "PduR": ["PduR/PduRRoutingPaths/PduRRoutingPath"],
127
+ "EcuC": ["EcuC/EcucConfigSet/EcucPduCollection/Pdu"],
128
+ "NvM": ["NvM/NvMBlockDescriptor"],
129
+ "Fee": ["Fee/FeeBlockConfiguration"],
130
+ "Ea": ["Ea/EaBlockConfiguration"],
131
+ "CanIf": ["CanIf/CanIfInitCfg/CanIfTxPduCfg", "CanIf/CanIfInitCfg/CanIfRxPduCfg"],
132
+ "Dem": ["Dem/DemConfigSet/DemEventParameter"],
133
+ }
134
+
135
+
136
+ def crosscheck(project: Project, module: str, limit: int = 200) -> dict:
137
+ from .rules import RuleContext, run_rules
138
+ mods = [m for m in project.modules if module in (m.name, (m.def_ref or "").rsplit("/", 1)[-1])]
139
+ if not mods:
140
+ return {"error": f"module {module!r} is not configured (modules: {', '.join(sorted({m.name for m in project.modules}))})"}
141
+ roots = [m.path for m in mods]
142
+ diags = [d for d in run_rules(project) if any(d["path"] == r or d["path"].startswith(r + "/") for r in roots)
143
+ or d["rule"].endswith("-000")]
144
+ ctx = RuleContext(project)
145
+ kinds = ELEMENTS.get((mods[0].def_ref or "").rsplit("/", 1)[-1] or module, [])
146
+ rows, counts = [], {"checked": 0, "findings": 0}
147
+ by_path: dict = {}
148
+ for d in diags:
149
+ by_path.setdefault(d["path"], []).append(d)
150
+ for k in kinds:
151
+ for e in ctx.containers(k):
152
+ counts["checked"] += 1
153
+ f = by_path.get(e.path, [])
154
+ if f:
155
+ counts["findings"] += 1
156
+ rows.append({"element": e.path, "def": k.rsplit("/", 1)[-1], "at": f"{project.rel(e.file)}:{e.line}",
157
+ "status": "FINDING", "findings": [f"{x['rule']} {x['severity']}: {x['message']}" for x in f]})
158
+ notes = [d["message"] for d in diags if d["severity"] == "info"]
159
+ other = [d for d in diags if d["severity"] != "info" and not any(r["element"] == d["path"] for r in rows)]
160
+ return {"module": module, "elementKinds": [k.rsplit("/", 1)[-1] for k in kinds], "counts": counts,
161
+ "rows": rows[:limit], "otherFindings": other[:limit], "notes": notes,
162
+ "summary": f"{counts['checked']} element(s) checked, {counts['findings']} with findings"}
@@ -0,0 +1,105 @@
1
+ """H2L-GEN-001: is the generated code up to date with the configuration?
2
+
3
+ Runs the project's configured `generate` command in a scratch copy (never in place), then compares the
4
+ copy with the tree. Every file the generator would change is reported with a short diff summary.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import difflib
9
+ import os
10
+ import shutil
11
+ import subprocess
12
+ import tempfile
13
+ import time
14
+
15
+ from . import paths
16
+
17
+ DEFAULT_TIMEOUT = 300
18
+ AUTO_LIMIT = 5 * 1024 * 1024 # on by default only when the configuration is smaller than this
19
+
20
+
21
+ def generate_cmd(cfg: dict):
22
+ g = cfg.get("generate")
23
+ if isinstance(g, str):
24
+ return g, None, DEFAULT_TIMEOUT
25
+ if isinstance(g, dict) and g.get("command"):
26
+ return g["command"], g.get("cwd"), int(g.get("timeoutSec") or DEFAULT_TIMEOUT)
27
+ return None, None, 0
28
+
29
+
30
+ def default_on(project, cfg: dict) -> bool:
31
+ if "checkGenerated" in cfg:
32
+ return bool(cfg["checkGenerated"])
33
+ if not generate_cmd(cfg)[0]:
34
+ return False
35
+ return sum(os.path.getsize(f) for f in project.files) < AUTO_LIMIT
36
+
37
+
38
+ def _snapshot(root: str) -> dict:
39
+ out = {}
40
+ for dp, dns, fns in os.walk(root):
41
+ dns[:] = [d for d in dns if d not in paths.TOOLING_DIRS]
42
+ for fn in fns:
43
+ f = os.path.join(dp, fn)
44
+ try:
45
+ st = os.stat(f)
46
+ except OSError:
47
+ continue
48
+ out[os.path.relpath(f, root)] = (st.st_size, st.st_mtime_ns)
49
+ return out
50
+
51
+
52
+ def check(project, cfg: dict, timeout: int = 0) -> list:
53
+ cmd, cwd, tmo = generate_cmd(cfg)
54
+ if not cmd:
55
+ return [_d("info", "", 0, "no `generate` command in .hydron/autosar.json; stale-Output check skipped")]
56
+ tmo = timeout or tmo
57
+ root = project.root
58
+ before = _snapshot(root)
59
+ t0 = time.time()
60
+ with tempfile.TemporaryDirectory(prefix="h2l-gen-") as tmp:
61
+ scratch = os.path.join(tmp, "proj")
62
+ shutil.copytree(root, scratch, symlinks=True, ignore=shutil.ignore_patterns(*paths.TOOLING_DIRS))
63
+ try:
64
+ r = subprocess.run(cmd, shell=True, cwd=os.path.join(scratch, cwd) if cwd else scratch,
65
+ capture_output=True, text=True, timeout=tmo)
66
+ except subprocess.TimeoutExpired:
67
+ return [_d("info", "", 0, f"NOT CHECKED: generate command timed out after {tmo}s in a scratch copy: {cmd}")]
68
+ if r.returncode != 0:
69
+ tail = (r.stderr or r.stdout).strip().splitlines()[-5:]
70
+ return [_d("warning", "", 0, f"generate command failed in a scratch copy (exit {r.returncode}): {cmd}: "
71
+ + " | ".join(tail) + " — the configuration may not be generatable")]
72
+ after = _snapshot(scratch)
73
+ out = []
74
+ for rel in sorted(set(before) | set(after)):
75
+ if rel in before and rel not in after:
76
+ continue # build artefacts removed by the generator are not staleness
77
+ a, b = os.path.join(root, rel), os.path.join(scratch, rel)
78
+ if rel not in before:
79
+ out.append(_d("error", rel, 0, f"{rel}: the generator creates this file but it is missing from the tree"))
80
+ continue
81
+ try:
82
+ x, y = open(a, "rb").read(), open(b, "rb").read()
83
+ except OSError:
84
+ continue
85
+ if x == y:
86
+ continue
87
+ xl = x.decode("utf-8", "replace").splitlines()
88
+ yl = y.decode("utf-8", "replace").splitlines()
89
+ diff = [ln for ln in difflib.unified_diff(xl, yl, "tree", "regenerated", n=0, lineterm="")
90
+ if ln.startswith(("+", "-")) and not ln.startswith(("+++", "---"))]
91
+ plus = sum(1 for ln in diff if ln.startswith("+"))
92
+ minus = len(diff) - plus
93
+ first = next((i for i, (p, q) in enumerate(zip(xl, yl)) if p != q), min(len(xl), len(yl))) + 1
94
+ sample = "; ".join(ln[:120] for ln in diff[:4])
95
+ out.append(_d("error", rel, first, f"{rel} is stale: regenerating changes {minus} line(s) / adds {plus} "
96
+ f"(e.g. {sample})"))
97
+ if not out:
98
+ out.append(_d("info", "", 0, f"generated files are up to date (regenerated in {time.time() - t0:.1f}s in a scratch copy)"))
99
+ return out
100
+
101
+
102
+ def _d(sev, file, line, msg):
103
+ return {"file": file, "line": line, "path": "", "rule": "H2L-GEN-001", "severity": sev,
104
+ "message": msg + " [TR_Methodology: generated BSW configuration must be regenerated after ECUC changes]",
105
+ "defRef": None}
@@ -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
@@ -283,9 +283,12 @@ class Project:
283
283
  self._load(f)
284
284
  self.foreign: dict = {}
285
285
  self.input_loaded = False
286
+ from .sysmodel import SystemIndex
287
+ self.system = SystemIndex()
286
288
  if load_input:
287
289
  for f in self.sources.input:
288
- self.foreign.update(index_identifiables(f))
290
+ self.system.add_file(f)
291
+ self.foreign = self.system.foreign()
289
292
  self.input_loaded = bool(self.sources.input)
290
293
  self.reindex()
291
294