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/cli.py ADDED
@@ -0,0 +1,256 @@
1
+ """Command-line interface."""
2
+ import argparse
3
+ import json
4
+ import pathlib
5
+ import sys
6
+
7
+ from . import GENERATOR, __version__
8
+ from .build import build
9
+ from .formats import FormatError, check_bundle_schema, check_references, check_requirement_files, check_standard
10
+ from .gates import evaluate, ratchet
11
+ from .report import render
12
+
13
+ EXIT_OK, EXIT_ERRORS, EXIT_GAPS = 0, 1, 2
14
+
15
+
16
+ def _err(msg):
17
+ print(msg, file=sys.stderr)
18
+
19
+
20
+ def _load_bundle(path, label):
21
+ try:
22
+ bundle = json.loads(pathlib.Path(path).read_text())
23
+ except (OSError, json.JSONDecodeError) as e:
24
+ raise FormatError(f"{label} {path}: {e}") from None
25
+ errs = check_bundle_schema(bundle)
26
+ if errs:
27
+ raise FormatError(f"{label} {path} is not a valid bundle:\n " + "\n ".join(errs))
28
+ return bundle
29
+
30
+
31
+ def _write(path, text):
32
+ p = pathlib.Path(path)
33
+ p.parent.mkdir(parents=True, exist_ok=True)
34
+ p.write_text(text)
35
+
36
+
37
+ def _bundle_text(bundle):
38
+ return json.dumps(bundle, indent=1, ensure_ascii=False) + "\n"
39
+
40
+
41
+ def _report_build(result):
42
+ for w in result.warnings:
43
+ _err(f"warning: {w}")
44
+ for e in result.errors:
45
+ _err(f"error: {e}")
46
+
47
+
48
+ def cmd_build(a):
49
+ previous = _load_bundle(a.previous, "previous bundle") if a.previous else None
50
+ res = build(a.config, previous=previous, baseline_id=a.baseline_id, commit=a.commit)
51
+ _report_build(res)
52
+ if res.errors:
53
+ _err(f"build failed: {len(res.errors)} error(s)")
54
+ return EXIT_ERRORS
55
+ _write(a.out, _bundle_text(res.bundle))
56
+ print(f"wrote {a.out}: {len(res.bundle['entities'])} entities, {len(res.bundle['links'])} links, "
57
+ f"{len(res.bundle['artifacts'])} artifacts")
58
+ if a.html:
59
+ _write(a.html, render(res.bundle, evaluate(res.bundle), warnings=res.warnings))
60
+ print(f"wrote {a.html}")
61
+ return EXIT_OK
62
+
63
+
64
+ def cmd_check(a):
65
+ baseline = _load_bundle(a.baseline, "baseline bundle") if a.baseline else None
66
+ warnings = []
67
+ if a.bundle:
68
+ bundle = _load_bundle(a.bundle, "bundle")
69
+ errs = check_references(bundle, root=a.root) if a.root else check_references(bundle)
70
+ if errs:
71
+ for e in errs:
72
+ _err(f"error: {e}")
73
+ return EXIT_ERRORS
74
+ else:
75
+ res = build(a.config, previous=baseline, baseline_id=a.baseline_id, commit=a.commit)
76
+ _report_build(res)
77
+ if res.errors:
78
+ _err(f"build failed: {len(res.errors)} error(s)")
79
+ return EXIT_ERRORS
80
+ bundle, warnings = res.bundle, res.warnings
81
+ if a.out:
82
+ _write(a.out, _bundle_text(bundle))
83
+ print(f"wrote {a.out}")
84
+
85
+ gaps = evaluate(bundle)
86
+ if baseline is not None:
87
+ new, _existing, fixed = ratchet(gaps, evaluate(baseline))
88
+ new_ids = {g.id for g in new}
89
+ else:
90
+ new, fixed, new_ids = gaps, [], None
91
+
92
+ for g in gaps:
93
+ marker = "" if new_ids is None else ("NEW " if g.id in new_ids else "base ")
94
+ print(f"{marker}rule {g.rule}: {g.message}")
95
+ for g in fixed:
96
+ print(f"fixed rule {g.rule}: {g.message}")
97
+
98
+ failing = new if baseline is not None else gaps
99
+ status = "fail" if failing else "pass"
100
+ summary = f"{len(gaps)} gap(s)"
101
+ if baseline is not None:
102
+ summary += f", {len(new)} new, {len(fixed)} fixed since baseline"
103
+ print(f"{status.upper()}: {summary}")
104
+
105
+ if a.html:
106
+ _write(a.html, render(bundle, gaps, new_ids=new_ids, fixed=fixed, warnings=warnings))
107
+ print(f"wrote {a.html}")
108
+ if a.json:
109
+ _write(a.json, json.dumps({
110
+ "status": status, "gaps": [g.as_dict() for g in gaps],
111
+ "new": sorted(new_ids) if new_ids is not None else None,
112
+ "fixed": [g.as_dict() for g in fixed],
113
+ }, indent=1) + "\n")
114
+ print(f"wrote {a.json}")
115
+ return EXIT_GAPS if failing else EXIT_OK
116
+
117
+
118
+ def cmd_report(a):
119
+ bundle = _load_bundle(a.bundle, "bundle")
120
+ baseline = _load_bundle(a.baseline, "baseline bundle") if a.baseline else None
121
+ gaps = evaluate(bundle)
122
+ if baseline is not None:
123
+ new, _e, fixed = ratchet(gaps, evaluate(baseline))
124
+ html = render(bundle, gaps, new_ids={g.id for g in new}, fixed=fixed)
125
+ else:
126
+ html = render(bundle, gaps)
127
+ _write(a.out, html)
128
+ print(f"wrote {a.out}")
129
+ return EXIT_OK
130
+
131
+
132
+ def cmd_hazards(a):
133
+ """Hazard analysis on its own: validate hazard/cause files, cross-check keys, print the hazard matrix."""
134
+ from .formats import check_item_files
135
+ errors = []
136
+ _, hazards = check_item_files(a.hazards, ".hazard.md"); errors += _
137
+ _, causes = check_item_files(a.causes, ".cause.md") if a.causes else ([], {}); errors += _
138
+ reqs = {}
139
+ if a.requirements:
140
+ _, reqs = check_item_files(a.requirements, ".req.md"); errors += _
141
+ for key, (f, fm, *_r) in causes.items():
142
+ for hz in fm["hazards"]:
143
+ if hz not in hazards:
144
+ errors.append(f"cause file {f}: unknown hazard {hz}")
145
+ mitigated = {}
146
+ for key, (f, fm, *_r) in reqs.items():
147
+ for k in fm.get("mitigates", []):
148
+ if k not in hazards and k not in causes:
149
+ errors.append(f"requirement file {f}: mitigates unknown hazard or cause {k}")
150
+ mitigated.setdefault(k, []).append(key)
151
+ for e in errors:
152
+ _err(f"error: {e}")
153
+ if errors:
154
+ return EXIT_ERRORS
155
+ gaps = 0
156
+ for hk, (f, fm, title, *_r) in sorted(hazards.items()):
157
+ if fm["status"] == "retired":
158
+ continue
159
+ print(f"{hk} {title}" + (f" [{fm['severity']}]" if fm.get("severity") else ""))
160
+ cs = [(ck, c) for ck, c in causes.items() if hk in c[1]["hazards"] and c[1]["status"] != "retired"]
161
+ for rk in mitigated.get(hk, []):
162
+ print(f" (direct) <- {rk}")
163
+ for ck, (cf, cfm, ctitle, *_x) in sorted(cs):
164
+ mits = mitigated.get(ck, [])
165
+ if mits:
166
+ print(f" {ck} {ctitle} <- {', '.join(mits)}")
167
+ elif cfm.get("accepted"):
168
+ print(f" {ck} {ctitle} (risk accepted: {cfm['accepted']['justification'][:60]})")
169
+ else:
170
+ print(f" {ck} {ctitle} !! UNMITIGATED"); gaps += 1
171
+ if not cs and hk not in mitigated:
172
+ print(" !! no causes and no direct mitigation"); gaps += 1
173
+ print(f"{'FAIL' if gaps else 'PASS'}: {len(hazards)} hazard(s), {len(causes)} cause(s), {gaps} unmitigated")
174
+ return EXIT_GAPS if gaps else EXIT_OK
175
+
176
+
177
+ def cmd_validate(a):
178
+ errors, std = [], None
179
+ if a.standard:
180
+ std, e = check_standard(a.standard)
181
+ errors += e
182
+ print(f"standard {a.standard}: {'ok' if not e else f'{len(e)} error(s)'}")
183
+ if e:
184
+ std = None
185
+ if a.requirements:
186
+ e, n = check_requirement_files(a.requirements)
187
+ errors += e
188
+ print(f"reqfiles {n} file(s): {'ok' if not e else f'{len(e)} error(s)'}")
189
+ if a.bundle:
190
+ bundle = json.loads(pathlib.Path(a.bundle).read_text())
191
+ e = check_bundle_schema(bundle)
192
+ if not e:
193
+ e = check_references(bundle, std, a.standard if std else None, a.root)
194
+ errors += e
195
+ print(f"bundle {a.bundle}: {'ok' if not e else f'{len(e)} error(s)'}")
196
+ for e in errors:
197
+ _err(f"error: {e}")
198
+ return EXIT_ERRORS if errors else EXIT_OK
199
+
200
+
201
+ def main(argv=None):
202
+ ap = argparse.ArgumentParser(prog=GENERATOR, description="Requirements traceability and certification evidence.")
203
+ ap.add_argument("--version", action="version", version=f"{GENERATOR} {__version__}")
204
+ sub = ap.add_subparsers(dest="cmd", required=True)
205
+
206
+ def build_args(p):
207
+ p.add_argument("-c", "--config", default="reqthread.yaml", help="project config (default: reqthread.yaml)")
208
+ p.add_argument("--baseline-id", help="baseline id (default: git tag or commit)")
209
+ p.add_argument("--commit", help="commit hash (default: git HEAD)")
210
+
211
+ p = sub.add_parser("build", help="build an evidence bundle")
212
+ build_args(p)
213
+ p.add_argument("-o", "--out", default="bundle.json")
214
+ p.add_argument("--previous", help="previous baseline's bundle, to chain hashes")
215
+ p.add_argument("--html", help="also write an HTML report")
216
+ p.set_defaults(func=cmd_build)
217
+
218
+ p = sub.add_parser("check", help="build (or load) a bundle and evaluate gate rules; exit 2 on gaps")
219
+ build_args(p)
220
+ p.add_argument("--bundle", help="check an existing bundle instead of building")
221
+ p.add_argument("--root", help="with --bundle: verify artifact hashes under this directory")
222
+ p.add_argument("--baseline", help="baseline bundle: fail only on gaps not already present in it")
223
+ p.add_argument("-o", "--out", help="write the built bundle here")
224
+ p.add_argument("--html", help="write an HTML report")
225
+ p.add_argument("--json", help="write a JSON gap report")
226
+ p.set_defaults(func=cmd_check)
227
+
228
+ p = sub.add_parser("report", help="render an HTML report from a bundle")
229
+ p.add_argument("--bundle", required=True)
230
+ p.add_argument("--baseline")
231
+ p.add_argument("-o", "--out", default="report.html")
232
+ p.set_defaults(func=cmd_report)
233
+
234
+ p = sub.add_parser("hazards", help="hazard analysis on its own: validate hazard/cause files and print the hazard matrix")
235
+ p.add_argument("--hazards", required=True, help="directory of *.hazard.md files")
236
+ p.add_argument("--causes", help="directory of *.cause.md files")
237
+ p.add_argument("--requirements", help="directory of *.req.md files, to show mitigations")
238
+ p.set_defaults(func=cmd_hazards)
239
+
240
+ p = sub.add_parser("validate", help="validate format files (standard data, requirement files, a bundle)")
241
+ p.add_argument("--standard")
242
+ p.add_argument("--requirements")
243
+ p.add_argument("--bundle")
244
+ p.add_argument("--root")
245
+ p.set_defaults(func=cmd_validate)
246
+
247
+ a = ap.parse_args(argv)
248
+ try:
249
+ return a.func(a)
250
+ except FormatError as e:
251
+ _err(f"error: {e}")
252
+ return EXIT_ERRORS
253
+
254
+
255
+ if __name__ == "__main__":
256
+ sys.exit(main())
reqthread/formats.py ADDED
@@ -0,0 +1,337 @@
1
+ """Schema loading, hashing, and format/reference validation for bundles, standard data and item files."""
2
+ import datetime as dt
3
+ import hashlib
4
+ import json
5
+ import pathlib
6
+ import re
7
+ from importlib import resources
8
+
9
+ import yaml
10
+ from jsonschema import Draft202012Validator
11
+
12
+ PREFIX = {
13
+ "requirement": "req", "architecture_element": "arch", "design_element": "design",
14
+ "code_unit": "code", "test_case": "test", "environment": "env", "test_result": "result",
15
+ "coverage_record": "cov", "finding": "finding", "deviation": "dev", "review": "review",
16
+ "tool": "tool", "preexisting_element": "pre", "technique_status": "tech",
17
+ "change": "change", "person": "person", "hazard": "haz", "cause": "cause",
18
+ }
19
+
20
+ TRACEABLE = ("requirement", "architecture_element", "design_element", "code_unit", "test_case")
21
+ LINK_PAIRS = {
22
+ "derives_from": {("requirement", "requirement"), ("design_element", "architecture_element")},
23
+ "allocated_to": {("requirement", "architecture_element")},
24
+ "satisfies": {("design_element", "requirement"), ("code_unit", "requirement")},
25
+ "implements": {("code_unit", "design_element"), ("code_unit", "architecture_element")},
26
+ "verifies": {("test_case", k) for k in ("requirement", "design_element", "architecture_element")},
27
+ "covers": {("test_case", "code_unit")},
28
+ "justifies": {("deviation", "finding")},
29
+ "reviews": {("review", k) for k in TRACEABLE + ("change",)},
30
+ "affects": {("change", k) for k in TRACEABLE},
31
+ "supersedes": {("requirement", "requirement")},
32
+ "mitigates": {("requirement", "hazard"), ("requirement", "cause")},
33
+ "leads_to": {("cause", "hazard")},
34
+ "located_in": {("cause", "architecture_element")},
35
+ }
36
+
37
+ REF_FIELDS = {
38
+ "test_result": {"test": "test_case", "environment": "environment"},
39
+ "coverage_record": {"code_unit": "code_unit", "environment": "environment"},
40
+ "finding": {"tool": "tool"},
41
+ "deviation": {"approved_by": "person"},
42
+ "review": {"author": "person", "approvers": "person"},
43
+ "change": {"author": "person"},
44
+ }
45
+ # nested person reference: cause.accepted.approved_by
46
+
47
+
48
+ FRONT_MATTER = re.compile(r"\A---\n(.*?)\n---\n(.*)\Z", re.S)
49
+ _schemas = {}
50
+
51
+
52
+ class FormatError(Exception):
53
+ """Raised for inputs that cannot be used at all (unreadable, not YAML, wrong shape)."""
54
+
55
+
56
+ def load_schema(name):
57
+ if name not in _schemas:
58
+ text = resources.files("reqthread").joinpath("schema", name).read_text()
59
+ _schemas[name] = json.loads(text)
60
+ return _schemas[name]
61
+
62
+
63
+ def file_hash(path):
64
+ return "sha256:" + hashlib.sha256(pathlib.Path(path).read_bytes()).hexdigest()
65
+
66
+
67
+ def _reject_floats(obj, where="$"):
68
+ if isinstance(obj, float):
69
+ raise ValueError(f"floating-point value at {where}; bundles must contain only integers")
70
+ if isinstance(obj, dict):
71
+ for k, v in obj.items():
72
+ _reject_floats(v, f"{where}.{k}")
73
+ elif isinstance(obj, list):
74
+ for i, v in enumerate(obj):
75
+ _reject_floats(v, f"{where}[{i}]")
76
+
77
+
78
+ def canonical_hash(obj):
79
+ """sha256 over the canonical serialisation of a bundle.
80
+
81
+ For float-free JSON with ASCII object keys — which every bundle is — sorted-key
82
+ compact serialisation is identical to RFC 8785 (JCS). Floats are rejected so
83
+ that equivalence holds.
84
+ """
85
+ _reject_floats(obj)
86
+ blob = json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
87
+ return "sha256:" + hashlib.sha256(blob).hexdigest()
88
+
89
+
90
+ def schema_errors(schema, instance, label):
91
+ out = []
92
+ for e in sorted(Draft202012Validator(schema).iter_errors(instance), key=lambda e: list(e.path)):
93
+ where = "/".join(map(str, e.path)) or "<root>"
94
+ out.append(f"{label}: {where}: {e.message}")
95
+ return out
96
+
97
+
98
+ def dates_to_str(obj):
99
+ """YAML turns timestamps into datetime/date objects; formats carry them as ISO 8601 strings."""
100
+ if isinstance(obj, dt.datetime):
101
+ s = obj.isoformat()
102
+ return s[:-6] + "Z" if s.endswith("+00:00") else s
103
+ if isinstance(obj, dt.date):
104
+ return obj.isoformat()
105
+ if isinstance(obj, dict):
106
+ return {k: dates_to_str(v) for k, v in obj.items()}
107
+ if isinstance(obj, list):
108
+ return [dates_to_str(v) for v in obj]
109
+ return obj
110
+
111
+
112
+ def load_yaml(path, what):
113
+ try:
114
+ return dates_to_str(yaml.safe_load(pathlib.Path(path).read_text()))
115
+ except FileNotFoundError:
116
+ raise FormatError(f"{what} not found: {path}") from None
117
+ except yaml.YAMLError as e:
118
+ raise FormatError(f"{what} is not valid YAML: {path}: {e}") from None
119
+
120
+
121
+ # ---------------------------------------------------------------- standard data
122
+
123
+ def check_standard(path):
124
+ data = load_yaml(path, "standard data")
125
+ errors = schema_errors(load_schema("standard-data.schema.json"), data, f"standard {path}")
126
+ if errors:
127
+ return data, errors
128
+
129
+ levels = {l["id"] for l in data["integrity_levels"]}
130
+ codes = {r["code"] for r in data["recommendation_scale"]}
131
+ sections = {s["id"] for s in data["package_sections"]}
132
+ for t in data["techniques"]:
133
+ if t["section"] not in sections:
134
+ errors.append(f"standard: technique {t['id']}: unknown section {t['section']!r}")
135
+ for lvl, code in t["levels"].items():
136
+ if lvl not in levels:
137
+ errors.append(f"standard: technique {t['id']}: unknown integrity level {lvl!r}")
138
+ if code not in codes:
139
+ errors.append(f"standard: technique {t['id']}: unknown recommendation code {code!r}")
140
+ for c in data["coverage_expectations"]:
141
+ if c["integrity_level"] not in levels:
142
+ errors.append(f"standard: coverage expectation: unknown integrity level {c['integrity_level']!r}")
143
+ if c["strength"] not in codes:
144
+ errors.append(f"standard: coverage expectation: unknown recommendation code {c['strength']!r}")
145
+ return data, errors
146
+
147
+
148
+ class StandardIndex:
149
+ """Id sets from standard data, for conformance checks."""
150
+
151
+ def __init__(self, data):
152
+ self.data = data
153
+ self.id = data["standard"]["id"]
154
+ self.edition = data["standard"]["edition"]
155
+ self.levels = {l["id"] for l in data["integrity_levels"]}
156
+ self.test_levels = {t["id"] for t in data["test_levels"]}
157
+ self.tool_classes = {t["id"]: t for t in data["tool_classes"]}
158
+ self.routes = {r["id"] for r in data["preexisting_routes"]}
159
+ self.sections = {s["id"] for s in data["package_sections"]}
160
+ self.techniques = {t["id"] for t in data["techniques"]}
161
+
162
+
163
+ # ---------------------------------------------------------------- item files
164
+
165
+ def parse_item_file(path):
166
+ """Return (front_matter, title, text, rationale) for a *.req.md / *.arch.md / *.design.md file."""
167
+ raw = pathlib.Path(path).read_text()
168
+ m = FRONT_MATTER.match(raw)
169
+ if not m:
170
+ return None, None, None, None
171
+ try:
172
+ fm = dates_to_str(yaml.safe_load(m.group(1)))
173
+ except yaml.YAMLError as e:
174
+ raise FormatError(f"{path}: front matter is not valid YAML: {e}") from None
175
+ body = m.group(2)
176
+ h1 = re.search(r"^# (.+)$", body, re.M)
177
+ title = h1.group(1).strip() if h1 else None
178
+ after_title = body[h1.end():] if h1 else body
179
+ parts = re.split(r"^## Rationale\s*$", after_title, maxsplit=1, flags=re.M)
180
+ text = parts[0].strip()
181
+ rationale = parts[1].strip() if len(parts) > 1 else None
182
+ return fm, title, text, rationale
183
+
184
+
185
+ ITEM_KINDS = {
186
+ ".req.md": ("requirement.schema.json", "requirement"),
187
+ ".arch.md": ("architecture.schema.json", "architecture element"),
188
+ ".design.md": ("design.schema.json", "design element"),
189
+ ".hazard.md": ("hazard.schema.json", "hazard"),
190
+ ".cause.md": ("cause.schema.json", "cause"),
191
+ }
192
+
193
+
194
+ def check_item_files(directory, suffix=".req.md"):
195
+ """Validate every item file of one kind in a directory. Returns (errors, {id: (path, fm, title, text, rationale)})."""
196
+ schema_name, what = ITEM_KINDS[suffix]
197
+ schema = load_schema(schema_name)
198
+ errors, items = [], {}
199
+ for f in sorted(pathlib.Path(directory).rglob(f"*{suffix}")):
200
+ fm, title, text, rationale = parse_item_file(f)
201
+ if fm is None:
202
+ errors.append(f"{what} file {f}: no YAML front matter")
203
+ continue
204
+ file_errors = schema_errors(schema, fm, f"{what} file {f}")
205
+ errors += file_errors
206
+ if not title:
207
+ errors.append(f"{what} file {f}: body has no '# ' title")
208
+ if suffix in (".req.md", ".hazard.md") and not text:
209
+ errors.append(f"{what} file {f}: no {what} text after the title")
210
+ rid = fm.get("id") if isinstance(fm, dict) else None
211
+ if rid in items:
212
+ errors.append(f"{what} file {f}: duplicate id {rid} (also {items[rid][0]})")
213
+ if rid and f.name != f"{rid}{suffix}":
214
+ errors.append(f"{what} file {f}: file name should be {rid}{suffix}")
215
+ if rid and not file_errors:
216
+ items[rid] = (f, fm, title, text, rationale)
217
+ return errors, items
218
+
219
+
220
+ def check_requirement_files(directory):
221
+ """Validate a directory of requirement files, including derives_from targets among them."""
222
+ errors, items = check_item_files(directory, ".req.md")
223
+ for rid, (f, fm, *_rest) in items.items():
224
+ for parent in fm.get("derives_from", []):
225
+ if parent not in items:
226
+ errors.append(f"requirement file {f}: derives_from unknown requirement {parent}")
227
+ return errors, len(list(pathlib.Path(directory).rglob("*.req.md")))
228
+
229
+
230
+ # ---------------------------------------------------------------- bundles
231
+
232
+ def check_bundle_schema(bundle):
233
+ schema = load_schema("bundle.schema.json")
234
+ errors, bad_entities = [], set()
235
+ for e in Draft202012Validator(schema).iter_errors(bundle):
236
+ path = list(e.path)
237
+ if len(path) >= 2 and path[0] == "entities":
238
+ bad_entities.add(path[1]) # re-validated per kind below for readable errors
239
+ continue
240
+ errors.append(f"bundle: {'/'.join(map(str, path)) or '<root>'}: {e.message}")
241
+
242
+ for i in sorted(bad_entities):
243
+ ent = bundle["entities"][i]
244
+ kind = ent.get("kind") if isinstance(ent, dict) else None
245
+ if kind not in PREFIX:
246
+ errors.append(f"bundle: entities/{i}: unknown kind {kind!r}")
247
+ continue
248
+ sub = {"$schema": schema["$schema"], "$defs": schema["$defs"], "$ref": f"#/$defs/{kind}"}
249
+ errors += schema_errors(sub, ent, f"bundle: entities/{i} ({ent.get('id')})")
250
+ return errors
251
+
252
+
253
+ def check_references(bundle, std=None, std_path=None, root=None):
254
+ """Referential integrity, standard-data conformance, and (with root) artifact hashes."""
255
+ errors = []
256
+ by_id = {}
257
+ for e in bundle["entities"]:
258
+ if e["id"] in by_id:
259
+ errors.append(f"refs: duplicate entity id {e['id']}")
260
+ by_id[e["id"]] = e
261
+
262
+ def kind_of(ref):
263
+ return by_id[ref]["kind"] if ref in by_id else None
264
+
265
+ for e in bundle["entities"]:
266
+ for field, want in REF_FIELDS.get(e["kind"], {}).items():
267
+ vals = e.get(field)
268
+ for ref in (vals if isinstance(vals, list) else [vals]):
269
+ if ref is not None and kind_of(ref) != want:
270
+ errors.append(f"refs: {e['id']}.{field} -> {ref}: expected an existing {want}")
271
+
272
+ for e in bundle["entities"]:
273
+ if e["kind"] == "cause" and e.get("accepted"):
274
+ ref = e["accepted"]["approved_by"]
275
+ if kind_of(ref) != "person":
276
+ errors.append(f"refs: {e['id']}.accepted.approved_by -> {ref}: expected an existing person")
277
+
278
+ for i, l in enumerate(bundle["links"]):
279
+ fk, tk = kind_of(l["from"]), kind_of(l["to"])
280
+ if fk is None or tk is None:
281
+ missing = l["from"] if fk is None else l["to"]
282
+ errors.append(f"refs: links/{i} ({l['type']}): unknown entity {missing}")
283
+ elif (fk, tk) not in LINK_PAIRS[l["type"]]:
284
+ errors.append(f"refs: links/{i}: {l['type']} not allowed from {fk} to {tk}")
285
+
286
+ if std is not None:
287
+ s = std if isinstance(std, StandardIndex) else StandardIndex(std)
288
+ proj = bundle["project"]
289
+ if proj["standard"]["id"] != s.id:
290
+ errors.append(f"std: bundle targets {proj['standard']['id']}, standard data is {s.id}")
291
+ if std_path is not None and proj["standard"]["data_hash"] != file_hash(std_path):
292
+ errors.append("std: project.standard.data_hash does not match the loaded standard-data file")
293
+ if proj["integrity_claim"] not in s.levels:
294
+ errors.append(f"std: integrity_claim {proj['integrity_claim']!r} not in standard")
295
+
296
+ for e in bundle["entities"]:
297
+ k, eid = e["kind"], e["id"]
298
+ if k in ("requirement", "architecture_element") and e.get("integrity_level") not in (None, *s.levels):
299
+ errors.append(f"std: {eid}: unknown integrity level {e['integrity_level']!r}")
300
+ if k == "requirement":
301
+ for t in e.get("test_levels", []):
302
+ if t not in s.test_levels:
303
+ errors.append(f"std: {eid}: unknown test level {t!r}")
304
+ if k == "test_case" and e["level"] not in s.test_levels:
305
+ errors.append(f"std: {eid}: unknown test level {e['level']!r}")
306
+ if k == "coverage_record" and e["test_level"] not in s.test_levels:
307
+ errors.append(f"std: {eid}: unknown test level {e['test_level']!r}")
308
+ if k == "tool" and e["class"] is not None and e["class"] not in s.tool_classes:
309
+ errors.append(f"std: {eid}: unknown tool class {e['class']!r}")
310
+ if k == "preexisting_element" and e["route"] is not None and e["route"] not in s.routes:
311
+ errors.append(f"std: {eid}: unknown route {e['route']!r}")
312
+ if k == "technique_status" and e["technique"] not in s.techniques:
313
+ errors.append(f"std: {eid}: unknown technique {e['technique']!r}")
314
+ if k == "review" and e.get("section") and e["section"] not in s.sections:
315
+ errors.append(f"std: {eid}: unknown section {e['section']!r}")
316
+ for a in bundle["artifacts"]:
317
+ if a["section"] not in s.sections:
318
+ errors.append(f"std: artifact {a['path']}: unknown section {a['section']!r}")
319
+
320
+ if root is not None:
321
+ root = pathlib.Path(root)
322
+ for a in bundle["artifacts"]:
323
+ p = root / a["path"]
324
+ if not p.is_file():
325
+ errors.append(f"artifacts: {a['path']}: missing under {root}")
326
+ elif file_hash(p) != a["hash"]:
327
+ errors.append(f"artifacts: {a['path']}: hash mismatch")
328
+ for e in bundle["entities"]:
329
+ if e["kind"] == "requirement" and "source" in e:
330
+ p = root / e["source"]["path"]
331
+ if p.is_file():
332
+ fm, title, _text, _rat = parse_item_file(p)
333
+ if fm and fm.get("id") != e["key"]:
334
+ errors.append(f"refs: {e['id']}: source file id is {fm.get('id')!r}")
335
+ if title and title != e["title"]:
336
+ errors.append(f"refs: {e['id']}: title differs from source file")
337
+ return errors