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