reqthread 0.2.0__py3-none-any.whl

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.
reqthread/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """Requirements traceability and certification evidence for safety-critical embedded software."""
2
+
3
+ __version__ = "0.2.0"
4
+ GENERATOR = "reqthread"
reqthread/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
reqthread/build.py ADDED
@@ -0,0 +1,467 @@
1
+ """Assemble an evidence bundle from a project configuration."""
2
+ import datetime as dt
3
+ import os
4
+ import pathlib
5
+ import re
6
+ import subprocess
7
+ from dataclasses import dataclass, field
8
+
9
+ from . import GENERATOR, __version__
10
+ from .formats import (
11
+ PREFIX, FormatError, StandardIndex, canonical_hash, check_bundle_schema, check_item_files,
12
+ check_references, check_standard, file_hash, load_schema, load_yaml, schema_errors,
13
+ )
14
+ from .ingest import (
15
+ OUTCOME_RANK, FileCoverage, match_junit, parse_cobertura, parse_junit, parse_lcov, parse_sarif, unit_metrics,
16
+ )
17
+ from .scan import scan_file
18
+
19
+ KIND_ORDER = {k: i for i, k in enumerate(PREFIX)}
20
+ LANG = {"c": "c", "cpp": "cpp", "python": "python"}
21
+
22
+
23
+ @dataclass
24
+ class BuildResult:
25
+ bundle: dict | None
26
+ errors: list = field(default_factory=list)
27
+ warnings: list = field(default_factory=list)
28
+ root: pathlib.Path | None = None
29
+ standard: StandardIndex | None = None
30
+ standard_path: pathlib.Path | None = None
31
+
32
+
33
+ def load_config(path):
34
+ path = pathlib.Path(path)
35
+ cfg = load_yaml(path, "config")
36
+ return cfg, schema_errors(load_schema("config.schema.json"), cfg, f"config {path}")
37
+
38
+
39
+ def _git(root, *args):
40
+ try:
41
+ out = subprocess.run(["git", "-C", str(root), *args], capture_output=True, text=True, timeout=10)
42
+ except (OSError, subprocess.SubprocessError):
43
+ return None
44
+ return out.stdout.strip() if out.returncode == 0 and out.stdout.strip() else None
45
+
46
+
47
+ def _now():
48
+ return dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
49
+
50
+
51
+ def _slug(s):
52
+ return re.sub(r"[^a-z0-9._-]+", "-", s.lower()).strip("-") or "tool"
53
+
54
+
55
+ def _rel(root, p):
56
+ try:
57
+ return pathlib.Path(p).resolve().relative_to(root.resolve()).as_posix()
58
+ except ValueError:
59
+ return None
60
+
61
+
62
+ class _Builder:
63
+ def __init__(self, config_path, now):
64
+ self.config_path = pathlib.Path(config_path)
65
+ self.root = self.config_path.resolve().parent
66
+ self.now = now or _now()
67
+ self.errors, self.warnings = [], []
68
+ self.entities, self.links, self.artifacts = {}, set(), {}
69
+ self.keys = {} # human key -> entity id, for requirements, architecture, design
70
+
71
+ # -- helpers
72
+ def add(self, ent):
73
+ if ent["id"] in self.entities:
74
+ self.errors.append(f"duplicate entity {ent['id']}")
75
+ return
76
+ self.entities[ent["id"]] = ent
77
+
78
+ def link(self, typ, frm, to, declared_at=None):
79
+ self.links.add((typ, frm, to, tuple(sorted(declared_at.items())) if declared_at else None))
80
+
81
+ def artifact(self, path, section, media_type, description=None):
82
+ rel = _rel(self.root, path)
83
+ if rel is None:
84
+ self.errors.append(f"{path}: outside the project root {self.root}")
85
+ return
86
+ a = {"path": rel, "hash": file_hash(path), "media_type": media_type, "section": section}
87
+ if description:
88
+ a["description"] = description
89
+ self.artifacts[rel] = a
90
+
91
+ def resolve(self, key, kinds, where):
92
+ for prefix in kinds:
93
+ eid = self.keys.get((prefix, key))
94
+ if eid:
95
+ return eid
96
+ what = " or ".join({"req": "requirement", "arch": "architecture element", "design": "design element",
97
+ "haz": "hazard", "cause": "cause"}[k] for k in kinds)
98
+ self.errors.append(f"{where}: unknown {what} {key}")
99
+ return None
100
+
101
+ # -- stages
102
+ def items(self, cfg):
103
+ reqs, archs, designs, hazards, causes = {}, {}, {}, {}, {}
104
+ for section_key, suffix, store in (("requirements", ".req.md", reqs), ("architecture", ".arch.md", archs),
105
+ ("design", ".design.md", designs), ("hazards", ".hazard.md", hazards),
106
+ ("causes", ".cause.md", causes)):
107
+ spec = cfg.get(section_key)
108
+ if not spec:
109
+ continue
110
+ for d in spec["paths"]:
111
+ directory = self.root / d
112
+ if not directory.is_dir():
113
+ self.errors.append(f"config: {section_key} path {d} is not a directory")
114
+ continue
115
+ errs, items = check_item_files(directory, suffix)
116
+ self.errors += errs
117
+ for key, item in items.items():
118
+ if key in reqs or key in archs or key in designs or key in hazards or key in causes:
119
+ self.errors.append(f"{item[0]}: id {key} is already used by another item")
120
+ continue
121
+ store[key] = item
122
+ self.artifact(item[0], spec["section"], "text/markdown")
123
+
124
+ for key, (f, fm, title, text, rationale) in reqs.items():
125
+ e = {"id": f"req:{key}", "kind": "requirement", "key": key, "level": fm["level"], "type": fm["type"],
126
+ "integrity_level": fm.get("integrity_level"), "title": title, "text": text,
127
+ "verification": fm.get("verification", []), "test_levels": fm.get("test_levels", []),
128
+ "status": fm["status"], "source": {"path": _rel(self.root, f)}}
129
+ if rationale:
130
+ e["rationale"] = rationale
131
+ self.add(e)
132
+ self.keys[("req", key)] = e["id"]
133
+ for key, (f, fm, title, text, _r) in archs.items():
134
+ e = {"id": f"arch:{key}", "kind": "architecture_element", "key": key, "title": title,
135
+ "safety_related": fm["safety_related"], "integrity_level": fm.get("integrity_level"),
136
+ "source": {"path": _rel(self.root, f)}}
137
+ if text:
138
+ e["description"] = text
139
+ self.add(e)
140
+ self.keys[("arch", key)] = e["id"]
141
+ for key, (f, fm, title, text, _r) in designs.items():
142
+ e = {"id": f"design:{key}", "kind": "design_element", "key": key, "title": title,
143
+ "source": {"path": _rel(self.root, f)}}
144
+ if text:
145
+ e["description"] = text
146
+ self.add(e)
147
+ self.keys[("design", key)] = e["id"]
148
+
149
+ for key, (f, fm, title, text, _r) in hazards.items():
150
+ e = {"id": f"haz:{key}", "kind": "hazard", "key": key, "title": title, "text": text,
151
+ "severity": fm.get("severity"), "status": fm["status"], "source": {"path": _rel(self.root, f)}}
152
+ self.add(e)
153
+ self.keys[("haz", key)] = e["id"]
154
+ for key, (f, fm, title, text, _r) in causes.items():
155
+ acc = fm.get("accepted")
156
+ e = {"id": f"cause:{key}", "kind": "cause", "key": key, "title": title, "cause_type": fm["type"],
157
+ "accepted": ({"justification": acc["justification"], "approved_by": f"person:{acc['approved_by']}",
158
+ "approved_at": acc["approved_at"]} if acc else None),
159
+ "status": fm["status"], "source": {"path": _rel(self.root, f)}}
160
+ if text:
161
+ e["text"] = text
162
+ self.add(e)
163
+ self.keys[("cause", key)] = e["id"]
164
+ for key, (f, fm, *_x) in causes.items():
165
+ where = _rel(self.root, f)
166
+ for k in fm["hazards"]:
167
+ to = self.resolve(k, ["haz"], where)
168
+ if to:
169
+ self.link("leads_to", f"cause:{key}", to)
170
+ if fm.get("component"):
171
+ to = self.resolve(fm["component"], ["arch"], where)
172
+ if to:
173
+ self.link("located_in", f"cause:{key}", to)
174
+
175
+ for key, (f, fm, *_x) in reqs.items():
176
+ where = _rel(self.root, f)
177
+ for typ, field_, kinds in (("derives_from", "derives_from", ["req"]), ("allocated_to", "allocated_to", ["arch"]),
178
+ ("supersedes", "supersedes", ["req"]), ("mitigates", "mitigates", ["haz", "cause"])):
179
+ for k in fm.get(field_, []):
180
+ to = self.resolve(k, kinds, where)
181
+ if to:
182
+ self.link(typ, f"req:{key}", to)
183
+ for key, (f, fm, *_x) in designs.items():
184
+ where = _rel(self.root, f)
185
+ for k in fm.get("derives_from", []):
186
+ to = self.resolve(k, ["arch"], where)
187
+ if to:
188
+ self.link("derives_from", f"design:{key}", to)
189
+ for k in fm.get("satisfies", []):
190
+ to = self.resolve(k, ["req"], where)
191
+ if to:
192
+ self.link("satisfies", f"design:{key}", to)
193
+
194
+ def code(self, cfg):
195
+ self.unit_ranges = {} # path -> [(start, end, code_id)] for function-level units
196
+ self.tests = {} # test id -> (path, symbol)
197
+ claimed = {}
198
+ for role, globs in (("source", cfg.get("sources", [])), ("test", cfg.get("tests", []))):
199
+ for g in globs:
200
+ matched = sorted(p for p in self.root.glob(g["glob"]) if p.is_file())
201
+ if not matched:
202
+ self.warnings.append(f"config: {role} glob {g['glob']!r} matches no files")
203
+ for p in matched:
204
+ rel = _rel(self.root, p)
205
+ if rel in claimed:
206
+ if claimed[rel] != role:
207
+ self.errors.append(f"{rel}: matched by both a source glob and a test glob")
208
+ continue
209
+ claimed[rel] = role
210
+ units, errs = scan_file(rel, p.read_text(errors="replace"), g["language"], role)
211
+ self.errors += errs
212
+ if role == "source":
213
+ self._code_units(rel, g["language"], units)
214
+ else:
215
+ self._test_cases(rel, g["level"], units)
216
+
217
+ def _code_units(self, rel, language, units):
218
+ for u in units:
219
+ cid = f"code:{rel}#{u.symbol}" if u.symbol else f"code:{rel}"
220
+ self.add({"id": cid, "kind": "code_unit", "path": rel, "symbol": u.symbol, "language": LANG[language],
221
+ "infrastructure": {"reason": u.infrastructure} if u.infrastructure else None,
222
+ "source": {"path": rel, "line": u.start}})
223
+ if u.symbol:
224
+ self.unit_ranges.setdefault(rel, []).append((u.start, u.end, cid))
225
+ for k in u.satisfies:
226
+ to = self.resolve(k, ["req"], f"{rel}:{u.tag_lines[('satisfies', k)]}")
227
+ if to:
228
+ self.link("satisfies", cid, to, {"path": rel, "line": u.tag_lines[("satisfies", k)]})
229
+ for k in u.implements:
230
+ to = self.resolve(k, ["design", "arch"], f"{rel}:{u.tag_lines[('implements', k)]}")
231
+ if to:
232
+ self.link("implements", cid, to, {"path": rel, "line": u.tag_lines[("implements", k)]})
233
+
234
+ def _test_cases(self, rel, level, units):
235
+ for u in units:
236
+ tid = f"test:{rel}::{u.symbol}"
237
+ self.add({"id": tid, "kind": "test_case", "name": f"{rel}::{u.symbol}", "level": u.test_level or level,
238
+ "source": {"path": rel, "line": u.start}})
239
+ self.tests[tid] = (rel, u.symbol)
240
+ for k in u.verifies:
241
+ to = self.resolve(k, ["req", "design", "arch"], f"{rel}:{u.tag_lines[('verifies', k)]}")
242
+ if to:
243
+ self.link("verifies", tid, to, {"path": rel, "line": u.tag_lines[("verifies", k)]})
244
+
245
+ def people_tools(self, cfg):
246
+ for p in cfg.get("people", []):
247
+ self.add({"id": f"person:{p['id']}", "kind": "person", "name": p["name"], "roles": p["roles"],
248
+ "competence_record": p.get("competence_record")})
249
+ self.tool_by_name = {}
250
+ for t in cfg.get("tools", []):
251
+ tid = f"tool:{t['id']}"
252
+ self.add({"id": tid, "kind": "tool", "name": t["name"], "version": t["version"], "class": t["class"],
253
+ "evidence": t.get("evidence", [])})
254
+ self.tool_by_name[t["name"].lower()] = tid
255
+ self.tool_by_name[t["id"].lower()] = tid
256
+ for p in cfg.get("preexisting", []):
257
+ e = {"id": f"pre:{p['id']}", "kind": "preexisting_element", "name": p["name"], "version": p["version"],
258
+ "route": p["route"], "justification": p.get("justification", [])}
259
+ if p.get("origin"):
260
+ e["origin"] = p["origin"]
261
+ self.add(e)
262
+ self.env_ids = set()
263
+ for env in cfg.get("environments", []):
264
+ e = {"id": f"env:{env['id']}", "kind": "environment", "name": env["name"], "env_type": env["env_type"]}
265
+ for opt in ("board", "description"):
266
+ if env.get(opt):
267
+ e[opt] = env[opt]
268
+ self.add(e)
269
+ self.env_ids.add(env["id"])
270
+
271
+ def ingest(self, cfg):
272
+ known = sorted(self.unit_ranges)
273
+ results = {} # (test id, env) -> (outcome, ts)
274
+ coverage = {} # (env, level) -> {path: FileCoverage}
275
+ sarif = []
276
+ for i, entry in enumerate(cfg.get("ingest", [])):
277
+ path = self.root / entry["path"]
278
+ if not path.is_file():
279
+ (self.warnings if entry.get("optional") else self.errors).append(
280
+ f"ingest[{i}]: {entry['path']} not found")
281
+ continue
282
+ env = entry.get("environment")
283
+ if env is not None and env not in self.env_ids:
284
+ self.errors.append(f"ingest[{i}]: unknown environment {env!r}")
285
+ continue
286
+ kind = entry["type"]
287
+ media = {"junit": "application/xml", "cobertura": "application/xml", "lcov": "text/plain",
288
+ "sarif": "application/sarif+json"}[kind]
289
+ self.artifact(path, entry["section"], media, f"{kind} input")
290
+ try:
291
+ if kind == "junit":
292
+ outcomes, unmatched = match_junit(parse_junit(path), self.tests)
293
+ for tid, (outcome, ts) in outcomes.items():
294
+ prev = results.get((tid, env))
295
+ if prev is None or OUTCOME_RANK[outcome] > OUTCOME_RANK[prev[0]]:
296
+ results[(tid, env)] = (outcome, ts)
297
+ for u in unmatched:
298
+ self.warnings.append(f"{entry['path']}: testcase {u} matches no test in the configured test globs")
299
+ elif kind in ("lcov", "cobertura"):
300
+ parse = parse_lcov if kind == "lcov" else parse_cobertura
301
+ files, unmapped = parse(path, self.root, known)
302
+ bucket = coverage.setdefault((env, entry["test_level"]), {})
303
+ for rel, fc in files.items():
304
+ merged = bucket.setdefault(rel, FileCoverage({}, {}))
305
+ for ln, h in fc.lines.items():
306
+ merged.lines[ln] = max(merged.lines.get(ln, 0), h)
307
+ for ln, b in fc.branches.items():
308
+ merged.branches[ln] = max(merged.branches.get(ln, (0, 0)), b)
309
+ if unmapped:
310
+ self.warnings.append(f"{entry['path']}: {len(unmapped)} file(s) outside the project ignored")
311
+ else:
312
+ found, unmapped = parse_sarif(path, self.root, known)
313
+ sarif += found
314
+ if unmapped:
315
+ self.warnings.append(f"{entry['path']}: {len(unmapped)} result(s) outside the project ignored")
316
+ except (OSError, ValueError, KeyError) as e:
317
+ self.errors.append(f"ingest[{i}]: cannot parse {entry['path']} as {kind}: {e}")
318
+
319
+ ci_job = os.environ.get("CI_JOB_ID") or os.environ.get("GITHUB_RUN_ID")
320
+ for (tid, env), (outcome, ts) in sorted(results.items()):
321
+ e = {"id": f"result:{tid.removeprefix('test:')}@{env}", "kind": "test_result", "test": tid,
322
+ "environment": f"env:{env}", "outcome": outcome, "run_at": ts or self.now}
323
+ if ci_job:
324
+ e["ci_job"] = ci_job
325
+ self.add(e)
326
+
327
+ for (env, level), files in sorted(coverage.items()):
328
+ for rel, fc in sorted(files.items()):
329
+ for start, end, cid in self.unit_ranges.get(rel, []):
330
+ m = unit_metrics(fc, start, end)
331
+ if m:
332
+ self.add({"id": f"cov:{cid.removeprefix('code:')}@{env}/{level}", "kind": "coverage_record",
333
+ "code_unit": cid, "environment": f"env:{env}", "test_level": level, "metrics": m})
334
+ self.sarif = sarif
335
+
336
+ def findings(self, cfg):
337
+ devs = []
338
+ spec = cfg.get("deviations")
339
+ if spec:
340
+ p = self.root / spec["path"]
341
+ try:
342
+ data = load_yaml(p, "deviations file") or []
343
+ except FormatError as e:
344
+ self.errors.append(str(e))
345
+ data = []
346
+ errs = schema_errors(load_schema("deviations.schema.json"), data, f"deviations {spec['path']}")
347
+ self.errors += errs
348
+ if not errs:
349
+ self.artifact(p, spec["section"], "application/yaml", "deviation register")
350
+ devs = data
351
+ for d in devs:
352
+ person = f"person:{d['approved_by']}"
353
+ if person not in self.entities:
354
+ self.errors.append(f"deviation {d['key']}: approved_by {d['approved_by']!r} is not in people")
355
+ self.add({"id": f"dev:{d['key']}", "kind": "deviation", "key": d["key"], "justification": d["justification"],
356
+ "approved_by": person, "approved_at": d["approved_at"], "expires_at": d["expires_at"],
357
+ **({"scope": d["scope"]} if d.get("scope") else {})})
358
+
359
+ used_devs, seen = set(), {}
360
+ for r in sorted(self.sarif, key=lambda r: (r.tool, r.rule_id, r.path, r.line or 0)):
361
+ tool_id = self.tool_by_name.get(r.tool.lower())
362
+ if tool_id is None:
363
+ tool_id = f"tool:{_slug(r.tool)}"
364
+ self.tool_by_name[r.tool.lower()] = tool_id
365
+ self.add({"id": tool_id, "kind": "tool", "name": r.tool, "version": r.tool_version or "unknown",
366
+ "class": None, "evidence": []})
367
+ self.warnings.append(f"tool {r.tool!r} reported findings but is not in config tools; recorded as unclassified")
368
+ # Prefer the tool's fingerprint: line numbers shift with unrelated edits and would churn gap ids.
369
+ anchor = f"#{r.fingerprint[:16]}" if r.fingerprint else f":{r.line or 0}"
370
+ base = f"finding:{tool_id.removeprefix('tool:')}/{r.rule_id}/{r.path}{anchor}"
371
+ seen[base] = seen.get(base, 0) + 1
372
+ fid = base if seen[base] == 1 else f"{base}#{seen[base]}"
373
+ matching = [d for d in devs if self._dev_matches(d["match"], r, tool_id)]
374
+ e = {"id": fid, "kind": "finding", "tool": tool_id, "rule_id": r.rule_id, "message": r.message,
375
+ "location": {"path": r.path, **({"line": r.line} if r.line else {})},
376
+ "severity": r.level if r.level in ("error", "warning", "note", "none") else "warning",
377
+ "state": "deviated" if matching else "open"}
378
+ if r.fingerprint:
379
+ e["fingerprint"] = r.fingerprint
380
+ if r.suppressed_in_source:
381
+ e["extra"] = {"suppressed_in_source": True}
382
+ self.add(e)
383
+ for d in matching:
384
+ self.link("justifies", f"dev:{d['key']}", fid)
385
+ used_devs.add(d["key"])
386
+ for d in devs:
387
+ if d["key"] not in used_devs:
388
+ self.warnings.append(f"deviation {d['key']} matches no current finding (stale?)")
389
+
390
+ def _dev_matches(self, m, r, tool_id):
391
+ if m["tool"].lower() not in (r.tool.lower(), tool_id.removeprefix("tool:")):
392
+ return False
393
+ if m["rule"] != r.rule_id:
394
+ return False
395
+ if "path" in m and m["path"] != r.path:
396
+ return False
397
+ if "line" in m and m["line"] != r.line:
398
+ return False
399
+ if "fingerprint" in m and m["fingerprint"] != r.fingerprint:
400
+ return False
401
+ return True
402
+
403
+ def bundle(self, cfg, std, std_path, previous, baseline_id, commit):
404
+ commit = commit or _git(self.root, "rev-parse", "HEAD")
405
+ if not commit:
406
+ self.warnings.append("not a git checkout; baseline commit recorded as all zeros")
407
+ commit = "0" * 40
408
+ tag = _git(self.root, "describe", "--tags", "--exact-match")
409
+ baseline = {"id": baseline_id or tag or f"commit-{commit[:12]}", "commit": commit,
410
+ "previous_bundle_hash": canonical_hash(previous) if previous else None}
411
+ if tag:
412
+ baseline["tag"] = tag
413
+ for e in self.entities.values():
414
+ if e["kind"] == "cause" and e["accepted"] and e["accepted"]["approved_by"] not in self.entities:
415
+ self.errors.append(f"cause {e['key']}: accepted.approved_by {e['accepted']['approved_by'].removeprefix('person:')!r} is not in people")
416
+ ents = sorted(self.entities.values(), key=lambda e: (KIND_ORDER[e["kind"]], e["id"]))
417
+ links = []
418
+ for typ, frm, to, decl in sorted(self.links, key=lambda l: (l[0], l[1], l[2], l[3] or ())):
419
+ l = {"type": typ, "from": frm, "to": to}
420
+ if decl:
421
+ l["declared_at"] = dict(decl)
422
+ links.append(l)
423
+ return {
424
+ "bundle_format": "0.2.0",
425
+ "generator": {"name": GENERATOR, "version": __version__},
426
+ "created_at": self.now,
427
+ "project": {"id": cfg["project"]["id"], "name": cfg["project"]["name"],
428
+ "integrity_claim": cfg["project"]["integrity_claim"],
429
+ "standard": {"id": std.id, "edition": std.edition, "data_hash": file_hash(std_path)}},
430
+ "baseline": baseline,
431
+ "entities": ents,
432
+ "links": links,
433
+ "artifacts": [self.artifacts[k] for k in sorted(self.artifacts)],
434
+ }
435
+
436
+
437
+ def build(config_path, previous=None, baseline_id=None, commit=None, now=None):
438
+ b = _Builder(config_path, now)
439
+ try:
440
+ cfg, errs = load_config(config_path)
441
+ except FormatError as e:
442
+ return BuildResult(None, [str(e)])
443
+ if errs:
444
+ return BuildResult(None, errs, root=b.root)
445
+
446
+ std_path = b.root / cfg["standard"]
447
+ try:
448
+ std_data, errs = check_standard(std_path)
449
+ except FormatError as e:
450
+ return BuildResult(None, [str(e)], root=b.root)
451
+ if errs:
452
+ return BuildResult(None, errs, root=b.root)
453
+ std = StandardIndex(std_data)
454
+
455
+ b.items(cfg)
456
+ b.people_tools(cfg)
457
+ b.code(cfg)
458
+ b.ingest(cfg)
459
+ b.findings(cfg)
460
+ bundle = b.bundle(cfg, std, std_path, previous, baseline_id, commit)
461
+
462
+ errors = list(b.errors)
463
+ if not errors:
464
+ errors += check_bundle_schema(bundle)
465
+ if not errors:
466
+ errors += check_references(bundle, std, std_path, b.root)
467
+ return BuildResult(bundle if not errors else None, errors, b.warnings, b.root, std, std_path)