wireforge 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wireforge/__init__.py +3 -0
- wireforge/_data/cmake/WireForgeConfig.cmake +29 -0
- wireforge/_data/cmake/WireForgeConfigVersion.cmake +15 -0
- wireforge/_data/cmake/WireForgeGenerate.cmake +64 -0
- wireforge/_data/runtime/LICENSE +21 -0
- wireforge/_data/runtime/include/wireforge/array_access.hpp +35 -0
- wireforge/_data/runtime/include/wireforge/bit_utils.hpp +226 -0
- wireforge/_data/runtime/include/wireforge/byte_io.hpp +387 -0
- wireforge/_data/runtime/include/wireforge/checksum.hpp +201 -0
- wireforge/_data/runtime/include/wireforge/codec_support.hpp +412 -0
- wireforge/_data/runtime/include/wireforge/config.hpp +41 -0
- wireforge/_data/runtime/include/wireforge/delegate.hpp +101 -0
- wireforge/_data/runtime/include/wireforge/dispatcher.hpp +339 -0
- wireforge/_data/runtime/include/wireforge/expr_support.hpp +38 -0
- wireforge/_data/runtime/include/wireforge/frame_decoder.hpp +262 -0
- wireforge/_data/runtime/include/wireforge/frame_layout.hpp +20 -0
- wireforge/_data/runtime/include/wireforge/link.hpp +223 -0
- wireforge/_data/runtime/include/wireforge/lock.hpp +146 -0
- wireforge/_data/runtime/include/wireforge/ring_buffer.hpp +226 -0
- wireforge/_data/runtime/include/wireforge/span.hpp +116 -0
- wireforge/_data/runtime/include/wireforge/static_vector.hpp +264 -0
- wireforge/_data/runtime/include/wireforge/status.hpp +75 -0
- wireforge/_data/runtime/include/wireforge/transport.hpp +95 -0
- wireforge/_data/runtime/include/wireforge/validation.hpp +153 -0
- wireforge/_data/runtime/include/wireforge/version.hpp +22 -0
- wireforge/_data/runtime/requirements.yaml +438 -0
- wireforge/_data/schema/deviations.schema.json +47 -0
- wireforge/_data/schema/fragment.schema.json +79 -0
- wireforge/_data/schema/llr_ids.schema.json +25 -0
- wireforge/_data/schema/project.schema.json +1240 -0
- wireforge/_data/schema/runtime_requirements.schema.json +34 -0
- wireforge/_data/ui/assets/index-6Pomu6U4.js +44 -0
- wireforge/_data/ui/assets/index-GVdDyUAG.css +1 -0
- wireforge/_data/ui/index.html +13 -0
- wireforge/bundle.py +177 -0
- wireforge/ci_check.py +155 -0
- wireforge/cli.py +617 -0
- wireforge/codec.py +897 -0
- wireforge/compat.py +536 -0
- wireforge/coverage_points.py +64 -0
- wireforge/cpp.py +273 -0
- wireforge/cpp_codec.py +440 -0
- wireforge/cpp_interface.py +874 -0
- wireforge/cpp_tests.py +1993 -0
- wireforge/differential.py +586 -0
- wireforge/docs_markdown.py +72 -0
- wireforge/expr.py +598 -0
- wireforge/findings.py +187 -0
- wireforge/fmt.py +351 -0
- wireforge/generate.py +644 -0
- wireforge/golden.py +253 -0
- wireforge/hlr_import.py +112 -0
- wireforge/impact.py +249 -0
- wireforge/importers.py +402 -0
- wireforge/issues.py +27 -0
- wireforge/layout.py +681 -0
- wireforge/lifecycle.py +1266 -0
- wireforge/lifecycle_results.py +205 -0
- wireforge/lifecycle_rules.py +78 -0
- wireforge/lifecycle_runtime.py +375 -0
- wireforge/llr.py +693 -0
- wireforge/llr_ids.py +104 -0
- wireforge/loader.py +422 -0
- wireforge/mcp_server.py +357 -0
- wireforge/model.py +322 -0
- wireforge/plain.py +15 -0
- wireforge/py.typed +0 -0
- wireforge/reqif.py +289 -0
- wireforge/resources.py +43 -0
- wireforge/results.py +377 -0
- wireforge/runtime_reqs.py +147 -0
- wireforge/schema.py +151 -0
- wireforge/server/__init__.py +1 -0
- wireforge/server/app.py +619 -0
- wireforge/server/oidc.py +99 -0
- wireforge/server/storage.py +146 -0
- wireforge/service.py +1354 -0
- wireforge/templates/command/project.yaml +38 -0
- wireforge/templates/raw/project.yaml +28 -0
- wireforge/templates/telemetry/project.yaml +51 -0
- wireforge/test_cases.py +475 -0
- wireforge/test_project.py +478 -0
- wireforge/trace.py +442 -0
- wireforge/validate.py +1241 -0
- wireforge/vcs.py +44 -0
- wireforge/vectors.py +222 -0
- wireforge/versioning.py +137 -0
- wireforge/wireshark.py +792 -0
- wireforge/wireshark_runtime.lua +725 -0
- wireforge-0.1.0.dist-info/METADATA +91 -0
- wireforge-0.1.0.dist-info/RECORD +96 -0
- wireforge-0.1.0.dist-info/WHEEL +5 -0
- wireforge-0.1.0.dist-info/entry_points.txt +2 -0
- wireforge-0.1.0.dist-info/licenses/LICENSE +98 -0
- wireforge-0.1.0.dist-info/licenses/runtime/LICENSE +21 -0
- wireforge-0.1.0.dist-info/top_level.txt +1 -0
wireforge/results.py
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"""Results of a test run of the generated test project, and the deviations that explain gaps.
|
|
2
|
+
|
|
3
|
+
`load()` reads the `results/` directory the generated CMake project writes next to its build
|
|
4
|
+
(`junit/<executable>/*.xml`, `ctest.xml`, `coverage.json`, `coverage_runtime.json`,
|
|
5
|
+
`toolchain.json`) and the `.su` stack-usage files of the compiler. The reader is strict: a
|
|
6
|
+
missing or damaged file is an error, never a silently empty report. The only optional file is the
|
|
7
|
+
runtime coverage, which is measured only when the build asked for it.
|
|
8
|
+
|
|
9
|
+
`load_deviations()` reads the optional `deviations.yaml` of a project: justifications for lines the
|
|
10
|
+
tests do not reach. `match_coverage()` says which uncovered lines are justified.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
20
|
+
from xml.parsers import expat
|
|
21
|
+
|
|
22
|
+
from wireforge import schema
|
|
23
|
+
from wireforge.plain import to_plain
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from collections.abc import Iterable
|
|
27
|
+
|
|
28
|
+
DEVIATIONS_FILE = "deviations.yaml"
|
|
29
|
+
LAST_CHUNK = True # expat's positional `isfinal`
|
|
30
|
+
SU_FIELDS = 2 # bytes and qualifier follow the function
|
|
31
|
+
SU_LINE = re.compile(r"^(?P<file>.*?):(?P<line>\d+):\d+:(?P<function>.*)$")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ResultsError(ValueError):
|
|
35
|
+
"""A result file is missing, damaged or does not follow the expected layout."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class Node:
|
|
40
|
+
"""An XML element: the few things the result readers need."""
|
|
41
|
+
|
|
42
|
+
tag: str
|
|
43
|
+
attrs: dict[str, str]
|
|
44
|
+
children: list[Node]
|
|
45
|
+
text: str = ""
|
|
46
|
+
|
|
47
|
+
def all_text(self) -> str:
|
|
48
|
+
"""Return the text of the element and of everything below it, in document order."""
|
|
49
|
+
return self.text + "".join(child.all_text() for child in self.children)
|
|
50
|
+
|
|
51
|
+
def find(self, tag: str) -> Node | None:
|
|
52
|
+
"""Return the first direct child with this tag."""
|
|
53
|
+
return next((child for child in self.children if child.tag == tag), None)
|
|
54
|
+
|
|
55
|
+
def iter(self, tag: str) -> list[Node]:
|
|
56
|
+
"""Return every descendant with this tag."""
|
|
57
|
+
found = [child for child in self.children if child.tag == tag]
|
|
58
|
+
for child in self.children:
|
|
59
|
+
found += child.iter(tag)
|
|
60
|
+
return found
|
|
61
|
+
|
|
62
|
+
def get(self, name: str, default: str = "") -> str:
|
|
63
|
+
"""Return an attribute."""
|
|
64
|
+
return self.attrs.get(name, default)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def parse_xml(text: str) -> Node:
|
|
68
|
+
"""Parse XML text into nodes (external entities are not resolved)."""
|
|
69
|
+
root = Node("", {}, [])
|
|
70
|
+
stack = [root]
|
|
71
|
+
|
|
72
|
+
def start(tag: str, attrs: dict[str, str]) -> None:
|
|
73
|
+
node = Node(tag.rsplit(":", 1)[-1], dict(attrs), [])
|
|
74
|
+
stack[-1].children.append(node)
|
|
75
|
+
stack.append(node)
|
|
76
|
+
|
|
77
|
+
def characters(data: str) -> None:
|
|
78
|
+
stack[-1].text += data
|
|
79
|
+
|
|
80
|
+
parser = expat.ParserCreate()
|
|
81
|
+
parser.CharacterDataHandler = characters
|
|
82
|
+
parser.StartElementHandler = start
|
|
83
|
+
parser.EndElementHandler = lambda _tag: stack.pop()
|
|
84
|
+
parser.Parse(text, LAST_CHUNK)
|
|
85
|
+
return root.children[0]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class TestResult:
|
|
90
|
+
"""Outcome of one test."""
|
|
91
|
+
|
|
92
|
+
__test__ = False # not a pytest class
|
|
93
|
+
|
|
94
|
+
id: str
|
|
95
|
+
executable: str
|
|
96
|
+
outcome: str
|
|
97
|
+
requirements: tuple[str, ...]
|
|
98
|
+
message: str
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True)
|
|
102
|
+
class FileCoverage:
|
|
103
|
+
"""Statement and branch coverage of one header; `uncovered` lists (line, function)."""
|
|
104
|
+
|
|
105
|
+
file: str
|
|
106
|
+
lines_total: int
|
|
107
|
+
lines_covered: int
|
|
108
|
+
uncovered: tuple[tuple[int, str], ...]
|
|
109
|
+
branches_total: int
|
|
110
|
+
branches_covered: int
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True)
|
|
114
|
+
class StackEntry:
|
|
115
|
+
"""Stack use of one function, the largest over all translation units."""
|
|
116
|
+
|
|
117
|
+
file: str
|
|
118
|
+
line: int
|
|
119
|
+
function: str
|
|
120
|
+
bytes: int
|
|
121
|
+
qualifier: str
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(frozen=True)
|
|
125
|
+
class Results:
|
|
126
|
+
"""Everything a test run produced."""
|
|
127
|
+
|
|
128
|
+
tests: tuple[TestResult, ...]
|
|
129
|
+
selftests: tuple[TestResult, ...]
|
|
130
|
+
generated: tuple[FileCoverage, ...]
|
|
131
|
+
runtime: tuple[FileCoverage, ...] | None
|
|
132
|
+
stack: tuple[StackEntry, ...]
|
|
133
|
+
toolchain: dict[str, str]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass(frozen=True)
|
|
137
|
+
class Deviation:
|
|
138
|
+
"""A reviewed justification for a coverage gap."""
|
|
139
|
+
|
|
140
|
+
kind: str
|
|
141
|
+
file: str
|
|
142
|
+
function: str
|
|
143
|
+
category: str
|
|
144
|
+
rationale: str
|
|
145
|
+
reviewer: str
|
|
146
|
+
lines: tuple[int, ...] = ()
|
|
147
|
+
tool: str = ""
|
|
148
|
+
rule: str = ""
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _need(path: Path) -> Path:
|
|
152
|
+
if not path.exists():
|
|
153
|
+
message = f"{path}: missing, the test run did not write it"
|
|
154
|
+
raise ResultsError(message)
|
|
155
|
+
return path
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _xml(path: Path) -> Node:
|
|
159
|
+
try:
|
|
160
|
+
return parse_xml(_need(path).read_text(encoding="utf-8"))
|
|
161
|
+
except (expat.ExpatError, OSError, IndexError) as error:
|
|
162
|
+
message = f"{path}: cannot read test results: {error}"
|
|
163
|
+
raise ResultsError(message) from error
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _json(path: Path) -> dict[str, Any]:
|
|
167
|
+
try:
|
|
168
|
+
loaded = json.loads(_need(path).read_text(encoding="utf-8"))
|
|
169
|
+
except (json.JSONDecodeError, OSError) as error:
|
|
170
|
+
message = f"{path}: cannot read: {error}"
|
|
171
|
+
raise ResultsError(message) from error
|
|
172
|
+
if not isinstance(loaded, dict):
|
|
173
|
+
message = f"{path}: not a JSON object"
|
|
174
|
+
raise ResultsError(message)
|
|
175
|
+
return loaded
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _case(element: Node, executable: str, name: str, classname: str) -> TestResult:
|
|
179
|
+
ident = f"{classname}.{name}" if classname else name
|
|
180
|
+
outcome, message = "passed", ""
|
|
181
|
+
failure = element.find("failure") or element.find("error")
|
|
182
|
+
if failure is not None:
|
|
183
|
+
outcome = "failed"
|
|
184
|
+
message = next(iter(failure.get("message").splitlines()), "")
|
|
185
|
+
elif element.find("skipped") is not None or element.get("status") in {"notrun", "disabled"}:
|
|
186
|
+
outcome = "skipped"
|
|
187
|
+
requirements: tuple[str, ...] = ()
|
|
188
|
+
for item in element.iter("property"):
|
|
189
|
+
if item.get("name") == "requirements":
|
|
190
|
+
requirements = tuple(part for part in item.get("value").split(",") if part)
|
|
191
|
+
return TestResult(ident, executable, outcome, requirements, message)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def read_gtest(directory: Path) -> tuple[TestResult, ...]:
|
|
195
|
+
"""Read the GoogleTest XML files below `directory`; the folder name is the executable."""
|
|
196
|
+
if not directory.is_dir() or not any(directory.rglob("*.xml")):
|
|
197
|
+
message = f"{directory}: no GoogleTest results (junit/<executable>/*.xml)"
|
|
198
|
+
raise ResultsError(message)
|
|
199
|
+
found: list[TestResult] = []
|
|
200
|
+
for path in sorted(directory.rglob("*.xml")):
|
|
201
|
+
executable = path.parent.name if path.parent != directory else ""
|
|
202
|
+
found += [
|
|
203
|
+
_case(element, executable, element.get("name"), element.get("classname"))
|
|
204
|
+
for element in _xml(path).iter("testcase")
|
|
205
|
+
]
|
|
206
|
+
return tuple(found)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _selftests(path: Path) -> tuple[TestResult, ...]:
|
|
210
|
+
root = _xml(path)
|
|
211
|
+
return tuple(
|
|
212
|
+
_case(element, element.get("name"), element.get("name"), "")
|
|
213
|
+
for element in root.iter("testcase")
|
|
214
|
+
if "_selftest" in element.get("name")
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def read_coverage(path: Path) -> tuple[FileCoverage, ...]:
|
|
219
|
+
"""Read a gcovr JSON report."""
|
|
220
|
+
files: list[FileCoverage] = []
|
|
221
|
+
entries = _json(path).get("files", [])
|
|
222
|
+
if not entries:
|
|
223
|
+
message = f"{path}: lists no files; the coverage filter matched nothing"
|
|
224
|
+
raise ResultsError(message)
|
|
225
|
+
for entry in entries:
|
|
226
|
+
lines = [line for line in entry.get("lines", []) if not line.get("gcovr/noncode")]
|
|
227
|
+
branches = [branch for line in lines for branch in line.get("branches", [])]
|
|
228
|
+
uncovered = tuple(
|
|
229
|
+
(int(line["line_number"]), str(line.get("function_name", "")))
|
|
230
|
+
for line in lines
|
|
231
|
+
if line.get("count", 0) == 0
|
|
232
|
+
)
|
|
233
|
+
files.append(
|
|
234
|
+
FileCoverage(
|
|
235
|
+
str(entry["file"]),
|
|
236
|
+
len(lines),
|
|
237
|
+
len(lines) - len(uncovered),
|
|
238
|
+
uncovered,
|
|
239
|
+
len(branches),
|
|
240
|
+
sum(1 for branch in branches if branch.get("count", 0) > 0),
|
|
241
|
+
)
|
|
242
|
+
)
|
|
243
|
+
return tuple(sorted(files, key=lambda item: item.file))
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def normalize_header(path: str) -> str | None:
|
|
247
|
+
"""Return the project- or runtime-relative name of a header, None for other files."""
|
|
248
|
+
parts = Path(path).as_posix().split("/")
|
|
249
|
+
if "interfaces" in parts and parts[-1].endswith(".hpp"):
|
|
250
|
+
return "/".join(parts[parts.index("interfaces") - 1 :])
|
|
251
|
+
if parts[-1] == "model.hpp" and len(parts) > 1:
|
|
252
|
+
return "/".join(parts[-2:])
|
|
253
|
+
if "wireforge" in parts and parts[-1].endswith(".hpp"):
|
|
254
|
+
return "/".join(parts[len(parts) - 1 - parts[::-1].index("wireforge") :])
|
|
255
|
+
return None
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _stack(build_dir: Path) -> tuple[StackEntry, ...]:
|
|
259
|
+
largest: dict[tuple[str, str], StackEntry] = {}
|
|
260
|
+
for path in sorted(build_dir.rglob("*.su")):
|
|
261
|
+
for text in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
262
|
+
head, _, rest = text.partition("\t")
|
|
263
|
+
match = SU_LINE.match(head)
|
|
264
|
+
sizes = rest.split("\t")
|
|
265
|
+
name = normalize_header(match["file"]) if match else None
|
|
266
|
+
if match is None or name is None or len(sizes) < SU_FIELDS:
|
|
267
|
+
continue
|
|
268
|
+
entry = StackEntry(name, int(match["line"]), match["function"], int(sizes[0]), sizes[1])
|
|
269
|
+
key = (name, entry.function)
|
|
270
|
+
if key not in largest or entry.bytes > largest[key].bytes:
|
|
271
|
+
largest[key] = entry
|
|
272
|
+
return tuple(sorted(largest.values(), key=lambda item: (item.file, item.line, item.function)))
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def load(build_dir: Path) -> Results:
|
|
276
|
+
"""Read the results of a test run in `build_dir` (the build directory, not `results/`)."""
|
|
277
|
+
results = build_dir / "results"
|
|
278
|
+
if not results.is_dir():
|
|
279
|
+
message = f"{results}: no results directory; build the coverage target of the test project"
|
|
280
|
+
raise ResultsError(message)
|
|
281
|
+
runtime_file = results / "coverage_runtime.json"
|
|
282
|
+
toolchain = {str(key): str(value) for key, value in _json(results / "toolchain.json").items()}
|
|
283
|
+
return Results(
|
|
284
|
+
read_gtest(results / "junit"),
|
|
285
|
+
_selftests(results / "ctest.xml"),
|
|
286
|
+
read_coverage(results / "coverage.json"),
|
|
287
|
+
read_coverage(runtime_file) if runtime_file.exists() else None,
|
|
288
|
+
_stack(build_dir),
|
|
289
|
+
toolchain,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# --- deviations ---------------------------------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def load_deviations(project_dir: Path) -> list[Deviation]:
|
|
297
|
+
"""Read the deviations of a project directory (none without `deviations.yaml`)."""
|
|
298
|
+
path = project_dir / DEVIATIONS_FILE
|
|
299
|
+
if not path.exists():
|
|
300
|
+
return []
|
|
301
|
+
document = schema.load_yaml(path)
|
|
302
|
+
issues = schema.validate_document(
|
|
303
|
+
document, "deviations.schema.json", file=str(path), source=document
|
|
304
|
+
)
|
|
305
|
+
if issues:
|
|
306
|
+
message = "\n".join(str(issue) for issue in issues)
|
|
307
|
+
raise ResultsError(message)
|
|
308
|
+
entries: list[dict[str, Any]] = cast("dict[str, Any]", to_plain(document))["deviations"]
|
|
309
|
+
return [
|
|
310
|
+
Deviation(
|
|
311
|
+
item["kind"],
|
|
312
|
+
item["file"],
|
|
313
|
+
item.get("function", "*"),
|
|
314
|
+
item["category"],
|
|
315
|
+
item["rationale"],
|
|
316
|
+
item["reviewer"],
|
|
317
|
+
tuple(item.get("lines", ())),
|
|
318
|
+
item.get("tool", ""),
|
|
319
|
+
item.get("rule", ""),
|
|
320
|
+
)
|
|
321
|
+
for item in entries
|
|
322
|
+
]
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def glob(pattern: str, text: str) -> bool:
|
|
326
|
+
"""Match `text` against a pattern where `*` stands for any characters."""
|
|
327
|
+
expression = ".*".join(re.escape(part) for part in pattern.split("*"))
|
|
328
|
+
return re.fullmatch(expression, text) is not None
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
@dataclass(frozen=True)
|
|
332
|
+
class Gap:
|
|
333
|
+
"""Uncovered lines of one function of one file."""
|
|
334
|
+
|
|
335
|
+
file: str
|
|
336
|
+
function: str
|
|
337
|
+
lines: tuple[int, ...]
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def gaps(coverage: Iterable[FileCoverage]) -> list[Gap]:
|
|
341
|
+
"""Group the uncovered lines by file and function."""
|
|
342
|
+
found: dict[tuple[str, str], list[int]] = {}
|
|
343
|
+
for item in coverage:
|
|
344
|
+
for line, function in item.uncovered:
|
|
345
|
+
found.setdefault((item.file, function), []).append(line)
|
|
346
|
+
return [Gap(file, function, tuple(lines)) for (file, function), lines in sorted(found.items())]
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def match_coverage(
|
|
350
|
+
coverage: Iterable[FileCoverage], deviations: list[Deviation]
|
|
351
|
+
) -> tuple[list[Gap], list[Deviation]]:
|
|
352
|
+
"""Split the gaps into unjustified ones and report the deviations nobody used.
|
|
353
|
+
|
|
354
|
+
A deviation covers a gap when file and function match; with `lines` it covers only those lines,
|
|
355
|
+
so a runtime update that moves the line makes the gap unjustified again.
|
|
356
|
+
"""
|
|
357
|
+
used: set[int] = set()
|
|
358
|
+
unjustified: list[Gap] = []
|
|
359
|
+
for gap in gaps(coverage):
|
|
360
|
+
remaining = list(gap.lines)
|
|
361
|
+
for index, deviation in enumerate(deviations):
|
|
362
|
+
if deviation.kind != "coverage" or not (
|
|
363
|
+
glob(deviation.file, gap.file) and glob(deviation.function, gap.function)
|
|
364
|
+
):
|
|
365
|
+
continue
|
|
366
|
+
covered = [line for line in remaining if not deviation.lines or line in deviation.lines]
|
|
367
|
+
if covered:
|
|
368
|
+
used.add(index)
|
|
369
|
+
remaining = [line for line in remaining if line not in covered]
|
|
370
|
+
if remaining:
|
|
371
|
+
unjustified.append(Gap(gap.file, gap.function, tuple(remaining)))
|
|
372
|
+
unused = [
|
|
373
|
+
item
|
|
374
|
+
for index, item in enumerate(deviations)
|
|
375
|
+
if item.kind == "coverage" and index not in used
|
|
376
|
+
]
|
|
377
|
+
return unjustified, unused
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Requirements of the runtime library (`runtime/requirements.yaml`) and their link to its tests.
|
|
2
|
+
|
|
3
|
+
The runtime is written by hand and does not come from a model, so its requirements are written and
|
|
4
|
+
reviewed by people. This module reads the file, checks it against the headers (rules RR-1 to RR-3),
|
|
5
|
+
finds the requirement ids the runtime tests record and answers which runtime requirements realize
|
|
6
|
+
which kind of generated low-level requirement.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from functools import lru_cache
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, cast
|
|
16
|
+
|
|
17
|
+
from wireforge import resources, schema
|
|
18
|
+
from wireforge.issues import Issue
|
|
19
|
+
from wireforge.plain import to_plain
|
|
20
|
+
|
|
21
|
+
HEAD = re.compile(r"^(?:TEST|TEST_F|TYPED_TEST|TEST_P)\((\w+), (\w+)\) \{$", re.MULTILINE)
|
|
22
|
+
PROPERTY = re.compile(r'^\s*::testing::Test::RecordProperty\("requirements", "([^"]*)"\);')
|
|
23
|
+
VERSION = re.compile(r"kVersion(Major|Minor|Patch) = (\d+)U;")
|
|
24
|
+
# Generated requirements without a runtime counterpart: they come from the model alone.
|
|
25
|
+
GENERATED_ONLY = frozenset({"DEFAULT", "VALUES"})
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class RuntimeRequirement:
|
|
30
|
+
"""One requirement of the runtime."""
|
|
31
|
+
|
|
32
|
+
id: str
|
|
33
|
+
text: str
|
|
34
|
+
header: str
|
|
35
|
+
code: tuple[str, ...]
|
|
36
|
+
verification: str
|
|
37
|
+
realizes: tuple[str, ...] = ()
|
|
38
|
+
retired: bool = False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class RuntimeTest:
|
|
43
|
+
"""A runtime unit test and the requirement ids it records."""
|
|
44
|
+
|
|
45
|
+
id: str
|
|
46
|
+
ids: tuple[str, ...]
|
|
47
|
+
file: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load(path: Path | None = None) -> list[RuntimeRequirement]:
|
|
51
|
+
"""Read and validate the requirements file (schema errors raise ValueError)."""
|
|
52
|
+
path = path or resources.runtime_requirements()
|
|
53
|
+
document = schema.load_yaml(path)
|
|
54
|
+
issues = schema.validate_document(
|
|
55
|
+
document, "runtime_requirements.schema.json", file=str(path), source=document
|
|
56
|
+
)
|
|
57
|
+
if issues:
|
|
58
|
+
raise ValueError("\n".join(str(issue) for issue in issues))
|
|
59
|
+
entries = cast("dict[str, Any]", to_plain(document))["requirements"]
|
|
60
|
+
return [
|
|
61
|
+
RuntimeRequirement(
|
|
62
|
+
ident,
|
|
63
|
+
item["text"],
|
|
64
|
+
item["header"],
|
|
65
|
+
tuple(item["code"]),
|
|
66
|
+
item["verification"],
|
|
67
|
+
tuple(item.get("realizes", ())),
|
|
68
|
+
bool(item.get("retired", False)),
|
|
69
|
+
)
|
|
70
|
+
for ident, item in entries.items()
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def check(
|
|
75
|
+
requirements: list[RuntimeRequirement], include_dir: Path, *, file: str = "requirements.yaml"
|
|
76
|
+
) -> list[Issue]:
|
|
77
|
+
"""Check the requirements against the headers.
|
|
78
|
+
|
|
79
|
+
RR-1: the id names the header it belongs to. RR-2: the header exists. RR-3: every code symbol
|
|
80
|
+
appears in the header.
|
|
81
|
+
"""
|
|
82
|
+
issues: list[Issue] = []
|
|
83
|
+
for item in requirements:
|
|
84
|
+
pointer = f"/requirements/{item.id}"
|
|
85
|
+
if item.id.split("-")[1] != Path(item.header).stem:
|
|
86
|
+
text = f"[RR-1] {item.id} belongs to {item.header}, the id names another header"
|
|
87
|
+
issues.append(Issue(file, None, pointer, text))
|
|
88
|
+
header = include_dir / "wireforge" / item.header
|
|
89
|
+
if not header.exists():
|
|
90
|
+
issues.append(Issue(file, None, pointer, f"[RR-2] {item.header} does not exist"))
|
|
91
|
+
continue
|
|
92
|
+
source = header.read_text(encoding="utf-8")
|
|
93
|
+
issues += [
|
|
94
|
+
Issue(file, None, pointer, f"[RR-3] {symbol} is not in {item.header}")
|
|
95
|
+
for symbol in item.code
|
|
96
|
+
if re.search(rf"\b{re.escape(symbol)}\b", source) is None
|
|
97
|
+
]
|
|
98
|
+
return issues
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def symbol_line(item: RuntimeRequirement, include_dir: Path) -> str:
|
|
102
|
+
"""Return `wireforge/<header>:<line>` of the first code symbol found in the header."""
|
|
103
|
+
header = include_dir / "wireforge" / item.header
|
|
104
|
+
lines = header.read_text(encoding="utf-8").splitlines()
|
|
105
|
+
for symbol in item.code:
|
|
106
|
+
pattern = re.compile(rf"\b{re.escape(symbol)}\b")
|
|
107
|
+
for number, text in enumerate(lines, start=1):
|
|
108
|
+
if pattern.search(text) and not text.lstrip().startswith(("///", "*", "/*")):
|
|
109
|
+
return f"wireforge/{item.header}:{number}"
|
|
110
|
+
return f"wireforge/{item.header}"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@lru_cache(maxsize=1)
|
|
114
|
+
def realized_by_kind() -> dict[str, tuple[str, ...]]:
|
|
115
|
+
"""Map each generated requirement kind to the active runtime requirements that realize it."""
|
|
116
|
+
path = resources.runtime_requirements()
|
|
117
|
+
if not path.exists():
|
|
118
|
+
return {}
|
|
119
|
+
found: dict[str, list[str]] = {}
|
|
120
|
+
for item in load(path):
|
|
121
|
+
if not item.retired:
|
|
122
|
+
for kind in item.realizes:
|
|
123
|
+
found.setdefault(kind, []).append(item.id)
|
|
124
|
+
return {kind: tuple(ids) for kind, ids in found.items()}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def tests(directory: Path) -> list[RuntimeTest]:
|
|
128
|
+
"""Find the unit tests of a directory with the requirement ids each one records."""
|
|
129
|
+
found: list[RuntimeTest] = []
|
|
130
|
+
for path in sorted(directory.glob("*_test.cpp")):
|
|
131
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
132
|
+
for index, line in enumerate(lines):
|
|
133
|
+
head = HEAD.match(line)
|
|
134
|
+
if head is None:
|
|
135
|
+
continue
|
|
136
|
+
following = lines[index + 1] if index + 1 < len(lines) else ""
|
|
137
|
+
recorded = PROPERTY.match(following)
|
|
138
|
+
ids = tuple(part for part in recorded[1].split(",") if part) if recorded else ()
|
|
139
|
+
found.append(RuntimeTest(f"{head[1]}.{head[2]}", ids, path.name))
|
|
140
|
+
return found
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def version(include_dir: Path) -> str:
|
|
144
|
+
"""Return the runtime version from `version.hpp`."""
|
|
145
|
+
text = (include_dir / "wireforge" / "version.hpp").read_text(encoding="utf-8")
|
|
146
|
+
parts = dict(VERSION.findall(text))
|
|
147
|
+
return f"{parts.get('Major', '?')}.{parts.get('Minor', '?')}.{parts.get('Patch', '?')}"
|
wireforge/schema.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""JSON Schema validation of WireForge project files.
|
|
2
|
+
|
|
3
|
+
This module only checks structure; references, ranges and layout are checked by
|
|
4
|
+
`wireforge.validate`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from jsonschema import Draft202012Validator
|
|
16
|
+
from jsonschema.exceptions import ValidationError, best_match
|
|
17
|
+
from referencing import Registry, Resource
|
|
18
|
+
from referencing.jsonschema import DRAFT202012
|
|
19
|
+
from ruamel.yaml import YAML
|
|
20
|
+
from ruamel.yaml.comments import CommentedMap, CommentedSeq
|
|
21
|
+
from ruamel.yaml.error import YAMLError
|
|
22
|
+
|
|
23
|
+
from wireforge import resources, versioning
|
|
24
|
+
from wireforge.issues import Issue, errors_only
|
|
25
|
+
|
|
26
|
+
SCHEMA_DIR = resources.schema_dir()
|
|
27
|
+
SCHEMA_FILES = ("project.schema.json", "fragment.schema.json")
|
|
28
|
+
ROOT_FILE_NAME = "project.yaml"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _registry() -> Registry:
|
|
32
|
+
resources = []
|
|
33
|
+
for name in SCHEMA_FILES:
|
|
34
|
+
contents = json.loads((SCHEMA_DIR / name).read_text(encoding="utf-8"))
|
|
35
|
+
resources.append(
|
|
36
|
+
(name, Resource.from_contents(contents, default_specification=DRAFT202012))
|
|
37
|
+
)
|
|
38
|
+
return Registry().with_resources(resources)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _validator(schema_file: str) -> Draft202012Validator:
|
|
42
|
+
contents = json.loads((SCHEMA_DIR / schema_file).read_text(encoding="utf-8"))
|
|
43
|
+
return Draft202012Validator(contents, registry=_registry())
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _line_of(document: object, path: list[str | int]) -> int | None:
|
|
47
|
+
"""Best-effort 1-based line of the node addressed by `path` in a round-trip loaded document."""
|
|
48
|
+
line: int | None = None
|
|
49
|
+
current: Any = document
|
|
50
|
+
for part in path:
|
|
51
|
+
try:
|
|
52
|
+
if isinstance(current, CommentedMap) and isinstance(part, str):
|
|
53
|
+
line = current.lc.key(part)[0]
|
|
54
|
+
elif isinstance(current, CommentedSeq) and isinstance(part, int):
|
|
55
|
+
line = current.lc.item(part)[0]
|
|
56
|
+
else:
|
|
57
|
+
break
|
|
58
|
+
current = current[part]
|
|
59
|
+
except (KeyError, IndexError, AttributeError):
|
|
60
|
+
break
|
|
61
|
+
return None if line is None else line + 1
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _pointer(path: list[str | int]) -> str:
|
|
65
|
+
return "".join("/" + str(part).replace("~", "~0").replace("/", "~1") for part in path)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _pick(error: ValidationError) -> ValidationError | None:
|
|
69
|
+
"""Most helpful sub-error of an anyOf/oneOf failure, ignoring wrong-branch type mismatches."""
|
|
70
|
+
if not error.context:
|
|
71
|
+
return None
|
|
72
|
+
relevant = [
|
|
73
|
+
sub for sub in error.context if not (sub.validator == "type" and not sub.relative_path)
|
|
74
|
+
]
|
|
75
|
+
picked: ValidationError | None = best_match(relevant or error.context)
|
|
76
|
+
return picked
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def validate_document(
|
|
80
|
+
document: object, schema_file: str, *, file: str = "<memory>", source: object | None = None
|
|
81
|
+
) -> list[Issue]:
|
|
82
|
+
"""Validate an already loaded document; `source` (round-trip loaded) enables line numbers."""
|
|
83
|
+
validator = _validator(schema_file)
|
|
84
|
+
issues: list[Issue] = []
|
|
85
|
+
errors: list[ValidationError] = sorted(
|
|
86
|
+
validator.iter_errors(document), key=lambda error: [str(p) for p in error.absolute_path]
|
|
87
|
+
)
|
|
88
|
+
for error in errors:
|
|
89
|
+
chosen = _pick(error)
|
|
90
|
+
reported = chosen or error
|
|
91
|
+
path = list(error.absolute_path) + (list(reported.relative_path) if chosen else [])
|
|
92
|
+
line = _line_of(source if source is not None else document, path)
|
|
93
|
+
issues.append(Issue(file, line, _pointer(path), reported.message))
|
|
94
|
+
return issues
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def load_yaml(path: Path) -> object:
|
|
98
|
+
"""Load a YAML 1.2 file preserving line information."""
|
|
99
|
+
return YAML(typ="rt").load(path.read_text(encoding="utf-8"))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def validate_file(path: Path, schema_file: str) -> list[Issue]:
|
|
103
|
+
"""Validate one YAML file against the named schema."""
|
|
104
|
+
try:
|
|
105
|
+
document = load_yaml(path)
|
|
106
|
+
except YAMLError as error:
|
|
107
|
+
return [Issue(str(path), None, "", f"YAML syntax error: {error}")]
|
|
108
|
+
if not isinstance(document, dict):
|
|
109
|
+
return [Issue(str(path), 1, "", "top level must be a mapping")]
|
|
110
|
+
version_issues = versioning.check_document(document, file=str(path), line=1)
|
|
111
|
+
if errors_only(version_issues):
|
|
112
|
+
return version_issues
|
|
113
|
+
return version_issues + validate_document(
|
|
114
|
+
document, schema_file, file=str(path), source=document
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def validate_project(location: Path) -> list[Issue]:
|
|
119
|
+
"""Validate a project root file and every fragment it includes."""
|
|
120
|
+
root = location / ROOT_FILE_NAME if location.is_dir() else location
|
|
121
|
+
issues = validate_file(root, "project.schema.json")
|
|
122
|
+
document = None
|
|
123
|
+
try:
|
|
124
|
+
document = load_yaml(root)
|
|
125
|
+
except YAMLError:
|
|
126
|
+
return issues
|
|
127
|
+
patterns = document.get("include", []) if isinstance(document, dict) else []
|
|
128
|
+
for pattern in patterns if isinstance(patterns, list) else []:
|
|
129
|
+
for fragment in sorted(root.parent.glob(str(pattern))):
|
|
130
|
+
issues.extend(validate_file(fragment, "fragment.schema.json"))
|
|
131
|
+
return issues
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def main(argv: list[str] | None = None) -> int:
|
|
135
|
+
"""Validate project directories or root files and print one line per issue."""
|
|
136
|
+
parser = argparse.ArgumentParser(prog="python -m wireforge.schema")
|
|
137
|
+
parser.add_argument("projects", nargs="+", type=Path)
|
|
138
|
+
args = parser.parse_args(argv)
|
|
139
|
+
total = 0
|
|
140
|
+
for project in args.projects:
|
|
141
|
+
found = validate_project(project)
|
|
142
|
+
total += len(found)
|
|
143
|
+
for issue in found:
|
|
144
|
+
sys.stdout.write(f"{issue}\n")
|
|
145
|
+
if not found:
|
|
146
|
+
sys.stdout.write(f"{project}: OK\n")
|
|
147
|
+
return 1 if total else 0
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
if __name__ == "__main__":
|
|
151
|
+
sys.exit(main())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""HTTP server of WireForge: workspace storage and REST API."""
|