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/llr.py
ADDED
|
@@ -0,0 +1,693 @@
|
|
|
1
|
+
"""Low-level requirements derived from the model, and the map from generated tests to them.
|
|
2
|
+
|
|
3
|
+
The model is the low-level design: every enum, checksum, struct, message, field attribute and
|
|
4
|
+
interface behaviour implies a testable statement. `derive()` writes these statements down as
|
|
5
|
+
`Llr` entries; nothing is stored. The key of an entry is built from names only
|
|
6
|
+
(`SensorReading.temperature.RANGE`), so reordering fields keeps it and renaming an element changes
|
|
7
|
+
it. Fields of a nested type belong to the type (`Vec3.x.RANGE`), not to every message that uses it.
|
|
8
|
+
Header and trailer fields of an interface use `<Interface>.header.<field>` and
|
|
9
|
+
`<Interface>.trailer.<field>`.
|
|
10
|
+
|
|
11
|
+
`display_id()` is the only place that turns a key into the id shown in code, tests, reports and
|
|
12
|
+
documents. `Verifier` maps every generated test case to the keys it verifies; the generated test
|
|
13
|
+
sources record them (`RecordProperty`) and the trace report reads them back from there.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
import textwrap
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from typing import TYPE_CHECKING, Any
|
|
23
|
+
|
|
24
|
+
from wireforge import layout, runtime_reqs
|
|
25
|
+
from wireforge.model import BUILTIN_BITS, INTEGER_TYPES
|
|
26
|
+
from wireforge.test_cases import CLEAR, OK
|
|
27
|
+
from wireforge.validate import Validator
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from collections.abc import Iterator
|
|
31
|
+
|
|
32
|
+
from wireforge.model import (
|
|
33
|
+
Checksum,
|
|
34
|
+
Enum,
|
|
35
|
+
Field,
|
|
36
|
+
IdMap,
|
|
37
|
+
Interface,
|
|
38
|
+
Project,
|
|
39
|
+
Source,
|
|
40
|
+
Struct,
|
|
41
|
+
Value,
|
|
42
|
+
)
|
|
43
|
+
from wireforge.test_cases import DecodeCase, EncodeCase
|
|
44
|
+
|
|
45
|
+
KINDS: tuple[str, ...] = (
|
|
46
|
+
"VALUES",
|
|
47
|
+
"ALGORITHM",
|
|
48
|
+
"LAYOUT",
|
|
49
|
+
"SIZE",
|
|
50
|
+
"TRUNCATED",
|
|
51
|
+
"TRAILING",
|
|
52
|
+
"DEFAULT",
|
|
53
|
+
"RANGE",
|
|
54
|
+
"ENUM",
|
|
55
|
+
"BITS",
|
|
56
|
+
"COUNT",
|
|
57
|
+
"CHARS",
|
|
58
|
+
"CONST",
|
|
59
|
+
"CHECKSUM",
|
|
60
|
+
"FRAME_ENCODE",
|
|
61
|
+
"FRAME_SYNC",
|
|
62
|
+
"FRAME_LENGTH",
|
|
63
|
+
"FRAME_RESYNC",
|
|
64
|
+
"FRAME_MAX",
|
|
65
|
+
"DISPATCH",
|
|
66
|
+
"DISPATCH_UNKNOWN",
|
|
67
|
+
"RAW",
|
|
68
|
+
)
|
|
69
|
+
# Requirements the runtime tests verify; the generated tests of a project do not cover them.
|
|
70
|
+
RUNTIME_VERIFIED = frozenset({"DISPATCH_UNKNOWN"})
|
|
71
|
+
GENERATED_ONLY = runtime_reqs.GENERATED_ONLY
|
|
72
|
+
ID_PREFIX = "LLR-"
|
|
73
|
+
NOTE_WIDTH = 96 # 100 columns minus the `/// ` prefix
|
|
74
|
+
_NUMBER = re.compile(r"-?(\d+|0[xX][0-9a-fA-F]+)")
|
|
75
|
+
_POINTER = re.compile(r"^/(types|messages|interfaces)/([^/]+)/(?:(header|trailer)/)?fields/\d+$")
|
|
76
|
+
_COVERS = {
|
|
77
|
+
"preceding": "all bytes before it",
|
|
78
|
+
"payload": "the payload",
|
|
79
|
+
"header_and_payload": "the header and the payload",
|
|
80
|
+
}
|
|
81
|
+
_BY_STATUS = {
|
|
82
|
+
"OutOfRange": ("RANGE", "ENUM", "BITS"),
|
|
83
|
+
"CountExceedsMax": ("COUNT",),
|
|
84
|
+
"ConstMismatch": ("CONST",),
|
|
85
|
+
"ChecksumMismatch": ("CHECKSUM",),
|
|
86
|
+
}
|
|
87
|
+
# Adds one requirement of the field being described: (kind, text, extra parent ids).
|
|
88
|
+
_Add = Callable[..., None]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class Llr:
|
|
93
|
+
"""One low-level requirement."""
|
|
94
|
+
|
|
95
|
+
key: str
|
|
96
|
+
kind: str
|
|
97
|
+
element: str
|
|
98
|
+
text: str
|
|
99
|
+
parents: tuple[str, ...]
|
|
100
|
+
source: Source
|
|
101
|
+
runtime_verified: bool = False
|
|
102
|
+
realized_by: tuple[str, ...] = ()
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def derived(self) -> bool:
|
|
106
|
+
"""Whether no high-level requirement is behind it."""
|
|
107
|
+
return not self.parents
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def display_id(key: str, ids: IdMap | None = None) -> str:
|
|
111
|
+
"""Return the id shown for a key; the only place that decides how ids look.
|
|
112
|
+
|
|
113
|
+
With a lock file (`ids`) a numbered key shows its number, any other key shows `LLR-<key>`.
|
|
114
|
+
"""
|
|
115
|
+
if ids is not None and key in ids.ids:
|
|
116
|
+
return ids.ids[key]
|
|
117
|
+
return ID_PREFIX + key
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def owner_of(field: Field) -> str:
|
|
121
|
+
"""Return the key prefix of the struct, header or trailer a field belongs to."""
|
|
122
|
+
match = _POINTER.match(field.source.pointer)
|
|
123
|
+
if match is None:
|
|
124
|
+
message = f"{field.name}: unexpected source pointer {field.source.pointer!r}"
|
|
125
|
+
raise ValueError(message)
|
|
126
|
+
section, name, part = match.groups()
|
|
127
|
+
return f"{name}.{part}" if section == "interfaces" else name
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def field_at(project: Project, owner: Struct, path: str) -> tuple[Struct, Field] | None:
|
|
131
|
+
"""Return a codec error path's last field and the struct (type or message) declaring it.
|
|
132
|
+
|
|
133
|
+
The path looks like `/<field>/<index>/<field>`; indices are skipped and nested types are
|
|
134
|
+
entered.
|
|
135
|
+
"""
|
|
136
|
+
current = owner
|
|
137
|
+
found: tuple[Struct, Field] | None = None
|
|
138
|
+
for part in path.split("/"):
|
|
139
|
+
if not part or part.isdigit():
|
|
140
|
+
continue
|
|
141
|
+
item = next((f for f in current.fields if f.name == part), None)
|
|
142
|
+
if item is None:
|
|
143
|
+
return None
|
|
144
|
+
found = (current, item)
|
|
145
|
+
current = project.types.get(item.type, current)
|
|
146
|
+
return found
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _unique(*groups: tuple[str, ...]) -> tuple[str, ...]:
|
|
150
|
+
return tuple(sorted({item for group in groups for item in group}))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _quote(value: object) -> str:
|
|
154
|
+
return f'"{value}"' if isinstance(value, str) else str(value).lower()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _literal(value: object) -> str:
|
|
158
|
+
if isinstance(value, list):
|
|
159
|
+
return ", ".join(_quote(item) for item in value)
|
|
160
|
+
return _quote(value)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _capitalize(text: str) -> str:
|
|
164
|
+
return text[:1].upper() + text[1:]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class _Deriver:
|
|
168
|
+
"""Builds the list of requirements of one project."""
|
|
169
|
+
|
|
170
|
+
def __init__(self, project: Project) -> None:
|
|
171
|
+
self.project = project
|
|
172
|
+
self.validator = Validator(project)
|
|
173
|
+
self.report = layout.analyze(project)
|
|
174
|
+
self.found: list[Llr] = []
|
|
175
|
+
|
|
176
|
+
# --- helpers --------------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
def add(
|
|
179
|
+
self, key: str, element: str, text: str, parents: tuple[str, ...], source: Source
|
|
180
|
+
) -> None:
|
|
181
|
+
kind = key.rsplit(".", 1)[1]
|
|
182
|
+
verified = kind in RUNTIME_VERIFIED
|
|
183
|
+
runtime = () if kind in GENERATED_ONLY else runtime_reqs.realized_by_kind().get(kind, ())
|
|
184
|
+
self.found.append(Llr(key, kind, element, text, parents, source, verified, runtime))
|
|
185
|
+
|
|
186
|
+
def value(self, raw: Value) -> str:
|
|
187
|
+
"""Write a number as given and an expression as given plus its value per profile."""
|
|
188
|
+
text = str(raw)
|
|
189
|
+
if isinstance(raw, (int, float)) or _NUMBER.fullmatch(text):
|
|
190
|
+
return text
|
|
191
|
+
values = [
|
|
192
|
+
(variant.name, self.validator.static_value(raw, variant))
|
|
193
|
+
for variant in self.validator.variants
|
|
194
|
+
]
|
|
195
|
+
known = [(name, number) for name, number in values if number is not None]
|
|
196
|
+
if not known:
|
|
197
|
+
return text
|
|
198
|
+
if not self.project.profiles:
|
|
199
|
+
return str(known[0][1])
|
|
200
|
+
return f"{text} ({', '.join(f'{name}: {number}' for name, number in known)})"
|
|
201
|
+
|
|
202
|
+
def directions(self, item: Struct | None) -> str:
|
|
203
|
+
"""Name the directions in which range checks are enabled ('' when in none)."""
|
|
204
|
+
checks: dict[str, Any] = {"encode": True, "decode": True}
|
|
205
|
+
configured = self.project.defaults.get("range_check")
|
|
206
|
+
checks.update(configured if isinstance(configured, dict) else {})
|
|
207
|
+
if item is not None and item.message is not None:
|
|
208
|
+
checks.update(item.message.range_check or {})
|
|
209
|
+
names = [
|
|
210
|
+
name for name, key in (("encoding", "encode"), ("decoding", "decode")) if checks[key]
|
|
211
|
+
]
|
|
212
|
+
return _capitalize(" and ".join(names))
|
|
213
|
+
|
|
214
|
+
# --- enums and checksums --------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
def enum(self, item: Enum) -> None:
|
|
217
|
+
members = ", ".join(f"{name} = {number}" for name, number in item.values.items())
|
|
218
|
+
text = f"`{item.name}` shall define the members {members} with underlying type {item.type}."
|
|
219
|
+
self.add(
|
|
220
|
+
f"{item.name}.VALUES", f"/enums/{item.name}", text, _unique(item.reqs), item.source
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
def checksum(self, item: Checksum) -> None:
|
|
224
|
+
if item.algo == "crc32":
|
|
225
|
+
how = "CRC-32 (IEEE)"
|
|
226
|
+
elif item.algo == "crc":
|
|
227
|
+
how = (
|
|
228
|
+
f"CRC-{item.width} (poly 0x{item.poly or 0:X}, init 0x{item.init:X}, "
|
|
229
|
+
f"refin {_quote(item.refin)}, refout {_quote(item.refout)}, "
|
|
230
|
+
f"xorout 0x{item.xorout:X})"
|
|
231
|
+
)
|
|
232
|
+
else:
|
|
233
|
+
how = f"computed by `{item.impl}` (user code)"
|
|
234
|
+
text = f"The checksum `{item.name}` shall be {how}."
|
|
235
|
+
pointer = f"/checksums/{item.name}"
|
|
236
|
+
self.add(f"{item.name}.ALGORITHM", pointer, text, _unique(item.reqs), item.source)
|
|
237
|
+
|
|
238
|
+
# --- structs --------------------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
def describe(self, item: Field) -> str:
|
|
241
|
+
if item.type in self.project.user_types:
|
|
242
|
+
user = self.project.user_types[item.type]
|
|
243
|
+
what = f"{user.size_bytes} opaque bytes encoded by `{user.cpp}`"
|
|
244
|
+
else:
|
|
245
|
+
what = item.type + (f", {item.bits} bits" if item.bits else "")
|
|
246
|
+
if item.occurs is not None:
|
|
247
|
+
what += f", up to {self.value(item.occurs.max)} times"
|
|
248
|
+
if item.reserved:
|
|
249
|
+
what += ", reserved"
|
|
250
|
+
text = f"{item.name} ({what})"
|
|
251
|
+
return text + (f" only when {item.condition}" if item.condition else "")
|
|
252
|
+
|
|
253
|
+
def layout_text(self, item: Struct) -> str:
|
|
254
|
+
fields = ", ".join(self.describe(member) for member in item.fields)
|
|
255
|
+
info = item.message
|
|
256
|
+
if info is None:
|
|
257
|
+
order = "the endian and bit order of the enclosing message"
|
|
258
|
+
else:
|
|
259
|
+
endian = info.endian or "the endian of the carrying interface"
|
|
260
|
+
order = f"endian {endian}, bit order {info.bit_order or 'of the carrying interface'}"
|
|
261
|
+
return (
|
|
262
|
+
f"`{item.name}` shall be encoded as the fields {fields} in this order with no "
|
|
263
|
+
f"padding, {order}."
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
def struct(self, item: Struct, section: str) -> None:
|
|
267
|
+
pointer = f"/{section}/{item.name}"
|
|
268
|
+
reqs = _unique(item.reqs)
|
|
269
|
+
self.add(f"{item.name}.LAYOUT", pointer, self.layout_text(item), reqs, item.source)
|
|
270
|
+
if item.message is not None:
|
|
271
|
+
self.message_rules(item, pointer, reqs)
|
|
272
|
+
for index, member in enumerate(item.fields):
|
|
273
|
+
self.field(item.name, member, item, f"{pointer}/fields/{index}", reqs)
|
|
274
|
+
|
|
275
|
+
def message_rules(self, item: Struct, pointer: str, reqs: tuple[str, ...]) -> None:
|
|
276
|
+
name = item.name
|
|
277
|
+
self.size(item, pointer, reqs)
|
|
278
|
+
short = (
|
|
279
|
+
f"Decoding `{name}` from fewer bytes than its fields need shall fail with `Truncated`."
|
|
280
|
+
)
|
|
281
|
+
long = f"Decoding `{name}` shall fail with `TrailingBytes` when payload bytes remain."
|
|
282
|
+
self.add(f"{name}.TRUNCATED", pointer, short, reqs, item.source)
|
|
283
|
+
self.add(f"{name}.TRAILING", pointer, long, reqs, item.source)
|
|
284
|
+
|
|
285
|
+
def size(self, item: Struct, pointer: str, reqs: tuple[str, ...]) -> None:
|
|
286
|
+
sizes = [
|
|
287
|
+
(profile.profile, message.size_min, message.size_max)
|
|
288
|
+
for profile in self.report.profiles
|
|
289
|
+
for message in profile.messages
|
|
290
|
+
if message.name == item.name
|
|
291
|
+
]
|
|
292
|
+
if not sizes:
|
|
293
|
+
return
|
|
294
|
+
|
|
295
|
+
def span(low: int, high: int) -> str:
|
|
296
|
+
return f"exactly {low}" if low == high else f"between {low} and {high}"
|
|
297
|
+
|
|
298
|
+
subject = f"The wire size of `{item.name}` shall be"
|
|
299
|
+
if len({(low, high) for _, low, high in sizes}) == 1:
|
|
300
|
+
text = f"{subject} {span(sizes[0][1], sizes[0][2])} bytes."
|
|
301
|
+
else:
|
|
302
|
+
parts = ", ".join(f"{name}: {span(low, high)}" for name, low, high in sizes)
|
|
303
|
+
text = f"{subject}, per profile, {parts} bytes."
|
|
304
|
+
self.add(f"{item.name}.SIZE", pointer, text, reqs, item.source)
|
|
305
|
+
|
|
306
|
+
def field(
|
|
307
|
+
self, owner: str, item: Field, container: Struct | None, pointer: str, reqs: tuple[str, ...]
|
|
308
|
+
) -> None:
|
|
309
|
+
"""Add the requirements of one field of a struct, header or trailer."""
|
|
310
|
+
parents = _unique(item.reqs, reqs)
|
|
311
|
+
|
|
312
|
+
def add(kind: str, text: str, more: tuple[str, ...] = ()) -> None:
|
|
313
|
+
key = f"{owner}.{item.name}.{kind}"
|
|
314
|
+
self.add(key, pointer, text, _unique(parents, more), item.source)
|
|
315
|
+
|
|
316
|
+
name = f"`{item.name}`"
|
|
317
|
+
if item.default is not None:
|
|
318
|
+
add("DEFAULT", f"The default value of {name} shall be {_literal(item.default)}.")
|
|
319
|
+
self.checked(item, container, add)
|
|
320
|
+
if item.occurs is not None:
|
|
321
|
+
self.counted(item, add)
|
|
322
|
+
if item.const is not None:
|
|
323
|
+
text = f"{name} shall be written as {_quote(item.const)}"
|
|
324
|
+
add("CONST", f"{text}; decoding shall fail with `ConstMismatch` otherwise.")
|
|
325
|
+
if item.checksum is not None:
|
|
326
|
+
use = item.checksum
|
|
327
|
+
covers = (
|
|
328
|
+
_COVERS[use.scope] if use.scope else f"the fields {use.first} through {use.last}"
|
|
329
|
+
)
|
|
330
|
+
text = f"{name} shall hold the `{use.use}` checksum over {covers}"
|
|
331
|
+
add(
|
|
332
|
+
"CHECKSUM",
|
|
333
|
+
f"{text}; decoding shall fail with `ChecksumMismatch` otherwise.",
|
|
334
|
+
self.project.checksums[use.use].reqs,
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
def checked(self, item: Field, container: Struct | None, add: _Add) -> None:
|
|
338
|
+
"""Range, enum membership and bit width checks of a field."""
|
|
339
|
+
who = self.directions(container)
|
|
340
|
+
if not who:
|
|
341
|
+
return
|
|
342
|
+
fail = f"{who} shall fail with `OutOfRange` when `{item.name}`"
|
|
343
|
+
if item.min is not None or item.max is not None:
|
|
344
|
+
add("RANGE", f"{fail} is {self.bounds(item)}.")
|
|
345
|
+
if item.type in self.project.enums:
|
|
346
|
+
enum = self.project.enums[item.type]
|
|
347
|
+
add("ENUM", f"{fail} is not a member of `{item.type}`.", enum.reqs)
|
|
348
|
+
width = BUILTIN_BITS.get(item.type) if item.type in INTEGER_TYPES else None
|
|
349
|
+
narrowed = item.bits is not None and width is not None and item.bits < width
|
|
350
|
+
if narrowed and item.min is None and item.max is None and not item.reserved:
|
|
351
|
+
add("BITS", f"{fail} does not fit {item.bits} bits.")
|
|
352
|
+
|
|
353
|
+
def bounds(self, item: Field) -> str:
|
|
354
|
+
units = f" {item.units}" if item.units else ""
|
|
355
|
+
if item.min is not None and item.max is not None:
|
|
356
|
+
return f"outside [{self.value(item.min)}, {self.value(item.max)}]{units}"
|
|
357
|
+
if item.min is not None:
|
|
358
|
+
return f"below {self.value(item.min)}{units}"
|
|
359
|
+
return f"above {self.value(item.max or 0)}{units}"
|
|
360
|
+
|
|
361
|
+
def counted(self, item: Field, add: _Add) -> None:
|
|
362
|
+
"""Element count and string rules of an array field."""
|
|
363
|
+
occurs = item.occurs
|
|
364
|
+
if occurs is None:
|
|
365
|
+
return
|
|
366
|
+
name = f"`{item.name}`"
|
|
367
|
+
top = self.value(occurs.max)
|
|
368
|
+
error = "otherwise `CountExceedsMax`"
|
|
369
|
+
if occurs.fill:
|
|
370
|
+
add(
|
|
371
|
+
"COUNT",
|
|
372
|
+
f"The elements of {name} shall fill the remaining payload, at most {top}; {error}.",
|
|
373
|
+
)
|
|
374
|
+
elif occurs.count is not None or occurs.min is not None:
|
|
375
|
+
source = f" taken from `{occurs.count}`" if occurs.count is not None else ""
|
|
376
|
+
low = self.value(occurs.min) if occurs.min is not None else "0"
|
|
377
|
+
between = f"between {low} and {top}"
|
|
378
|
+
add("COUNT", f"The number of {name} elements shall be{source} and {between}; {error}.")
|
|
379
|
+
elif item.type == "char":
|
|
380
|
+
add("COUNT", f"{name} shall hold at most {top} characters; {error}.")
|
|
381
|
+
if item.type == "char":
|
|
382
|
+
pad = "zero padded on encode and stripped of trailing zeros on decode"
|
|
383
|
+
add("CHARS", f"{name} shall be {pad}.")
|
|
384
|
+
|
|
385
|
+
# --- interfaces -----------------------------------------------------------------------------
|
|
386
|
+
|
|
387
|
+
def interface(self, item: Interface) -> None:
|
|
388
|
+
pointer = f"/interfaces/{item.name}"
|
|
389
|
+
own = _unique(item.reqs)
|
|
390
|
+
for part, fields in (("header", item.header or ()), ("trailer", item.trailer)):
|
|
391
|
+
for index, member in enumerate(fields):
|
|
392
|
+
where = f"{pointer}/{part}/fields/{index}"
|
|
393
|
+
self.field(f"{item.name}.{part}", member, None, where, own)
|
|
394
|
+
reqs = _unique(item.reqs, item.framing.reqs)
|
|
395
|
+
for kind, text in self.frame_rules(item):
|
|
396
|
+
self.add(f"{item.name}.{kind}", pointer, text, reqs, item.source)
|
|
397
|
+
self.dispatch(item, pointer, reqs)
|
|
398
|
+
|
|
399
|
+
def frame_rules(self, item: Interface) -> Iterator[tuple[str, str]]:
|
|
400
|
+
"""Framing requirements as (kind, text), in KIND order."""
|
|
401
|
+
name = f"`{item.name}`"
|
|
402
|
+
header = item.header or ()
|
|
403
|
+
framing = item.framing
|
|
404
|
+
written = "the header (ids, lengths, constants), " if item.header else ""
|
|
405
|
+
written += "the payload" + (" and the trailer" if item.trailer else "")
|
|
406
|
+
yield (
|
|
407
|
+
"FRAME_ENCODE",
|
|
408
|
+
(
|
|
409
|
+
f"Encoding a frame of {name} shall write {written}; it shall fail with "
|
|
410
|
+
"`BufferTooSmall` when the buffer cannot hold it."
|
|
411
|
+
),
|
|
412
|
+
)
|
|
413
|
+
sync = [member for member in header if member.role == "sync"]
|
|
414
|
+
if sync:
|
|
415
|
+
values = ", ".join(_quote(member.const) for member in sync)
|
|
416
|
+
yield (
|
|
417
|
+
"FRAME_SYNC",
|
|
418
|
+
f"The receiver of {name} shall skip bytes until the sync value {values}.",
|
|
419
|
+
)
|
|
420
|
+
length = next((member for member in header if member.role == "payload_length"), None)
|
|
421
|
+
if framing.kind == "fixed":
|
|
422
|
+
size = self.value(framing.frame_size or 0)
|
|
423
|
+
yield "FRAME_LENGTH", f"Every frame of {name} shall be exactly {size} bytes."
|
|
424
|
+
elif length is not None or framing.kind != "none":
|
|
425
|
+
where = f"`{length.name}`" if length is not None else "the length prefix"
|
|
426
|
+
counts = framing.length_counts.replace("_", " ")
|
|
427
|
+
taken = f"shall be taken from {where} counting the {counts}"
|
|
428
|
+
yield "FRAME_LENGTH", f"The length of a frame of {name} {taken}."
|
|
429
|
+
if framing.kind != "none":
|
|
430
|
+
after = f"After a rejected frame the receiver of {name} shall"
|
|
431
|
+
how = (
|
|
432
|
+
"skip one byte and scan for the next frame"
|
|
433
|
+
if framing.resync
|
|
434
|
+
else "drop all buffered bytes"
|
|
435
|
+
)
|
|
436
|
+
yield "FRAME_RESYNC", f"{after} {how}."
|
|
437
|
+
limit = framing.max_frame_size if framing.max_frame_size is not None else framing.frame_size
|
|
438
|
+
if limit is not None:
|
|
439
|
+
rejected = f"larger than {self.value(limit)} bytes shall be rejected"
|
|
440
|
+
yield "FRAME_MAX", f"Frames of {name} {rejected} with `InvalidLength`."
|
|
441
|
+
else:
|
|
442
|
+
yield (
|
|
443
|
+
"FRAME_MAX",
|
|
444
|
+
(
|
|
445
|
+
f"The largest frame of {name} shall be the header, the largest payload and "
|
|
446
|
+
"the trailer of its messages."
|
|
447
|
+
),
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
def dispatch(self, item: Interface, pointer: str, reqs: tuple[str, ...]) -> None:
|
|
451
|
+
name = f"`{item.name}`"
|
|
452
|
+
routes = []
|
|
453
|
+
for entry in item.messages:
|
|
454
|
+
message = self.project.messages[entry.name]
|
|
455
|
+
number = entry.id
|
|
456
|
+
if number is None and message.message is not None:
|
|
457
|
+
number = message.message.id
|
|
458
|
+
ident = f" (id {number})" if number is not None else ""
|
|
459
|
+
routes.append(f"{entry.name}{ident} to `on{entry.name}Received`")
|
|
460
|
+
parents = _unique(
|
|
461
|
+
reqs,
|
|
462
|
+
*(entry.reqs for entry in item.messages),
|
|
463
|
+
*(self.project.messages[entry.name].reqs for entry in item.messages),
|
|
464
|
+
)
|
|
465
|
+
text = (
|
|
466
|
+
f"Every valid frame of {name} shall be delivered to the handler: {', '.join(routes)}."
|
|
467
|
+
)
|
|
468
|
+
self.add(f"{item.name}.DISPATCH", pointer, text, parents, item.source)
|
|
469
|
+
if any(member.role == "message_id" for member in item.header or ()):
|
|
470
|
+
text = (
|
|
471
|
+
f"A frame of {name} with an unknown message id shall be reported as "
|
|
472
|
+
"`UnknownMessageId` and not dispatched."
|
|
473
|
+
)
|
|
474
|
+
self.add(f"{item.name}.DISPATCH_UNKNOWN", pointer, text, reqs, item.source)
|
|
475
|
+
text = (
|
|
476
|
+
f"Every valid frame of {name} shall also reach the raw hook `on<Message>Raw` with its "
|
|
477
|
+
"payload and the whole frame; the default of the hook shall decode the payload and "
|
|
478
|
+
"call `on<Message>Received`."
|
|
479
|
+
)
|
|
480
|
+
self.add(f"{item.name}.RAW", pointer, text, reqs, item.source)
|
|
481
|
+
|
|
482
|
+
def run(self) -> list[Llr]:
|
|
483
|
+
project = self.project
|
|
484
|
+
for name in sorted(project.enums):
|
|
485
|
+
self.enum(project.enums[name])
|
|
486
|
+
for name in sorted(project.checksums):
|
|
487
|
+
self.checksum(project.checksums[name])
|
|
488
|
+
for name in sorted(project.types):
|
|
489
|
+
self.struct(project.types[name], "types")
|
|
490
|
+
for name in sorted(project.messages):
|
|
491
|
+
self.struct(project.messages[name], "messages")
|
|
492
|
+
for name in sorted(project.interfaces):
|
|
493
|
+
self.interface(project.interfaces[name])
|
|
494
|
+
return self.found
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def derive(project: Project) -> list[Llr]:
|
|
498
|
+
"""Return every low-level requirement of a valid project, in a stable order."""
|
|
499
|
+
return _Deriver(project).run()
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def requirement_notes(project: Project) -> dict[str, list[str]]:
|
|
503
|
+
"""Return the Doxygen lines naming the requirements behind each generated element.
|
|
504
|
+
|
|
505
|
+
The key is the name of the enum, checksum, struct or interface. A project without a
|
|
506
|
+
`requirements` catalog gets no notes, so its generated code is the same as before.
|
|
507
|
+
"""
|
|
508
|
+
if not project.requirements:
|
|
509
|
+
return {}
|
|
510
|
+
owned: dict[str, list[Llr]] = {}
|
|
511
|
+
for item in derive(project):
|
|
512
|
+
owned.setdefault(item.key.split(".", 1)[0], []).append(item)
|
|
513
|
+
notes: dict[str, list[str]] = {}
|
|
514
|
+
for owner, items in owned.items():
|
|
515
|
+
highs = ", ".join(sorted({parent for item in items for parent in item.parents}))
|
|
516
|
+
lows = ", ".join(display_id(item.key, project.llr_ids) for item in items)
|
|
517
|
+
text = f"Requirements: {highs}; {lows}" if highs else f"Requirements: {lows}"
|
|
518
|
+
wrapped = textwrap.wrap(
|
|
519
|
+
text, width=NOTE_WIDTH, break_long_words=False, break_on_hyphens=False
|
|
520
|
+
)
|
|
521
|
+
notes[owner] = [f"/// {line}" for line in wrapped]
|
|
522
|
+
return notes
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
# --- tests -> requirements ---------------------------------------------------------------------
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
class Verifier:
|
|
529
|
+
"""Requirement keys verified by each generated test case of one project."""
|
|
530
|
+
|
|
531
|
+
def __init__(self, project: Project, llrs: list[Llr] | None = None) -> None:
|
|
532
|
+
"""Index the keys of `llrs` (derived from `project` when not given)."""
|
|
533
|
+
self.project = project
|
|
534
|
+
self.known = {item.key for item in (derive(project) if llrs is None else llrs)}
|
|
535
|
+
|
|
536
|
+
def shown(self, keys: tuple[str, ...]) -> tuple[str, ...]:
|
|
537
|
+
"""Return the displayed ids of keys (numbers from the lock file, if the project has one)."""
|
|
538
|
+
return tuple(display_id(key, self.project.llr_ids) for key in keys)
|
|
539
|
+
|
|
540
|
+
def keep(self, *keys: str) -> tuple[str, ...]:
|
|
541
|
+
"""Return the given keys that exist, without repeats, in the order given."""
|
|
542
|
+
result: list[str] = []
|
|
543
|
+
for key in keys:
|
|
544
|
+
if key in self.known and key not in result:
|
|
545
|
+
result.append(key)
|
|
546
|
+
return tuple(result)
|
|
547
|
+
|
|
548
|
+
def first(self, *keys: str) -> tuple[str, ...]:
|
|
549
|
+
"""Return the first of the given keys that exists."""
|
|
550
|
+
return self.keep(*keys)[:1]
|
|
551
|
+
|
|
552
|
+
def nested(self, item: Struct) -> Iterator[Struct]:
|
|
553
|
+
"""Yield the types used (directly or through other types) by the fields of a struct."""
|
|
554
|
+
for member in item.fields:
|
|
555
|
+
inner = self.project.types.get(member.type)
|
|
556
|
+
if inner is not None:
|
|
557
|
+
yield inner
|
|
558
|
+
yield from self.nested(inner)
|
|
559
|
+
|
|
560
|
+
@staticmethod
|
|
561
|
+
def frame_parts(interface: Interface) -> tuple[str, ...]:
|
|
562
|
+
"""Header constants and trailer checksums (with their algorithms) of an interface."""
|
|
563
|
+
keys = [
|
|
564
|
+
f"{interface.name}.header.{member.name}.CONST"
|
|
565
|
+
for member in interface.header or ()
|
|
566
|
+
if member.const is not None
|
|
567
|
+
]
|
|
568
|
+
for member in interface.trailer:
|
|
569
|
+
if member.checksum is not None:
|
|
570
|
+
keys += [
|
|
571
|
+
f"{interface.name}.trailer.{member.name}.CHECKSUM",
|
|
572
|
+
f"{member.checksum.use}.ALGORITHM",
|
|
573
|
+
]
|
|
574
|
+
return tuple(keys)
|
|
575
|
+
|
|
576
|
+
@staticmethod
|
|
577
|
+
def checksum_keys(owner: Struct) -> Iterator[str]:
|
|
578
|
+
"""Checksum requirements and algorithms of the fields of a struct."""
|
|
579
|
+
for member in owner.fields:
|
|
580
|
+
if member.checksum is not None:
|
|
581
|
+
yield f"{owner.name}.{member.name}.CHECKSUM"
|
|
582
|
+
yield f"{member.checksum.use}.ALGORITHM"
|
|
583
|
+
|
|
584
|
+
def golden(self, vector: dict[str, Any]) -> tuple[str, ...]:
|
|
585
|
+
"""Golden vector: layouts, frame encoding, dispatch, header constants and checksums."""
|
|
586
|
+
message = self.project.messages[vector["message"]]
|
|
587
|
+
interface = self.project.interfaces[vector["interface"]]
|
|
588
|
+
owners = (message, *self.nested(message))
|
|
589
|
+
return self.keep(
|
|
590
|
+
f"{message.name}.LAYOUT",
|
|
591
|
+
*(f"{inner.name}.LAYOUT" for inner in owners[1:]),
|
|
592
|
+
f"{interface.name}.FRAME_ENCODE",
|
|
593
|
+
f"{interface.name}.DISPATCH",
|
|
594
|
+
*(key for owner in owners for key in self.checksum_keys(owner)),
|
|
595
|
+
*self.frame_parts(interface),
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
def defaults(self, message: Struct) -> tuple[str, ...]:
|
|
599
|
+
"""Default value test: layout and every declared default."""
|
|
600
|
+
keys = [f"{message.name}.LAYOUT"]
|
|
601
|
+
for owner in (message, *self.nested(message)):
|
|
602
|
+
keys += [
|
|
603
|
+
f"{owner.name}.{f.name}.DEFAULT" for f in owner.fields if f.default is not None
|
|
604
|
+
]
|
|
605
|
+
return self.keep(*keys)
|
|
606
|
+
|
|
607
|
+
def wire_size(self, message: Struct) -> tuple[str, ...]:
|
|
608
|
+
"""Wire size assertion of a message."""
|
|
609
|
+
return self.keep(f"{message.name}.SIZE")
|
|
610
|
+
|
|
611
|
+
def encode_case(self, message: Struct, case: EncodeCase) -> tuple[str, ...]:
|
|
612
|
+
"""Boundary, enum member and emptied-array tests of one field."""
|
|
613
|
+
item = case.edit.field
|
|
614
|
+
owner = owner_of(item)
|
|
615
|
+
base = f"{owner}.{item.name}"
|
|
616
|
+
layout_key = f"{message.name}.LAYOUT"
|
|
617
|
+
if case.edit.value is CLEAR:
|
|
618
|
+
return self.keep(f"{base}.COUNT", layout_key)
|
|
619
|
+
if item.type in self.project.enums:
|
|
620
|
+
members = (f"{item.type}.VALUES",) if "_member_" in case.name else ()
|
|
621
|
+
return self.keep(f"{base}.ENUM", *members)
|
|
622
|
+
found = self.first(f"{base}.RANGE", f"{base}.BITS", f"{owner}.LAYOUT")
|
|
623
|
+
return self.keep(*found, layout_key)
|
|
624
|
+
|
|
625
|
+
def decode_case(self, message: Struct, case: DecodeCase) -> tuple[str, ...]:
|
|
626
|
+
"""Decode test: the requirement behind the status the mutation provokes."""
|
|
627
|
+
name = message.name
|
|
628
|
+
if case.name == "truncated" or case.status == "Truncated":
|
|
629
|
+
return self.first(f"{name}.TRUNCATED")
|
|
630
|
+
if case.name == "extended":
|
|
631
|
+
return self.first(f"{name}.TRAILING" if case.status != OK else f"{name}.LAYOUT")
|
|
632
|
+
if case.status == "TrailingBytes":
|
|
633
|
+
return self.first(f"{name}.TRAILING")
|
|
634
|
+
located = field_at(self.project, message, case.path)
|
|
635
|
+
if located is None:
|
|
636
|
+
return self.first(f"{name}.LAYOUT")
|
|
637
|
+
owner, item = located
|
|
638
|
+
kinds = _BY_STATUS.get(case.status, ())
|
|
639
|
+
found = self.first(
|
|
640
|
+
*(f"{owner.name}.{item.name}.{kind}" for kind in kinds), f"{owner.name}.LAYOUT"
|
|
641
|
+
)
|
|
642
|
+
if case.status == "ChecksumMismatch" and item.checksum is not None:
|
|
643
|
+
return self.keep(*found, f"{item.checksum.use}.ALGORITHM")
|
|
644
|
+
return found
|
|
645
|
+
|
|
646
|
+
def interface_test(
|
|
647
|
+
self,
|
|
648
|
+
interface: Interface,
|
|
649
|
+
test: str,
|
|
650
|
+
*,
|
|
651
|
+
corrupt_status: str = OK,
|
|
652
|
+
decode_keys: tuple[str, ...] = (),
|
|
653
|
+
) -> tuple[str, ...]:
|
|
654
|
+
"""Map a test of the `Interface_<I>` suite.
|
|
655
|
+
|
|
656
|
+
`corrupt_status` is the status the corrupted frame is rejected with; `decode_keys` the
|
|
657
|
+
message requirements behind a raw decode error.
|
|
658
|
+
"""
|
|
659
|
+
name = interface.name
|
|
660
|
+
|
|
661
|
+
def key(kind: str) -> str:
|
|
662
|
+
return f"{name}.{kind}"
|
|
663
|
+
|
|
664
|
+
by_name = {
|
|
665
|
+
"DispatchesEveryMessage": (key("DISPATCH"), key("FRAME_LENGTH")),
|
|
666
|
+
"DeliversByteByByte": (key("FRAME_LENGTH"), key("FRAME_SYNC")),
|
|
667
|
+
"SkipsBytesBeforeSync": (key("FRAME_SYNC"),),
|
|
668
|
+
"EncodeFrameNeedsRoom": (key("FRAME_ENCODE"),),
|
|
669
|
+
"WireSize": (key("FRAME_MAX"),),
|
|
670
|
+
}
|
|
671
|
+
if test == "RejectsCorruptedFrame":
|
|
672
|
+
checksums = [
|
|
673
|
+
f"{name}.trailer.{member.name}.CHECKSUM"
|
|
674
|
+
for member in interface.trailer
|
|
675
|
+
if member.checksum is not None and corrupt_status == "ChecksumMismatch"
|
|
676
|
+
]
|
|
677
|
+
return self.keep(*checksums, key("FRAME_RESYNC")) or self.keep(key("DISPATCH"))
|
|
678
|
+
if test == "RejectsBrokenFrames":
|
|
679
|
+
constants = [
|
|
680
|
+
f"{name}.header.{member.name}.CONST"
|
|
681
|
+
for member in interface.header or ()
|
|
682
|
+
if member.const is not None
|
|
683
|
+
]
|
|
684
|
+
sums = [
|
|
685
|
+
f"{name}.trailer.{member.name}.CHECKSUM"
|
|
686
|
+
for member in interface.trailer
|
|
687
|
+
if member.checksum is not None
|
|
688
|
+
]
|
|
689
|
+
return self.keep(*constants, *sums) or self.keep(key("DISPATCH"))
|
|
690
|
+
if test.startswith("RawDecodeErrorOf"):
|
|
691
|
+
return self.keep(key("RAW"), *decode_keys)
|
|
692
|
+
# DeliversRawBytesWithoutDecoding and RawCanForwardToDecoder fall through to RAW.
|
|
693
|
+
return self.keep(*by_name.get(test, (key("RAW"),)))
|