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 +4 -0
- reqthread/__main__.py +5 -0
- reqthread/build.py +467 -0
- reqthread/cli.py +256 -0
- reqthread/formats.py +337 -0
- reqthread/gates.py +138 -0
- reqthread/ingest.py +221 -0
- reqthread/report.py +224 -0
- reqthread/scan.py +345 -0
- reqthread/schema/architecture.schema.json +13 -0
- reqthread/schema/bundle.schema.json +1149 -0
- reqthread/schema/cause.schema.json +74 -0
- reqthread/schema/config.schema.json +368 -0
- reqthread/schema/design.schema.json +17 -0
- reqthread/schema/deviations.schema.json +32 -0
- reqthread/schema/hazard.schema.json +29 -0
- reqthread/schema/requirement.schema.json +88 -0
- reqthread/schema/standard-data.schema.json +138 -0
- reqthread-0.2.0.dist-info/METADATA +148 -0
- reqthread-0.2.0.dist-info/RECORD +24 -0
- reqthread-0.2.0.dist-info/WHEEL +5 -0
- reqthread-0.2.0.dist-info/entry_points.txt +2 -0
- reqthread-0.2.0.dist-info/licenses/LICENSE +202 -0
- reqthread-0.2.0.dist-info/top_level.txt +1 -0
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
|