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.
Files changed (96) hide show
  1. wireforge/__init__.py +3 -0
  2. wireforge/_data/cmake/WireForgeConfig.cmake +29 -0
  3. wireforge/_data/cmake/WireForgeConfigVersion.cmake +15 -0
  4. wireforge/_data/cmake/WireForgeGenerate.cmake +64 -0
  5. wireforge/_data/runtime/LICENSE +21 -0
  6. wireforge/_data/runtime/include/wireforge/array_access.hpp +35 -0
  7. wireforge/_data/runtime/include/wireforge/bit_utils.hpp +226 -0
  8. wireforge/_data/runtime/include/wireforge/byte_io.hpp +387 -0
  9. wireforge/_data/runtime/include/wireforge/checksum.hpp +201 -0
  10. wireforge/_data/runtime/include/wireforge/codec_support.hpp +412 -0
  11. wireforge/_data/runtime/include/wireforge/config.hpp +41 -0
  12. wireforge/_data/runtime/include/wireforge/delegate.hpp +101 -0
  13. wireforge/_data/runtime/include/wireforge/dispatcher.hpp +339 -0
  14. wireforge/_data/runtime/include/wireforge/expr_support.hpp +38 -0
  15. wireforge/_data/runtime/include/wireforge/frame_decoder.hpp +262 -0
  16. wireforge/_data/runtime/include/wireforge/frame_layout.hpp +20 -0
  17. wireforge/_data/runtime/include/wireforge/link.hpp +223 -0
  18. wireforge/_data/runtime/include/wireforge/lock.hpp +146 -0
  19. wireforge/_data/runtime/include/wireforge/ring_buffer.hpp +226 -0
  20. wireforge/_data/runtime/include/wireforge/span.hpp +116 -0
  21. wireforge/_data/runtime/include/wireforge/static_vector.hpp +264 -0
  22. wireforge/_data/runtime/include/wireforge/status.hpp +75 -0
  23. wireforge/_data/runtime/include/wireforge/transport.hpp +95 -0
  24. wireforge/_data/runtime/include/wireforge/validation.hpp +153 -0
  25. wireforge/_data/runtime/include/wireforge/version.hpp +22 -0
  26. wireforge/_data/runtime/requirements.yaml +438 -0
  27. wireforge/_data/schema/deviations.schema.json +47 -0
  28. wireforge/_data/schema/fragment.schema.json +79 -0
  29. wireforge/_data/schema/llr_ids.schema.json +25 -0
  30. wireforge/_data/schema/project.schema.json +1240 -0
  31. wireforge/_data/schema/runtime_requirements.schema.json +34 -0
  32. wireforge/_data/ui/assets/index-6Pomu6U4.js +44 -0
  33. wireforge/_data/ui/assets/index-GVdDyUAG.css +1 -0
  34. wireforge/_data/ui/index.html +13 -0
  35. wireforge/bundle.py +177 -0
  36. wireforge/ci_check.py +155 -0
  37. wireforge/cli.py +617 -0
  38. wireforge/codec.py +897 -0
  39. wireforge/compat.py +536 -0
  40. wireforge/coverage_points.py +64 -0
  41. wireforge/cpp.py +273 -0
  42. wireforge/cpp_codec.py +440 -0
  43. wireforge/cpp_interface.py +874 -0
  44. wireforge/cpp_tests.py +1993 -0
  45. wireforge/differential.py +586 -0
  46. wireforge/docs_markdown.py +72 -0
  47. wireforge/expr.py +598 -0
  48. wireforge/findings.py +187 -0
  49. wireforge/fmt.py +351 -0
  50. wireforge/generate.py +644 -0
  51. wireforge/golden.py +253 -0
  52. wireforge/hlr_import.py +112 -0
  53. wireforge/impact.py +249 -0
  54. wireforge/importers.py +402 -0
  55. wireforge/issues.py +27 -0
  56. wireforge/layout.py +681 -0
  57. wireforge/lifecycle.py +1266 -0
  58. wireforge/lifecycle_results.py +205 -0
  59. wireforge/lifecycle_rules.py +78 -0
  60. wireforge/lifecycle_runtime.py +375 -0
  61. wireforge/llr.py +693 -0
  62. wireforge/llr_ids.py +104 -0
  63. wireforge/loader.py +422 -0
  64. wireforge/mcp_server.py +357 -0
  65. wireforge/model.py +322 -0
  66. wireforge/plain.py +15 -0
  67. wireforge/py.typed +0 -0
  68. wireforge/reqif.py +289 -0
  69. wireforge/resources.py +43 -0
  70. wireforge/results.py +377 -0
  71. wireforge/runtime_reqs.py +147 -0
  72. wireforge/schema.py +151 -0
  73. wireforge/server/__init__.py +1 -0
  74. wireforge/server/app.py +619 -0
  75. wireforge/server/oidc.py +99 -0
  76. wireforge/server/storage.py +146 -0
  77. wireforge/service.py +1354 -0
  78. wireforge/templates/command/project.yaml +38 -0
  79. wireforge/templates/raw/project.yaml +28 -0
  80. wireforge/templates/telemetry/project.yaml +51 -0
  81. wireforge/test_cases.py +475 -0
  82. wireforge/test_project.py +478 -0
  83. wireforge/trace.py +442 -0
  84. wireforge/validate.py +1241 -0
  85. wireforge/vcs.py +44 -0
  86. wireforge/vectors.py +222 -0
  87. wireforge/versioning.py +137 -0
  88. wireforge/wireshark.py +792 -0
  89. wireforge/wireshark_runtime.lua +725 -0
  90. wireforge-0.1.0.dist-info/METADATA +91 -0
  91. wireforge-0.1.0.dist-info/RECORD +96 -0
  92. wireforge-0.1.0.dist-info/WHEEL +5 -0
  93. wireforge-0.1.0.dist-info/entry_points.txt +2 -0
  94. wireforge-0.1.0.dist-info/licenses/LICENSE +98 -0
  95. wireforge-0.1.0.dist-info/licenses/runtime/LICENSE +21 -0
  96. wireforge-0.1.0.dist-info/top_level.txt +1 -0
