aero-agent-roles 1.2.2 → 1.2.3

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 (27) hide show
  1. package/manifest.json +17 -3
  2. package/package.json +1 -1
  3. package/roles/software-product-assurance-engineer/ROLE.md +243 -0
  4. package/roles/software-product-assurance-engineer/SOURCES.md +40 -0
  5. package/roles/software-product-assurance-engineer/cli.py +542 -0
  6. package/roles/software-product-assurance-engineer/core/software_product_assurance_core.py +2108 -0
  7. package/roles/software-product-assurance-engineer/example/docs/EX-AUDIT-2026-01.md +7 -0
  8. package/roles/software-product-assurance-engineer/example/docs/EX-CSTD-001.md +7 -0
  9. package/roles/software-product-assurance-engineer/example/docs/EX-DSA-001.md +7 -0
  10. package/roles/software-product-assurance-engineer/example/docs/EX-DSTD-001.md +7 -0
  11. package/roles/software-product-assurance-engineer/example/docs/EX-RB-001.md +7 -0
  12. package/roles/software-product-assurance-engineer/example/docs/EX-SCMP-001.md +7 -0
  13. package/roles/software-product-assurance-engineer/example/docs/EX-SDP-001.md +7 -0
  14. package/roles/software-product-assurance-engineer/example/docs/EX-SPAP-001.md +7 -0
  15. package/roles/software-product-assurance-engineer/example/docs/EX-SPAR-2026-Q3.md +7 -0
  16. package/roles/software-product-assurance-engineer/example/docs/EX-SPR-LOG.md +7 -0
  17. package/roles/software-product-assurance-engineer/example/docs/EX-SRF-001.md +7 -0
  18. package/roles/software-product-assurance-engineer/example/docs/EX-SVALP-001.md +7 -0
  19. package/roles/software-product-assurance-engineer/example/docs/EX-SVERP-001.md +7 -0
  20. package/roles/software-product-assurance-engineer/example/docs/EX-TS-001.md +7 -0
  21. package/roles/software-product-assurance-engineer/example/evidence.csv +251 -0
  22. package/roles/software-product-assurance-engineer/example/metrics.csv +11 -0
  23. package/roles/software-product-assurance-engineer/example/pdr-pack.csv +17 -0
  24. package/roles/software-product-assurance-engineer/templates/q80-compliance-matrix-template.md +572 -0
  25. package/roles/software-product-assurance-engineer/tests/test_role_software_product_assurance_engineer.py +202 -0
  26. package/roles/software-product-assurance-engineer/tests/test_software_product_assurance_cli.py +271 -0
  27. package/roles/software-product-assurance-engineer/tests/test_software_product_assurance_core.py +570 -0
