ineedvalidation 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ineedvalidation/__init__.py +12 -0
- ineedvalidation/_version.py +24 -0
- ineedvalidation/cli.py +143 -0
- ineedvalidation/d2.py +344 -0
- ineedvalidation/evidence.py +97 -0
- ineedvalidation/marks.py +71 -0
- ineedvalidation/nodes.py +143 -0
- ineedvalidation/plugin.py +175 -0
- ineedvalidation/schema.py +160 -0
- ineedvalidation/views.py +65 -0
- ineedvalidation-0.0.1.dist-info/METADATA +106 -0
- ineedvalidation-0.0.1.dist-info/RECORD +15 -0
- ineedvalidation-0.0.1.dist-info/WHEEL +4 -0
- ineedvalidation-0.0.1.dist-info/entry_points.txt +5 -0
- ineedvalidation-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Evidence declarations for tests and a validation hierarchy built from them.
|
|
2
|
+
|
|
3
|
+
The test half is a pytest plugin: :func:`case`, :func:`seam` and
|
|
4
|
+
:func:`regression` mark what a test demonstrates, and the plugin collects those
|
|
5
|
+
marks into an evidence ledger. The render half reads hierarchy node notes plus
|
|
6
|
+
that ledger, lints them against each other, and renders the hierarchy.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from ineedvalidation._version import __version__
|
|
10
|
+
from ineedvalidation.marks import TIERS, case, regression, seam
|
|
11
|
+
|
|
12
|
+
__all__ = ["TIERS", "__version__", "case", "regression", "seam"]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.0.1'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 0, 1)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
ineedvalidation/cli.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Command-line entry point: ``ineedvalidation lint | render | scaffold``."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from ineedvalidation import __version__, d2, evidence, nodes, views
|
|
9
|
+
from ineedvalidation.schema import HIERARCHY_TIERS
|
|
10
|
+
|
|
11
|
+
STUB = """---
|
|
12
|
+
id: {stub_id}
|
|
13
|
+
tier: ''
|
|
14
|
+
title: {case}
|
|
15
|
+
couples_to: []
|
|
16
|
+
cases:
|
|
17
|
+
- {case}
|
|
18
|
+
srqs: []
|
|
19
|
+
referent: ''
|
|
20
|
+
referent_status: none
|
|
21
|
+
validation_level: 0
|
|
22
|
+
---
|
|
23
|
+
# {case}
|
|
24
|
+
|
|
25
|
+
Stub written by ineedvalidation scaffold. Fill in the tier, the coupling, the
|
|
26
|
+
system response quantities and the referent, then delete this line.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_parser():
|
|
31
|
+
"""Build the argument parser."""
|
|
32
|
+
parser = argparse.ArgumentParser(
|
|
33
|
+
prog="ineedvalidation",
|
|
34
|
+
description=(
|
|
35
|
+
"Lint and render a validation hierarchy from node notes and test evidence."
|
|
36
|
+
),
|
|
37
|
+
)
|
|
38
|
+
parser.add_argument("--version", action="version", version=__version__)
|
|
39
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
40
|
+
for name in ("lint", "render", "scaffold"):
|
|
41
|
+
command = sub.add_parser(name)
|
|
42
|
+
command.add_argument(
|
|
43
|
+
"hierarchy", help="directory holding nodes/, evidence/ and views/"
|
|
44
|
+
)
|
|
45
|
+
command.add_argument("--nodes", help="node notes directory")
|
|
46
|
+
command.add_argument("--evidence", help="evidence files directory")
|
|
47
|
+
lint = sub.choices["lint"]
|
|
48
|
+
lint.add_argument("--library-map", help="file listing the known library names")
|
|
49
|
+
render = sub.choices["render"]
|
|
50
|
+
render.add_argument("--view", help="render only this named view")
|
|
51
|
+
render.add_argument(
|
|
52
|
+
"--all-views", action="store_true", help="render every view file as well"
|
|
53
|
+
)
|
|
54
|
+
render.add_argument("--root", help="render only this node and everything below it")
|
|
55
|
+
render.add_argument("--views", help="view files directory")
|
|
56
|
+
render.add_argument("--out", help="output directory")
|
|
57
|
+
return parser
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _load(args):
|
|
61
|
+
"""Load the nodes, the evidence files and the merged summary."""
|
|
62
|
+
root = Path(args.hierarchy)
|
|
63
|
+
node_dir = Path(args.nodes) if args.nodes else root / "nodes"
|
|
64
|
+
evidence_dir = Path(args.evidence) if args.evidence else root / "evidence"
|
|
65
|
+
loaded = nodes.load(node_dir)
|
|
66
|
+
files = evidence.load_dir(evidence_dir)
|
|
67
|
+
return root, loaded, files, evidence.summarize(loaded, files)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _lint(args):
|
|
71
|
+
"""Report every problem in the hierarchy."""
|
|
72
|
+
_, loaded, files, summary = _load(args)
|
|
73
|
+
library_text = Path(args.library_map).read_text() if args.library_map else None
|
|
74
|
+
# Without a ledger there is nothing to lint the notes against, so the
|
|
75
|
+
# two-way rules stay off and the declared fields carry the evidence.
|
|
76
|
+
problems = nodes.lint(
|
|
77
|
+
loaded,
|
|
78
|
+
summary=summary if files else None,
|
|
79
|
+
library_text=library_text,
|
|
80
|
+
unfiled=evidence.unfiled_cases(loaded, files) if files else None,
|
|
81
|
+
)
|
|
82
|
+
for problem in problems:
|
|
83
|
+
print("LINT:", problem)
|
|
84
|
+
census = ", ".join(
|
|
85
|
+
f"{tier}={sum(node.tier == tier for node in loaded.values())}"
|
|
86
|
+
for tier in HIERARCHY_TIERS
|
|
87
|
+
)
|
|
88
|
+
print(f"{len(loaded)} nodes, {census}")
|
|
89
|
+
return 1 if problems else 0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _render(args):
|
|
93
|
+
"""Render one view, the two built-in views, or every view."""
|
|
94
|
+
root, loaded, _, summary = _load(args)
|
|
95
|
+
views_dir = Path(args.views) if args.views else root / "views"
|
|
96
|
+
outdir = Path(args.out) if args.out else root / "output"
|
|
97
|
+
names = [args.view] if args.view else list(views.builtin())
|
|
98
|
+
if args.all_views:
|
|
99
|
+
names = sorted(set(names) | set(views.load_dir(views_dir)))
|
|
100
|
+
for name in names:
|
|
101
|
+
try:
|
|
102
|
+
view = views.resolve(name, views_dir)
|
|
103
|
+
except KeyError as error:
|
|
104
|
+
print(error.args[0], file=sys.stderr)
|
|
105
|
+
return 2
|
|
106
|
+
if args.root:
|
|
107
|
+
view = replace(
|
|
108
|
+
view,
|
|
109
|
+
name=f"{name}_{args.root}",
|
|
110
|
+
root=args.root,
|
|
111
|
+
title=view.title or loaded[args.root].title,
|
|
112
|
+
)
|
|
113
|
+
selected = nodes.subtree(loaded, view.root) if view.root else loaded
|
|
114
|
+
d2.render(selected, summary, view, outdir, views_dir)
|
|
115
|
+
print("rendered", outdir / f"{view.name}.png")
|
|
116
|
+
return 0
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _scaffold(args):
|
|
120
|
+
"""Write a stub note for every case no node owns."""
|
|
121
|
+
root, loaded, files, _ = _load(args)
|
|
122
|
+
node_dir = Path(args.nodes) if args.nodes else root / "nodes"
|
|
123
|
+
written = 0
|
|
124
|
+
for case, libraries in evidence.unfiled_cases(loaded, files).items():
|
|
125
|
+
target = node_dir / f"unfiled-{case}.md"
|
|
126
|
+
if target.exists():
|
|
127
|
+
print("skipped, already present:", target)
|
|
128
|
+
continue
|
|
129
|
+
target.write_text(STUB.format(stub_id=f"unfiled-{case}", case=case))
|
|
130
|
+
print("wrote", target, "for", ", ".join(libraries))
|
|
131
|
+
written += 1
|
|
132
|
+
print(f"{written} stub(s) written")
|
|
133
|
+
return 0
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def main(argv=None):
|
|
137
|
+
"""Run the command line."""
|
|
138
|
+
args = build_parser().parse_args(argv)
|
|
139
|
+
return {"lint": _lint, "render": _render, "scaffold": _scaffold}[args.command](args)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
if __name__ == "__main__":
|
|
143
|
+
sys.exit(main())
|
ineedvalidation/d2.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""Emit the tiered hierarchy diagram as D2, and drive the rendering binaries.
|
|
2
|
+
|
|
3
|
+
The layout is deterministic: one row per tier, uniform boxes, and an invisible
|
|
4
|
+
anchor chain that pins each node to its tier rank from above and below, only
|
|
5
|
+
where its real edges would let it drift.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import textwrap
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from . import views
|
|
18
|
+
from .schema import HIERARCHY_TIERS, NodeSummary, View
|
|
19
|
+
|
|
20
|
+
NODE_W, NODE_H = 250, 110 # px; uniform for every node
|
|
21
|
+
FONT_FAMILY = "Source Sans 3"
|
|
22
|
+
|
|
23
|
+
TIER_LABEL = {
|
|
24
|
+
"complete": "Complete\nsystem",
|
|
25
|
+
"system": "System\ntier",
|
|
26
|
+
"subsystem": "Subsystem\ntier",
|
|
27
|
+
"benchmark": "Benchmark\ntier",
|
|
28
|
+
"unit": "Unit\nproblems",
|
|
29
|
+
}
|
|
30
|
+
D2_SHAPE = {
|
|
31
|
+
"complete": "oval",
|
|
32
|
+
"system": "oval",
|
|
33
|
+
"subsystem": "hexagon",
|
|
34
|
+
"benchmark": "rectangle",
|
|
35
|
+
"unit": "rectangle",
|
|
36
|
+
}
|
|
37
|
+
STATUS_EDGE = {
|
|
38
|
+
"none": "#9a9a9a",
|
|
39
|
+
"identified": "#F5D300",
|
|
40
|
+
"requested": "#FE53BB",
|
|
41
|
+
"in-hand": "#08F7FE",
|
|
42
|
+
}
|
|
43
|
+
LEVEL_FILL = {0: "#000000", 1: "#3a3a3a", 2: "#0b3d16", 3: "#0f6b25", 4: "#22a544"}
|
|
44
|
+
EVIDENCE_TEXT = {
|
|
45
|
+
"A": "A code verification",
|
|
46
|
+
"C": "C solution verification",
|
|
47
|
+
"B": "B cross-code benchmark",
|
|
48
|
+
"D": "D validation against measurement",
|
|
49
|
+
"E": "E uncertainty quantification",
|
|
50
|
+
}
|
|
51
|
+
LEVEL_TEXT = {
|
|
52
|
+
0: "0 insufficient evidence",
|
|
53
|
+
1: "1 conceptually validated",
|
|
54
|
+
2: "2 representative system, in domain",
|
|
55
|
+
3: "3 real system, representative environment",
|
|
56
|
+
4: "4 real system, operating environment",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _wrap(text: str, chars: int) -> str:
|
|
61
|
+
"""Wrap a label to a width, joined by the D2 line break."""
|
|
62
|
+
return "\\n".join(textwrap.wrap(text, chars))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _evidence_text(summary: NodeSummary) -> str:
|
|
66
|
+
"""The evidence line for a label: demonstrated plain, claimed in parentheses."""
|
|
67
|
+
parts = list(summary.demonstrated)
|
|
68
|
+
if summary.computed:
|
|
69
|
+
parts += [f"({tier})" for tier in summary.claimed]
|
|
70
|
+
return " ".join(parts) or "none"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def emit(nodes: dict, summary: dict, view: View) -> str:
|
|
74
|
+
"""Render the hierarchy as D2 source for one view."""
|
|
75
|
+
dark = view.mode == "status"
|
|
76
|
+
fg, bg = ("#ffffff", "#000000") if dark else ("#000000", "#ffffff")
|
|
77
|
+
lines = [
|
|
78
|
+
"direction: down",
|
|
79
|
+
f"vars: {{ d2-config: {{ layout-engine: {view.layout} }} }}",
|
|
80
|
+
f'style.fill: "{bg}"',
|
|
81
|
+
]
|
|
82
|
+
tiers = [t for t in HIERARCHY_TIERS if any(n.tier == t for n in nodes.values())]
|
|
83
|
+
# Rank pinning. Anchor chain A0 -> ... -> A(k): A(i) carries the label of tier i
|
|
84
|
+
# and shares its rank with row i; A(k) is a hidden floor. A node is pinned from
|
|
85
|
+
# above (A(i-1) -> node) when no real parent sits in the tier directly above it,
|
|
86
|
+
# and from below (node -> A(i+1)) when no real child sits in the tier directly
|
|
87
|
+
# below it. Together with the real edges this fixes every node to its tier rank
|
|
88
|
+
# under both layout engines with the fewest invisible edges.
|
|
89
|
+
depth = len(tiers)
|
|
90
|
+
for i in range(depth + 1):
|
|
91
|
+
lines.append(
|
|
92
|
+
f'A{i}: {{ label: ""; shape: rectangle; width: 1; height: 1; '
|
|
93
|
+
"style.opacity: 0 }"
|
|
94
|
+
)
|
|
95
|
+
if i:
|
|
96
|
+
lines.append(f"A{i - 1} -> A{i}: {{ style.opacity: 0 }}")
|
|
97
|
+
children = {nid: [c for c in nodes if nid in nodes[c].couples_to] for nid in nodes}
|
|
98
|
+
highlight = set(view.highlight)
|
|
99
|
+
for nid, node in nodes.items():
|
|
100
|
+
dim = bool(highlight) and not highlight & set(summary[nid].libraries)
|
|
101
|
+
i = tiers.index(node.tier)
|
|
102
|
+
parents = [p for p in node.couples_to if p in nodes]
|
|
103
|
+
has_parent_above = any(tiers.index(nodes[p].tier) == i - 1 for p in parents)
|
|
104
|
+
has_child_below = any(
|
|
105
|
+
tiers.index(nodes[c].tier) == i + 1 for c in children[nid]
|
|
106
|
+
)
|
|
107
|
+
wrap = 26 if node.tier in ("benchmark", "unit") else 22
|
|
108
|
+
label = _wrap(node.title, wrap)
|
|
109
|
+
if dark and "libraries" in view.show:
|
|
110
|
+
label += "\\n" + _wrap(", ".join(summary[nid].libraries), 34)
|
|
111
|
+
if dark and "tiers" in view.show:
|
|
112
|
+
label += "\\nevidence " + _evidence_text(summary[nid])
|
|
113
|
+
fill = LEVEL_FILL[node.validation_level] if dark else bg
|
|
114
|
+
stroke = STATUS_EDGE[node.referent_status] if dark else fg
|
|
115
|
+
width = 3 if dark and node.referent_status != "none" else 2
|
|
116
|
+
box = (
|
|
117
|
+
NODE_W + 40 if node.tier in ("complete", "system", "subsystem") else NODE_W
|
|
118
|
+
)
|
|
119
|
+
lines.append(
|
|
120
|
+
f'{nid}: {{ label: "{label}"; shape: {D2_SHAPE[node.tier]}; '
|
|
121
|
+
f"width: {box}; height: {NODE_H}; "
|
|
122
|
+
f'style.fill: "{fill}"; style.stroke: "{stroke}"; '
|
|
123
|
+
f"style.stroke-width: {width}; "
|
|
124
|
+
f'style.font-color: "{fg}"; style.font-size: 15; style.bold: true'
|
|
125
|
+
+ ("; style.border-radius: 8" if node.tier == "benchmark" else "")
|
|
126
|
+
+ ("; style.opacity: 0.25" if dim else "")
|
|
127
|
+
+ " }"
|
|
128
|
+
)
|
|
129
|
+
if i > 0 and not has_parent_above:
|
|
130
|
+
lines.append(f"A{i - 1} -> {nid}: {{ style.opacity: 0 }}")
|
|
131
|
+
if not has_child_below:
|
|
132
|
+
lines.append(f"{nid} -> A{i + 1}: {{ style.opacity: 0 }}")
|
|
133
|
+
for nid, node in nodes.items():
|
|
134
|
+
for parent in node.couples_to:
|
|
135
|
+
if parent not in nodes:
|
|
136
|
+
continue
|
|
137
|
+
dim = bool(highlight) and not (
|
|
138
|
+
highlight & set(summary[nid].libraries)
|
|
139
|
+
and highlight & set(summary[parent].libraries)
|
|
140
|
+
)
|
|
141
|
+
lines.append(
|
|
142
|
+
f'{parent} -> {nid}: {{ style.stroke: "{fg}"; style.stroke-width: 1'
|
|
143
|
+
+ ("; style.opacity: 0.2" if dim else "")
|
|
144
|
+
+ " }"
|
|
145
|
+
)
|
|
146
|
+
if dark:
|
|
147
|
+
present = sorted({n.validation_level for n in nodes.values()})
|
|
148
|
+
subtitle = (
|
|
149
|
+
"validation levels present: "
|
|
150
|
+
+ ", ".join(str(v) for v in present)
|
|
151
|
+
+ " (NASA-STD-7009B Table 9; level 2 is the ceiling before launch)"
|
|
152
|
+
)
|
|
153
|
+
head = (view.title + "\\n" if view.title else "") + subtitle
|
|
154
|
+
lines.insert(
|
|
155
|
+
3,
|
|
156
|
+
f'title: {{ label: "{head}"; shape: text; near: top-center; '
|
|
157
|
+
f"style.font-size: {30 if view.title else 18}; "
|
|
158
|
+
f"style.bold: {'true' if view.title else 'false'}; "
|
|
159
|
+
f'style.font-color: "{fg}" }}',
|
|
160
|
+
)
|
|
161
|
+
elif view.title:
|
|
162
|
+
lines.insert(
|
|
163
|
+
3,
|
|
164
|
+
f'title: {{ label: "{view.title}"; shape: text; near: top-center; '
|
|
165
|
+
f'style.font-size: 30; style.bold: true; style.font-color: "{fg}" }}',
|
|
166
|
+
)
|
|
167
|
+
if dark:
|
|
168
|
+
lines.append("legend: {")
|
|
169
|
+
lines.append(' label: ""')
|
|
170
|
+
lines.append(" near: bottom-center")
|
|
171
|
+
lines.append(" grid-rows: 3")
|
|
172
|
+
lines.append(" grid-gap: 6")
|
|
173
|
+
lines.append(f' style.fill: "{bg}"')
|
|
174
|
+
lines.append(f' style.stroke: "{bg}"')
|
|
175
|
+
cell = (
|
|
176
|
+
f"shape: rectangle; width: 300; height: 38; style.font-size: 13; "
|
|
177
|
+
f'style.font-color: "{fg}"'
|
|
178
|
+
)
|
|
179
|
+
# D2 fills a grid-rows grid column by column: three cells per column,
|
|
180
|
+
# read top to bottom
|
|
181
|
+
entries = []
|
|
182
|
+
for level, color in LEVEL_FILL.items():
|
|
183
|
+
entries.append(
|
|
184
|
+
f'l{level}: {{ label: "level {LEVEL_TEXT[level]}"; {cell}; '
|
|
185
|
+
f'style.fill: "{color}"; style.stroke: "{fg}"; style.stroke-width: 1 }}'
|
|
186
|
+
)
|
|
187
|
+
entries.append(f'gap1: {{ label: ""; {cell}; style.opacity: 0 }}')
|
|
188
|
+
for status, color in STATUS_EDGE.items():
|
|
189
|
+
entries.append(
|
|
190
|
+
f'r{status}: {{ label: "referent {status}"; {cell}; '
|
|
191
|
+
f'style.fill: "{bg}"; style.stroke: "{color}"; style.stroke-width: 3 }}'
|
|
192
|
+
)
|
|
193
|
+
entries.append(f'gap2: {{ label: ""; {cell}; style.opacity: 0 }}')
|
|
194
|
+
entries.append(f'gap3: {{ label: ""; {cell}; style.opacity: 0 }}')
|
|
195
|
+
for tier, text in EVIDENCE_TEXT.items():
|
|
196
|
+
entries.append(
|
|
197
|
+
f'e{tier}: {{ label: "evidence {text}"; {cell}; '
|
|
198
|
+
f'style.fill: "{bg}"; style.stroke: "#555555"; '
|
|
199
|
+
"style.stroke-width: 1; style.stroke-dash: 3 }"
|
|
200
|
+
)
|
|
201
|
+
for entry in entries:
|
|
202
|
+
lines.append(" " + entry)
|
|
203
|
+
lines.append("}")
|
|
204
|
+
return "\n".join(lines) + "\n"
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def inject_tier_labels(svg: str, tiers: list[str], fg: str, margin: int = 230) -> str:
|
|
208
|
+
"""Write each tier label at the left margin of its row, and widen the drawing.
|
|
209
|
+
|
|
210
|
+
Rows come from the hidden anchors ``A<i>``. The font faces are pointed at the
|
|
211
|
+
installed family because rsvg-convert cannot read the data-URI faces D2 embeds.
|
|
212
|
+
"""
|
|
213
|
+
xs = [float(v) for v in re.findall(r'<rect x="([\d.]+)"', svg)]
|
|
214
|
+
xs += [float(v) for v in re.findall(r'<path d="M ([\d.]+) ', svg)]
|
|
215
|
+
xs += [
|
|
216
|
+
float(v) - float(r)
|
|
217
|
+
for v, r in re.findall(r'<ellipse cx="([\d.]+)" cy="[\d.]+" rx="([\d.]+)"', svg)
|
|
218
|
+
]
|
|
219
|
+
left = min(xs) if xs else 0.0
|
|
220
|
+
labels = []
|
|
221
|
+
for i, tier in enumerate(tiers):
|
|
222
|
+
cls = base64.b64encode(f"A{i}".encode()).decode()
|
|
223
|
+
found = re.search(
|
|
224
|
+
r'<g class="'
|
|
225
|
+
+ re.escape(cls)
|
|
226
|
+
+ r'"[^>]*>.*?<rect x="([\d.]+)" y="([\d.]+)" '
|
|
227
|
+
r'width="[\d.]+" height="([\d.]+)"',
|
|
228
|
+
svg,
|
|
229
|
+
re.S,
|
|
230
|
+
)
|
|
231
|
+
if not found:
|
|
232
|
+
continue
|
|
233
|
+
y = float(found.group(2)) + float(found.group(3)) / 2
|
|
234
|
+
rows = TIER_LABEL[tier].split("\n")
|
|
235
|
+
x = left - 40
|
|
236
|
+
tspans = "".join(
|
|
237
|
+
f'<tspan x="{x:.1f}" dy="{0 if j == 0 else 26}">{row}</tspan>'
|
|
238
|
+
for j, row in enumerate(rows)
|
|
239
|
+
)
|
|
240
|
+
labels.append(
|
|
241
|
+
f'<text x="{x:.1f}" y="{y - 13 * (len(rows) - 1) + 8:.1f}" '
|
|
242
|
+
f'fill="{fg}" class="text-bold" '
|
|
243
|
+
f'style="text-anchor:end;font-size:22px">{tspans}</text>'
|
|
244
|
+
)
|
|
245
|
+
# geometry: outer <svg viewBox="0 0 W H"> wraps inner
|
|
246
|
+
# <svg width="W" height="H" viewBox="x y w h">
|
|
247
|
+
svg = re.sub(
|
|
248
|
+
r'<svg class="([^"]+)" width="([\d.]+)" height="([\d.]+)" '
|
|
249
|
+
r'viewBox="([-\d.]+) ([-\d.]+) ([\d.]+) ([\d.]+)">',
|
|
250
|
+
lambda m: (
|
|
251
|
+
f'<svg class="{m.group(1)}" width="{int(float(m.group(2))) + margin}" '
|
|
252
|
+
f'height="{m.group(3)}" '
|
|
253
|
+
f'viewBox="{float(m.group(4)) - margin:.0f} {m.group(5)} '
|
|
254
|
+
f'{float(m.group(6)) + margin:.0f} {m.group(7)}">'
|
|
255
|
+
),
|
|
256
|
+
svg,
|
|
257
|
+
count=1,
|
|
258
|
+
)
|
|
259
|
+
svg = re.sub(
|
|
260
|
+
r'(preserveAspectRatio="xMinYMin meet" viewBox=")'
|
|
261
|
+
r'([-\d.]+) ([-\d.]+) ([\d.]+) ([\d.]+)"',
|
|
262
|
+
lambda m: (
|
|
263
|
+
f"{m.group(1)}{m.group(2)} {m.group(3)} "
|
|
264
|
+
f'{float(m.group(4)) + margin:.0f} {m.group(5)}"'
|
|
265
|
+
),
|
|
266
|
+
svg,
|
|
267
|
+
count=1,
|
|
268
|
+
)
|
|
269
|
+
# background rect of the inner svg: widen too
|
|
270
|
+
svg = re.sub(
|
|
271
|
+
r'<rect x="([-\d.]+)" y="([-\d.]+)" width="([\d.]+)" height="([\d.]+)" '
|
|
272
|
+
r'rx="0.000000" fill="(#[0-9a-fA-F]+)" stroke-width="0" />',
|
|
273
|
+
lambda m: (
|
|
274
|
+
f'<rect x="{float(m.group(1)) - margin:.0f}" y="{m.group(2)}" '
|
|
275
|
+
f'width="{float(m.group(3)) + margin:.0f}" height="{m.group(4)}" '
|
|
276
|
+
f'rx="0" fill="{m.group(5)}" stroke-width="0" />'
|
|
277
|
+
),
|
|
278
|
+
svg,
|
|
279
|
+
count=1,
|
|
280
|
+
)
|
|
281
|
+
# fonts: rsvg cannot read D2's data-URI faces; use an installed family
|
|
282
|
+
svg = re.sub(
|
|
283
|
+
r'font-family: "?d2-\d+-font-bold"?;',
|
|
284
|
+
f'font-family: "{FONT_FAMILY}"; font-weight: 700;',
|
|
285
|
+
svg,
|
|
286
|
+
)
|
|
287
|
+
svg = re.sub(
|
|
288
|
+
r'font-family: "?d2-\d+-font-italic"?;',
|
|
289
|
+
f'font-family: "{FONT_FAMILY}"; font-style: italic;',
|
|
290
|
+
svg,
|
|
291
|
+
)
|
|
292
|
+
svg = re.sub(
|
|
293
|
+
r'font-family: "?d2-\d+-font-regular"?;', f'font-family: "{FONT_FAMILY}";', svg
|
|
294
|
+
)
|
|
295
|
+
svg = re.sub(r"@font-face \{[^}]*\}", "", svg)
|
|
296
|
+
return svg.replace("</svg>", "".join(labels) + "</svg>", 1)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def require_binary(name: str) -> None:
|
|
300
|
+
"""Raise when an external rendering binary is missing."""
|
|
301
|
+
if shutil.which(name) is None:
|
|
302
|
+
raise RuntimeError(
|
|
303
|
+
f"{name} is not on PATH; it is required to render the hierarchy"
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _run(command: list[str]) -> None:
|
|
308
|
+
"""Run an external command and raise with its stderr when it fails."""
|
|
309
|
+
result = subprocess.run(command, capture_output=True, text=True, check=False)
|
|
310
|
+
if result.returncode:
|
|
311
|
+
raise RuntimeError(f"{command[0]} failed: {result.stderr.strip()}")
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def render(nodes, summary, view, outdir, views_dir=None):
|
|
315
|
+
"""Write the generated D2, compile it or its override, and rasterize."""
|
|
316
|
+
require_binary("d2")
|
|
317
|
+
require_binary("rsvg-convert")
|
|
318
|
+
outdir = Path(outdir)
|
|
319
|
+
outdir.mkdir(parents=True, exist_ok=True)
|
|
320
|
+
generated = outdir / f"{view.name}.gen.d2"
|
|
321
|
+
generated.write_text(emit(nodes, summary, view))
|
|
322
|
+
source = views.override_path(views_dir, view.name) or generated
|
|
323
|
+
svg = outdir / f"{view.name}.svg"
|
|
324
|
+
theme = "200" if view.mode == "status" else "0"
|
|
325
|
+
_run(
|
|
326
|
+
[
|
|
327
|
+
"d2",
|
|
328
|
+
"--layout",
|
|
329
|
+
view.layout,
|
|
330
|
+
"--theme",
|
|
331
|
+
theme,
|
|
332
|
+
"--pad",
|
|
333
|
+
"30",
|
|
334
|
+
str(source),
|
|
335
|
+
str(svg),
|
|
336
|
+
]
|
|
337
|
+
)
|
|
338
|
+
fg = "#ffffff" if view.mode == "status" else "#000000"
|
|
339
|
+
tiers = [t for t in HIERARCHY_TIERS if any(n.tier == t for n in nodes.values())]
|
|
340
|
+
svg.write_text(inject_tier_labels(svg.read_text(), tiers, fg))
|
|
341
|
+
png = outdir / f"{view.name}.png"
|
|
342
|
+
background = "black" if view.mode == "status" else "white"
|
|
343
|
+
_run(["rsvg-convert", "-z", "2", "-b", background, "-o", str(png), str(svg)])
|
|
344
|
+
return png
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Fold per-repository evidence files into one summary per hierarchy node."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .schema import EVIDENCE_ORDER, EvidenceFile, Node, NodeSummary
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def load_dir(directory: Path) -> list[EvidenceFile]:
|
|
12
|
+
"""Read every ``*.json`` evidence file in a directory."""
|
|
13
|
+
directory = Path(directory)
|
|
14
|
+
if not directory.is_dir():
|
|
15
|
+
return []
|
|
16
|
+
return [
|
|
17
|
+
EvidenceFile.from_dict(json.loads(path.read_text()))
|
|
18
|
+
for path in sorted(directory.glob("*.json"))
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _order(tiers) -> tuple[str, ...]:
|
|
23
|
+
"""Sort evidence tiers into the reading order A, C, B, D, E."""
|
|
24
|
+
return tuple(tier for tier in EVIDENCE_ORDER if tier in tiers)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def summarize(
|
|
28
|
+
nodes: dict[str, Node], files: list[EvidenceFile]
|
|
29
|
+
) -> dict[str, NodeSummary]:
|
|
30
|
+
"""One summary per node, computed where tests exist, declared where they do not."""
|
|
31
|
+
owner = {case: nid for nid, node in nodes.items() for case in node.cases}
|
|
32
|
+
collected: dict[str, list[tuple[str, object]]] = {nid: [] for nid in nodes}
|
|
33
|
+
for evidence_file in files:
|
|
34
|
+
for record in evidence_file.tests:
|
|
35
|
+
nid = owner.get(record.case)
|
|
36
|
+
if nid is not None:
|
|
37
|
+
collected[nid].append((evidence_file.library, record))
|
|
38
|
+
summaries = {}
|
|
39
|
+
for nid, node in nodes.items():
|
|
40
|
+
rows = collected[nid]
|
|
41
|
+
if not rows:
|
|
42
|
+
summaries[nid] = NodeSummary(
|
|
43
|
+
node_id=nid,
|
|
44
|
+
libraries=node.libraries_planned,
|
|
45
|
+
demonstrated=_order(node.evidence_declared),
|
|
46
|
+
computed=False,
|
|
47
|
+
)
|
|
48
|
+
continue
|
|
49
|
+
demonstrated: set[str] = set()
|
|
50
|
+
claimed: set[str] = set()
|
|
51
|
+
libraries: list[str] = []
|
|
52
|
+
srqs: list[str] = []
|
|
53
|
+
refs: list[str] = []
|
|
54
|
+
seams: list[tuple[str, str]] = []
|
|
55
|
+
skipped = 0
|
|
56
|
+
for library, record in rows:
|
|
57
|
+
if library not in libraries:
|
|
58
|
+
libraries.append(library)
|
|
59
|
+
if record.tier:
|
|
60
|
+
target = demonstrated if record.outcome == "passed" else claimed
|
|
61
|
+
target.add(record.tier)
|
|
62
|
+
if record.srq and record.srq not in srqs:
|
|
63
|
+
srqs.append(record.srq)
|
|
64
|
+
if record.ref and record.ref not in refs:
|
|
65
|
+
refs.append(record.ref)
|
|
66
|
+
if record.seam and record.seam not in seams:
|
|
67
|
+
seams.append(record.seam)
|
|
68
|
+
if record.outcome in ("skipped", "xfailed"):
|
|
69
|
+
skipped += 1
|
|
70
|
+
summaries[nid] = NodeSummary(
|
|
71
|
+
node_id=nid,
|
|
72
|
+
libraries=tuple(libraries),
|
|
73
|
+
demonstrated=_order(demonstrated),
|
|
74
|
+
claimed=_order(claimed - demonstrated),
|
|
75
|
+
srqs_covered=tuple(srqs),
|
|
76
|
+
refs=tuple(refs),
|
|
77
|
+
seams=tuple(seams),
|
|
78
|
+
tests=len(rows),
|
|
79
|
+
skipped=skipped,
|
|
80
|
+
computed=True,
|
|
81
|
+
)
|
|
82
|
+
return summaries
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def unfiled_cases(
|
|
86
|
+
nodes: dict[str, Node], files: list[EvidenceFile]
|
|
87
|
+
) -> dict[str, tuple[str, ...]]:
|
|
88
|
+
"""Cases named by a test that no node owns, mapped to the libraries naming them."""
|
|
89
|
+
known = {case for node in nodes.values() for case in node.cases}
|
|
90
|
+
found: dict[str, list[str]] = {}
|
|
91
|
+
for evidence_file in files:
|
|
92
|
+
for record in evidence_file.tests:
|
|
93
|
+
if record.case and record.case not in known:
|
|
94
|
+
libraries = found.setdefault(record.case, [])
|
|
95
|
+
if evidence_file.library not in libraries:
|
|
96
|
+
libraries.append(evidence_file.library)
|
|
97
|
+
return {case: tuple(libraries) for case, libraries in sorted(found.items())}
|