wireforge/validate.py ADDED
@@ -0,0 +1,1241 @@
1
+ """Semantic validation of a loaded project.
2
+
3
+ Every issue is tagged with its rule id (`[SR-n]`). Duplicates across files (SR-1) are reported by
4
+ the loader and version stamps (SR-19) by the structural stage. Expressions are evaluated once per
5
+ variant: each profile, or the plain defines when the project has no profiles. `reqs` lists are
6
+ checked against the `requirements` catalog (SR-21).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import dataclasses
12
+ import re
13
+ import struct
14
+ import sys
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import TYPE_CHECKING
18
+
19
+ from wireforge import expr
20
+ from wireforge.cpp import wire_only
21
+ from wireforge.expr import ExprError, Scope
22
+ from wireforge.loader import load_project
23
+ from wireforge.model import (
24
+ BUILTIN_BITS,
25
+ FLOAT_TYPES,
26
+ INTEGER_TYPES,
27
+ SIGNED_TYPES,
28
+ UNSIGNED_TYPES,
29
+ Checksum,
30
+ Field,
31
+ Interface,
32
+ Project,
33
+ Scalar,
34
+ Source,
35
+ Struct,
36
+ Value,
37
+ Vector,
38
+ )
39
+
40
+ if TYPE_CHECKING:
41
+ from collections.abc import Iterable
42
+
43
+ from wireforge.issues import Issue
44
+
45
+ BYTE_BITS = 8
46
+ MAX_GROUP_BITS = 64
47
+ MAX_CHECKSUM_BYTES = 8
48
+ FLOAT32_MAX = struct.unpack(">f", bytes.fromhex("7f7fffff"))[0]
49
+ CHECK_INPUT = b"123456789"
50
+ ROLE_TYPES_UNSIGNED = frozenset({"message_id", "payload_length", "sequence", "protocol_version"})
51
+ SINGLE_ROLES = ("message_id", "payload_length")
52
+
53
+ CPP_KEYWORDS = frozenset(
54
+ {
55
+ "alignas",
56
+ "alignof",
57
+ "and",
58
+ "and_eq",
59
+ "asm",
60
+ "auto",
61
+ "bitand",
62
+ "bitor",
63
+ "bool",
64
+ "break",
65
+ "case",
66
+ "catch",
67
+ "char",
68
+ "char8_t",
69
+ "char16_t",
70
+ "char32_t",
71
+ "class",
72
+ "compl",
73
+ "concept",
74
+ "const",
75
+ "consteval",
76
+ "constexpr",
77
+ "constinit",
78
+ "const_cast",
79
+ "continue",
80
+ "co_await",
81
+ "co_return",
82
+ "co_yield",
83
+ "decltype",
84
+ "default",
85
+ "delete",
86
+ "do",
87
+ "double",
88
+ "dynamic_cast",
89
+ "else",
90
+ "enum",
91
+ "explicit",
92
+ "export",
93
+ "extern",
94
+ "false",
95
+ "float",
96
+ "for",
97
+ "friend",
98
+ "goto",
99
+ "if",
100
+ "inline",
101
+ "int",
102
+ "long",
103
+ "mutable",
104
+ "namespace",
105
+ "new",
106
+ "noexcept",
107
+ "not",
108
+ "not_eq",
109
+ "nullptr",
110
+ "operator",
111
+ "or",
112
+ "or_eq",
113
+ "private",
114
+ "protected",
115
+ "public",
116
+ "register",
117
+ "reinterpret_cast",
118
+ "requires",
119
+ "return",
120
+ "short",
121
+ "signed",
122
+ "sizeof",
123
+ "static",
124
+ "static_assert",
125
+ "static_cast",
126
+ "struct",
127
+ "switch",
128
+ "template",
129
+ "this",
130
+ "thread_local",
131
+ "throw",
132
+ "true",
133
+ "try",
134
+ "typedef",
135
+ "typeid",
136
+ "typename",
137
+ "union",
138
+ "unsigned",
139
+ "using",
140
+ "virtual",
141
+ "void",
142
+ "volatile",
143
+ "wchar_t",
144
+ "while",
145
+ "xor",
146
+ "xor_eq",
147
+ }
148
+ )
149
+ _RESERVED_IDENTIFIER = re.compile(r"^_[A-Z]|__")
150
+
151
+ # Kinds of value expressions and what they may reference.
152
+ COUNT = "count" # occurs.count: earlier fields allowed, may be dynamic
153
+ LAYOUT = "layout" # capacities and frame sizes: static, no opaque constants
154
+ RANGE = "range" # field min/max: static, opaque constants allowed (not evaluated)
155
+
156
+
157
+ @dataclass(frozen=True)
158
+ class Variant:
159
+ """One configuration to evaluate: a profile, or the plain defines (name "")."""
160
+
161
+ name: str
162
+ scope: Scope
163
+ opaque: frozenset[str]
164
+
165
+ def label(self) -> str:
166
+ """Suffix for messages: where the problem occurs."""
167
+ return f" in profile {self.name!r}" if self.name else ""
168
+
169
+ @property
170
+ def rule(self) -> str:
171
+ """Evaluation failures are SR-16 inside profiles, SR-5 otherwise."""
172
+ return "SR-16" if self.name else "SR-5"
173
+
174
+
175
+ @dataclass(frozen=True)
176
+ class _At:
177
+ """Location of a reported problem: an element plus an optional sub-pointer."""
178
+
179
+ source: Source
180
+ suffix: str = ""
181
+
182
+ def below(self, suffix: str) -> _At:
183
+ return _At(self.source, self.suffix + suffix)
184
+
185
+
186
+ @dataclass(frozen=True)
187
+ class _Context:
188
+ """The struct (type, message, header or trailer) whose fields are being checked."""
189
+
190
+ owner: str
191
+ fields: tuple[Field, ...]
192
+ is_message: bool = False
193
+ frame_part: bool = False
194
+
195
+ def index_of(self, name: str) -> int | None:
196
+ for index, item in enumerate(self.fields):
197
+ if item.name == name:
198
+ return index
199
+ return None
200
+
201
+
202
+ def _crc(checksum: Checksum, data: bytes) -> int:
203
+ """Rocksoft-model CRC of `data` with the parameters of `checksum`."""
204
+ width = checksum.width or 0
205
+ top = 1 << (width - 1)
206
+ mask = (1 << width) - 1
207
+ register = checksum.init
208
+ for byte in data:
209
+ value = int(f"{byte:08b}"[::-1], 2) if checksum.refin else byte
210
+ for bit in range(7, -1, -1):
211
+ feedback = bool(register & top) ^ bool((value >> bit) & 1)
212
+ register = ((register << 1) & mask) ^ ((checksum.poly or 0) if feedback else 0)
213
+ if checksum.refout:
214
+ register = int(f"{register:0{width}b}"[::-1], 2)
215
+ return register ^ checksum.xorout
216
+
217
+
218
+ def _checksum_bits(checksum: Checksum) -> int:
219
+ if checksum.algo == "crc32":
220
+ return 32
221
+ if checksum.algo == "crc":
222
+ return checksum.width or 0
223
+ return (checksum.size_bytes or 0) * BYTE_BITS
224
+
225
+
226
+ def _int_bounds(type_name: str, bits: int) -> tuple[int, int]:
227
+ if type_name in SIGNED_TYPES:
228
+ return -(1 << (bits - 1)), (1 << (bits - 1)) - 1
229
+ return 0, (1 << bits) - 1
230
+
231
+
232
+ def _is_relative_path(path: str) -> bool:
233
+ parts = re.split(r"[\\/]", path)
234
+ absolute = path.startswith(("/", "\\")) or re.match(r"^[A-Za-z]:", path) is not None
235
+ return not absolute and ".." not in parts
236
+
237
+
238
+ class Validator:
239
+ """Checks one project; `run()` returns every semantic issue."""
240
+
241
+ def __init__(self, project: Project) -> None:
242
+ """Prepare variants and lookup tables."""
243
+ self.project = project
244
+ self.issues: list[Issue] = []
245
+ self.members = {
246
+ (enum.name, member): value
247
+ for enum in project.enums.values()
248
+ for member, value in enum.values.items()
249
+ }
250
+ self.variants = self._variants()
251
+ self.cyclic: set[str] = set()
252
+ self._current: _Context | None = None # struct whose fields are being checked
253
+ self._bits_cache: dict[tuple[str, str, bool], int | None] = {}
254
+
255
+ # --- infrastructure -------------------------------------------------------------------------
256
+
257
+ def report(self, at: Source | _At, rule: str, message: str, suffix: str = "") -> None:
258
+ """Record one error at an element (plus optional sub-pointer)."""
259
+ where = at if isinstance(at, _At) else _At(at)
260
+ self.issues.append(where.source.issue(rule, message, where.suffix + suffix))
261
+
262
+ def _variants(self) -> list[Variant]:
263
+ defines = {name: define.value for name, define in self.project.defines.items()}
264
+ if not self.project.profiles:
265
+ return [self._variant("", defines, None)]
266
+ return [
267
+ self._variant(name, {**defines, **profile.defines}, name)
268
+ for name, profile in self.project.profiles.items()
269
+ ]
270
+
271
+ def _variant(self, name: str, defines: dict[str, int], profile: str | None) -> Variant:
272
+ values = dict(defines)
273
+ opaque = set()
274
+ for constant in self.project.constants.values():
275
+ value = constant.profile_values.get(profile or "", constant.value)
276
+ if value is None:
277
+ opaque.add(constant.name)
278
+ else:
279
+ values[constant.name] = value
280
+ scope = Scope(values=values, members=self.members, defined=frozenset(values))
281
+ return Variant(name, scope, frozenset(opaque))
282
+
283
+ def run(self) -> list[Issue]:
284
+ """Check every rule and return the issues found."""
285
+ self._names()
286
+ self._enums()
287
+ self._resolve_types()
288
+ self._constants()
289
+ self._checksums()
290
+ self._user_types()
291
+ for item in self.project.types.values():
292
+ self._struct(_Context(f"type {item.name}", item.fields), item)
293
+ for item in self.project.messages.values():
294
+ self._struct(_Context(f"message {item.name}", item.fields, is_message=True), item)
295
+ for interface in self.project.interfaces.values():
296
+ self._interface(interface)
297
+ self._user_includes()
298
+ self._requirement_refs()
299
+ self._vectors()
300
+ return self.issues
301
+
302
+ # --- requirement references -------------------------------------------------------------------
303
+
304
+ def _requirement_refs(self) -> None:
305
+ """SR-21: every `reqs` entry names a requirement of the catalog."""
306
+ project = self.project
307
+ for owner, source, reqs, suffix in self._requirement_users():
308
+ for index, rid in enumerate(reqs):
309
+ if rid not in project.requirements:
310
+ self.report(
311
+ source,
312
+ "SR-21",
313
+ f"{owner}: unknown requirement {rid!r}",
314
+ f"{suffix}/reqs/{index}",
315
+ )
316
+
317
+ def _requirement_users(self) -> Iterable[tuple[str, Source, tuple[str, ...], str]]:
318
+ """Every element that can carry `reqs`: (description, source, ids, pointer suffix)."""
319
+ project = self.project
320
+ for enum in project.enums.values():
321
+ yield f"enum {enum.name}", enum.source, enum.reqs, ""
322
+ for checksum in project.checksums.values():
323
+ yield f"checksum {checksum.name}", checksum.source, checksum.reqs, ""
324
+ for user_type in project.user_types.values():
325
+ yield f"user type {user_type.name}", user_type.source, user_type.reqs, ""
326
+ for kind, structs in (("type", project.types), ("message", project.messages)):
327
+ for struct_ in structs.values():
328
+ yield f"{kind} {struct_.name}", struct_.source, struct_.reqs, ""
329
+ for owner, fields in self._all_field_lists():
330
+ for item in fields:
331
+ yield f"{owner}: field {item.name}", item.source, item.reqs, ""
332
+ for vector in project.vectors.values():
333
+ yield f"vector {vector.name}", vector.source, vector.reqs, ""
334
+ yield from self._interface_requirement_users()
335
+
336
+ def _interface_requirement_users(self) -> Iterable[tuple[str, Source, tuple[str, ...], str]]:
337
+ for interface in self.project.interfaces.values():
338
+ yield f"interface {interface.name}", interface.source, interface.reqs, ""
339
+ yield (
340
+ f"interface {interface.name}",
341
+ interface.source,
342
+ interface.framing.reqs,
343
+ "/framing",
344
+ )
345
+ for entry in interface.messages:
346
+ yield (
347
+ f"interface {interface.name}: message {entry.name}",
348
+ entry.source,
349
+ entry.reqs,
350
+ "",
351
+ )
352
+
353
+ # --- requirement test vectors -----------------------------------------------------------------
354
+
355
+ def _vectors(self) -> None:
356
+ """SR-22: what a vector says about itself is consistent (the codec check is `vectors`)."""
357
+ for vector in self.project.vectors.values():
358
+ for problem, suffix in self._vector_problems(vector):
359
+ self.report(vector.source, "SR-22", f"vector {vector.name}: {problem}", suffix)
360
+
361
+ def _vector_problems(self, vector: Vector) -> Iterable[tuple[str, str]]:
362
+ project = self.project
363
+ if vector.data is None:
364
+ yield "bytes is not hexadecimal", "/bytes"
365
+ message = project.messages.get(vector.message)
366
+ if message is None:
367
+ yield f"unknown message {vector.message!r}", "/message"
368
+ if vector.profile is not None and vector.profile not in project.profiles:
369
+ yield f"unknown profile {vector.profile!r}", "/profile"
370
+ if vector.interface is not None:
371
+ interface = project.interfaces.get(vector.interface)
372
+ if interface is None:
373
+ yield f"unknown interface {vector.interface!r}", "/interface"
374
+ elif message is not None and vector.message not in {m.name for m in interface.messages}:
375
+ yield f"message {vector.message} is not on interface {vector.interface}", "/message"
376
+ if vector.direction != "decode" and vector.fields is None:
377
+ yield f"direction {vector.direction} needs expect.fields", "/direction"
378
+ if message is not None and vector.fields is not None:
379
+ yield from self._vector_fields(vector, message)
380
+
381
+ def _vector_fields(self, vector: Vector, message: Struct) -> Iterable[tuple[str, str]]:
382
+ fields = vector.fields or {}
383
+ wire_only_names = wire_only(message)
384
+ names = {item.name for item in message.fields}
385
+ for name in fields:
386
+ if name not in names:
387
+ yield f"expect.fields names unknown field {name!r}", f"/expect/fields/{name}"
388
+ elif name in wire_only_names:
389
+ yield f"field {name!r} exists only on the wire", f"/expect/fields/{name}"
390
+ if vector.direction != "decode":
391
+ missing = [
392
+ item.name
393
+ for item in message.fields
394
+ if item.name not in wire_only_names
395
+ and item.condition is None
396
+ and item.name not in fields
397
+ ]
398
+ if missing:
399
+ yield f"encoding needs every field, missing {', '.join(missing)}", "/expect/fields"
400
+
401
+ # --- names ------------------------------------------------------------------------------------
402
+
403
+ def _check_identifier(self, name: str, source: Source, what: str) -> None:
404
+ if name in CPP_KEYWORDS:
405
+ self.report(source, "SR-2", f"{what} {name!r} is a C++ keyword")
406
+ elif _RESERVED_IDENTIFIER.search(name):
407
+ self.report(source, "SR-2", f"{what} {name!r} is a reserved C++ identifier")
408
+
409
+ def _names(self) -> None:
410
+ project = self.project
411
+ generated: dict[str, str] = {}
412
+ sections: list[tuple[str, Iterable[tuple[str, Source]]]] = [
413
+ ("define", ((n, d.source) for n, d in project.defines.items())),
414
+ ("enum", ((n, e.source) for n, e in project.enums.items())),
415
+ ("type", ((n, t.source) for n, t in project.types.items())),
416
+ ("user type", ((n, t.source) for n, t in project.user_types.items())),
417
+ ("message", ((n, m.source) for n, m in project.messages.items())),
418
+ ("interface", ((n, i.source) for n, i in project.interfaces.items())),
419
+ ("checksum", ((n, c.source) for n, c in project.checksums.items())),
420
+ ("constant", ((n, c.source) for n, c in project.constants.items())),
421
+ ]
422
+ for what, entries in sections:
423
+ for name, source in entries:
424
+ self._check_identifier(name, source, what)
425
+ if what in {"constant", "checksum"}:
426
+ continue
427
+ if name in generated:
428
+ message = f"{what} {name!r} collides with {generated[name]} {name!r} in C++"
429
+ self.report(source, "SR-2", message)
430
+ generated.setdefault(name, what)
431
+ for enum in project.enums.values():
432
+ for member in enum.values:
433
+ self._check_identifier(member, enum.source, f"member of enum {enum.name}")
434
+
435
+ # --- enums ------------------------------------------------------------------------------------
436
+
437
+ def _enums(self) -> None:
438
+ for enum in self.project.enums.values():
439
+ low, high = _int_bounds(enum.type, BUILTIN_BITS[enum.type])
440
+ seen: dict[int, str] = {}
441
+ for member, value in enum.values.items():
442
+ if not low <= value <= high:
443
+ message = f"enum {enum.name}.{member} = {value} does not fit {enum.type}"
444
+ self.report(enum.source, "SR-15", message, f"/values/{member}")
445
+ if value in seen:
446
+ message = f"enum {enum.name}: {member} and {seen[value]} share value {value}"
447
+ self.report(enum.source, "SR-15", message, f"/values/{member}")
448
+ seen.setdefault(value, member)
449
+
450
+ # --- types ------------------------------------------------------------------------------------
451
+
452
+ def _all_field_lists(self) -> Iterable[tuple[str, tuple[Field, ...]]]:
453
+ for item in self.project.types.values():
454
+ yield f"type {item.name}", item.fields
455
+ for item in self.project.messages.values():
456
+ yield f"message {item.name}", item.fields
457
+ for interface in self.project.interfaces.values():
458
+ yield f"header of interface {interface.name}", interface.header or ()
459
+ yield f"trailer of interface {interface.name}", interface.trailer
460
+
461
+ def _resolve_types(self) -> None:
462
+ known = (
463
+ set(BUILTIN_BITS)
464
+ | set(self.project.enums)
465
+ | set(self.project.types)
466
+ | set(self.project.user_types)
467
+ )
468
+ for owner, fields in self._all_field_lists():
469
+ for item in fields:
470
+ if item.type in known:
471
+ continue
472
+ if item.type in self.project.messages:
473
+ message = f"{owner}: field {item.name} uses message {item.type} as a type; "
474
+ message += "move the shared fields to `types`"
475
+ else:
476
+ message = f"{owner}: field {item.name} has unknown type {item.type!r}"
477
+ self.report(item.source, "SR-3", message, "/type")
478
+ self._find_cycles()
479
+
480
+ def _find_cycles(self) -> None:
481
+ done: set[str] = set()
482
+ for name in self.project.types:
483
+ self._visit(name, [], done)
484
+
485
+ def _visit(self, name: str, path: list[str], done: set[str]) -> None:
486
+ if name in path:
487
+ cycle = [*path[path.index(name) :], name]
488
+ self.cyclic.update(cycle)
489
+ message = f"type {name} contains itself: {' -> '.join(cycle)}"
490
+ self.report(self.project.types[name].source, "SR-3", message)
491
+ return
492
+ if name in done:
493
+ return
494
+ for inner in self.project.types[name].fields:
495
+ if inner.type in self.project.types:
496
+ self._visit(inner.type, [*path, name], done)
497
+ done.add(name)
498
+
499
+ # --- user constants ---------------------------------------------------------------------------
500
+
501
+ def _constants(self) -> None:
502
+ field_names = {item.name for _, fields in self._all_field_lists() for item in fields}
503
+ profile_defines = {name for p in self.project.profiles.values() for name in p.defines}
504
+ for constant in self.project.constants.values():
505
+ source = constant.source
506
+ if constant.name in self.project.defines or constant.name in profile_defines:
507
+ self.report(source, "SR-18", f"constant {constant.name} collides with a define")
508
+ if constant.name in field_names:
509
+ message = f"constant {constant.name} collides with a field of the same name"
510
+ self.report(source, "SR-18", message)
511
+ for profile in constant.profile_values:
512
+ if profile not in self.project.profiles:
513
+ message = f"constant {constant.name}: unknown profile {profile!r}"
514
+ self.report(source, "SR-18", message, f"/profile_values/{profile}")
515
+ given = set(constant.profile_values)
516
+ partial = given and given != set(self.project.profiles)
517
+ if constant.value is None and partial:
518
+ message = (
519
+ f"constant {constant.name} has no value and values for only some profiles; "
520
+ "give `value` or a value for every profile"
521
+ )
522
+ self.report(source, "SR-18", message)
523
+
524
+ # --- checksum declarations --------------------------------------------------------------------
525
+
526
+ def _checksums(self) -> None:
527
+ for checksum in self.project.checksums.values():
528
+ if checksum.algo == "crc":
529
+ self._crc_parameters(checksum)
530
+ if checksum.algo == "custom":
531
+ self._custom_checksum(checksum)
532
+
533
+ def _crc_parameters(self, checksum: Checksum) -> None:
534
+ width = checksum.width or 0
535
+ limit = 1 << width
536
+ for key in ("poly", "init", "xorout"):
537
+ value = getattr(checksum, key)
538
+ if value is not None and value >= limit:
539
+ message = f"checksum {checksum.name}: {key} {value:#x} does not fit width {width}"
540
+ self.report(checksum.source, "SR-12", message, f"/{key}")
541
+ if checksum.check is not None and checksum.poly is not None:
542
+ actual = _crc(checksum, CHECK_INPUT)
543
+ if actual != checksum.check:
544
+ message = (
545
+ f"checksum {checksum.name}: check value {checksum.check:#x} does not match "
546
+ f"the computed {actual:#x} for '123456789'"
547
+ )
548
+ self.report(checksum.source, "SR-12", message, "/check")
549
+
550
+ def _custom_checksum(self, checksum: Checksum) -> None:
551
+ if (checksum.size_bytes or 0) > MAX_CHECKSUM_BYTES:
552
+ message = f"checksum {checksum.name}: size_bytes must be 1..8 (runtime contract)"
553
+ self.report(checksum.source, "SR-12", message, "/size_bytes")
554
+ if checksum.include is not None and not _is_relative_path(checksum.include):
555
+ message = f"checksum {checksum.name}: include {checksum.include!r} must be relative"
556
+ self.report(checksum.source, "SR-20", message + " without '..'", "/include")
557
+
558
+ # --- user types -------------------------------------------------------------------------------
559
+
560
+ def _user_types(self) -> None:
561
+ for item in self.project.user_types.values():
562
+ if item.size_bytes <= 0:
563
+ self.report(
564
+ item.source,
565
+ "SR-6",
566
+ f"user type {item.name}: size_bytes must be positive",
567
+ "/size_bytes",
568
+ )
569
+ if item.include is not None and not _is_relative_path(item.include):
570
+ message = f"user type {item.name}: include {item.include!r} must be relative"
571
+ self.report(item.source, "SR-20", message + " without '..'", "/include")
572
+
573
+ # --- expressions ------------------------------------------------------------------------------
574
+
575
+ def _parse(self, raw: Value, at: _At) -> expr.Node | None:
576
+ if isinstance(raw, float):
577
+ self.report(at, "SR-5", f"invalid expression {raw!r}: not an integer")
578
+ return None
579
+ try:
580
+ return expr.as_expression(raw)
581
+ except ExprError as error:
582
+ self.report(at, "SR-5", f"invalid expression {raw!r}: {error}")
583
+ return None
584
+
585
+ def _profile_define(self, name: str) -> bool:
586
+ return any(name in profile.defines for profile in self.project.profiles.values())
587
+
588
+ def _constant_use(self, name: str, at: _At, kind: str) -> None:
589
+ if kind == LAYOUT and any(name in variant.opaque for variant in self.variants):
590
+ message = (
591
+ f"constant {name} has no value (opaque) and may only be used in min/max, "
592
+ "not in sizes or counts"
593
+ )
594
+ self.report(at, "SR-18", message)
595
+
596
+ def _names_resolve(self, node: expr.Node, at: _At, kind: str, context: _Context | None) -> bool:
597
+ """Check every name; returns False when the expression refers to fields (not static)."""
598
+ static = True
599
+ for name in sorted(expr.referenced_names(node)):
600
+ if context is not None and context.index_of(name) is not None:
601
+ static = False
602
+ if kind != COUNT:
603
+ self.report(
604
+ at, "SR-5", f"field {name!r} may only be referenced by occurs.count"
605
+ )
606
+ elif name in self.project.constants:
607
+ self._constant_use(name, at, kind)
608
+ elif name not in self.project.defines and not self._profile_define(name):
609
+ self.report(at, "SR-5", f"unknown name {name!r}")
610
+ for enum, member in sorted(expr.referenced_members(node)):
611
+ if (enum, member) not in self.members:
612
+ self.report(at, "SR-5", f"unknown enum member {enum}.{member}")
613
+ for type_name in sorted(expr.call_arguments(node, "sizeof")):
614
+ known_sizeof = type_name in self.project.types or type_name in BUILTIN_BITS
615
+ known_sizeof = known_sizeof or type_name in self.project.user_types
616
+ if not known_sizeof:
617
+ self.report(at, "SR-5", f"sizeof: unknown type {type_name!r}")
618
+ return static
619
+
620
+ def _scope_with_sizes(self, node: expr.Node, variant: Variant) -> Scope | None:
621
+ sizes = {}
622
+ for type_name in expr.call_arguments(node, "sizeof"):
623
+ bits = self._type_bits(type_name, variant, maximum=False)
624
+ if bits is None or bits % BYTE_BITS:
625
+ return None
626
+ sizes[type_name] = bits // BYTE_BITS
627
+ return dataclasses.replace(variant.scope, sizes=sizes) if sizes else variant.scope
628
+
629
+ def _evaluate(
630
+ self, raw: Value, at: _At, kind: str, context: _Context | None = None
631
+ ) -> dict[str, int] | None:
632
+ """Values per variant, or None when the expression is invalid or not static."""
633
+ node = self._parse(raw, at)
634
+ if node is None or not self._names_resolve(node, at, kind, context):
635
+ return None
636
+ names = expr.referenced_names(node)
637
+ results: dict[str, int] = {}
638
+ for variant in self.variants:
639
+ if names & variant.opaque:
640
+ continue
641
+ scope = self._scope_with_sizes(node, variant)
642
+ if scope is None:
643
+ self.report(at, "SR-5", "sizeof() needs a fixed-size type" + variant.label())
644
+ return None
645
+ try:
646
+ results[variant.name] = expr.evaluate(node, scope)
647
+ except ExprError as error:
648
+ self.report(at, variant.rule, f"cannot evaluate {raw!r}{variant.label()}: {error}")
649
+ return None
650
+ return results
651
+
652
+ def static_value(self, raw: Value, variant: Variant) -> int | None:
653
+ """Silent evaluation of a layout expression (problems are reported elsewhere)."""
654
+ try:
655
+ node = expr.as_expression(raw) if not isinstance(raw, float) else None
656
+ scope = None if node is None else self._scope_with_sizes(node, variant)
657
+ return None if node is None or scope is None else expr.evaluate(node, scope)
658
+ except ExprError:
659
+ return None
660
+
661
+ def _condition(self, raw: str | None, at: _At, context: _Context | None = None) -> None:
662
+ if raw is None:
663
+ return
664
+ try:
665
+ node = expr.parse_condition(raw)
666
+ except ExprError as error:
667
+ self.report(at, "SR-5", f"invalid if: condition {raw!r}: {error}")
668
+ return
669
+ for name in sorted(expr.referenced_names(node)):
670
+ if context is not None and context.index_of(name) is not None:
671
+ self.report(at, "SR-5", f"if: conditions must not reference fields ({name!r})")
672
+ elif name in self.project.constants:
673
+ message = f"constant {name} is a C++ symbol, not a macro; it cannot appear in if:"
674
+ self.report(at, "SR-18", message)
675
+ elif name not in self.project.defines:
676
+ message = f"if: may name only defines; use defined({name}) for profile macros"
677
+ self.report(at, "SR-5", message)
678
+
679
+ # --- sizes ----------------------------------------------------------------------------------
680
+
681
+ def _type_bits(self, type_name: str, variant: Variant, *, maximum: bool) -> int | None:
682
+ """Wire size in bits of one element; with maximum=True dynamic arrays count at capacity."""
683
+ if type_name in BUILTIN_BITS:
684
+ return BUILTIN_BITS[type_name]
685
+ if type_name in self.project.enums:
686
+ return BUILTIN_BITS[self.project.enums[type_name].type]
687
+ if type_name in self.project.user_types:
688
+ return self.project.user_types[type_name].size_bytes * BYTE_BITS
689
+ item = self.project.types.get(type_name)
690
+ if item is None or type_name in self.cyclic:
691
+ return None
692
+ key = (type_name, variant.name, maximum)
693
+ if key not in self._bits_cache:
694
+ self._bits_cache[key] = self._fields_bits(item.fields, variant, maximum=maximum)
695
+ return self._bits_cache[key]
696
+
697
+ def present(self, item: Field, variant: Variant) -> bool:
698
+ """Return whether the field's `if:` condition holds in `variant` (true when unknown)."""
699
+ if item.condition is None:
700
+ return True
701
+ try:
702
+ return expr.evaluate(expr.parse_condition(item.condition), variant.scope) != 0
703
+ except ExprError:
704
+ return True
705
+
706
+ def _fields_bits(
707
+ self, fields: tuple[Field, ...], variant: Variant, *, maximum: bool
708
+ ) -> int | None:
709
+ total = 0
710
+ for item in fields:
711
+ if not self.present(item, variant):
712
+ continue
713
+ bits = self._field_bits(item, variant, maximum=maximum)
714
+ if bits is None:
715
+ return None
716
+ total += bits
717
+ return total
718
+
719
+ def _field_bits(self, item: Field, variant: Variant, *, maximum: bool) -> int | None:
720
+ element = item.bits or self._type_bits(item.type, variant, maximum=maximum)
721
+ if element is None or item.occurs is None:
722
+ return element
723
+ dynamic = item.occurs.count is not None or item.occurs.fill
724
+ if dynamic and not maximum:
725
+ return None
726
+ count = self.static_value(item.occurs.max, variant)
727
+ return None if count is None else element * count
728
+
729
+ # --- structs --------------------------------------------------------------------------------
730
+
731
+ def _struct(self, context: _Context, item: Struct | None = None) -> None:
732
+ if item is not None:
733
+ self._condition(item.condition, _At(item.source, "/if"))
734
+ seen: set[str] = set()
735
+ self._current = context
736
+ for index, field in enumerate(context.fields):
737
+ at = _At(field.source)
738
+ self._check_identifier(field.name, field.source, f"{context.owner}: field")
739
+ if field.name in seen:
740
+ self.report(at, "SR-2", f"{context.owner}: duplicate field {field.name!r}")
741
+ seen.add(field.name)
742
+ self._field_bits_rules(field, at)
743
+ self._field_occurs(field, index, context)
744
+ self._field_role(field, context)
745
+ self._field_checksum(field, index, context)
746
+ self._field_values(field, at)
747
+ self._condition(field.condition, at.below("/if"), context)
748
+ pure_bits_type = item is not None and item.message is None and self._pure_bits(item)
749
+ self._bit_groups(context, check_total=not pure_bits_type)
750
+
751
+ def _pure_bits(self, item: Struct) -> bool:
752
+ return bool(item.fields) and all(field.bits is not None for field in item.fields)
753
+
754
+ def _field_bits_rules(self, field: Field, at: _At) -> None:
755
+ if field.bits is None:
756
+ return
757
+ base = (
758
+ self.project.enums[field.type].type if field.type in self.project.enums else field.type
759
+ )
760
+ if base not in INTEGER_TYPES and base != "bool":
761
+ message = f"bits is only allowed on integer, bool and enum fields ({field.name})"
762
+ self.report(at, "SR-10", message, "/bits")
763
+ elif field.bits > BUILTIN_BITS[base]:
764
+ message = f"field {field.name}: bits {field.bits} exceeds the width of {base}"
765
+ self.report(at, "SR-10", message, "/bits")
766
+ if field.occurs is not None:
767
+ self.report(at, "SR-11", f"field {field.name}: occurs cannot be combined with bits")
768
+
769
+ def _group_bits(self, field: Field) -> int | None:
770
+ """Bits contributed to a bit group, or None when the field is byte-oriented."""
771
+ if field.bits is not None:
772
+ return field.bits if field.occurs is None else None
773
+ item = self.project.types.get(field.type)
774
+ if item is None or field.occurs is not None or not self._pure_bits(item):
775
+ return None
776
+ return sum(inner.bits or 0 for inner in item.fields)
777
+
778
+ def _bit_groups(self, context: _Context, *, check_total: bool) -> None:
779
+ group: list[Field] = []
780
+ for field in [*context.fields, None]:
781
+ bits = None if field is None else self._group_bits(field)
782
+ if bits is not None and field is not None:
783
+ group.append(field)
784
+ continue
785
+ if group:
786
+ self._close_group(context, group, check_total=check_total)
787
+ group = []
788
+
789
+ def _close_group(self, context: _Context, group: list[Field], *, check_total: bool) -> None:
790
+ total = sum(self._group_bits(field) or 0 for field in group)
791
+ first = group[0]
792
+ if check_total and total % BYTE_BITS:
793
+ message = (
794
+ f"{context.owner}: bit group starting at {first.name} totals {total} bits; "
795
+ "add reserved fields up to a multiple of 8"
796
+ )
797
+ self.report(first.source, "SR-10", message)
798
+ if total > MAX_GROUP_BITS:
799
+ message = f"{context.owner}: bit group starting at {first.name} exceeds 64 bits"
800
+ self.report(first.source, "SR-10", message)
801
+ for field in group:
802
+ if field.condition is not None:
803
+ message = (
804
+ f"{context.owner}: conditional field {field.name} inside a bit group would "
805
+ "change the bit layout per profile; make the whole group conditional"
806
+ )
807
+ self.report(field.source, "SR-17", message, "/if")
808
+
809
+ def _field_occurs(self, field: Field, index: int, context: _Context) -> None:
810
+ occurs = field.occurs
811
+ if occurs is None:
812
+ return
813
+ at = _At(field.source, "/occurs")
814
+ maxima = self._evaluate(occurs.max, at.below("/max"), LAYOUT) or {}
815
+ minima = {} if occurs.min is None else self._evaluate(occurs.min, at.below("/min"), LAYOUT)
816
+ for variant in self.variants:
817
+ high = maxima.get(variant.name)
818
+ low = (minima or {}).get(variant.name, 0)
819
+ if high is not None and high <= 0:
820
+ message = f"field {field.name}: occurs.max must be positive{variant.label()}"
821
+ self.report(at, "SR-8", message, "/max")
822
+ if high is not None and not 0 <= low <= high:
823
+ message = f"field {field.name}: occurs.min must lie in 0..max{variant.label()}"
824
+ self.report(at, "SR-8", message, "/min")
825
+ if occurs.count is not None:
826
+ self._evaluate(occurs.count, at.below("/count"), COUNT, context)
827
+ self._count_references(field, index, context, at.below("/count"))
828
+ if occurs.fill:
829
+ self._fill(field, index, context, at)
830
+
831
+ def _count_references(self, field: Field, index: int, context: _Context, at: _At) -> None:
832
+ count = field.occurs.count if field.occurs is not None else None
833
+ try:
834
+ node = None if count is None or isinstance(count, float) else expr.as_expression(count)
835
+ except ExprError:
836
+ node = None # already reported by _evaluate
837
+ for name in sorted(expr.referenced_names(node) if node is not None else ()):
838
+ position = context.index_of(name)
839
+ if position is None:
840
+ continue
841
+ target = context.fields[position]
842
+ base = self.project.enums.get(target.type)
843
+ scalar = target.type in INTEGER_TYPES or target.type == "bool" or base is not None
844
+ if position >= index:
845
+ message = f"count field {name!r} must precede {field.name}"
846
+ self.report(at, "SR-7", message)
847
+ elif not scalar or target.occurs is not None:
848
+ message = f"count field {name!r} must be a single integer or bool"
849
+ self.report(at, "SR-7", message)
850
+
851
+ def _fill(self, field: Field, index: int, context: _Context, at: _At) -> None:
852
+ if not context.is_message or index != len(context.fields) - 1:
853
+ message = f"field {field.name}: fill is only allowed on the last field of a message"
854
+ self.report(at, "SR-8", message, "/fill")
855
+ return
856
+ for variant in self.variants:
857
+ element = field.bits or self._type_bits(field.type, variant, maximum=False)
858
+ if element is None:
859
+ message = f"field {field.name}: fill needs a constant element size{variant.label()}"
860
+ self.report(at, "SR-8", message, "/fill")
861
+ return
862
+
863
+ def _field_role(self, field: Field, context: _Context) -> None:
864
+ if field.role is not None and not context.frame_part:
865
+ message = f"{context.owner}: role {field.role!r} is only allowed in headers/trailers"
866
+ self.report(field.source, "SR-13", message, "/role")
867
+
868
+ # --- checksum fields --------------------------------------------------------------------------
869
+
870
+ def _field_checksum(self, field: Field, index: int, context: _Context) -> None:
871
+ use = field.checksum
872
+ if use is None:
873
+ return
874
+ at = _At(field.source, "/checksum")
875
+ algo = self.project.checksums.get(use.use)
876
+ width = field.bits or BUILTIN_BITS.get(field.type, 0)
877
+ if algo is None:
878
+ self.report(at, "SR-4", f"field {field.name}: unknown checksum {use.use!r}", "/use")
879
+ elif field.type not in UNSIGNED_TYPES or field.occurs is not None:
880
+ message = f"checksum field {field.name} must be a single unsigned integer"
881
+ self.report(at, "SR-12", message)
882
+ elif width != _checksum_bits(algo):
883
+ message = (
884
+ f"checksum field {field.name} has {width} bits but {algo.name} produces "
885
+ f"{_checksum_bits(algo)}"
886
+ )
887
+ self.report(at, "SR-12", message)
888
+ if use.scope in {"payload", "header_and_payload"} and not context.frame_part:
889
+ message = f"covers scope {use.scope!r} is only allowed in headers/trailers"
890
+ self.report(at, "SR-12", message, "/covers/scope")
891
+ if use.first is not None and use.last is not None:
892
+ self._covers_range(field, index, context, at.below("/covers"))
893
+
894
+ def _covers_range(self, field: Field, index: int, context: _Context, at: _At) -> None:
895
+ use = field.checksum
896
+ if use is None or use.first is None or use.last is None:
897
+ return
898
+ first = context.index_of(use.first)
899
+ last = context.index_of(use.last)
900
+ for name, position in ((use.first, first), (use.last, last)):
901
+ if position is None:
902
+ self.report(at, "SR-4", f"covers names unknown field {name!r}")
903
+ if first is not None and last is not None and not first <= last < index:
904
+ message = (
905
+ f"covers {use.first}..{use.last} must be ordered and precede the checksum "
906
+ f"field {field.name}"
907
+ )
908
+ self.report(at, "SR-12", message)
909
+
910
+ # --- values and ranges ------------------------------------------------------------------------
911
+
912
+ def _value_kind(self, field: Field) -> str:
913
+ if field.type in self.project.enums:
914
+ return "enum"
915
+ if field.type in FLOAT_TYPES:
916
+ return "float"
917
+ if field.type in INTEGER_TYPES:
918
+ return "int"
919
+ if field.type in {"bool", "char"}:
920
+ return field.type
921
+ return "struct"
922
+
923
+ def _type_range(self, field: Field, kind: str) -> tuple[float, float]:
924
+ if kind == "float":
925
+ limit = FLOAT32_MAX if field.type == "f32" else sys.float_info.max
926
+ return -limit, limit
927
+ if kind in {"bool", "char"}:
928
+ return 0, 1 if kind == "bool" else 255
929
+ base = self.project.enums[field.type].type if kind == "enum" else field.type
930
+ return _int_bounds(base, field.bits or BUILTIN_BITS[base])
931
+
932
+ def _field_values(self, field: Field, at: _At) -> None:
933
+ kind = self._value_kind(field)
934
+ given = [
935
+ key for key in ("min", "max", "default", "const") if getattr(field, key) is not None
936
+ ]
937
+ if kind == "struct":
938
+ for key in given:
939
+ self.report(at, "SR-6", f"field {field.name}: {key} needs a scalar type", f"/{key}")
940
+ return
941
+ self._check_enum_start(field, kind, at)
942
+ type_range = self._type_range(field, kind)
943
+ lows = self._bound(field, "min", kind, at)
944
+ highs = self._bound(field, "max", kind, at)
945
+ self._check_bounds(field, at, (lows, highs), type_range)
946
+ limits = (lows, highs, type_range)
947
+ if field.default is not None:
948
+ self._check_default(field, at.below("/default"), kind, limits)
949
+ if field.const is not None:
950
+ if field.occurs is not None:
951
+ self.report(at, "SR-6", f"field {field.name}: const cannot have occurs", "/const")
952
+ self._check_scalar(field, field.const, at.below("/const"), kind, limits)
953
+
954
+ def _check_enum_start(self, field: Field, kind: str, at: _At) -> None:
955
+ """SR-23: a field starts as zero, so an enum field without a default needs a member 0."""
956
+ dynamic = field.occurs is not None and (field.occurs.count is not None or field.occurs.fill)
957
+ if kind != "enum" or field.default is not None or field.const is not None or dynamic:
958
+ return
959
+ if 0 not in self.project.enums[field.type].values.values():
960
+ message = (
961
+ f"field {field.name}: enum {field.type} has no member with value 0 and the field "
962
+ "has no default, so init() would start it with a value that is not a member"
963
+ )
964
+ self.report(at, "SR-23", message)
965
+
966
+ def _bound(self, field: Field, key: str, kind: str, at: _At) -> dict[str, float]:
967
+ raw = getattr(field, key)
968
+ if raw is None:
969
+ return {}
970
+ if isinstance(raw, float) and kind != "float":
971
+ message = f"field {field.name}: {key} {raw} must be an integer for type {field.type}"
972
+ self.report(at, "SR-6", message, f"/{key}")
973
+ return {}
974
+ if isinstance(raw, (int, float)):
975
+ return {variant.name: raw for variant in self.variants}
976
+ return dict(self._evaluate(raw, at.below(f"/{key}"), RANGE, self._current) or {})
977
+
978
+ def _check_bounds(
979
+ self,
980
+ field: Field,
981
+ at: _At,
982
+ bounds: tuple[dict[str, float], dict[str, float]],
983
+ type_range: tuple[float, float],
984
+ ) -> None:
985
+ lows, highs = bounds
986
+ for variant in self.variants:
987
+ low = lows.get(variant.name)
988
+ high = highs.get(variant.name)
989
+ if low is not None and high is not None and low > high:
990
+ message = f"field {field.name}: min {low} > max {high}{variant.label()}"
991
+ self.report(at, "SR-6", message, "/min")
992
+ for key, value in (("min", low), ("max", high)):
993
+ if value is not None and not type_range[0] <= value <= type_range[1]:
994
+ message = (
995
+ f"field {field.name}: {key} {value} is outside what {field.type}"
996
+ f"{f' with {field.bits} bits' if field.bits else ''} can hold"
997
+ )
998
+ self.report(at, "SR-6", message, f"/{key}")
999
+
1000
+ def _check_default(
1001
+ self,
1002
+ field: Field,
1003
+ at: _At,
1004
+ kind: str,
1005
+ limits: tuple[dict[str, float], dict[str, float], tuple[float, float]],
1006
+ ) -> None:
1007
+ default = field.default
1008
+ text = isinstance(default, str) and kind == "char" and field.occurs is not None
1009
+ if not isinstance(default, list) and not text:
1010
+ self._check_scalar(field, default, at, kind, limits)
1011
+ return
1012
+ if field.occurs is None:
1013
+ self.report(at, "SR-6", f"field {field.name}: a list default needs occurs")
1014
+ return
1015
+ elements: list[Scalar] = list(default) if isinstance(default, (list, str)) else []
1016
+ for variant in self.variants:
1017
+ capacity = self.static_value(field.occurs.max, variant)
1018
+ if capacity is not None and len(elements) > capacity:
1019
+ message = (
1020
+ f"field {field.name}: default has {len(elements)} elements, "
1021
+ f"max is {capacity}{variant.label()}"
1022
+ )
1023
+ self.report(at, "SR-6", message)
1024
+ for position, element in enumerate(elements):
1025
+ self._check_scalar(field, element, at.below(f"/{position}"), kind, limits)
1026
+
1027
+ def _scalar_number(
1028
+ self, field: Field, value: Scalar | list[Scalar] | None, kind: str, at: _At
1029
+ ) -> float | None:
1030
+ number: float | None = None
1031
+ if kind == "enum" and isinstance(value, str):
1032
+ number = self.project.enums[field.type].values.get(value)
1033
+ if number is None:
1034
+ self.report(
1035
+ at, "SR-4", f"field {field.name}: {value!r} is not a member of {field.type}"
1036
+ )
1037
+ return None
1038
+ elif kind == "enum" and isinstance(value, int) and not isinstance(value, bool):
1039
+ if value in self.project.enums[field.type].values.values():
1040
+ number = value
1041
+ elif kind == "bool" and isinstance(value, bool):
1042
+ number = int(value)
1043
+ elif kind == "char" and isinstance(value, str) and len(value) == 1:
1044
+ number = ord(value)
1045
+ elif (kind in {"char", "int"} and isinstance(value, int)) or (
1046
+ kind == "float" and isinstance(value, (int, float))
1047
+ ):
1048
+ number = None if isinstance(value, bool) else value
1049
+ if number is None:
1050
+ self.report(
1051
+ at, "SR-6", f"field {field.name}: {value!r} is not a valid {field.type} value"
1052
+ )
1053
+ return number
1054
+
1055
+ def _check_scalar(
1056
+ self,
1057
+ field: Field,
1058
+ value: Scalar | list[Scalar] | None,
1059
+ at: _At,
1060
+ kind: str,
1061
+ limits: tuple[dict[str, float], dict[str, float], tuple[float, float]],
1062
+ ) -> None:
1063
+ number = self._scalar_number(field, value, kind, at)
1064
+ if number is None:
1065
+ return
1066
+ lows, highs, type_range = limits
1067
+ if not type_range[0] <= number <= type_range[1]:
1068
+ message = f"field {field.name}: {value!r} does not fit {field.type}"
1069
+ self.report(at, "SR-6", message)
1070
+ return
1071
+ for variant in self.variants:
1072
+ low = lows.get(variant.name, type_range[0])
1073
+ high = highs.get(variant.name, type_range[1])
1074
+ if not low <= number <= high:
1075
+ message = (
1076
+ f"field {field.name}: {value!r} is outside [{low}, {high}]{variant.label()}"
1077
+ )
1078
+ self.report(at, "SR-6", message)
1079
+ return
1080
+
1081
+ # --- interfaces -------------------------------------------------------------------------------
1082
+
1083
+ def _interface(self, interface: Interface) -> None:
1084
+ if interface.header is not None:
1085
+ owner = f"header of interface {interface.name}"
1086
+ self._struct(_Context(owner, interface.header, frame_part=True))
1087
+ self._header_roles(interface)
1088
+ if interface.trailer:
1089
+ owner = f"trailer of interface {interface.name}"
1090
+ self._struct(_Context(owner, interface.trailer, frame_part=True))
1091
+ self._framing(interface)
1092
+ self._interface_messages(interface)
1093
+ self._frame_sizes(interface)
1094
+
1095
+ def _header_roles(self, interface: Interface) -> None:
1096
+ header = interface.header or ()
1097
+ for role in SINGLE_ROLES:
1098
+ holders = [field for field in header if field.role == role]
1099
+ for extra in holders[1:]:
1100
+ message = f"interface {interface.name}: role {role} appears more than once"
1101
+ self.report(extra.source, "SR-13", message, "/role")
1102
+ for field in header:
1103
+ if field.role == "sync" and field.const is None:
1104
+ message = f"interface {interface.name}: sync field {field.name} needs a const value"
1105
+ self.report(field.source, "SR-13", message)
1106
+ if field.role in ROLE_TYPES_UNSIGNED and field.type not in UNSIGNED_TYPES:
1107
+ message = f"interface {interface.name}: {field.role} field must be unsigned"
1108
+ self.report(field.source, "SR-13", message, "/type")
1109
+
1110
+ def _framing(self, interface: Interface) -> None:
1111
+ at = _At(interface.source)
1112
+ roles = {field.role for field in interface.header or ()}
1113
+ needed = {"sync_length": {"sync", "payload_length"}, "length_prefixed": {"payload_length"}}
1114
+ missing = needed.get(interface.framing.kind, set()) - roles
1115
+ if missing:
1116
+ message = (
1117
+ f"interface {interface.name}: framing {interface.framing.kind} needs header "
1118
+ f"fields with role {', '.join(sorted(missing))}"
1119
+ )
1120
+ self.report(at, "SR-14", message, "/framing/kind")
1121
+ if len(interface.messages) > 1 and "message_id" not in roles:
1122
+ message = (
1123
+ f"interface {interface.name} carries {len(interface.messages)} messages and "
1124
+ "needs a header field with role message_id (raw interfaces carry at most one)"
1125
+ )
1126
+ self.report(at, "SR-14", message, "/messages")
1127
+ sizes = [("framing/frame_size", interface.framing.frame_size)]
1128
+ sizes.append(("framing/max_frame_size", interface.framing.max_frame_size))
1129
+ sizes += [(f"buffers/{key}", value) for key, value in interface.buffers.items()]
1130
+ for path, raw in sizes:
1131
+ if raw is None:
1132
+ continue
1133
+ for name, value in (self._evaluate(raw, at.below(f"/{path}"), LAYOUT) or {}).items():
1134
+ if value <= 0:
1135
+ label = f" in profile {name!r}" if name else ""
1136
+ self.report(at, "SR-16", f"{path} must be positive{label}", f"/{path}")
1137
+
1138
+ def _message_ids(self, interface: Interface) -> list[tuple[int, str, Source]]:
1139
+ ids = []
1140
+ for entry in interface.messages:
1141
+ message = self.project.messages.get(entry.name)
1142
+ if message is None:
1143
+ continue
1144
+ declared = message.message.id if message.message is not None else None
1145
+ identifier = entry.id if entry.id is not None else declared
1146
+ if identifier is None:
1147
+ text = f"message {entry.name} has no id on interface {interface.name}"
1148
+ self.report(entry.source, "SR-9", text)
1149
+ else:
1150
+ ids.append((identifier, entry.name, entry.source))
1151
+ return ids
1152
+
1153
+ def _interface_messages(self, interface: Interface) -> None:
1154
+ seen: set[str] = set()
1155
+ for entry in interface.messages:
1156
+ if entry.name not in self.project.messages:
1157
+ self.report(entry.source, "SR-4", f"unknown message {entry.name!r}")
1158
+ if entry.name in seen:
1159
+ self.report(entry.source, "SR-9", f"message {entry.name} is listed twice")
1160
+ seen.add(entry.name)
1161
+ self._condition(entry.condition, _At(entry.source, "/if"))
1162
+ id_field = next((f for f in interface.header or () if f.role == "message_id"), None)
1163
+ if id_field is None:
1164
+ return
1165
+ width = id_field.bits or BUILTIN_BITS.get(id_field.type, 0)
1166
+ owners: dict[int, str] = {}
1167
+ for identifier, name, source in self._message_ids(interface):
1168
+ if identifier >= 1 << width:
1169
+ self.report(
1170
+ source, "SR-9", f"id {identifier:#x} of {name} does not fit {width} bits"
1171
+ )
1172
+ if identifier in owners:
1173
+ text = f"id {identifier:#x} of {name} is already used by {owners[identifier]}"
1174
+ self.report(source, "SR-9", text)
1175
+ owners.setdefault(identifier, name)
1176
+
1177
+ def _frame_sizes(self, interface: Interface) -> None:
1178
+ framing = interface.framing
1179
+ limit_raw = framing.frame_size if framing.kind == "fixed" else framing.max_frame_size
1180
+ if limit_raw is None:
1181
+ return
1182
+ for variant in self.variants:
1183
+ limit = self.static_value(limit_raw, variant)
1184
+ frame = self._fields_bits(
1185
+ (*(interface.header or ()), *interface.trailer), variant, maximum=True
1186
+ )
1187
+ if limit is None or frame is None:
1188
+ continue
1189
+ for entry in interface.messages:
1190
+ message = self.project.messages.get(entry.name)
1191
+ payload = (
1192
+ None
1193
+ if message is None
1194
+ else self._fields_bits(message.fields, variant, maximum=True)
1195
+ )
1196
+ if payload is not None and (frame + payload) // BYTE_BITS > limit:
1197
+ text = (
1198
+ f"interface {interface.name}: message {entry.name} needs up to "
1199
+ f"{(frame + payload) // BYTE_BITS} bytes per frame, more than {limit}"
1200
+ f"{variant.label()}"
1201
+ )
1202
+ self.report(entry.source, "SR-16", text)
1203
+
1204
+ # --- user includes ----------------------------------------------------------------------------
1205
+
1206
+ def _user_includes(self) -> None:
1207
+ for directory, source in self.project.include_dirs:
1208
+ if not _is_relative_path(directory):
1209
+ text = f"include directory {directory!r} must be relative without '..'"
1210
+ self.report(source, "SR-20", text)
1211
+ for header in self.project.headers:
1212
+ if not _is_relative_path(header.path):
1213
+ text = f"header {header.path!r} must be relative without '..'"
1214
+ self.report(header.source, "SR-20", text, "/path")
1215
+ self._condition(header.condition, _At(header.source, "/if"))
1216
+
1217
+
1218
+ def validate_project(location: Path) -> list[Issue]:
1219
+ """Load and validate a project: structural issues first, semantic issues after."""
1220
+ result = load_project(location)
1221
+ if result.project is None:
1222
+ return result.issues
1223
+ return result.issues + Validator(result.project).run()
1224
+
1225
+
1226
+ def main(argv: list[str] | None = None) -> int:
1227
+ """Validate project directories and print one line per issue."""
1228
+ paths = [Path(arg) for arg in (sys.argv[1:] if argv is None else argv)]
1229
+ failures = 0
1230
+ for path in paths:
1231
+ issues = validate_project(path)
1232
+ failures += len([issue for issue in issues if issue.severity == "error"])
1233
+ for issue in issues:
1234
+ sys.stdout.write(f"{issue}\n")
1235
+ if not issues:
1236
+ sys.stdout.write(f"{path}: OK\n")
1237
+ return 1 if failures else 0
1238
+
1239
+
1240
+ if __name__ == "__main__":
1241
+ sys.exit(main())