@@ -0,0 +1,2108 @@
1
+ #!/usr/bin/env python3
2
+ """software_product_assurance_core.py - the engine of the Software Product
3
+ Assurance Engineer role.
4
+
5
+ Turns a space project's software product assurance evidence into a
6
+ clause-by-clause compliance matrix against ECSS-Q-ST-80C Rev.2 (30 April
7
+ 2025), and stops at a human sign-off. Standalone: stdlib only, no Aero
8
+ Agent Skills checkout needed. When the skills library is present the role
9
+ CLI dispatches the bound q80 leaves and cross-checks this engine against
10
+ them (a second, independent implementation of the same rules).
11
+
12
+ What the engine does
13
+ --------------------
14
+ 1. Software criticality category: taken as given (A to D) or derived from
15
+ the highest severity of the functions the software serves (I to IV)
16
+ and the compensating provisions at system level.
17
+ 2. Tailoring: every requirement of the standard resolved for the category
18
+ (applicable, reduced, not applicable; the security clauses follow the
19
+ security sensitivity instead of the category).
20
+ 3. Compliance matrix: the clause list joined to the evidence index
21
+ (clause, document, section, status, justification). One row per
22
+ clause; several evidence rows combine to the weakest status; silence
23
+ is never compliance; a claim with no document reference is downgraded;
24
+ a not-applicable claim the tailoring keeps is a gap.
25
+ 4. Document trace: every document the evidence cites is looked up in the
26
+ project's document folder; a citation that resolves to no file is a
27
+ gap, and a compliant claim that rests only on missing files is
28
+ downgraded, so every clause traces to a real file.
29
+ 5. Milestone evidence: the documents a review (SRR, PDR, CDR, QR, AR...)
30
+ owes for the category and scope, each with the clauses that drive it
31
+ and the state of their evidence; a submitted review pack graded for
32
+ missing and immature items; the SPAP maturity owed; the milestone
33
+ report (SPAMR) skeleton with the sections the evidence can fill.
34
+ 6. Product metrics: a measurement set graded against the category
35
+ thresholds (illustrative defaults; the project's agreed values win).
36
+ 7. Gates: the deliverable is checked for its DRAFT status, the stop line,
37
+ one row per clause, a coverage summary that matches the rows, and an
38
+ honest sign-off state. The engine never marks a matrix approved.
39
+
40
+ The standard is referenced, never reproduced: clause identifiers are
41
+ factual identifiers, and every topic label and rule below is our own
42
+ paraphrase. Author: ashfordeOU.
43
+ """
44
+ from __future__ import annotations
45
+
46
+ import csv
47
+ import io
48
+ import os
49
+ import re
50
+
51
+ __version__ = "0.1.0"
52
+
53
+ STANDARD = "ECSS-Q-ST-80C Rev.2 (30 April 2025)"
54
+ TITLE = ("Software Product Assurance Compliance Matrix "
55
+ "(ECSS-Q-ST-80C Rev.2)")
56
+
57
+ CATEGORIES = ("A", "B", "C", "D")
58
+ SEVERITIES = ("I", "II", "III", "IV")
59
+ STATUSES = ("compliant", "partially-compliant", "not-compliant",
60
+ "not-applicable")
61
+ REVIEWS = ("srr", "pdr", "cdr", "trr", "qr", "ar", "orr")
62
+ REVIEW_NAMES = {
63
+ "srr": "system requirements review (SRR)",
64
+ "pdr": "preliminary design review (PDR)",
65
+ "cdr": "critical design review (CDR)",
66
+ "trr": "test readiness review (TRR)",
67
+ "qr": "qualification review (QR)",
68
+ "ar": "acceptance review (AR)",
69
+ "orr": "operational readiness review (ORR)",
70
+ }
71
+ SCOPE_FLAGS = ("suppliers", "procured", "reuse", "security", "operations")
72
+ PROVISION_KINDS = ("hardware", "software", "operational-procedure")
73
+
74
+ DRAFT_BANNER = ("DRAFT - prepared by an agent for review. Not a statement of "
75
+ "compliance until signed off by the responsible human.")
76
+ STOP_LINE = "STOP: human sign-off required before submission."
77
+ NOT_SIGNED = "Sign-off: none recorded."
78
+
79
+ # Sign-off block a HUMAN appends to the Markdown matrix (never the engine).
80
+ SIGN_OFF_KEYS = ("Signed-off-by", "Sign-off-role", "Sign-off-date",
81
+ "Sign-off-decision", "Accepted-open-gaps")
82
+
83
+ STATUS_ALIASES = {
84
+ "c": "compliant", "compliant": "compliant", "yes": "compliant",
85
+ "y": "compliant", "full": "compliant", "fc": "compliant",
86
+ "pc": "partially-compliant", "partial": "partially-compliant",
87
+ "partially": "partially-compliant",
88
+ "partially compliant": "partially-compliant",
89
+ "partially-compliant": "partially-compliant",
90
+ "nc": "not-compliant", "no": "not-compliant", "n": "not-compliant",
91
+ "not compliant": "not-compliant", "not-compliant": "not-compliant",
92
+ "non-compliant": "not-compliant", "noncompliant": "not-compliant",
93
+ "na": "not-applicable", "n/a": "not-applicable",
94
+ "not applicable": "not-applicable", "not-applicable": "not-applicable",
95
+ }
96
+
97
+ _SEVERITY_ALIASES = {
98
+ "1": "I", "2": "II", "3": "III", "4": "IV",
99
+ "i": "I", "ii": "II", "iii": "III", "iv": "IV",
100
+ "catastrophic": "I", "critical": "II", "major": "III",
101
+ "minor": "IV", "negligible": "IV",
102
+ }
103
+
104
+ _PROVISION_ALIASES = {
105
+ "hw": "hardware", "hardware": "hardware",
106
+ "sw": "software", "software": "software",
107
+ "procedure": "operational-procedure",
108
+ "operational": "operational-procedure",
109
+ "operational-procedure": "operational-procedure",
110
+ "ops-procedure": "operational-procedure",
111
+ }
112
+
113
+ _REVIEW_ALIASES = {
114
+ "system-requirements-review": "srr",
115
+ "preliminary-design-review": "pdr",
116
+ "critical-design-review": "cdr",
117
+ "test-readiness-review": "trr",
118
+ "qualification-review": "qr",
119
+ "acceptance-review": "ar",
120
+ "operational-readiness-review": "orr",
121
+ }
122
+
123
+ # Plan (SPAP) maturity owed at each review (Annex B practice, paraphrased).
124
+ PLAN_MATURITY_BY_REVIEW = {
125
+ "srr": "issued", "pdr": "updated", "cdr": "updated",
126
+ "qr": "maintained", "ar": "maintained", "orr": "maintained",
127
+ }
128
+
129
+ _MATURITY = {"draft": 0, "issued": 1, "approved": 2}
130
+
131
+ # Which clause groups feed each input of the milestone report skeleton.
132
+ # A SPAMR input counts as available when at least one applicable clause of
133
+ # its groups carries evidence that resolves to a real document.
134
+ SPAMR_INPUT_CLAUSES = {
135
+ "verification_activities": ("6.2.6",),
136
+ "methods_tools": ("5.6",),
137
+ "standards_adherence": ("6.3.3", "6.3.4"),
138
+ "metrics": ("7.1", "6.2.5"),
139
+ "testing": ("6.3.5",),
140
+ "problems": ("5.2.5", "5.2.6"),
141
+ "progress_reports": ("5.2.2",),
142
+ }
143
+
144
+ # Where a gap goes next: the bound skill that resolves it.
145
+ _LEAF = "space-systems/ecss/"
146
+ GAP_ROUTES_BY_KIND = {
147
+ "na-conflicts-with-tailoring": _LEAF + "q80-software-criticality-tailoring",
148
+ "evidence-for-tailored-out-clause": _LEAF + "q80-software-criticality-tailoring",
149
+ "evidence-for-unlisted-clause": _LEAF + "q80-compliance-matrix",
150
+ "conflicting-status": _LEAF + "q80-compliance-matrix",
151
+ }
152
+ GAP_ROUTES_BY_CLAUSE = (
153
+ ("7.1.", _LEAF + "q80-software-product-quality-metrics"),
154
+ ("7.4.", _LEAF + "q80-software-product-assurance-plan"),
155
+ ("7.5.", _LEAF + "q80-software-product-assurance-plan"),
156
+ ("5.", _LEAF + "q80-software-product-assurance-plan"),
157
+ ("6.", _LEAF + "q80-software-process-assurance"),
158
+ ("7.", _LEAF + "q80-software-process-assurance"),
159
+ )
160
+
161
+ TOLERANCE = 1e-9
162
+
163
+ # ---------------------------------------------------------------------------
164
+ # Clause and applicability data. Derived from the clause structure of the
165
+ # standard: identifiers are facts; the topic labels are our own wording and
166
+ # no requirement or heading text is reproduced. Applicability codes per
167
+ # requirement in category order A B C D: Y applicable, N not applicable,
168
+ # R reduced, S set by security sensitivity. Default YYYY.
169
+ # ---------------------------------------------------------------------------
170
+
171
+ TOPICS = {'5': 'assurance programme',
172
+ '5.1': 'organisation',
173
+ '5.1.1': 'assurance organisation set-up',
174
+ '5.1.2': 'who answers for what',
175
+ '5.1.3': 'staff and means',
176
+ '5.1.4': 'the assurance lead',
177
+ '5.1.5': 'skills and training',
178
+ '5.2': 'programme management',
179
+ '5.2.1': 'planning and control of the programme',
180
+ '5.2.2': 'assurance reporting',
181
+ '5.2.3': 'audit programme',
182
+ '5.2.4': 'alert handling',
183
+ '5.2.5': 'problem reporting',
184
+ '5.2.6': 'nonconformance handling',
185
+ '5.2.7': 'quality model',
186
+ '5.3': 'risks and critical items',
187
+ '5.3.1': 'risk handling',
188
+ '5.3.2': 'critical items',
189
+ '5.4': 'suppliers',
190
+ '5.4.1': 'choosing suppliers',
191
+ '5.4.2': 'what suppliers are held to',
192
+ '5.4.3': 'watching suppliers',
193
+ '5.4.4': 'category flow-down to suppliers',
194
+ '5.4.5': 'security flow-down to suppliers',
195
+ '5.5': 'buying software',
196
+ '5.5.1': 'purchase documents',
197
+ '5.5.2': 'bought-in component list',
198
+ '5.5.3': 'purchase data',
199
+ '5.5.4': 'marking of bought items',
200
+ '5.5.5': 'incoming checks',
201
+ '5.5.6': 'export constraints',
202
+ '5.6': 'tools and environment',
203
+ '5.6.1': 'methods and tools chosen',
204
+ '5.6.2': 'choice of development environment',
205
+ '5.7': 'process capability',
206
+ '5.7.1': 'capability assessment',
207
+ '5.7.2': 'how assessments are run',
208
+ '5.7.3': 'improving the process',
209
+ '6': 'process assurance',
210
+ '6.1': 'life cycle',
211
+ '6.1.1': 'defining the life cycle',
212
+ '6.1.2': 'process targets',
213
+ '6.1.3': 'reviewing the life cycle',
214
+ '6.1.4': 'means for the life cycle',
215
+ '6.1.5': 'validation timing',
216
+ '6.2': 'cross-process obligations',
217
+ '6.2.1': 'process documentation',
218
+ '6.2.2': 'dependability and safety of software',
219
+ '6.2.3': 'critical software',
220
+ '6.2.4': 'configuration management',
221
+ '6.2.5': 'process measurement',
222
+ '6.2.6': 'verification',
223
+ '6.2.7': 'reusing existing software',
224
+ '6.2.8': 'generated code',
225
+ '6.2.9': 'security',
226
+ '6.2.10': 'security-sensitive software',
227
+ '6.3': 'per-process obligations',
228
+ '6.3.1': 'system-level software requirements',
229
+ '6.3.2': 'requirements analysis',
230
+ '6.3.3': 'architecture and design',
231
+ '6.3.4': 'coding',
232
+ '6.3.5': 'testing and validation',
233
+ '6.3.6': 'delivery and installation',
234
+ '6.3.7': 'acceptance',
235
+ '6.3.8': 'operations',
236
+ '6.3.9': 'maintenance',
237
+ '7': 'product quality assurance',
238
+ '7.1': 'quality targets and measurement',
239
+ '7.1.1': 'deriving quality requirements',
240
+ '7.1.2': 'quality requirements as numbers',
241
+ '7.1.3': 'checking quality requirements',
242
+ '7.1.4': 'product measures',
243
+ '7.1.5': 'basic measures',
244
+ '7.1.6': 'reporting measures',
245
+ '7.1.7': 'numerical accuracy',
246
+ '7.1.8': 'maturity analysis',
247
+ '7.2': 'product quality obligations',
248
+ '7.2.1': 'requirement documents',
249
+ '7.2.2': 'design documents',
250
+ '7.2.3': 'test and validation documents',
251
+ '7.3': 'software built for reuse',
252
+ '7.3.1': 'what the customer asks for',
253
+ '7.3.2': 'own documentation set',
254
+ '7.3.3': 'standalone information',
255
+ '7.3.4': 'reuse requirements',
256
+ '7.3.5': 'configuration of reusable items',
257
+ '7.3.6': 'multi-platform testing',
258
+ '7.3.7': 'conformance certificate',
259
+ '7.4': 'ground hardware and services',
260
+ '7.4.1': 'buying ground hardware',
261
+ '7.4.2': 'buying services',
262
+ '7.4.3': 'limits',
263
+ '7.4.4': 'choice',
264
+ '7.4.5': 'upkeep',
265
+ '7.5': 'programmable devices',
266
+ '7.5.1': 'programming devices',
267
+ '7.5.2': 'device marking',
268
+ '7.5.3': 'device calibration'}
269
+
270
+ REQUIREMENT_IDS = (
271
+ '5.1.1', '5.1.2.1', '5.1.2.2', '5.1.2.3', '5.1.3.1', '5.1.3.2', '5.1.4.1', '5.1.4.2',
272
+ '5.1.5.1', '5.1.5.2', '5.1.5.3', '5.1.5.4', '5.2.1.1', '5.2.1.2', '5.2.1.3', '5.2.1.4',
273
+ '5.2.1.5', '5.2.2.1', '5.2.2.2', '5.2.2.3', '5.2.3', '5.2.4', '5.2.5.1', '5.2.5.2',
274
+ '5.2.5.3', '5.2.5.4', '5.2.6.1', '5.2.6.2', '5.2.7.1', '5.2.7.2', '5.3.1', '5.3.2.1',
275
+ '5.3.2.2', '5.4.1.1', '5.4.1.2', '5.4.2.1', '5.4.2.2', '5.4.3.1', '5.4.3.2', '5.4.3.3',
276
+ '5.4.3.4', '5.4.4', '5.4.5', '5.5.1', '5.5.2', '5.5.3', '5.5.4', '5.5.5',
277
+ '5.5.6', '5.6.1.1', '5.6.1.2', '5.6.1.3', '5.6.2.1', '5.6.2.2', '5.6.2.3', '5.7.1',
278
+ '5.7.2.1', '5.7.2.2', '5.7.2.3', '5.7.2.4', '5.7.3.1', '5.7.3.2', '5.7.3.3', '6.1.1',
279
+ '6.1.2', '6.1.3', '6.1.4', '6.1.5', '6.2.1.1', '6.2.1.2', '6.2.1.3', '6.2.1.4',
280
+ '6.2.1.5', '6.2.1.6', '6.2.1.7', '6.2.1.8', '6.2.1.9', '6.2.2.1', '6.2.2.2', '6.2.2.3',
281
+ '6.2.2.4', '6.2.2.5', '6.2.2.6', '6.2.2.7', '6.2.2.8', '6.2.2.9', '6.2.2.10', '6.2.3.2',
282
+ '6.2.3.3', '6.2.3.4', '6.2.3.5', '6.2.3.6', '6.2.3.7', '6.2.3.8', '6.2.4.1', '6.2.4.2',
283
+ '6.2.4.3', '6.2.4.4', '6.2.4.5', '6.2.4.6', '6.2.4.7', '6.2.4.8', '6.2.4.9', '6.2.4.10',
284
+ '6.2.4.11', '6.2.4.12', '6.2.5.1', '6.2.5.2', '6.2.5.3', '6.2.5.4', '6.2.5.5', '6.2.6.1',
285
+ '6.2.6.2', '6.2.6.3', '6.2.6.4', '6.2.6.5', '6.2.6.6', '6.2.6.7', '6.2.6.8', '6.2.6.9',
286
+ '6.2.6.10', '6.2.6.11', '6.2.6.12', '6.2.6.13', '6.2.7.1', '6.2.7.2', '6.2.7.3', '6.2.7.4',
287
+ '6.2.7.5', '6.2.7.6', '6.2.7.7', '6.2.7.8', '6.2.7.9', '6.2.7.10', '6.2.7.11', '6.2.8.1',
288
+ '6.2.8.2', '6.2.8.3', '6.2.8.4', '6.2.8.5', '6.2.8.6', '6.2.8.7', '6.2.9.1', '6.2.9.2',
289
+ '6.2.9.3', '6.2.9.4', '6.2.9.5', '6.2.9.6', '6.2.9.7', '6.2.10.1', '6.2.10.2', '6.2.10.3',
290
+ '6.2.10.4', '6.3.1.1', '6.3.1.2', '6.3.1.3', '6.3.2.1', '6.3.2.2', '6.3.2.3', '6.3.2.4',
291
+ '6.3.2.5', '6.3.3.1', '6.3.3.2', '6.3.3.3', '6.3.3.4', '6.3.3.5', '6.3.3.6', '6.3.3.7',
292
+ '6.3.4.1', '6.3.4.2', '6.3.4.3', '6.3.4.4', '6.3.4.5', '6.3.4.6', '6.3.4.7', '6.3.4.8',
293
+ '6.3.5.1', '6.3.5.2', '6.3.5.3', '6.3.5.4', '6.3.5.5', '6.3.5.6', '6.3.5.7', '6.3.5.8',
294
+ '6.3.5.9', '6.3.5.10', '6.3.5.11', '6.3.5.12', '6.3.5.13', '6.3.5.14', '6.3.5.15', '6.3.5.16',
295
+ '6.3.5.17', '6.3.5.18', '6.3.5.19', '6.3.5.20', '6.3.5.21', '6.3.5.22', '6.3.5.23', '6.3.5.24',
296
+ '6.3.5.25', '6.3.5.26', '6.3.5.27', '6.3.5.28', '6.3.5.29', '6.3.5.30', '6.3.5.31', '6.3.5.32',
297
+ '6.3.5.33', '6.3.6.1', '6.3.6.2', '6.3.6.3', '6.3.6.4', '6.3.7.1', '6.3.7.2', '6.3.7.3',
298
+ '6.3.7.5', '6.3.7.6', '6.3.7.7', '6.3.8.1', '6.3.8.2', '6.3.8.3', '6.3.9.1', '6.3.9.2',
299
+ '6.3.9.3', '6.3.9.4', '6.3.9.5', '6.3.9.6', '6.3.9.7', '7.1.1', '7.1.2', '7.1.3',
300
+ '7.1.4', '7.1.5', '7.1.6', '7.1.7', '7.1.8', '7.2.1.1', '7.2.1.2', '7.2.1.3',
301
+ '7.2.2.1', '7.2.2.2', '7.2.2.3', '7.2.3.1', '7.2.3.2', '7.2.3.3', '7.2.3.4', '7.2.3.5',
302
+ '7.2.3.6', '7.3.1', '7.3.2', '7.3.3', '7.3.4', '7.3.5', '7.3.6', '7.3.7',
303
+ '7.4.1', '7.4.2', '7.4.3', '7.4.4', '7.4.5', '7.5.1', '7.5.2', '7.5.3',
304
+ )
305
+
306
+ APPLICABILITY = {'5.1.3.2': 'YYYN',
307
+ '5.1.5.1': 'YYYR',
308
+ '5.1.5.2': 'YYYN',
309
+ '5.2.3': 'YYYR',
310
+ '5.2.7.2': 'YYYR',
311
+ '5.4.1.1': 'YYYR',
312
+ '5.4.2.2': 'YYYN',
313
+ '5.4.3.4': 'YYYN',
314
+ '5.6.1.1': 'YYYR',
315
+ '5.6.1.2': 'YYYR',
316
+ '5.6.1.3': 'YYYR',
317
+ '5.6.2.1': 'YYYR',
318
+ '5.6.2.2': 'YYYR',
319
+ '5.7.1': 'YYYN',
320
+ '5.7.2.1': 'YYYN',
321
+ '5.7.2.2': 'YYYN',
322
+ '5.7.2.3': 'YYYN',
323
+ '5.7.2.4': 'YYYN',
324
+ '5.7.3.1': 'YYYN',
325
+ '5.7.3.2': 'YYYN',
326
+ '5.7.3.3': 'YYYN',
327
+ '6.2.1.9': 'YYYN',
328
+ '6.2.2.2': 'YYYN',
329
+ '6.2.2.3': 'YYYN',
330
+ '6.2.2.4': 'YYYN',
331
+ '6.2.2.5': 'YYYN',
332
+ '6.2.2.6': 'YYYN',
333
+ '6.2.2.7': 'YYYN',
334
+ '6.2.3.2': 'YYYN',
335
+ '6.2.3.3': 'YYYN',
336
+ '6.2.3.4': 'YYYN',
337
+ '6.2.3.5': 'YYYN',
338
+ '6.2.3.6': 'YYYN',
339
+ '6.2.3.7': 'YYNN',
340
+ '6.2.3.8': 'YYYN',
341
+ '6.2.5.4': 'YYYR',
342
+ '6.2.6.5': 'YYYN',
343
+ '6.2.6.6': 'YYYN',
344
+ '6.2.6.13': 'YYNN',
345
+ '6.2.7.4': 'YYYR',
346
+ '6.2.7.7': 'YYYR',
347
+ '6.2.7.8': 'YYYR',
348
+ '6.2.9.1': 'SSSS',
349
+ '6.2.9.2': 'SSSS',
350
+ '6.2.9.3': 'SSSS',
351
+ '6.2.9.4': 'SSSS',
352
+ '6.2.9.5': 'SSSS',
353
+ '6.2.9.6': 'SSSS',
354
+ '6.2.9.7': 'SSSS',
355
+ '6.2.10.1': 'SSSS',
356
+ '6.2.10.2': 'SSSS',
357
+ '6.2.10.3': 'SSSS',
358
+ '6.2.10.4': 'SSSS',
359
+ '6.3.3.1': 'YYYR',
360
+ '6.3.3.2': 'YYYR',
361
+ '6.3.3.3': 'YYYN',
362
+ '6.3.3.4': 'YYYR',
363
+ '6.3.3.5': 'YYYN',
364
+ '6.3.3.6': 'YYYN',
365
+ '6.3.4.3': 'YYYN',
366
+ '6.3.4.4': 'YYYR',
367
+ '6.3.4.8': 'YYYR',
368
+ '6.3.5.1': 'YYYR',
369
+ '6.3.5.2': 'YYYR',
370
+ '6.3.5.3': 'YYYR',
371
+ '6.3.5.4': 'YYYR',
372
+ '6.3.5.9': 'YYYN',
373
+ '6.3.5.10': 'YYYN',
374
+ '6.3.5.14': 'YYYR',
375
+ '6.3.5.19': 'YYYN',
376
+ '6.3.5.28': 'YYNN',
377
+ '6.3.5.30': 'YYYN',
378
+ '6.3.5.31': 'YYYN',
379
+ '6.3.8.2': 'YYRR',
380
+ '6.3.9.7': 'YYYR',
381
+ '7.1.4': 'YYYR',
382
+ '7.1.5': 'YYYR',
383
+ '7.1.8': 'YYYN'}
384
+
385
+ REDUCTION_NOTES = {'5.1.5.1': 'training need still met; the formal training output is not demanded',
386
+ '5.2.3': 'audits held only where a need is identified, not on a fixed plan',
387
+ '5.2.7.2': 'only the quality characteristics that matter for this software are kept',
388
+ '5.4.1.1': 'supplier selection still done; the formal output is not demanded',
389
+ '5.6.1.1': 'methods and tools must have a prior successful use, any domain',
390
+ '5.6.1.2': 'the activity stays; the formal output is not demanded',
391
+ '5.6.1.3': 'the activity stays; the formal output is not demanded',
392
+ '5.6.2.1': 'the activity stays; the formal output is not demanded',
393
+ '5.6.2.2': 'the activity stays; the formal output is not demanded',
394
+ '6.2.5.4': 'process metrics limited to problems found during validation',
395
+ '6.2.7.4': 'reuse analysis trimmed to a subset of its items, design scope limited',
396
+ '6.2.7.7': 'kept only as far as maintainability of the software needs it',
397
+ '6.2.7.8': 'kept only as far as maintainability of the software needs it',
398
+ '6.3.3.1': 'only document control is asked of the design',
399
+ '6.3.3.2': 'design standards become a recommendation',
400
+ '6.3.3.4': 'applies only if design standards were adopted',
401
+ '6.3.4.4': 'not asked unless the software is security sensitive',
402
+ '6.3.4.8': 'code enters configuration control no later than validation start',
403
+ '6.3.5.1': 'no formal unit and integration test activity is demanded',
404
+ '6.3.5.2': 'no formal unit and integration test activity is demanded',
405
+ '6.3.5.3': 'test procedures and data are verified by sampling',
406
+ '6.3.5.4': 'held for validation and acceptance testing only',
407
+ '6.3.5.14': 'held for validation and acceptance testing only',
408
+ '6.3.8.2': 'the safety-feature item of the clause drops out',
409
+ '6.3.9.7': 'statistical data need not be collected',
410
+ '7.1.4': 'one product metric item drops out',
411
+ '7.1.5': 'design and fault-density or failure-intensity metrics not demanded'}
412
+
413
+ DOCUMENT_TITLES = {'acceptance-documentation': 'acceptance test plan, report and joint review',
414
+ 'alerts': 'alert information raised and received',
415
+ 'audit-plan': 'audit plan and schedule',
416
+ 'coding-standards': 'coding standards and their tools',
417
+ 'criticality-classification': 'criticality classification of software products and '
418
+ 'components',
419
+ 'dependability-safety-analysis': 'software dependability and safety analysis report',
420
+ 'design-justification': 'justification of design choices',
421
+ 'design-standards': 'design and modelling standards',
422
+ 'isvv-plan': 'independent software verification and validation plan',
423
+ 'isvv-report': 'independent software verification and validation report',
424
+ 'maintenance': 'maintenance plan and records',
425
+ 'nonconformance-reports': 'nonconformance reports and review board records',
426
+ 'operations': 'operations support and ground equipment selection',
427
+ 'problem-reports': 'software problem reports and their closure',
428
+ 'procedures-standards': 'procedures and standards in force',
429
+ 'process-assessment': 'process assessment and improvement records',
430
+ 'procurement': 'procurement data and receiving inspection',
431
+ 'requirements-baseline': 'quality requirements in the requirements baseline',
432
+ 'review-inspection-records': 'review and inspection plans and reports',
433
+ 'scm-plan': 'software configuration management plan',
434
+ 'sdp': 'software development plan and project plans',
435
+ 'security-analysis': 'software security analysis report',
436
+ 'security-management-plan': 'software security management plan',
437
+ 'software-configuration-file': 'software configuration file and release data',
438
+ 'software-reuse-file': 'software reuse file',
439
+ 'software-verification-plan': 'software verification plan',
440
+ 'software-verification-report': 'software verification report',
441
+ 'spa-reports': 'periodic software product assurance reports',
442
+ 'spamr': 'software product assurance milestone report (SPAMR)',
443
+ 'spap': 'software product assurance plan (SPAP)',
444
+ 'supplier-control': 'supplier selection, requirements flow-down and monitoring',
445
+ 'technical-specification': 'quality requirements in the technical specification',
446
+ 'test-readiness': 'test readiness confirmation and test compliance statement',
447
+ 'training': 'training plan and records',
448
+ 'validation-documentation': 'test and validation plans, specifications and reports'}
449
+
450
+ DOCUMENT_CONDITIONS = {'software-reuse-file': 'reuse',
451
+ 'security-analysis': 'security',
452
+ 'security-management-plan': 'security',
453
+ 'supplier-control': 'suppliers',
454
+ 'procurement': 'procured',
455
+ 'operations': 'operations',
456
+ 'maintenance': 'operations'}
457
+
458
+ DOCUMENT_CLAUSE_REVIEWS = {'acceptance-documentation': {'6.3.7.1': ('qr', 'ar'),
459
+ '6.3.7.6': ('ar',),
460
+ '6.3.7.7': ('ar',)},
461
+ 'alerts': {'5.2.4': ()},
462
+ 'audit-plan': {'5.2.3': ('srr',)},
463
+ 'coding-standards': {'6.3.4.1': ('pdr',), '6.3.4.2': ('pdr',), '6.3.4.4': ('pdr',)},
464
+ 'criticality-classification': {'6.2.2.1': ('srr', 'pdr'), '6.2.2.3': ('pdr',)},
465
+ 'dependability-safety-analysis': {'6.2.2.2': ('pdr',),
466
+ '6.2.2.5': ('cdr', 'qr', 'ar'),
467
+ '6.2.2.6': ('cdr', 'qr', 'ar'),
468
+ '6.2.2.7': ('pdr', 'cdr'),
469
+ '6.2.2.10': ('pdr', 'cdr', 'qr', 'ar'),
470
+ '6.2.10.1': ('pdr', 'cdr', 'qr', 'ar')},
471
+ 'design-justification': {'7.2.2.3': ('pdr', 'cdr')},
472
+ 'design-standards': {'6.2.8.4': ('srr', 'pdr'), '6.3.3.2': ('srr', 'pdr')},
473
+ 'isvv-plan': {'6.2.6.13': ('srr', 'pdr'), '6.3.5.28': ('srr', 'pdr')},
474
+ 'isvv-report': {'6.2.6.13': ('pdr', 'cdr', 'qr', 'ar'),
475
+ '6.3.5.28': ('pdr', 'cdr', 'qr', 'ar')},
476
+ 'maintenance': {'6.3.9.1': ('qr', 'ar', 'orr'),
477
+ '6.3.9.2': ('qr', 'ar', 'orr'),
478
+ '6.3.9.4': ('qr', 'ar', 'orr'),
479
+ '6.3.9.5': ('qr', 'ar', 'orr'),
480
+ '6.3.9.6': (),
481
+ '6.3.9.7': ()},
482
+ 'nonconformance-reports': {'5.2.6.1': ('srr',), '6.3.7.5': ('ar',)},
483
+ 'operations': {'6.3.8.1': ('orr',),
484
+ '6.3.8.2': ('orr',),
485
+ '7.4.1': ('srr', 'pdr'),
486
+ '7.4.2': ('srr', 'pdr'),
487
+ '7.4.3': ('srr', 'pdr'),
488
+ '7.4.4': ('srr', 'pdr')},
489
+ 'problem-reports': {'5.2.5.1': ('pdr',),
490
+ '5.2.5.2': ('pdr',),
491
+ '5.2.5.3': ('pdr',),
492
+ '6.2.6.4': ('srr', 'pdr', 'cdr', 'qr', 'ar', 'orr'),
493
+ '6.3.5.6': ('cdr', 'qr', 'ar', 'orr'),
494
+ '6.3.5.8': ('srr', 'pdr', 'cdr', 'qr', 'ar', 'orr')},
495
+ 'procedures-standards': {'6.2.1.6': ('pdr',), '6.2.1.7': ('pdr',)},
496
+ 'process-assessment': {'5.7.1': (),
497
+ '5.7.2.1': (),
498
+ '5.7.2.2': (),
499
+ '5.7.2.3': (),
500
+ '5.7.2.4': (),
501
+ '5.7.3.1': (),
502
+ '5.7.3.2': (),
503
+ '5.7.3.3': ()},
504
+ 'procurement': {'5.5.3': ('srr', 'pdr'),
505
+ '5.5.5': ('pdr', 'cdr', 'qr'),
506
+ '7.4.1': ('srr', 'pdr')},
507
+ 'requirements-baseline': {'7.1.1': ('srr',),
508
+ '7.1.2': ('srr',),
509
+ '7.2.1.1': ('srr',),
510
+ '7.2.1.3': ('srr',)},
511
+ 'review-inspection-records': {'6.2.6.9': (), '6.2.6.10': (), '6.2.6.11': ()},
512
+ 'scm-plan': {'6.2.4.2': ('srr', 'pdr'),
513
+ '6.2.4.5': ('srr', 'pdr'),
514
+ '6.2.4.12': ('srr', 'pdr'),
515
+ '7.3.5': ('srr', 'pdr')},
516
+ 'sdp': {'5.5.2': ('srr', 'pdr'),
517
+ '5.6.2.1': ('srr', 'pdr'),
518
+ '5.6.2.2': ('srr', 'pdr'),
519
+ '6.2.1.1': (),
520
+ '6.2.1.2': (),
521
+ '6.2.1.3': (),
522
+ '6.3.4.5': ('pdr',)},
523
+ 'security-analysis': {'5.4.5': ('srr',),
524
+ '6.2.9.2': ('pdr',),
525
+ '6.2.9.5': ('cdr', 'qr', 'ar'),
526
+ '6.2.9.6': ('cdr', 'qr', 'ar'),
527
+ '6.2.9.7': ('pdr', 'cdr'),
528
+ '6.2.10.1': ('pdr', 'cdr', 'qr', 'ar')},
529
+ 'security-management-plan': {'6.2.4.8': ('srr', 'pdr'),
530
+ '6.2.4.9': ('srr',),
531
+ '6.2.4.11': ('srr',),
532
+ '6.2.10.2': ('pdr', 'cdr', 'qr', 'ar'),
533
+ '6.2.10.3': ('pdr', 'cdr'),
534
+ '6.2.10.4': ('pdr', 'cdr')},
535
+ 'software-configuration-file': {'6.2.4.3': (),
536
+ '6.2.4.4': ('cdr', 'qr', 'ar', 'orr'),
537
+ '6.2.4.5': ('cdr', 'qr', 'ar', 'orr'),
538
+ '6.2.4.6': ('cdr', 'qr', 'ar', 'orr'),
539
+ '6.2.4.8': ('cdr', 'qr', 'ar', 'orr'),
540
+ '6.2.4.10': (),
541
+ '6.2.4.11': (),
542
+ '6.3.6.2': ('ar',)},
543
+ 'software-reuse-file': {'6.2.7.2': ('srr', 'pdr'),
544
+ '6.2.7.3': ('srr', 'pdr'),
545
+ '6.2.7.4': ('srr', 'pdr'),
546
+ '6.2.7.5': ('srr', 'pdr'),
547
+ '6.2.7.6': ('srr', 'pdr'),
548
+ '6.2.7.7': ('srr', 'pdr'),
549
+ '6.2.7.8': ('srr', 'pdr'),
550
+ '6.2.7.9': ('cdr', 'qr', 'ar'),
551
+ '6.2.7.11': ('srr', 'pdr', 'cdr', 'qr', 'ar'),
552
+ '7.3.6': ('cdr',),
553
+ '7.3.7': ('cdr',)},
554
+ 'software-verification-plan': {'6.2.6.1': ('srr', 'pdr')},
555
+ 'software-verification-report': {'6.2.6.5': ('cdr', 'qr', 'ar'),
556
+ '6.2.6.6': ('cdr', 'qr', 'ar'),
557
+ '7.1.7': ('pdr', 'cdr', 'qr'),
558
+ '7.2.3.6': ('cdr', 'qr', 'ar')},
559
+ 'spa-reports': {'5.2.2.1': (),
560
+ '5.2.2.2': (),
561
+ '5.6.1.3': (),
562
+ '6.2.5.4': (),
563
+ '6.2.5.5': (),
564
+ '6.2.6.2': (),
565
+ '6.2.6.3': (),
566
+ '6.2.6.7': (),
567
+ '6.2.8.5': (),
568
+ '6.3.3.4': (),
569
+ '6.3.3.6': (),
570
+ '6.3.3.7': (),
571
+ '6.3.4.7': (),
572
+ '6.3.5.3': (),
573
+ '6.3.5.5': (),
574
+ '6.3.5.12': (),
575
+ '7.1.6': (),
576
+ '7.1.8': ()},
577
+ 'spamr': {'5.2.2.3': ('srr', 'pdr', 'cdr', 'qr', 'ar', 'orr'),
578
+ '5.6.1.2': ('srr', 'pdr'),
579
+ '6.2.3.3': ('pdr', 'cdr', 'qr', 'ar'),
580
+ '6.2.6.12': ('srr', 'pdr', 'cdr', 'qr', 'ar', 'orr')},
581
+ 'spap': {'5.1.2.1': ('srr',),
582
+ '5.1.2.2': ('srr',),
583
+ '5.1.2.3': ('srr',),
584
+ '5.1.3.1': ('srr',),
585
+ '5.1.4.1': ('srr',),
586
+ '5.2.1.1': ('srr', 'pdr'),
587
+ '5.2.1.3': ('cdr', 'qr', 'ar', 'orr'),
588
+ '5.2.1.4': ('ar',),
589
+ '5.2.1.5': ('srr', 'pdr'),
590
+ '5.2.6.1': ('srr',),
591
+ '5.2.6.2': ('srr', 'pdr'),
592
+ '5.2.7.1': ('pdr',),
593
+ '5.2.7.2': ('pdr',),
594
+ '5.4.3.3': ('pdr',),
595
+ '5.4.3.4': ('pdr',),
596
+ '5.6.1.1': ('srr', 'pdr'),
597
+ '6.1.1': ('srr', 'pdr'),
598
+ '6.2.1.4': ('srr', 'pdr'),
599
+ '6.2.2.10': ('pdr', 'cdr'),
600
+ '6.2.3.2': ('pdr', 'cdr'),
601
+ '6.2.3.4': ('pdr', 'cdr'),
602
+ '6.2.3.5': ('pdr', 'cdr'),
603
+ '6.2.4.8': ('srr', 'pdr'),
604
+ '6.2.4.9': ('srr', 'pdr'),
605
+ '6.2.4.11': ('srr', 'pdr'),
606
+ '6.2.5.1': ('srr', 'pdr'),
607
+ '6.2.5.2': ('srr', 'pdr'),
608
+ '6.2.7.2': ('srr', 'pdr'),
609
+ '6.2.7.3': ('srr', 'pdr'),
610
+ '6.2.7.4': ('srr', 'pdr'),
611
+ '6.2.7.5': ('srr', 'pdr'),
612
+ '6.2.9.1': ('srr', 'pdr', 'cdr'),
613
+ '6.2.10.3': ('pdr', 'cdr'),
614
+ '6.2.10.4': ('pdr', 'cdr'),
615
+ '6.3.3.3': ('pdr',),
616
+ '6.3.3.5': ('pdr',),
617
+ '6.3.3.7': ('pdr',),
618
+ '6.3.4.3': ('pdr',),
619
+ '6.3.4.6': ('pdr',),
620
+ '6.3.5.1': ('pdr', 'cdr'),
621
+ '6.3.5.2': ('pdr', 'cdr'),
622
+ '6.3.8.3': ('orr',),
623
+ '7.1.3': ('srr', 'pdr'),
624
+ '7.1.4': ('srr', 'pdr'),
625
+ '7.1.5': ('srr', 'pdr'),
626
+ '7.2.2.3': ('srr', 'pdr'),
627
+ '7.5.1': ('pdr',),
628
+ '7.5.2': ('pdr',)},
629
+ 'supplier-control': {'5.4.1.1': (),
630
+ '5.4.2.1': ('srr',),
631
+ '5.4.2.2': ('srr',),
632
+ '5.4.4': ('srr',)},
633
+ 'technical-specification': {'6.3.2.4': ('pdr',),
634
+ '7.1.1': ('pdr',),
635
+ '7.1.2': ('pdr',),
636
+ '7.2.1.1': ('pdr',),
637
+ '7.2.1.3': ('pdr',),
638
+ '7.3.4': ('pdr',)},
639
+ 'test-readiness': {'6.1.5': ('trr',),
640
+ '6.3.5.4': ('trr',),
641
+ '6.3.5.7': ('cdr', 'qr', 'ar', 'orr'),
642
+ '6.3.5.11': ('cdr', 'qr', 'ar', 'orr')},
643
+ 'training': {'5.1.5.1': ('srr',), '5.1.5.2': ()},
644
+ 'validation-documentation': {'6.2.8.2': ('pdr', 'cdr', 'qr', 'ar'),
645
+ '6.2.8.7': ('pdr', 'cdr', 'qr', 'ar'),
646
+ '6.3.5.13': ('cdr', 'qr', 'ar', 'orr'),
647
+ '6.3.5.16': ('cdr', 'qr', 'ar', 'orr'),
648
+ '6.3.5.17': ('cdr', 'qr', 'ar', 'orr'),
649
+ '6.3.5.18': ('cdr', 'qr', 'ar', 'orr'),
650
+ '6.3.5.22': ('pdr', 'cdr'),
651
+ '6.3.5.23': ('pdr', 'cdr'),
652
+ '6.3.5.24': ('pdr', 'cdr'),
653
+ '6.3.5.25': ('pdr', 'cdr', 'qr', 'ar'),
654
+ '6.3.5.27': ('ar',),
655
+ '6.3.5.29': ('pdr', 'cdr', 'qr', 'ar'),
656
+ '6.3.5.30': ('cdr', 'qr', 'ar'),
657
+ '6.3.5.31': ('cdr', 'qr', 'ar'),
658
+ '6.3.5.32': ('cdr', 'qr', 'ar'),
659
+ '6.3.5.33': ('cdr', 'qr', 'ar')}}
660
+
661
+ SPAMR_SECTIONS = (('1', 'purpose and scope of this milestone report', None),
662
+ ('2', 'applicable and reference documents', None),
663
+ ('3', 'terms and abbreviations', None),
664
+ ('4',
665
+ 'verification activities performed by product assurance',
666
+ 'verification_activities'),
667
+ ('5', 'suitability of methods and tools', 'methods_tools'),
668
+ ('6', 'adherence to design and coding standards', 'standards_adherence'),
669
+ ('7', 'product and process metrics against their targets', 'metrics'),
670
+ ('8', 'testing and validation status and coverage', 'testing'),
671
+ ('9', 'status of software problem reports and nonconformances', 'problems'),
672
+ ('10', 'references to progress reports', 'progress_reports'))
673
+
674
+ METRICS = {'cyclomatic_complexity': ('product', 'per function', 'max', '7.1.5'),
675
+ 'nesting_depth': ('product', 'levels', 'max', '7.1.4'),
676
+ 'function_size_loc': ('product', 'lines', 'max', '7.1.4'),
677
+ 'comment_density': ('product', 'ratio', 'min', '7.1.4'),
678
+ 'requirement_coverage': ('product', 'ratio', 'min', '7.2.1'),
679
+ 'requirement_test_coverage': ('product', 'ratio', 'min', '6.3.5.2'),
680
+ 'statement_coverage': ('product', 'ratio', 'min', '6.3.5.2'),
681
+ 'decision_coverage': ('product', 'ratio', 'min', '6.3.5.2'),
682
+ 'mcdc_coverage': ('product', 'ratio', 'min', '6.3.5.2'),
683
+ 'open_major_nonconformances': ('process', 'count', 'max', '5.2.6'),
684
+ 'open_problem_reports': ('process', 'count', 'max', '5.2.5'),
685
+ 'coding_standard_violations': ('product', 'count', 'max', '6.3.4')}
686
+
687
+ DEFAULT_THRESHOLDS = {'A': {'cyclomatic_complexity': 10,
688
+ 'nesting_depth': 4,
689
+ 'function_size_loc': 60,
690
+ 'comment_density': 0.25,
691
+ 'requirement_coverage': 1.0,
692
+ 'requirement_test_coverage': 1.0,
693
+ 'statement_coverage': 1.0,
694
+ 'decision_coverage': 1.0,
695
+ 'mcdc_coverage': 1.0,
696
+ 'open_major_nonconformances': 0,
697
+ 'open_problem_reports': None,
698
+ 'coding_standard_violations': 0},
699
+ 'B': {'cyclomatic_complexity': 10,
700
+ 'nesting_depth': 4,
701
+ 'function_size_loc': 80,
702
+ 'comment_density': 0.2,
703
+ 'requirement_coverage': 1.0,
704
+ 'requirement_test_coverage': 1.0,
705
+ 'statement_coverage': 1.0,
706
+ 'decision_coverage': 1.0,
707
+ 'mcdc_coverage': None,
708
+ 'open_major_nonconformances': 0,
709
+ 'open_problem_reports': None,
710
+ 'coding_standard_violations': 0},
711
+ 'C': {'cyclomatic_complexity': 15,
712
+ 'nesting_depth': 5,
713
+ 'function_size_loc': 100,
714
+ 'comment_density': 0.15,
715
+ 'requirement_coverage': 1.0,
716
+ 'requirement_test_coverage': 1.0,
717
+ 'statement_coverage': None,
718
+ 'decision_coverage': None,
719
+ 'mcdc_coverage': None,
720
+ 'open_major_nonconformances': 0,
721
+ 'open_problem_reports': None,
722
+ 'coding_standard_violations': None},
723
+ 'D': {'cyclomatic_complexity': 20,
724
+ 'nesting_depth': 6,
725
+ 'function_size_loc': 150,
726
+ 'comment_density': 0.1,
727
+ 'requirement_coverage': 1.0,
728
+ 'requirement_test_coverage': 0.9,
729
+ 'statement_coverage': None,
730
+ 'decision_coverage': None,
731
+ 'mcdc_coverage': None,
732
+ 'open_major_nonconformances': 0,
733
+ 'open_problem_reports': None,
734
+ 'coding_standard_violations': None}}
735
+
736
+ _REQUIREMENT_SET = frozenset(REQUIREMENT_IDS)
737
+
738
+ # Worst first: several evidence rows on one clause combine to the weakest.
739
+ _SEVERITY_RANK = {"not-compliant": 0, "partially-compliant": 1,
740
+ "compliant": 2}
741
+
742
+
743
+ # ---------------------------------------------------------------------------
744
+ # Normalisation
745
+ # ---------------------------------------------------------------------------
746
+
747
+ def normalise_category(value):
748
+ """Return the category letter A to D ('b', 'cat-B', ' B ' accepted)."""
749
+ text = str(value if value is not None else "").strip().upper()
750
+ for prefix in ("CATEGORY", "CAT-", "CAT"):
751
+ if text.startswith(prefix):
752
+ text = text[len(prefix):].strip(" -")
753
+ if text not in CATEGORIES:
754
+ raise ValueError("unknown software criticality category %r "
755
+ "(use A, B, C or D)" % (value,))
756
+ return text
757
+
758
+
759
+ def normalise_severity(value):
760
+ """Return the function severity I to IV (numeral, digit or name)."""
761
+ key = str(value if value is not None else "").strip()
762
+ if key.upper() in SEVERITIES:
763
+ return key.upper()
764
+ if key.lower() in _SEVERITY_ALIASES:
765
+ return _SEVERITY_ALIASES[key.lower()]
766
+ raise ValueError("unknown function severity %r (use I, II, III, IV or "
767
+ "catastrophic, critical, major, minor)" % (value,))
768
+
769
+
770
+ def normalise_provision(value):
771
+ key = str(value).strip().lower()
772
+ if key not in _PROVISION_ALIASES:
773
+ raise ValueError("unknown compensating provision %r (use hardware, "
774
+ "software or operational-procedure)" % (value,))
775
+ return _PROVISION_ALIASES[key]
776
+
777
+
778
+ def normalise_clause_id(value):
779
+ """Return a clause id such as '6.2.3.4'.
780
+
781
+ A 'clause' / 'cl.' / section-sign prefix, a trailing dot and a trailing
782
+ item letter are cut: '6.2.3.4a', '6.2.3.4.a' and 'cl. 6.2.3.4' all give
783
+ '6.2.3.4'. Leading zeros in a part are dropped.
784
+ """
785
+ if not isinstance(value, str):
786
+ raise ValueError("clause id must be text, got %r" % (value,))
787
+ text = value.strip().rstrip(".")
788
+ low = text.lower()
789
+ for prefix in ("clause", "cl.", "§"):
790
+ if low.startswith(prefix):
791
+ text = text[len(prefix):].strip()
792
+ low = text.lower()
793
+ while text and text[-1].isalpha():
794
+ text = text[:-1].rstrip(".")
795
+ parts = text.split(".")
796
+ if not text or not all(p.isdigit() for p in parts):
797
+ raise ValueError("not a clause identifier: %r" % (value,))
798
+ return ".".join(str(int(p)) for p in parts)
799
+
800
+
801
+ def normalise_status(value):
802
+ """Map a status spelling to one of the four statuses; refuse the rest."""
803
+ key = str(value or "").strip().lower().replace("_", " ")
804
+ if key in STATUS_ALIASES:
805
+ return STATUS_ALIASES[key]
806
+ dashed = key.replace(" ", "-")
807
+ if dashed in STATUS_ALIASES:
808
+ return STATUS_ALIASES[dashed]
809
+ raise ValueError("unknown compliance status %r (use compliant, "
810
+ "partially-compliant, not-compliant or not-applicable)"
811
+ % (value,))
812
+
813
+
814
+ def normalise_review(value):
815
+ """Return the short review name (srr, pdr, cdr, trr, qr, ar, orr)."""
816
+ key = str(value).strip().lower().replace(" ", "-")
817
+ key = _REVIEW_ALIASES.get(key, key)
818
+ if key not in REVIEWS:
819
+ raise ValueError("unknown review %r (use SRR, PDR, CDR, TRR, QR, AR "
820
+ "or ORR)" % (value,))
821
+ return key
822
+
823
+
824
+ def normalise_scope(flags):
825
+ """Return the scope flags as a sorted tuple; refuse unknown flags."""
826
+ out = set()
827
+ for flag in flags or ():
828
+ key = str(flag).strip().lower()
829
+ if not key:
830
+ continue
831
+ if key not in SCOPE_FLAGS:
832
+ raise ValueError("unknown scope flag %r (use %s)"
833
+ % (flag, ", ".join(SCOPE_FLAGS)))
834
+ out.add(key)
835
+ return tuple(sorted(out))
836
+
837
+
838
+ def _clause_key(cid):
839
+ return [int(p) for p in cid.split(".")]
840
+
841
+
842
+ def topic_of(clause):
843
+ """Our topic label for the heading a clause sits under, or ''."""
844
+ parts = normalise_clause_id(clause).split(".")
845
+ for depth in range(min(3, len(parts)), 0, -1):
846
+ key = ".".join(parts[:depth])
847
+ if key in TOPICS:
848
+ return TOPICS[key]
849
+ return ""
850
+
851
+
852
+ def _in_group(cid, group):
853
+ return cid == group or cid.startswith(group + ".")
854
+
855
+
856
+ # ---------------------------------------------------------------------------
857
+ # Stage 1-2: category and tailoring
858
+ # ---------------------------------------------------------------------------
859
+
860
+ def derive_category(severity, provisions=(), provides_provision_for=None):
861
+ """Derive the software criticality category.
862
+
863
+ The base category follows the highest severity of the functions the
864
+ software takes part in (I -> A ... IV -> D). A compensating provision
865
+ at system level (hardware, software, operational procedure) lowers it
866
+ by one step. Software that IS the compensating provision for functions
867
+ of some severity takes that severity's category with no credit.
868
+
869
+ Returns category, base_category, lowered_by_provision, rationale lines
870
+ and the constraints the assignment places on other items.
871
+ """
872
+ sev = normalise_severity(severity)
873
+ base = CATEGORIES[SEVERITIES.index(sev)]
874
+ rationale = ["highest function severity %s gives base category %s"
875
+ % (sev, base)]
876
+ constraints = []
877
+ if provides_provision_for:
878
+ prov = normalise_severity(provides_provision_for)
879
+ prov_base = CATEGORIES[SEVERITIES.index(prov)]
880
+ category = min(base, prov_base) # 'A' sorts before 'B': stricter
881
+ rationale.append("the software is itself a compensating provision "
882
+ "for severity %s functions, so it takes category "
883
+ "%s without credit" % (prov, prov_base))
884
+ return {"severity": sev, "category": category, "base_category": base,
885
+ "lowered_by_provision": False, "provisions": [],
886
+ "rationale": rationale, "constraints": constraints}
887
+ kinds = sorted(set(normalise_provision(k) for k in (provisions or ())))
888
+ lowered = bool(kinds) and base != "D"
889
+ if lowered:
890
+ category = CATEGORIES[CATEGORIES.index(base) + 1]
891
+ rationale.append("compensating provision(s) %s lower %s to %s"
892
+ % (", ".join(kinds), base, category))
893
+ if "software" in kinds:
894
+ constraints.append("the software implementing the provision must "
895
+ "itself be category %s" % base)
896
+ constraints.append("each provision must meet the system dependability "
897
+ "and safety requirements before the lower category "
898
+ "is claimed")
899
+ else:
900
+ category = base
901
+ if kinds:
902
+ rationale.append("severity IV software is already category D; "
903
+ "no credit to take")
904
+ return {"severity": sev, "category": category, "base_category": base,
905
+ "lowered_by_provision": lowered, "provisions": kinds,
906
+ "rationale": rationale, "constraints": constraints}
907
+
908
+
909
+ def tailored_status(clause, category, security_sensitive=False):
910
+ """'applicable', 'reduced', 'not-applicable', or 'unknown'.
911
+
912
+ 'unknown' = the id is not a requirement of this revision (a heading,
913
+ a typo, or an id from an older list); the matrix treats it as
914
+ applicable and says so.
915
+ """
916
+ cid = normalise_clause_id(clause)
917
+ if cid not in _REQUIREMENT_SET:
918
+ return "unknown"
919
+ code = APPLICABILITY.get(cid, "YYYY")[
920
+ CATEGORIES.index(normalise_category(category))]
921
+ if code == "S":
922
+ return "applicable" if security_sensitive else "not-applicable"
923
+ return {"Y": "applicable", "R": "reduced", "N": "not-applicable"}[code]
924
+
925
+
926
+ def tailoring_summary(category, security_sensitive=False):
927
+ """Tailoring for one category, per clause group and in total."""
928
+ cat = normalise_category(category)
929
+ groups, order = {}, []
930
+ totals = {"applicable": 0, "reduced": 0, "not-applicable": 0}
931
+ reduced = []
932
+ for rid in REQUIREMENT_IDS:
933
+ status = tailored_status(rid, cat, security_sensitive)
934
+ group = ".".join(rid.split(".")[:2])
935
+ if group not in groups:
936
+ groups[group] = {"group": group, "topic": TOPICS.get(group, ""),
937
+ "applicable": 0, "reduced": 0,
938
+ "not-applicable": 0}
939
+ order.append(group)
940
+ groups[group][status] += 1
941
+ totals[status] += 1
942
+ if status == "reduced":
943
+ reduced.append({"clause": rid,
944
+ "note": REDUCTION_NOTES.get(
945
+ rid, "reduced scope for this category")})
946
+ return {"category": cat, "security_sensitive": bool(security_sensitive),
947
+ "groups": [groups[g] for g in order], "totals": totals,
948
+ "reduced": reduced}
949
+
950
+
951
+ def default_clauses():
952
+ """The requirement list of the standard as (id, topic) pairs."""
953
+ return [(rid, topic_of(rid)) for rid in REQUIREMENT_IDS]
954
+
955
+
956
+ def parse_clause_list(text):
957
+ """Parse a customer clause list: one id per line, or CSV with id[,title].
958
+
959
+ Blank lines and lines starting with '#' are skipped; a header row whose
960
+ first cell is not a clause id is skipped. Duplicates are refused.
961
+ """
962
+ out, seen = [], set()
963
+ for n, raw in enumerate(csv.reader(io.StringIO(str(text))), 1):
964
+ if not raw or not raw[0].strip() or raw[0].strip().startswith("#"):
965
+ continue
966
+ try:
967
+ cid = normalise_clause_id(raw[0])
968
+ except ValueError:
969
+ if n == 1:
970
+ continue # header row
971
+ raise
972
+ if cid in seen:
973
+ raise ValueError("clause %s listed twice" % cid)
974
+ seen.add(cid)
975
+ title = raw[1].strip() if len(raw) > 1 and raw[1].strip() else ""
976
+ out.append((cid, title or topic_of(cid)))
977
+ if not out:
978
+ raise ValueError("clause list is empty")
979
+ return out
980
+
981
+
982
+ # ---------------------------------------------------------------------------
983
+ # Stage 3: evidence index and compliance matrix
984
+ # ---------------------------------------------------------------------------
985
+
986
+ _FIELD_ALIASES = {
987
+ "clause": "clause", "clause_id": "clause", "requirement": "clause",
988
+ "id": "clause",
989
+ "document": "document", "doc": "document", "evidence": "document",
990
+ "section": "section", "sect": "section", "paragraph": "section",
991
+ "status": "status", "compliance": "status",
992
+ "justification": "justification", "comment": "justification",
993
+ "rationale": "justification",
994
+ }
995
+
996
+
997
+ def parse_evidence_csv(text):
998
+ """Parse the evidence index from CSV text.
999
+
1000
+ Needs a clause column and a status column; document, section and
1001
+ justification are optional. Header spellings are matched loosely
1002
+ (clause/requirement/id, document/doc/evidence, section/paragraph,
1003
+ justification/comment/rationale). Blank rows are skipped.
1004
+ """
1005
+ reader = csv.DictReader(io.StringIO(str(text)))
1006
+ if not reader.fieldnames:
1007
+ raise ValueError("evidence CSV has no header row")
1008
+ mapping = {}
1009
+ for name in reader.fieldnames:
1010
+ key = _FIELD_ALIASES.get(str(name).strip().lower())
1011
+ if key and key not in mapping.values():
1012
+ mapping[name] = key
1013
+ if "clause" not in mapping.values() or "status" not in mapping.values():
1014
+ raise ValueError("evidence CSV needs a clause column and a status "
1015
+ "column (optional: document, section, "
1016
+ "justification)")
1017
+ rows = []
1018
+ for raw in reader:
1019
+ row = {mapping[k]: (v or "").strip() for k, v in raw.items()
1020
+ if k in mapping}
1021
+ if not any(row.values()):
1022
+ continue
1023
+ rows.append(row)
1024
+ return rows
1025
+
1026
+
1027
+ def index_evidence(rows):
1028
+ """{clause id: [entries]} with normalised status and stripped fields."""
1029
+ index = {}
1030
+ for n, row in enumerate(rows, 1):
1031
+ if not str(row.get("clause") or "").strip():
1032
+ raise ValueError("evidence row %d has no clause" % n)
1033
+ cid = normalise_clause_id(str(row["clause"]))
1034
+ try:
1035
+ status = normalise_status(row.get("status"))
1036
+ except ValueError as exc:
1037
+ raise ValueError("evidence row %d (clause %s): %s" % (n, cid, exc))
1038
+ index.setdefault(cid, []).append({
1039
+ "status": status,
1040
+ "document": str(row.get("document") or "").strip(),
1041
+ "section": str(row.get("section") or "").strip(),
1042
+ "justification": str(row.get("justification") or "").strip(),
1043
+ })
1044
+ return index
1045
+
1046
+
1047
+ def route_gap(kind, clause):
1048
+ """The bound skill that resolves a gap of this kind on this clause."""
1049
+ if kind in GAP_ROUTES_BY_KIND:
1050
+ return GAP_ROUTES_BY_KIND[kind]
1051
+ for prefix, leaf in GAP_ROUTES_BY_CLAUSE:
1052
+ if clause.startswith(prefix):
1053
+ return leaf
1054
+ return _LEAF + "q80-compliance-matrix"
1055
+
1056
+
1057
+ def _matrix_row(cid, title, entries, category, security_sensitive):
1058
+ gaps = []
1059
+ tailoring = (tailored_status(cid, category, security_sensitive)
1060
+ if category else "unknown")
1061
+ refs, docs = [], []
1062
+ for e in entries:
1063
+ if e["document"]:
1064
+ refs.append(e["document"] + (" " + e["section"]
1065
+ if e["section"] else ""))
1066
+ if e["document"] not in docs:
1067
+ docs.append(e["document"])
1068
+ justification = "; ".join(e["justification"] for e in entries
1069
+ if e["justification"])
1070
+ if not entries:
1071
+ if tailoring == "not-applicable":
1072
+ status = "not-applicable"
1073
+ justification = ("tailored out for software category %s"
1074
+ % category)
1075
+ else:
1076
+ status = "not-compliant"
1077
+ gaps.append("no-evidence")
1078
+ justification = "no evidence mapped; open until assessed"
1079
+ else:
1080
+ stated = [e["status"] for e in entries]
1081
+ graded = [s for s in stated if s != "not-applicable"]
1082
+ if graded:
1083
+ status = min(graded, key=_SEVERITY_RANK.get)
1084
+ if "not-applicable" in stated:
1085
+ gaps.append("conflicting-status")
1086
+ else:
1087
+ status = "not-applicable"
1088
+ if status == "not-applicable":
1089
+ if tailoring in ("applicable", "reduced"):
1090
+ gaps.append("na-conflicts-with-tailoring")
1091
+ if not justification:
1092
+ gaps.append("na-without-justification")
1093
+ else:
1094
+ if not refs:
1095
+ gaps.append("no-evidence-reference")
1096
+ if status == "compliant":
1097
+ status = "partially-compliant"
1098
+ elif any(e["document"] and not e["section"] for e in entries):
1099
+ gaps.append("reference-without-section")
1100
+ if (status in ("partially-compliant", "not-compliant")
1101
+ and not justification):
1102
+ gaps.append("deviation-without-justification")
1103
+ if tailoring == "not-applicable":
1104
+ gaps.append("evidence-for-tailored-out-clause")
1105
+ return {"clause": cid, "title": title, "status": status,
1106
+ "tailoring": tailoring, "justification": justification,
1107
+ "evidence": refs, "documents": docs,
1108
+ "evidence_rows": len(entries), "gaps": gaps, "notes": [],
1109
+ "trace": None}
1110
+
1111
+
1112
+ def build_matrix(clauses, evidence_rows, category, security_sensitive=False):
1113
+ """Build the compliance matrix. The result is ALWAYS a draft.
1114
+
1115
+ clauses: list of (id, topic) pairs (default_clauses() for the whole
1116
+ standard) or plain ids.
1117
+ evidence_rows: rows from parse_evidence_csv (or dicts of that shape).
1118
+ category: software criticality category A to D.
1119
+ """
1120
+ cat = normalise_category(category)
1121
+ index = index_evidence(evidence_rows)
1122
+ listed, seen = [], set()
1123
+ for item in clauses:
1124
+ if isinstance(item, (tuple, list)):
1125
+ cid, title = normalise_clause_id(str(item[0])), str(item[1] or "")
1126
+ else:
1127
+ cid, title = normalise_clause_id(str(item)), ""
1128
+ if cid in seen:
1129
+ raise ValueError("clause %s listed twice" % cid)
1130
+ seen.add(cid)
1131
+ listed.append((cid, title or topic_of(cid)))
1132
+ rows = [_matrix_row(cid, title, index.get(cid, []), cat,
1133
+ security_sensitive) for cid, title in listed]
1134
+ orphans = sorted((cid for cid in index if cid not in seen),
1135
+ key=_clause_key)
1136
+ matrix = {"standard": STANDARD, "category": cat, "status": "DRAFT",
1137
+ "requires_human_sign_off": True, "rows": rows,
1138
+ "orphan_evidence": orphans,
1139
+ "orphan_documents": {cid: [e["document"] for e in index[cid]
1140
+ if e["document"]]
1141
+ for cid in orphans},
1142
+ "trace": {"ran": False}}
1143
+ _refresh(matrix)
1144
+ return matrix
1145
+
1146
+
1147
+ def coverage_summary(rows, trace_ran=False):
1148
+ """Counts per status and the three coverage fractions.
1149
+
1150
+ compliant fraction = compliant / applicable; evidenced fraction =
1151
+ applicable clauses carrying a document reference / applicable; traced
1152
+ fraction = applicable clauses with at least one reference that resolves
1153
+ to a real file / applicable (None when no document folder was given).
1154
+ """
1155
+ counts = {s: 0 for s in STATUSES}
1156
+ with_ref = traced = gapped = 0
1157
+ for row in rows:
1158
+ counts[row["status"]] += 1
1159
+ applicable = row["status"] != "not-applicable"
1160
+ if applicable and row["evidence"]:
1161
+ with_ref += 1
1162
+ if applicable and row.get("trace") and row["trace"]["found"]:
1163
+ traced += 1
1164
+ if row["gaps"]:
1165
+ gapped += 1
1166
+ applicable = len(rows) - counts["not-applicable"]
1167
+
1168
+ def frac(n):
1169
+ return round(n / applicable, 4) if applicable else 1.0
1170
+ return {"clauses": len(rows), "counts": counts, "applicable": applicable,
1171
+ "compliant_fraction": frac(counts["compliant"]),
1172
+ "evidenced_fraction": frac(with_ref),
1173
+ "traced_fraction": frac(traced) if trace_ran else None,
1174
+ "rows_with_gaps": gapped}
1175
+
1176
+
1177
+ def find_gaps(matrix):
1178
+ """One entry per (clause, gap kind), with the skill that resolves it.
1179
+
1180
+ Every row gap; every clause not fully compliant (unless it already
1181
+ carries no-evidence); one entry per clause that has evidence but is not
1182
+ in the clause list.
1183
+ """
1184
+ out = []
1185
+ for row in matrix["rows"]:
1186
+ for kind in row["gaps"]:
1187
+ out.append({"clause": row["clause"], "kind": kind,
1188
+ "status": row["status"],
1189
+ "next_skill": route_gap(kind, row["clause"])})
1190
+ if (row["status"] in ("partially-compliant", "not-compliant")
1191
+ and "no-evidence" not in row["gaps"]):
1192
+ out.append({"clause": row["clause"], "kind": row["status"],
1193
+ "status": row["status"],
1194
+ "next_skill": route_gap(row["status"],
1195
+ row["clause"])})
1196
+ for cid in matrix.get("orphan_evidence", []):
1197
+ out.append({"clause": cid, "kind": "evidence-for-unlisted-clause",
1198
+ "status": None,
1199
+ "next_skill": route_gap("evidence-for-unlisted-clause",
1200
+ cid)})
1201
+ return out
1202
+
1203
+
1204
+ def _refresh(matrix):
1205
+ matrix["coverage"] = coverage_summary(matrix["rows"],
1206
+ matrix["trace"]["ran"])
1207
+ matrix["gaps"] = find_gaps(matrix)
1208
+
1209
+
1210
+ # ---------------------------------------------------------------------------
1211
+ # Stage 4: document trace
1212
+ # ---------------------------------------------------------------------------
1213
+
1214
+ def list_documents(folder):
1215
+ """Every file under a folder, as sorted relative paths ('/' separated)."""
1216
+ if not os.path.isdir(folder):
1217
+ raise ValueError("document folder not found: %s" % folder)
1218
+ out = []
1219
+ for root, dirs, files in os.walk(folder):
1220
+ dirs[:] = sorted(d for d in dirs if not d.startswith("."))
1221
+ for f in sorted(files):
1222
+ if f.startswith("."):
1223
+ continue
1224
+ rel = os.path.relpath(os.path.join(root, f), folder)
1225
+ out.append(rel.replace(os.sep, "/"))
1226
+ return sorted(out)
1227
+
1228
+
1229
+ def _doc_keys(name):
1230
+ base = str(name).replace("\\", "/").rsplit("/", 1)[-1].strip().lower()
1231
+ stem = base.rsplit(".", 1)[0] if "." in base else base
1232
+ return base, stem
1233
+
1234
+
1235
+ def resolve_document(document, files):
1236
+ """The file a cited document resolves to, or None.
1237
+
1238
+ A citation resolves when a file in the folder has the same name, or the
1239
+ same name without its extension ('EX-SPAP-001' -> 'EX-SPAP-001.md').
1240
+ Matching ignores case and the folder a file sits in.
1241
+ """
1242
+ base, stem = _doc_keys(document)
1243
+ for rel in files:
1244
+ fbase, fstem = _doc_keys(rel)
1245
+ if base in (fbase, fstem) or stem == fbase:
1246
+ return rel
1247
+ return None
1248
+
1249
+
1250
+ def apply_document_trace(matrix, files, folder_label="docs/"):
1251
+ """Trace every cited document to a file; raise gaps for the misses.
1252
+
1253
+ A clause whose cited document is missing gets a document-not-found
1254
+ gap; a compliant clause whose every citation is missing is downgraded
1255
+ to partially compliant (a claim resting on no file is not evidence).
1256
+ """
1257
+ files = list(files)
1258
+ cited, missing = {}, {}
1259
+ for row in matrix["rows"]:
1260
+ found, lost = [], []
1261
+ for doc in row["documents"]:
1262
+ rel = resolve_document(doc, files)
1263
+ cited.setdefault(doc, rel)
1264
+ if rel is None:
1265
+ lost.append(doc)
1266
+ missing.setdefault(doc, []).append(row["clause"])
1267
+ else:
1268
+ found.append(doc)
1269
+ row["trace"] = {"found": found, "missing": lost}
1270
+ if lost and "document-not-found" not in row["gaps"]:
1271
+ row["gaps"].append("document-not-found")
1272
+ if row["status"] == "compliant" and row["documents"] and not found:
1273
+ row["status"] = "partially-compliant"
1274
+ row["notes"].append("downgraded from compliant: no cited "
1275
+ "document was found in the folder")
1276
+ for cid, docs in matrix.get("orphan_documents", {}).items():
1277
+ for doc in docs:
1278
+ rel = resolve_document(doc, files)
1279
+ cited.setdefault(doc, rel)
1280
+ if rel is None:
1281
+ missing.setdefault(doc, []).append(cid)
1282
+ used = set(r for r in cited.values() if r)
1283
+ matrix["trace"] = {
1284
+ "ran": True, "folder": folder_label, "files": len(files),
1285
+ "cited": len(cited),
1286
+ "found": sorted(d for d, r in cited.items() if r),
1287
+ "resolved": {d: r for d, r in sorted(cited.items()) if r},
1288
+ "missing": {d: sorted(set(c), key=_clause_key)
1289
+ for d, c in sorted(missing.items())},
1290
+ "uncited_files": [f for f in files if f not in used],
1291
+ }
1292
+ _refresh(matrix)
1293
+ return matrix
1294
+
1295
+
1296
+ # ---------------------------------------------------------------------------
1297
+ # Stage 5: milestone evidence
1298
+ # ---------------------------------------------------------------------------
1299
+
1300
+ def clause_applies(clause, category, security_sensitive=False):
1301
+ """True when a clause applies (fully or reduced) for a category."""
1302
+ return tailored_status(clause, category, security_sensitive) in (
1303
+ "applicable", "reduced")
1304
+
1305
+
1306
+ def evidence_due(review, category, scope=()):
1307
+ """Documents a review owes for a category and scope, with their clauses.
1308
+
1309
+ A document kind is owed when at least one of its driving clauses
1310
+ expects it at this review and applies to the category; kinds tied to a
1311
+ scope (suppliers, procured items, reuse, security, operations) are owed
1312
+ only when the project has that scope.
1313
+ """
1314
+ rev = normalise_review(review)
1315
+ cat = normalise_category(category)
1316
+ flags = set(normalise_scope(scope))
1317
+ out = []
1318
+ for doc in sorted(DOCUMENT_CLAUSE_REVIEWS):
1319
+ cond = DOCUMENT_CONDITIONS.get(doc)
1320
+ if cond is not None and cond not in flags:
1321
+ continue
1322
+ clauses = [c for c, revs in DOCUMENT_CLAUSE_REVIEWS[doc].items()
1323
+ if rev in revs and clause_applies(c, cat,
1324
+ "security" in flags)]
1325
+ if clauses:
1326
+ out.append({"document": doc, "title": DOCUMENT_TITLES[doc],
1327
+ "clauses": clauses})
1328
+ return out
1329
+
1330
+
1331
+ def parse_pack_csv(text):
1332
+ """Parse a review pack: CSV with document (a document kind) + maturity."""
1333
+ reader = csv.DictReader(io.StringIO(str(text)))
1334
+ fields = [str(f).strip().lower() for f in (reader.fieldnames or [])]
1335
+ if "document" not in fields or "maturity" not in fields:
1336
+ raise ValueError("review pack CSV needs 'document' and 'maturity' "
1337
+ "columns (document = a document kind such as spap, "
1338
+ "sdp, scm-plan)")
1339
+ out = []
1340
+ for raw in reader:
1341
+ row = {str(k).strip().lower(): (v or "").strip()
1342
+ for k, v in raw.items() if k is not None}
1343
+ if not row.get("document"):
1344
+ continue
1345
+ out.append({"document": row["document"],
1346
+ "maturity": row.get("maturity", ""),
1347
+ "file": row.get("file", "")})
1348
+ return out
1349
+
1350
+
1351
+ def assess_review_pack(review, category, submitted, scope=(),
1352
+ required_maturity="issued"):
1353
+ """Grade a submitted pack against what the review owes.
1354
+
1355
+ Returns owed, missing, immature (below required maturity), unplanned
1356
+ (submitted but not owed at this review) and complete.
1357
+ """
1358
+ if required_maturity not in _MATURITY:
1359
+ raise ValueError("unknown maturity %r" % (required_maturity,))
1360
+ owed = [d["document"] for d in evidence_due(review, category, scope)]
1361
+ got = {}
1362
+ for item in submitted:
1363
+ doc = str(item.get("document") or "").strip()
1364
+ if doc not in DOCUMENT_TITLES:
1365
+ raise ValueError("unknown document kind %r in the review pack "
1366
+ "(known kinds: %s)"
1367
+ % (doc, ", ".join(sorted(DOCUMENT_TITLES))))
1368
+ if doc in got:
1369
+ raise ValueError("document %s submitted twice" % doc)
1370
+ mat = str(item.get("maturity") or "").strip().lower()
1371
+ if mat not in _MATURITY:
1372
+ raise ValueError("unknown maturity %r for %s (use draft, issued "
1373
+ "or approved)" % (mat, doc))
1374
+ got[doc] = mat
1375
+ missing = [d for d in owed if d not in got]
1376
+ immature = [d for d in owed if d in got
1377
+ and _MATURITY[got[d]] < _MATURITY[required_maturity]]
1378
+ unplanned = sorted(d for d in got if d not in owed)
1379
+ return {"review": normalise_review(review), "owed": owed,
1380
+ "missing": missing, "immature": immature,
1381
+ "unplanned": unplanned,
1382
+ "complete": not missing and not immature}
1383
+
1384
+
1385
+ def milestone_check(matrix, review, category, scope=(), pack=None):
1386
+ """What the review owes, traced to the matrix rows that evidence it."""
1387
+ rev = normalise_review(review)
1388
+ by_clause = {r["clause"]: r for r in matrix["rows"]}
1389
+ trace_ran = matrix["trace"]["ran"]
1390
+ owed = []
1391
+ for item in evidence_due(rev, category, scope):
1392
+ rows = [by_clause.get(c) for c in item["clauses"]]
1393
+ listed = [r for r in rows if r is not None]
1394
+ docs = []
1395
+ for r in listed:
1396
+ for d in (r["trace"]["found"] if trace_ran and r["trace"]
1397
+ else r["documents"]):
1398
+ if d not in docs:
1399
+ docs.append(d)
1400
+ if not listed:
1401
+ state = "not-in-clause-list"
1402
+ elif len(listed) == len(rows) and all(
1403
+ r["status"] == "compliant"
1404
+ and (not trace_ran or r["trace"]["found"]) for r in listed):
1405
+ state = "evidenced"
1406
+ elif any(r["evidence"] for r in listed):
1407
+ state = "partial"
1408
+ else:
1409
+ state = "no-evidence"
1410
+ owed.append(dict(item, state=state, cited=docs))
1411
+ return {"review": rev, "review_name": REVIEW_NAMES[rev],
1412
+ "spap_maturity_due": PLAN_MATURITY_BY_REVIEW.get(rev),
1413
+ "owed": owed,
1414
+ "pack": (assess_review_pack(rev, category, pack, scope)
1415
+ if pack is not None else None)}
1416
+
1417
+
1418
+ def spamr_inputs(matrix, metrics=None):
1419
+ """Which milestone-report inputs the evidence can supply, and from what.
1420
+
1421
+ An input is available when an applicable clause of its groups carries
1422
+ evidence that resolves to a real document (or, with no folder given,
1423
+ that cites one). The metrics input is also fed by a graded measurement
1424
+ set.
1425
+ """
1426
+ trace_ran = matrix["trace"]["ran"]
1427
+ out = {}
1428
+ for key, groups in SPAMR_INPUT_CLAUSES.items():
1429
+ docs = []
1430
+ for r in matrix["rows"]:
1431
+ if r["status"] == "not-applicable":
1432
+ continue
1433
+ if not any(_in_group(r["clause"], g) for g in groups):
1434
+ continue
1435
+ for d in (r["trace"]["found"] if trace_ran and r["trace"]
1436
+ else r["documents"]):
1437
+ if d not in docs:
1438
+ docs.append(d)
1439
+ if key == "metrics" and metrics is not None:
1440
+ docs.append("graded measurement set")
1441
+ out[key] = sorted(docs)
1442
+ return out
1443
+
1444
+
1445
+ def spamr_skeleton(review, inputs):
1446
+ """Milestone report (SPAMR) skeleton: section status per available input.
1447
+
1448
+ Status is 'boilerplate' (no project input needed), 'filled' (input
1449
+ available) or 'missing-input'. Always a draft for human review.
1450
+ """
1451
+ rev = normalise_review(review)
1452
+ sections, missing = [], []
1453
+ for sid, topic, key in SPAMR_SECTIONS:
1454
+ if key is None:
1455
+ status = "boilerplate"
1456
+ elif inputs.get(key):
1457
+ status = "filled"
1458
+ else:
1459
+ status = "missing-input"
1460
+ missing.append(key)
1461
+ sections.append({"id": sid, "topic": topic, "status": status,
1462
+ "input": key,
1463
+ "draws_on": list(inputs.get(key) or [])
1464
+ if key else []})
1465
+ return {"review": rev, "sections": sections, "missing_inputs": missing,
1466
+ "status": "DRAFT - requires human review and sign-off"}
1467
+
1468
+
1469
+ # ---------------------------------------------------------------------------
1470
+ # Stage 6: product quality metrics
1471
+ # ---------------------------------------------------------------------------
1472
+
1473
+ def parse_metrics_csv(text):
1474
+ """Parse a measurement set: CSV with metric,value (blank = not measured)."""
1475
+ reader = csv.DictReader(io.StringIO(str(text)))
1476
+ fields = [str(f).strip().lower() for f in (reader.fieldnames or [])]
1477
+ if "metric" not in fields or "value" not in fields:
1478
+ raise ValueError("metrics CSV needs 'metric' and 'value' columns")
1479
+ out = {}
1480
+ for raw in reader:
1481
+ row = {str(k).strip().lower(): (v or "").strip()
1482
+ for k, v in raw.items() if k is not None}
1483
+ name = row.get("metric", "")
1484
+ if not name:
1485
+ continue
1486
+ value = row.get("value", "")
1487
+ try:
1488
+ out[name] = float(value) if value != "" else None
1489
+ except ValueError:
1490
+ raise ValueError("metric %s has a non-numeric value %r"
1491
+ % (name, value))
1492
+ return out
1493
+
1494
+
1495
+ def thresholds_for(category, overrides=None):
1496
+ """{metric: (threshold, source)}; source 'project' for an override."""
1497
+ cat = normalise_category(category)
1498
+ out = {n: (v, "default") for n, v in DEFAULT_THRESHOLDS[cat].items()
1499
+ if v is not None}
1500
+ for name, value in dict(overrides or {}).items():
1501
+ if name not in METRICS:
1502
+ raise ValueError("unknown metric %r" % (name,))
1503
+ if value is None:
1504
+ out.pop(name, None)
1505
+ else:
1506
+ out[name] = (value, "project")
1507
+ return out
1508
+
1509
+
1510
+ def evaluate_metrics(measurements, category, overrides=None):
1511
+ """Grade a measurement set: pass / fail / missing, with the margin."""
1512
+ limits = thresholds_for(category, overrides)
1513
+ unknown = sorted(n for n in measurements if n not in METRICS)
1514
+ rows, failing, missing = [], [], []
1515
+ for name in sorted(limits):
1516
+ threshold, source = limits[name]
1517
+ direction = METRICS[name][2]
1518
+ value = measurements.get(name)
1519
+ if value is None:
1520
+ rows.append({"metric": name, "value": None,
1521
+ "threshold": threshold, "source": source,
1522
+ "direction": direction, "status": "missing",
1523
+ "margin": None})
1524
+ missing.append(name)
1525
+ continue
1526
+ if direction == "max":
1527
+ ok, margin = value <= threshold + TOLERANCE, threshold - value
1528
+ else:
1529
+ ok, margin = value + TOLERANCE >= threshold, value - threshold
1530
+ rows.append({"metric": name, "value": value, "threshold": threshold,
1531
+ "source": source, "direction": direction,
1532
+ "status": "pass" if ok else "fail",
1533
+ "margin": round(margin, 4)})
1534
+ if not ok:
1535
+ failing.append(name)
1536
+ return {"category": normalise_category(category), "rows": rows,
1537
+ "failing": failing, "missing": missing, "unknown": unknown,
1538
+ "verdict": "pass" if not failing and not missing else "fail"}
1539
+
1540
+
1541
+ # ---------------------------------------------------------------------------
1542
+ # The deliverable
1543
+ # ---------------------------------------------------------------------------
1544
+
1545
+ class ProjectInputs(object):
1546
+ """Everything the role is given for one project."""
1547
+
1548
+ def __init__(self, project="", category=None, severity=None,
1549
+ provisions=(), provides_provision_for=None,
1550
+ security_sensitive=False, scope=(), review=None,
1551
+ clauses=None, clause_label="", evidence_text="",
1552
+ evidence_label="evidence.csv", docs_files=None,
1553
+ docs_label="", pack_text=None, pack_label="",
1554
+ metrics_text=None, metrics_label="", example=False):
1555
+ self.project = project
1556
+ self.category = category
1557
+ self.severity = severity
1558
+ self.provisions = list(provisions or ())
1559
+ self.provides_provision_for = provides_provision_for
1560
+ self.security_sensitive = bool(security_sensitive)
1561
+ self.scope = list(scope or ())
1562
+ self.review = review
1563
+ self.clauses = clauses
1564
+ self.clause_label = clause_label
1565
+ self.evidence_text = evidence_text
1566
+ self.evidence_label = evidence_label
1567
+ self.docs_files = docs_files
1568
+ self.docs_label = docs_label
1569
+ self.pack_text = pack_text
1570
+ self.pack_label = pack_label
1571
+ self.metrics_text = metrics_text
1572
+ self.metrics_label = metrics_label
1573
+ self.example = example
1574
+
1575
+
1576
+ EXAMPLE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)),
1577
+ "..", "example")
1578
+ EXAMPLE_PROJECT = ("EXAMPLE-SAT on-board software (worked example, "
1579
+ "not a real project)")
1580
+
1581
+
1582
+ def _read(path):
1583
+ with open(path, encoding="utf-8") as f:
1584
+ return f.read()
1585
+
1586
+
1587
+ def example_item(example_dir=None):
1588
+ """The bundled worked example: a category-B on-board software at PDR.
1589
+
1590
+ Severity I functions with a hardware compensating provision (so the
1591
+ category derives to B), reuse in scope, an evidence index, a document
1592
+ folder of stub files (one cited document deliberately absent), a PDR
1593
+ review pack and a measurement set.
1594
+ """
1595
+ d = os.path.normpath(example_dir or EXAMPLE_DIR)
1596
+ return ProjectInputs(
1597
+ project=EXAMPLE_PROJECT, severity="I", provisions=["hardware"],
1598
+ scope=["reuse"], review="pdr",
1599
+ evidence_text=_read(os.path.join(d, "evidence.csv")),
1600
+ evidence_label="example/evidence.csv",
1601
+ docs_files=list_documents(os.path.join(d, "docs")),
1602
+ docs_label="example/docs/",
1603
+ pack_text=_read(os.path.join(d, "pdr-pack.csv")),
1604
+ pack_label="example/pdr-pack.csv",
1605
+ metrics_text=_read(os.path.join(d, "metrics.csv")),
1606
+ metrics_label="example/metrics.csv", example=True)
1607
+
1608
+
1609
+ def resolve_category(item):
1610
+ """(category, derivation or None, notes) from the inputs."""
1611
+ notes = []
1612
+ derivation = None
1613
+ if item.severity:
1614
+ derivation = derive_category(item.severity, item.provisions,
1615
+ item.provides_provision_for)
1616
+ if item.category:
1617
+ cat = normalise_category(item.category)
1618
+ if derivation and derivation["category"] != cat:
1619
+ notes.append("the category given (%s) differs from the one the "
1620
+ "severity derives (%s): the human must settle it "
1621
+ "before the matrix is used" % (
1622
+ cat, derivation["category"]))
1623
+ return cat, derivation, notes
1624
+ if derivation:
1625
+ return derivation["category"], derivation, notes
1626
+ raise ValueError("give the software criticality category (--category "
1627
+ "A-D) or the function severity to derive it from "
1628
+ "(--severity I-IV)")
1629
+
1630
+
1631
+ def build_report(item):
1632
+ """Build the full deliverable model from the project inputs."""
1633
+ cat, derivation, notes = resolve_category(item)
1634
+ scope = set(normalise_scope(item.scope))
1635
+ security = bool(item.security_sensitive) or "security" in scope
1636
+ if security:
1637
+ scope.add("security")
1638
+ scope = tuple(sorted(scope))
1639
+ clauses = item.clauses if item.clauses is not None else default_clauses()
1640
+ evidence_rows = parse_evidence_csv(item.evidence_text)
1641
+ matrix = build_matrix(clauses, evidence_rows, cat, security)
1642
+ if item.docs_files is not None:
1643
+ apply_document_trace(matrix, item.docs_files,
1644
+ item.docs_label or "docs/")
1645
+ metrics = None
1646
+ if item.metrics_text is not None:
1647
+ metrics = evaluate_metrics(parse_metrics_csv(item.metrics_text), cat)
1648
+ review = normalise_review(item.review) if item.review else None
1649
+ milestone = spamr = None
1650
+ if review:
1651
+ pack = (parse_pack_csv(item.pack_text)
1652
+ if item.pack_text is not None else None)
1653
+ milestone = milestone_check(matrix, review, cat, scope, pack)
1654
+ spamr = spamr_skeleton(review, spamr_inputs(matrix, metrics))
1655
+ return {
1656
+ "role": "software-product-assurance-engineer",
1657
+ "title": TITLE, "standard": STANDARD,
1658
+ "project": item.project or "unnamed project",
1659
+ "example": bool(item.example),
1660
+ "status": "DRAFT", "requires_human_sign_off": True,
1661
+ "banner": DRAFT_BANNER, "stop_line": STOP_LINE,
1662
+ "category": cat, "category_derivation": derivation,
1663
+ "category_notes": notes,
1664
+ "security_sensitive": security, "scope": list(scope),
1665
+ "review": review,
1666
+ "inputs": {
1667
+ "clause_list": item.clause_label or (
1668
+ "the full requirement list of the standard"
1669
+ if item.clauses is None else "customer clause list"),
1670
+ "evidence": item.evidence_label,
1671
+ "evidence_rows": len(evidence_rows),
1672
+ "docs": item.docs_label if item.docs_files is not None else None,
1673
+ "pack": item.pack_label if item.pack_text is not None else None,
1674
+ "metrics": (item.metrics_label
1675
+ if item.metrics_text is not None else None),
1676
+ },
1677
+ "matrix": matrix,
1678
+ "tailoring": tailoring_summary(cat, security),
1679
+ "milestone": milestone, "spamr": spamr, "metrics": metrics,
1680
+ "sign_off": {"signed": False, "signatory": None, "role": None,
1681
+ "date": None, "decision": None},
1682
+ "disclaimer": ("DRAFT for human review. Not an approval, not a "
1683
+ "certification, not a statement of compliance."),
1684
+ }
1685
+
1686
+
1687
+ # ---------------------------------------------------------------------------
1688
+ # Rendering
1689
+ # ---------------------------------------------------------------------------
1690
+
1691
+ def _cell(text):
1692
+ return str(text).replace("|", "/").replace("\n", " ").strip()
1693
+
1694
+
1695
+ def _frac(x):
1696
+ return "not run" if x is None else "%.4f" % x
1697
+
1698
+
1699
+ def coverage_line(cov):
1700
+ c = cov["counts"]
1701
+ return ("Coverage: %d clauses, %d applicable; compliant %d, partially %d, "
1702
+ "not compliant %d, not applicable %d; compliant fraction %s; "
1703
+ "evidenced fraction %s; traced fraction %s." % (
1704
+ cov["clauses"], cov["applicable"], c["compliant"],
1705
+ c["partially-compliant"], c["not-compliant"],
1706
+ c["not-applicable"], _frac(cov["compliant_fraction"]),
1707
+ _frac(cov["evidenced_fraction"]),
1708
+ _frac(cov["traced_fraction"])))
1709
+
1710
+
1711
+ def render_report_markdown(model):
1712
+ """Render the deliverable: banner, scope, coverage, matrix, gaps, trace,
1713
+ tailoring, milestone, SPAMR skeleton, metrics, boundaries, stop line."""
1714
+ m = model["matrix"]
1715
+ cov = m["coverage"]
1716
+ L = ["# " + model["title"], "", "> " + model["banner"], "",
1717
+ "Status: %s. Human sign-off required." % model["status"], ""]
1718
+
1719
+ # 1. scope
1720
+ L += ["## 1. Scope and basis", ""]
1721
+ L.append("- Project: %s" % model["project"])
1722
+ L.append("- Standard: %s, referenced summary-only (clause identifiers "
1723
+ "with topic labels of our own wording)" % model["standard"])
1724
+ L.append("- Software criticality category: **%s**" % model["category"])
1725
+ der = model["category_derivation"]
1726
+ if der:
1727
+ L.append("- Category derivation: %s" % "; ".join(der["rationale"]))
1728
+ for c in der["constraints"]:
1729
+ L.append("- Constraint: %s" % c)
1730
+ for n in model["category_notes"]:
1731
+ L.append("- Open point: %s" % n)
1732
+ L.append("- Security sensitive: %s" % (
1733
+ "yes" if model["security_sensitive"] else "no"))
1734
+ L.append("- Project scope: %s" % (", ".join(model["scope"]) or
1735
+ "no conditional scope"))
1736
+ L.append("- Target review: %s" % (
1737
+ REVIEW_NAMES[model["review"]] if model["review"] else "none given"))
1738
+ inp = model["inputs"]
1739
+ L.append("- Clause list: %s (%d clauses)" % (inp["clause_list"],
1740
+ cov["clauses"]))
1741
+ L.append("- Evidence index: %s (%d rows)" % (inp["evidence"],
1742
+ inp["evidence_rows"]))
1743
+ L.append("- Document folder: %s" % (
1744
+ "%s (%d files)" % (inp["docs"], m["trace"]["files"])
1745
+ if inp["docs"] else "not given, so no citation was traced to a file"))
1746
+ L.append("- Review pack: %s" % (inp["pack"] or "not given"))
1747
+ L.append("- Measurement set: %s" % (inp["metrics"] or "not given"))
1748
+ L.append("")
1749
+
1750
+ # 2. coverage
1751
+ c = cov["counts"]
1752
+ L += ["## 2. Coverage summary", "", coverage_line(cov), "",
1753
+ "| Measure | Value |", "|---|---|",
1754
+ "| Clauses in the matrix | %d |" % cov["clauses"],
1755
+ "| Applicable clauses | %d |" % cov["applicable"],
1756
+ "| Compliant | %d |" % c["compliant"],
1757
+ "| Partially compliant | %d |" % c["partially-compliant"],
1758
+ "| Not compliant (including no evidence) | %d |" % c["not-compliant"],
1759
+ "| Not applicable | %d |" % c["not-applicable"],
1760
+ "| Compliant fraction of applicable clauses | %s |"
1761
+ % _frac(cov["compliant_fraction"]),
1762
+ "| Applicable clauses with a document reference | %s |"
1763
+ % _frac(cov["evidenced_fraction"]),
1764
+ "| Applicable clauses traced to a real file | %s |"
1765
+ % _frac(cov["traced_fraction"]),
1766
+ "| Rows with at least one gap | %d |" % cov["rows_with_gaps"],
1767
+ "", "Open gaps: %d." % len(m["gaps"]), ""]
1768
+
1769
+ # 3. matrix
1770
+ L += ["## 3. Compliance matrix", "",
1771
+ "| Clause | Topic | Tailoring | Status | Justification | Evidence "
1772
+ "| Gaps |", "|---|---|---|---|---|---|---|"]
1773
+ for r in m["rows"]:
1774
+ just = r["justification"]
1775
+ if r["notes"]:
1776
+ just = "; ".join([just] + r["notes"]) if just else "; ".join(
1777
+ r["notes"])
1778
+ L.append("| %s | %s | %s | %s | %s | %s | %s |" % (
1779
+ r["clause"], _cell(r["title"]), r["tailoring"], r["status"],
1780
+ _cell(just), _cell("; ".join(r["evidence"])),
1781
+ _cell(", ".join(r["gaps"]))))
1782
+ L.append("")
1783
+
1784
+ # 4. gaps
1785
+ L += ["## 4. Gaps", ""]
1786
+ if m["gaps"]:
1787
+ L += ["Each gap names the bound skill that resolves it.", "",
1788
+ "| # | Clause | Gap | Status | Next skill |",
1789
+ "|---|---|---|---|---|"]
1790
+ for i, g in enumerate(m["gaps"], 1):
1791
+ L.append("| %d | %s | %s | %s | %s |" % (
1792
+ i, g["clause"], g["kind"], g["status"] or "not in list",
1793
+ g["next_skill"].rsplit("/", 1)[-1]))
1794
+ else:
1795
+ L.append("No open gaps.")
1796
+ if m["orphan_evidence"]:
1797
+ L += ["", "Evidence was given for clauses that are not in the clause "
1798
+ "list (a mistyped id, or an id from an older revision): %s."
1799
+ % ", ".join(m["orphan_evidence"])]
1800
+ L.append("")
1801
+
1802
+ # 5. trace
1803
+ L += ["## 5. Document trace", ""]
1804
+ t = m["trace"]
1805
+ if t["ran"]:
1806
+ L.append("Folder %s holds %d files; the evidence cites %d documents, "
1807
+ "%d of which resolve to a file." % (
1808
+ t["folder"], t["files"], t["cited"], len(t["found"])))
1809
+ L.append("")
1810
+ if t["missing"]:
1811
+ L += ["| Cited document not found | Cited by clauses |",
1812
+ "|---|---|"]
1813
+ for doc, cl in t["missing"].items():
1814
+ L.append("| %s | %s |" % (_cell(doc), ", ".join(cl)))
1815
+ L.append("")
1816
+ else:
1817
+ L += ["Every cited document resolves to a file.", ""]
1818
+ if t["resolved"]:
1819
+ L += ["| Cited document | File |", "|---|---|"]
1820
+ for doc, rel in t["resolved"].items():
1821
+ L.append("| %s | %s |" % (_cell(doc), _cell(rel)))
1822
+ L.append("")
1823
+ if t["uncited_files"]:
1824
+ L += ["Files no evidence row cites: %s." % ", ".join(
1825
+ t["uncited_files"]), ""]
1826
+ else:
1827
+ L += ["Not run: no document folder was given, so the references in "
1828
+ "the matrix are claims that have not been traced to files. "
1829
+ "Run the build again with the project's document folder.", ""]
1830
+
1831
+ # 6. tailoring
1832
+ tl = model["tailoring"]
1833
+ L += ["## 6. Tailoring for category %s" % tl["category"], "",
1834
+ "| Group | Topic | Applicable | Reduced | Not applicable |",
1835
+ "|---|---|---|---|---|"]
1836
+ for g in tl["groups"]:
1837
+ L.append("| %s | %s | %d | %d | %d |" % (
1838
+ g["group"], _cell(g["topic"]), g["applicable"], g["reduced"],
1839
+ g["not-applicable"]))
1840
+ tt = tl["totals"]
1841
+ L += ["", "Total: %d applicable, %d reduced, %d not applicable. The "
1842
+ "security clauses follow the security sensitivity (%s), not the "
1843
+ "category." % (tt["applicable"], tt["reduced"],
1844
+ tt["not-applicable"],
1845
+ "yes" if tl["security_sensitive"] else "no"), ""]
1846
+ if tl["reduced"]:
1847
+ L += ["Reduced for this category:", ""]
1848
+ for r in tl["reduced"]:
1849
+ L.append("- %s: %s" % (r["clause"], r["note"]))
1850
+ L.append("")
1851
+
1852
+ # 7. milestone
1853
+ ms = model["milestone"]
1854
+ L += ["## 7. Milestone evidence check", ""]
1855
+ if ms:
1856
+ L.append("Review: %s. Software product assurance plan (SPAP) "
1857
+ "maturity owed: %s." % (
1858
+ ms["review_name"],
1859
+ ms["spap_maturity_due"] or "not set for this review"))
1860
+ L += ["", "| Document owed | Driving clauses | Evidence state | "
1861
+ "Documents cited |", "|---|---|---|---|"]
1862
+ for o in ms["owed"]:
1863
+ L.append("| %s | %s | %s | %s |" % (
1864
+ _cell(o["title"]), ", ".join(o["clauses"]), o["state"],
1865
+ _cell(", ".join(o["cited"]) or "none")))
1866
+ L.append("")
1867
+ pk = ms["pack"]
1868
+ if pk:
1869
+ L += ["Review pack against what is owed: %d owed; missing: %s; "
1870
+ "below issued maturity: %s; submitted but not owed at this "
1871
+ "review: %s; pack complete: %s." % (
1872
+ len(pk["owed"]), ", ".join(pk["missing"]) or "none",
1873
+ ", ".join(pk["immature"]) or "none",
1874
+ ", ".join(pk["unplanned"]) or "none",
1875
+ "yes" if pk["complete"] else "no"), ""]
1876
+ else:
1877
+ L += ["No review pack was given, so only the clause evidence was "
1878
+ "checked.", ""]
1879
+ else:
1880
+ L += ["No target review was given.", ""]
1881
+
1882
+ # 8. SPAMR
1883
+ sp = model["spamr"]
1884
+ L += ["## 8. Milestone report skeleton (SPAMR)", ""]
1885
+ if sp:
1886
+ L += ["Software product assurance milestone report (SPAMR) for the "
1887
+ "%s, %s." % (REVIEW_NAMES[sp["review"]], sp["status"]), "",
1888
+ "| Section | Topic | Status | Draws on |", "|---|---|---|---|"]
1889
+ for s in sp["sections"]:
1890
+ L.append("| %s | %s | %s | %s |" % (
1891
+ s["id"], _cell(s["topic"]), s["status"],
1892
+ _cell(", ".join(s["draws_on"])) or "-"))
1893
+ L += ["", "Inputs still missing: %s." % (
1894
+ ", ".join(sp["missing_inputs"]) or "none"), ""]
1895
+ else:
1896
+ L += ["No target review was given.", ""]
1897
+
1898
+ # 9. metrics
1899
+ mt = model["metrics"]
1900
+ L += ["## 9. Product quality metrics", ""]
1901
+ if mt:
1902
+ L += ["| Metric | Value | Threshold | Source | Direction | Status | "
1903
+ "Margin |", "|---|---|---|---|---|---|---|"]
1904
+ for r in mt["rows"]:
1905
+ L.append("| %s | %s | %s | %s | %s | %s | %s |" % (
1906
+ r["metric"], "-" if r["value"] is None else "%g" % r["value"],
1907
+ "%g" % r["threshold"], r["source"], r["direction"],
1908
+ r["status"],
1909
+ "-" if r["margin"] is None else "%g" % r["margin"]))
1910
+ L += ["", "Verdict: %s (failing: %s; not measured: %s). The standard "
1911
+ "fixes no threshold values: the defaults are illustrative and "
1912
+ "the values agreed in the contract and the plan replace them."
1913
+ % (mt["verdict"], ", ".join(mt["failing"]) or "none",
1914
+ ", ".join(mt["missing"]) or "none"), ""]
1915
+ if mt["unknown"]:
1916
+ L += ["Metrics not in the catalogue (not graded): %s."
1917
+ % ", ".join(mt["unknown"]), ""]
1918
+ else:
1919
+ L += ["No measurement set was given.", ""]
1920
+
1921
+ # 10. boundaries
1922
+ L += ["## 10. Limitations and boundaries", "",
1923
+ "- This matrix is a DRAFT prepared by an agent. It is not an "
1924
+ "approval, not a certification and not a statement of compliance.",
1925
+ "- Statuses are those the evidence index claims, combined to the "
1926
+ "weakest per clause and downgraded where a reference is missing. "
1927
+ "The role checks that each cited document exists; it does not "
1928
+ "judge what the document says.",
1929
+ "- A not-applicable claim on a clause the tailoring keeps is a "
1930
+ "deviation and needs the customer's agreement.",
1931
+ "- The standard is referenced, never reproduced: clause "
1932
+ "identifiers are facts, topic labels are our own wording."]
1933
+ if model["example"]:
1934
+ L += ["- Worked example: the project, documents and evidence are "
1935
+ "invented to show the deliverable. Nothing here describes a "
1936
+ "real mission."]
1937
+ L.append("")
1938
+
1939
+ # 11. sign-off
1940
+ L += ["## 11. Human sign-off", "", NOT_SIGNED,
1941
+ "The responsible human records the decision (see ROLE.md, "
1942
+ "Human sign-off). Approving over open gaps has to list them as "
1943
+ "accepted.", "", model["stop_line"]]
1944
+ return "\n".join(L) + "\n"
1945
+
1946
+
1947
+ def render_matrix_csv(model):
1948
+ """The matrix as CSV: DRAFT on every row, the stop line as the last row."""
1949
+ buf = io.StringIO()
1950
+ w = csv.writer(buf, lineterminator="\n")
1951
+ w.writerow(["clause", "topic", "tailoring", "status", "justification",
1952
+ "evidence", "documents_found", "documents_missing", "gaps",
1953
+ "next_skill", "matrix_status"])
1954
+ for r in model["matrix"]["rows"]:
1955
+ tr = r["trace"] or {}
1956
+ just = "; ".join(([r["justification"]] if r["justification"] else [])
1957
+ + r["notes"])
1958
+ w.writerow([r["clause"], r["title"], r["tailoring"], r["status"],
1959
+ just, "; ".join(r["evidence"]),
1960
+ "; ".join(tr.get("found", [])),
1961
+ "; ".join(tr.get("missing", [])),
1962
+ ", ".join(r["gaps"]),
1963
+ route_gap(r["gaps"][0], r["clause"]).rsplit("/", 1)[-1]
1964
+ if r["gaps"] else "",
1965
+ model["status"]])
1966
+ w.writerow(["STOP", model["stop_line"], "", "", "", "", "", "", "", "",
1967
+ model["status"]])
1968
+ return buf.getvalue()
1969
+
1970
+
1971
+ def example_report_markdown():
1972
+ return render_report_markdown(build_report(example_item()))
1973
+
1974
+
1975
+ # ---------------------------------------------------------------------------
1976
+ # Gates
1977
+ # ---------------------------------------------------------------------------
1978
+
1979
+ def check_report(model):
1980
+ """Evidence gates on the content model. True = pass."""
1981
+ m = model["matrix"]
1982
+ rows = m["rows"]
1983
+ ids = [r["clause"] for r in rows]
1984
+ gates = {}
1985
+ gates["draft_status"] = (model["status"] == "DRAFT"
1986
+ and model["requires_human_sign_off"] is True
1987
+ and m["status"] == "DRAFT"
1988
+ and not model["sign_off"]["signed"])
1989
+ gates["category_resolved"] = model["category"] in CATEGORIES
1990
+ gates["one_row_per_clause"] = (bool(rows) and len(set(ids)) == len(ids)
1991
+ and all(r["status"] in STATUSES
1992
+ for r in rows))
1993
+ gates["no_silent_compliance"] = all(
1994
+ r["evidence_rows"] > 0
1995
+ or (r["status"] == "not-applicable"
1996
+ and r["tailoring"] == "not-applicable")
1997
+ or (r["status"] == "not-compliant" and "no-evidence" in r["gaps"])
1998
+ for r in rows)
1999
+ gates["compliant_rows_referenced"] = all(
2000
+ r["evidence"] and (not m["trace"]["ran"] or r["trace"]["found"])
2001
+ for r in rows if r["status"] == "compliant")
2002
+ gates["coverage_consistent"] = (
2003
+ m["coverage"] == coverage_summary(rows, m["trace"]["ran"]))
2004
+ gates["gaps_consistent"] = m["gaps"] == find_gaps(m)
2005
+ if m["trace"]["ran"]:
2006
+ flagged = set(c for r in rows if "document-not-found" in r["gaps"]
2007
+ for c in [r["clause"]])
2008
+ gates["trace_honest"] = all(
2009
+ c in flagged or c in m["orphan_evidence"]
2010
+ for cl in m["trace"]["missing"].values() for c in cl)
2011
+ else:
2012
+ gates["trace_honest"] = m["coverage"]["traced_fraction"] is None
2013
+ ms = model["milestone"]
2014
+ gates["milestone_consistent"] = (ms is None) or (
2015
+ bool(ms["owed"]) and all(o["state"] in (
2016
+ "evidenced", "partial", "no-evidence", "not-in-clause-list")
2017
+ for o in ms["owed"]))
2018
+ gates["all_pass"] = all(gates.values())
2019
+ return gates
2020
+
2021
+
2022
+ _COVERAGE_RE = re.compile(
2023
+ r"^Coverage: (\d+) clauses, (\d+) applicable; compliant (\d+), "
2024
+ r"partially (\d+), not compliant (\d+), not applicable (\d+);", re.M)
2025
+ _ROW_RE = re.compile(r"^\| (\d+(?:\.\d+)*) \| [^|]* \| ([a-z-]+) \| "
2026
+ r"([a-z-]+) \|", re.M)
2027
+ _ISO_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
2028
+
2029
+
2030
+ def read_sign_off(md):
2031
+ """Parse the human sign-off block, if one was appended.
2032
+
2033
+ Returns {'state': 'unsigned'} or {'state': 'signed', fields..., 'errors'}.
2034
+ """
2035
+ found = {}
2036
+ for line in md.splitlines():
2037
+ for key in SIGN_OFF_KEYS:
2038
+ if line.startswith(key + ":"):
2039
+ found[key] = line[len(key) + 1:].strip()
2040
+ if not found:
2041
+ return {"state": "unsigned"}
2042
+ errors = []
2043
+ for key in SIGN_OFF_KEYS[:4]:
2044
+ if not found.get(key):
2045
+ errors.append("%s is missing or empty" % key)
2046
+ if found.get("Sign-off-date") and not _ISO_DATE.match(
2047
+ found["Sign-off-date"]):
2048
+ errors.append("Sign-off-date must be YYYY-MM-DD")
2049
+ decision = found.get("Sign-off-decision", "").lower()
2050
+ if decision and decision not in ("approved", "rejected"):
2051
+ errors.append("Sign-off-decision must be approved or rejected")
2052
+ return {"state": "signed", "signatory": found.get("Signed-off-by"),
2053
+ "role": found.get("Sign-off-role"),
2054
+ "date": found.get("Sign-off-date"), "decision": decision,
2055
+ "accepted_open_gaps": found.get("Accepted-open-gaps"),
2056
+ "errors": errors}
2057
+
2058
+
2059
+ def _section(md, heading):
2060
+ """The text of one '## ' section of the Markdown, or ''."""
2061
+ if heading not in md:
2062
+ return ""
2063
+ return md.split(heading, 1)[1].split("\n## ", 1)[0]
2064
+
2065
+
2066
+ def check_report_markdown(md):
2067
+ """Gate-check a rendered (or human-signed) matrix. True = pass."""
2068
+ gates = {}
2069
+ low = md.lower()
2070
+ gates["title_present"] = TITLE in md
2071
+ gates["draft_marked"] = DRAFT_BANNER in md and "not an approval" in low
2072
+ gates["category_stated"] = bool(re.search(
2073
+ r"Software criticality category: \*\*[ABCD]\*\*", md))
2074
+ rows = _ROW_RE.findall(_section(md, "## 3. Compliance matrix"))
2075
+ gates["matrix_present"] = bool(rows) and all(
2076
+ s in STATUSES for _, _, s in rows)
2077
+ counts = {s: 0 for s in STATUSES}
2078
+ for _, _, s in rows:
2079
+ if s in counts:
2080
+ counts[s] += 1
2081
+ cov = _COVERAGE_RE.search(md)
2082
+ gates["coverage_matches_rows"] = bool(cov) and [
2083
+ int(x) for x in cov.groups()] == [
2084
+ len(rows), len(rows) - counts["not-applicable"], counts["compliant"],
2085
+ counts["partially-compliant"], counts["not-compliant"],
2086
+ counts["not-applicable"]]
2087
+ gap_sec = _section(md, "## 4. Gaps")
2088
+ gap_rows = len(re.findall(r"^\| \d+ \| ", gap_sec, re.M))
2089
+ og = re.search(r"^Open gaps: (\d+)\.", md, re.M)
2090
+ open_gaps = int(og.group(1)) if og else None
2091
+ gates["open_gaps_match"] = og is not None and open_gaps == gap_rows
2092
+ so = read_sign_off(md)
2093
+ if so["state"] == "unsigned":
2094
+ gates["sign_off_honest"] = (STOP_LINE in md and NOT_SIGNED in md
2095
+ and "Status: DRAFT." in md)
2096
+ else:
2097
+ ok = not so["errors"]
2098
+ if ok and so["decision"] == "approved" and open_gaps:
2099
+ acc = so.get("accepted_open_gaps") or ""
2100
+ ok = acc.isdigit() and int(acc) == open_gaps
2101
+ if not ok:
2102
+ so["errors"].append(
2103
+ "approved over %d open gaps without Accepted-open-gaps: "
2104
+ "%d" % (open_gaps, open_gaps))
2105
+ gates["sign_off_honest"] = ok
2106
+ gates["all_pass"] = all(gates.values())
2107
+ gates["sign_off"] = so
2108
+ return gates