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/service.py ADDED
@@ -0,0 +1,1354 @@
1
+ """Service layer shared by the CLI, the Python API, the server and the MCP server.
2
+
3
+ Every operation returns a `Result`: success flag, issues, JSON-compatible data and a human-readable
4
+ text. Operations never print and never exit; front ends decide how to present results.
5
+
6
+ Editing operations (`get`, `set_value`, `upsert`, `remove`) address model elements by JSON pointer
7
+ (`/messages/NavSolution/fields/2/max`). The first two segments locate the entry; the file that
8
+ defines it (root project file or fragment) is edited in place with comments, styles and key order
9
+ kept, then written in the canonical layout of `wireforge.fmt`, so tool edits and hand edits
10
+ produce minimal diffs. With `dry_run` nothing is written and the result carries the diff.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import dataclasses
16
+ import difflib
17
+ import hashlib
18
+ import io
19
+ import json
20
+ import math
21
+ import re
22
+ import shutil
23
+ import subprocess
24
+ import tarfile
25
+ import tempfile
26
+ import zipfile
27
+ from contextlib import contextmanager
28
+ from dataclasses import asdict, dataclass, field
29
+ from pathlib import Path
30
+ from typing import TYPE_CHECKING, Any
31
+
32
+ from ruamel.yaml import YAML
33
+ from ruamel.yaml.comments import CommentedMap, CommentedSeq
34
+ from ruamel.yaml.scalarstring import DoubleQuotedScalarString
35
+
36
+ from wireforge import (
37
+ bundle,
38
+ compat,
39
+ fmt,
40
+ generate,
41
+ golden,
42
+ hlr_import,
43
+ impact,
44
+ importers,
45
+ layout,
46
+ lifecycle,
47
+ lifecycle_runtime,
48
+ llr,
49
+ llr_ids,
50
+ reqif,
51
+ resources,
52
+ runtime_reqs,
53
+ schema,
54
+ test_project,
55
+ trace,
56
+ vcs,
57
+ vectors,
58
+ versioning,
59
+ wireshark,
60
+ )
61
+ from wireforge import (
62
+ findings as rule_checkers,
63
+ )
64
+ from wireforge import results as test_results
65
+ from wireforge.codec import CodecError, ReferenceCodec
66
+ from wireforge.cpp_interface import InterfaceError
67
+ from wireforge.issues import Issue, errors_only
68
+ from wireforge.loader import SECTIONS, load_project
69
+ from wireforge.plain import to_plain
70
+ from wireforge.validate import Validator
71
+
72
+ if TYPE_CHECKING:
73
+ from collections.abc import Iterator
74
+
75
+ from wireforge.codec import ChecksumPlugin
76
+ from wireforge.model import IdMap, Project
77
+
78
+ SIMPLE_FLOW_KEYS = 8
79
+ # Pointer segments that name a section entry: /<section>/<name>.
80
+ ENTRY_DEPTH = 2
81
+
82
+
83
+ class ServiceError(Exception):
84
+ """An operation could not run (bad argument, missing file or tool)."""
85
+
86
+
87
+ @dataclass
88
+ class Result:
89
+ """Outcome of a service operation."""
90
+
91
+ ok: bool
92
+ issues: list[Issue] = field(default_factory=list)
93
+ data: dict[str, Any] = field(default_factory=dict)
94
+ text: str = ""
95
+
96
+ def to_dict(self) -> dict[str, Any]:
97
+ """Return the result as JSON-compatible data."""
98
+ return {
99
+ "ok": self.ok,
100
+ "issues": [asdict(issue) for issue in self.issues],
101
+ "data": self.data,
102
+ "text": self.text,
103
+ }
104
+
105
+
106
+ def _issues_text(issues: list[Issue]) -> str:
107
+ return "".join(f"{issue}\n" for issue in issues)
108
+
109
+
110
+ def _root(location: Path) -> Path:
111
+ return location / schema.ROOT_FILE_NAME if location.is_dir() else location
112
+
113
+
114
+ def _load_valid(location: Path) -> tuple[Project | None, list[Issue]]:
115
+ result = load_project(location)
116
+ if result.project is None:
117
+ return None, result.issues
118
+ issues = result.issues + Validator(result.project).run()
119
+ if not errors_only(issues):
120
+ plugins = load_plugins(find_plugins(location))
121
+ issues += vectors.check(result.project, plugins)
122
+ return (None if errors_only(issues) else result.project), issues
123
+
124
+
125
+ # --- init ---------------------------------------------------------------------------------------
126
+
127
+ TEMPLATES = Path(__file__).resolve().parent / "templates"
128
+ DEFAULT_TEMPLATE = "telemetry"
129
+ IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
130
+
131
+
132
+ def templates() -> list[str]:
133
+ """Return the names of the project templates `init_project` knows."""
134
+ return sorted(path.parent.name for path in TEMPLATES.glob("*/project.yaml"))
135
+
136
+
137
+ def template_summaries() -> list[dict[str, str]]:
138
+ """Return name and one-line description (the first comment line) of every template."""
139
+ result = []
140
+ for name in templates():
141
+ first = (TEMPLATES / name / "project.yaml").read_text(encoding="utf-8").splitlines()[0]
142
+ result.append({"name": name, "summary": first.lstrip("# ").strip()})
143
+ return result
144
+
145
+
146
+ def _identifier(text: str) -> str:
147
+ cleaned = re.sub(r"[^A-Za-z0-9_]", "_", text)
148
+ return cleaned if IDENTIFIER.match(cleaned) else f"p_{cleaned}"
149
+
150
+
151
+ def render_template(
152
+ template: str, name: str, namespace: str | None = None, version: str | None = None
153
+ ) -> str:
154
+ """Return the `project.yaml` of a template with its name, namespace and version replaced."""
155
+ source = TEMPLATES / template / "project.yaml"
156
+ if template not in templates():
157
+ message = f"unknown template '{template}' (templates: {', '.join(templates())})"
158
+ raise ServiceError(message)
159
+ if not IDENTIFIER.match(name):
160
+ message = f"'{name}' is not a valid project name (letters, digits and _; not first digit)"
161
+ raise ServiceError(message)
162
+ text = source.read_text(encoding="utf-8")
163
+ for key, value in (
164
+ ("project", name),
165
+ ("namespace", namespace or name),
166
+ ("version", f'"{version}"' if version else None),
167
+ ):
168
+ if value is not None:
169
+ text = re.sub(rf"^{key}: .*$", f"{key}: {value}", text, count=1, flags=re.MULTILINE)
170
+ return text
171
+
172
+
173
+ def init_project(
174
+ directory: Path,
175
+ template: str = DEFAULT_TEMPLATE,
176
+ name: str | None = None,
177
+ namespace: str | None = None,
178
+ version: str | None = None,
179
+ ) -> Result:
180
+ """Create a project directory from a template and validate it.
181
+
182
+ An existing directory must be empty; nothing is overwritten.
183
+ """
184
+ if directory.exists() and (not directory.is_dir() or any(directory.iterdir())):
185
+ message = f"{directory} exists and is not an empty directory"
186
+ raise ServiceError(message)
187
+ text = render_template(
188
+ template, name or _identifier(directory.resolve().name), namespace, version
189
+ )
190
+ directory.mkdir(parents=True, exist_ok=True)
191
+ (directory / schema.ROOT_FILE_NAME).write_text(text, encoding="utf-8")
192
+ checked = validate(directory)
193
+ steps = (
194
+ f"created {directory / schema.ROOT_FILE_NAME} from the template '{template}'\n"
195
+ f"next: wireforge validate {directory}\n"
196
+ f" wireforge generate {directory} -o gen --tests\n"
197
+ f" wireforge test {directory} -o build/{directory.name}\n"
198
+ )
199
+ data = {"template": template, "file": str(directory / schema.ROOT_FILE_NAME)}
200
+ return Result(checked.ok, checked.issues, data, steps + checked.text)
201
+
202
+
203
+ # --- validate, fmt, migrate ---------------------------------------------------------------------
204
+
205
+
206
+ def validate(location: Path) -> Result:
207
+ """Load and validate a project (structural and semantic rules)."""
208
+ if not _root(location).exists():
209
+ message = f"{_root(location)}: no such project file"
210
+ raise ServiceError(message)
211
+ result = load_project(location)
212
+ issues = list(result.issues)
213
+ if result.project is not None:
214
+ issues += Validator(result.project).run()
215
+ if not errors_only(issues):
216
+ issues += vectors.check(result.project, load_plugins(find_plugins(location)))
217
+ ok = not errors_only(issues)
218
+ files = result.project.files if result.project is not None else []
219
+ text = _issues_text(issues) or f"{location}: OK\n"
220
+ return Result(ok, issues, {"files": files}, text)
221
+
222
+
223
+ def project_files(location: Path) -> list[Path]:
224
+ """Return the root file and every fragment it includes."""
225
+ root = _root(location)
226
+ if not root.exists():
227
+ message = f"{root}: no such project file"
228
+ raise ServiceError(message)
229
+ result = load_project(location)
230
+ if result.documents:
231
+ return [Path(name) for name in result.documents]
232
+ return [root]
233
+
234
+
235
+ def format_project(location: Path, *, check: bool = False) -> Result:
236
+ """Canonical layout of every project file; with `check` only report files that would change."""
237
+ changed = [str(path) for path in project_files(location) if fmt.format_file(path, check=check)]
238
+ verb = "would reformat" if check else "reformatted"
239
+ text = "".join(f"{verb} {name}\n" for name in changed) or "all files canonical\n"
240
+ return Result(not (check and changed), data={"changed": changed}, text=text)
241
+
242
+
243
+ def migrate(location: Path, *, check: bool = False) -> Result:
244
+ """Upgrade every project file to the current file format and refresh its stamps."""
245
+ applied: dict[str, list[str]] = {}
246
+ for path in project_files(location):
247
+ document = fmt.load(path.read_text(encoding="utf-8"))
248
+ if not isinstance(document, CommentedMap):
249
+ continue
250
+ steps = versioning.migrate_document(document)
251
+ versioning.stamp(document)
252
+ fmt.canonicalize(document)
253
+ text = fmt.dump(document)
254
+ if text != path.read_text(encoding="utf-8"):
255
+ applied[str(path)] = steps
256
+ if not check:
257
+ path.write_text(text, encoding="utf-8")
258
+ lines = [f"{name}: {', '.join(steps) or 'stamped'}\n" for name, steps in applied.items()]
259
+ return Result(
260
+ not (check and applied), data={"migrated": applied}, text="".join(lines) or "up to date\n"
261
+ )
262
+
263
+
264
+ # --- generate, analyze, compat ------------------------------------------------------------------
265
+
266
+
267
+ def generate_code(
268
+ location: Path, output: Path, tests: test_project.TestOptions | None = None
269
+ ) -> Result:
270
+ """Generate the C++ code (and with `tests` the GoogleTest project) below `output`."""
271
+ issues = generate.generate(location, output, tests)
272
+ result = load_project(location)
273
+ name = result.project.name if result.project is not None else ""
274
+ data = {"output": str(output), "project": name, "tests": tests is not None}
275
+ text = _issues_text(issues) or f"generated {name} in {output}\n"
276
+ return Result(not issues, issues, data, text)
277
+
278
+
279
+ def analyze(location: Path, profile: str | None = None, export: str = "markdown") -> Result:
280
+ """Layout report of a valid project (`export`: markdown, csv or json)."""
281
+ project, issues = _load_valid(location)
282
+ if project is None:
283
+ return Result(ok=False, issues=issues, text=_issues_text(issues))
284
+ try:
285
+ report = layout.analyze(project, profile)
286
+ except layout.LayoutError as error:
287
+ raise ServiceError(str(error)) from error
288
+ text = layout.EXPORTERS[export](report)
289
+ return Result(ok=True, issues=issues, data=report.to_dict(), text=text)
290
+
291
+
292
+ def compatibility(old: Path, new: Path, interfaces: list[str] | None = None) -> Result:
293
+ """Compare two versions of a project; fails when a version bump is insufficient."""
294
+ reports, issues = compat.compare_paths(old, new, interfaces)
295
+ lines = [_issues_text(issues)]
296
+ data = []
297
+ for report in reports:
298
+ lines.append(f"{report.summary()}\n")
299
+ lines += [f" {change}\n" for change in report.changes]
300
+ data.append(
301
+ {
302
+ "interface": report.name,
303
+ "old_version": report.old_version,
304
+ "new_version": report.new_version,
305
+ "required_bump": report.required_bump,
306
+ "actual_bump": report.actual_bump,
307
+ "version_ok": report.version_ok,
308
+ "changes": [str(change) for change in report.changes],
309
+ }
310
+ )
311
+ ok = not errors_only(issues) and all(report.version_ok for report in reports)
312
+ return Result(ok, issues, {"interfaces": data}, "".join(lines))
313
+
314
+
315
+ # --- test ---------------------------------------------------------------------------------------
316
+
317
+
318
+ @dataclass(frozen=True)
319
+ class BuildOptions:
320
+ """How `run_tests` builds the generated test project."""
321
+
322
+ build_dir: Path | None = None
323
+ coverage: bool = False
324
+ coverage_runtime: bool = False
325
+ generator: str | None = None
326
+ extra_cmake_args: tuple[str, ...] = ()
327
+
328
+
329
+ def _run(command: list[str], cwd: Path | None = None) -> subprocess.CompletedProcess[str]:
330
+ return subprocess.run(command, cwd=cwd, capture_output=True, text=True, check=False)
331
+
332
+
333
+ def run_tests(
334
+ location: Path,
335
+ output: Path,
336
+ tests: test_project.TestOptions | None = None,
337
+ build: BuildOptions | None = None,
338
+ ) -> Result:
339
+ """Generate the code and the test project, build it with CMake and run its tests.
340
+
341
+ With `build.coverage` the coverage target runs instead (tests plus the coverage threshold);
342
+ `build.coverage_runtime` adds the runtime headers to the coverage report. The machine-readable
343
+ results are written to `<build_dir>/results` (`wireforge docs --results <build_dir>`).
344
+ """
345
+ build = build or BuildOptions()
346
+ cmake = shutil.which("cmake")
347
+ if cmake is None:
348
+ message = "cmake is not installed"
349
+ raise ServiceError(message)
350
+ generated = generate_code(location, output, tests or test_project.TestOptions())
351
+ if not generated.ok:
352
+ return generated
353
+ project = output / generated.data["project"] / test_project.DIRECTORY
354
+ build_dir = build.build_dir or project / "build" / ("coverage" if build.coverage else "test")
355
+ configure = [cmake, "-S", str(project), "-B", str(build_dir), "-DCMAKE_BUILD_TYPE=Debug"]
356
+ if build.generator:
357
+ configure += ["-G", build.generator]
358
+ if build.coverage:
359
+ configure.append("-DWIREFORGE_COVERAGE=ON")
360
+ if build.coverage_runtime:
361
+ configure.append("-DWIREFORGE_COVERAGE_RUNTIME=ON")
362
+ configure += list(build.extra_cmake_args)
363
+ steps = [configure, [cmake, "--build", str(build_dir)]]
364
+ if build.coverage:
365
+ steps.append([cmake, "--build", str(build_dir), "--target", "coverage"])
366
+ else:
367
+ ctest = shutil.which("ctest") or "ctest"
368
+ steps.append([ctest, "--test-dir", str(build_dir), "--output-on-failure"])
369
+ log = []
370
+ for command in steps:
371
+ completed = _run(command)
372
+ log.append(completed.stdout + completed.stderr)
373
+ if completed.returncode != 0:
374
+ text = "".join(log)
375
+ return Result(
376
+ ok=False, data={"build_dir": str(build_dir), "failed": command}, text=text
377
+ )
378
+ data = {"build_dir": str(build_dir), "results_dir": str(build_dir / "results")}
379
+ if build.coverage:
380
+ log.append(f"results in {build_dir}\n")
381
+ return Result(ok=True, data=data, text="".join(log))
382
+
383
+
384
+ # --- bundles ------------------------------------------------------------------------------------
385
+
386
+
387
+ def export_project(location: Path, output: Path) -> Result:
388
+ """Write the single-file bundle of a valid project."""
389
+ try:
390
+ bundle.export_bundle(location, output)
391
+ except bundle.BundleError as error:
392
+ return Result(ok=False, issues=error.issues, text=f"{error}\n{_issues_text(error.issues)}")
393
+ return Result(ok=True, data={"bundle": str(output)}, text=f"exported {output}\n")
394
+
395
+
396
+ def import_project(
397
+ source: Path, destination: Path, *, verify: bool = True, single_file: bool = False
398
+ ) -> Result:
399
+ """Unpack a bundle into the standard layout and validate the result."""
400
+ try:
401
+ issues = bundle.import_bundle(source, destination, verify=verify, single_file=single_file)
402
+ except bundle.BundleError as error:
403
+ return Result(ok=False, issues=error.issues, text=f"{error}\n{_issues_text(error.issues)}")
404
+ text = _issues_text(issues) or f"imported into {destination}\n"
405
+ return Result(not errors_only(issues), issues, {"destination": str(destination)}, text)
406
+
407
+
408
+ # --- encode / decode ----------------------------------------------------------------------------
409
+
410
+
411
+ @dataclass(frozen=True)
412
+ class CodecTarget:
413
+ """Which message (and interface/profile) a codec operation uses."""
414
+
415
+ message: str | None = None
416
+ interface: str | None = None
417
+ profile: str | None = None
418
+ plugins: dict[str, ChecksumPlugin] | None = None
419
+
420
+
421
+ def _codec(location: Path, target: CodecTarget) -> ReferenceCodec:
422
+ project, issues = _load_valid(location)
423
+ if project is None:
424
+ raise ServiceError(_issues_text(issues))
425
+ try:
426
+ return ReferenceCodec(project, target.profile, target.plugins)
427
+ except ValueError as error:
428
+ raise ServiceError(str(error)) from error
429
+
430
+
431
+ def _jsonable(value: object) -> object:
432
+ if isinstance(value, float) and math.isnan(value):
433
+ return "NaN"
434
+ if isinstance(value, dict):
435
+ return {key: _jsonable(item) for key, item in value.items()}
436
+ if isinstance(value, list):
437
+ return [_jsonable(item) for item in value]
438
+ return value
439
+
440
+
441
+ def encode(location: Path, value: dict[str, Any], target: CodecTarget) -> Result:
442
+ """Encode a message value (payload, and the frame when an interface is given)."""
443
+ codec = _codec(location, target)
444
+ message = target.message or ""
445
+ try:
446
+ payload = codec.encode_message(message, value, target.interface)
447
+ frame = codec.encode_frame(target.interface, message, value) if target.interface else None
448
+ except CodecError as error:
449
+ return Result(
450
+ ok=False, data={"status": error.status, "path": error.path}, text=f"{error}\n"
451
+ )
452
+ data: dict[str, Any] = {"payload": payload.hex()}
453
+ if frame is not None:
454
+ data["frame"] = frame.hex()
455
+ return Result(ok=True, data=data, text=f"{(frame or payload).hex()}\n")
456
+
457
+
458
+ def decode(location: Path, data: bytes, target: CodecTarget) -> Result:
459
+ """Decode a payload (with `message`) or a complete frame (with `interface` only)."""
460
+ codec = _codec(location, target)
461
+ try:
462
+ if target.message is None:
463
+ if target.interface is None:
464
+ message = "decode needs a message or an interface"
465
+ raise ServiceError(message)
466
+ frame = codec.decode_frame(target.interface, data)
467
+ decoded: dict[str, Any] = {
468
+ "message": frame.message,
469
+ "value": _jsonable(frame.value),
470
+ "header": _jsonable(frame.header),
471
+ "trailer": _jsonable(frame.trailer),
472
+ }
473
+ else:
474
+ value = codec.decode_message(target.message, data, target.interface)
475
+ decoded = {"message": target.message, "value": _jsonable(value)}
476
+ except CodecError as error:
477
+ return Result(
478
+ ok=False, data={"status": error.status, "path": error.path}, text=f"{error}\n"
479
+ )
480
+ return Result(ok=True, data=decoded, text=json.dumps(decoded, indent=2) + "\n")
481
+
482
+
483
+ # --- editing ------------------------------------------------------------------------------------
484
+
485
+
486
+ def _segments(pointer: str) -> list[str]:
487
+ if pointer in {"", "/"}:
488
+ return []
489
+ if not pointer.startswith("/"):
490
+ message = f"{pointer!r} is not a JSON pointer"
491
+ raise ServiceError(message)
492
+ return [part.replace("~1", "/").replace("~0", "~") for part in pointer[1:].split("/")]
493
+
494
+
495
+ def _owner(location: Path, segments: list[str]) -> Path:
496
+ """File that defines the element at `segments` (or where a new section entry goes).
497
+
498
+ A new entry goes to the file that already holds its section; when every holder holds just
499
+ one entry and they share a directory (one file per entry, e.g. `interfaces/<name>.yaml`),
500
+ it gets a new file `<directory>/<name>.yaml` in that directory instead.
501
+ """
502
+ files = project_files(location)
503
+ if len(segments) < ENTRY_DEPTH or segments[0] not in SECTIONS:
504
+ return files[0]
505
+ section, name = segments[0], segments[1]
506
+ holders: list[tuple[Path, int]] = []
507
+ for path in files:
508
+ document = fmt.load(path.read_text(encoding="utf-8"))
509
+ entries = document.get(section) if isinstance(document, CommentedMap) else None
510
+ if isinstance(entries, CommentedMap):
511
+ if name in entries:
512
+ return path
513
+ holders.append((path, len(entries)))
514
+ if not holders:
515
+ return files[0]
516
+ directories = {path.parent for path, _ in holders}
517
+ one_each = all(count == 1 for _, count in holders) and len(holders) > 1
518
+ if one_each and len(directories) == 1 and holders[0][0] != files[0]:
519
+ return _new_fragment(location, holders[0][0].parent / f"{name}.yaml", section)
520
+ return holders[0][0]
521
+
522
+
523
+ def _new_fragment(location: Path, path: Path, section: str) -> Path:
524
+ """Return the path of a new fragment for `section` when the root's `include` covers it."""
525
+ root = _root(location)
526
+ document = fmt.load(root.read_text(encoding="utf-8"))
527
+ patterns = document.get("include", []) if isinstance(document, CommentedMap) else []
528
+ relative = path.relative_to(root.parent)
529
+ if not any(relative.match(str(pattern)) for pattern in patterns):
530
+ message = f"{relative} would not be included by {root.name}"
531
+ raise ServiceError(message)
532
+ del section
533
+ return path
534
+
535
+
536
+ def _text(path: Path, segments: list[str]) -> str:
537
+ """Return the text of a project file; a fragment that does not exist yet starts empty."""
538
+ if path.exists():
539
+ return path.read_text(encoding="utf-8")
540
+ return f"schema_version: 1\n{segments[0]}: {{}}\n"
541
+
542
+
543
+ def _child(node: object, key: str) -> object:
544
+ if isinstance(node, CommentedSeq):
545
+ return node[int(key)]
546
+ if isinstance(node, CommentedMap):
547
+ return node[key]
548
+ message = f"cannot index {type(node).__name__} with {key!r}"
549
+ raise KeyError(message)
550
+
551
+
552
+ def _resolve(document: object, segments: list[str]) -> object:
553
+ node = document
554
+ for key in segments:
555
+ node = _child(node, key)
556
+ return node
557
+
558
+
559
+ def get(location: Path, pointer: str) -> Result:
560
+ """Return the value at `pointer` as written in the project files."""
561
+ segments = _segments(pointer)
562
+ path = _owner(location, segments)
563
+ document = fmt.load(path.read_text(encoding="utf-8"))
564
+ try:
565
+ value = to_plain(_resolve(document, segments))
566
+ except (KeyError, IndexError, ValueError) as error:
567
+ message = f"{pointer}: not found"
568
+ raise ServiceError(message) from error
569
+ return Result(ok=True, data={"file": str(path), "value": value}, text=json.dumps(value) + "\n")
570
+
571
+
572
+ def _flow_like(container: object) -> bool:
573
+ """Whether new mappings in `container` should be written in flow style (like siblings)."""
574
+ if isinstance(container, CommentedSeq):
575
+ maps = [item for item in container if isinstance(item, CommentedMap)]
576
+ return bool(maps) and all(item.fa.flow_style() for item in maps)
577
+ return False
578
+
579
+
580
+ def _yamlify(value: object, *, flow: bool = False) -> object:
581
+ """Convert plain data to ruamel nodes; small flat mappings use flow style when `flow`."""
582
+ if isinstance(value, dict):
583
+ node = CommentedMap()
584
+ for key, item in value.items():
585
+ node[key] = _yamlify(item)
586
+ simple = all(not isinstance(item, (dict, list)) for item in value.values())
587
+ if flow and simple and len(value) <= SIMPLE_FLOW_KEYS:
588
+ node.fa.set_flow_style()
589
+ return node
590
+ if isinstance(value, list):
591
+ sequence = CommentedSeq()
592
+ flow_items = all(not isinstance(item, (dict, list)) for item in value)
593
+ for item in value:
594
+ sequence.append(_yamlify(item, flow=True))
595
+ if flow_items:
596
+ sequence.fa.set_flow_style()
597
+ return sequence
598
+ return value
599
+
600
+
601
+ def _merge(target: object, value: object) -> object:
602
+ """Update `target` to equal `value`, keeping nodes (and their comments) that survive."""
603
+ if isinstance(target, CommentedMap) and isinstance(value, dict):
604
+ for key in [key for key in target if key not in value]:
605
+ del target[key]
606
+ for key, item in value.items():
607
+ target[key] = _merge(target[key], item) if key in target else _yamlify(item)
608
+ return target
609
+ if isinstance(target, CommentedSeq) and isinstance(value, list):
610
+ return _merge_sequence(target, value)
611
+ if isinstance(value, (dict, list)):
612
+ return _yamlify(value)
613
+ if (
614
+ not isinstance(target, (CommentedMap, CommentedSeq))
615
+ and type(value) is not bool
616
+ and target == value
617
+ ):
618
+ return target # keep the written form, e.g. 0xA55A
619
+ return value
620
+
621
+
622
+ def _merge_sequence(target: CommentedSeq, value: list[object]) -> CommentedSeq:
623
+ named = {
624
+ item.get("name"): item
625
+ for item in target
626
+ if isinstance(item, CommentedMap) and item.get("name") is not None
627
+ }
628
+ flow = _flow_like(target)
629
+ items = []
630
+ for index, item in enumerate(value):
631
+ name = item.get("name") if isinstance(item, dict) else None
632
+ if name is not None and name in named:
633
+ items.append(_merge(named[name], item))
634
+ elif name is None and index < len(target):
635
+ items.append(_merge(target[index], item))
636
+ else:
637
+ items.append(_yamlify(item, flow=flow))
638
+ for index in range(len(target) - 1, -1, -1):
639
+ if all(target[index] is not item for item in items):
640
+ del target[index]
641
+ for index, item in enumerate(items):
642
+ if index < len(target):
643
+ if target[index] is not item:
644
+ target.insert(index, item)
645
+ else:
646
+ target.append(item)
647
+ while len(target) > len(items):
648
+ del target[len(items)]
649
+ return target
650
+
651
+
652
+ @dataclass(frozen=True)
653
+ class EditOptions:
654
+ """How an edit is applied: `dry_run` writes nothing, `strict` refuses invalid results."""
655
+
656
+ dry_run: bool = False
657
+ strict: bool = False
658
+
659
+
660
+ def _diff(path: Path, before: str, after: str) -> str:
661
+ lines = difflib.unified_diff(
662
+ before.splitlines(keepends=True),
663
+ after.splitlines(keepends=True),
664
+ fromfile=f"a/{path.name}",
665
+ tofile=f"b/{path.name}",
666
+ )
667
+ return "".join(lines)
668
+
669
+
670
+ def _write(location: Path, path: Path, document: object, options: EditOptions) -> Result:
671
+ existed = path.exists()
672
+ before = path.read_text(encoding="utf-8") if existed else ""
673
+ fmt.canonicalize(document)
674
+ after = fmt.dump(document)
675
+ diff = _diff(path, before, after)
676
+ data = {"file": str(path), "diff": diff, "written": False}
677
+ if options.dry_run or after == before:
678
+ return Result(ok=True, data=data, text=diff)
679
+ path.write_text(after, encoding="utf-8")
680
+ checked = validate(location)
681
+ if options.strict and not checked.ok:
682
+ if existed:
683
+ path.write_text(before, encoding="utf-8")
684
+ else:
685
+ path.unlink()
686
+ return Result(ok=False, issues=checked.issues, data=data, text=checked.text)
687
+ data["written"] = True
688
+ return Result(ok=True, issues=checked.issues, data=data, text=diff)
689
+
690
+
691
+ def set_value(
692
+ location: Path, pointer: str, value: object, options: EditOptions | None = None
693
+ ) -> Result:
694
+ """Set the element at `pointer` (missing mappings on the way are created)."""
695
+ options = options or EditOptions()
696
+ segments = _segments(pointer)
697
+ if not segments:
698
+ message = "set needs a pointer below the document root"
699
+ raise ServiceError(message)
700
+ path = _owner(location, segments)
701
+ original = _text(path, segments)
702
+ document = fmt.load(original)
703
+ parent: object = document
704
+ for key in segments[:-1]:
705
+ if isinstance(parent, CommentedMap) and key not in parent:
706
+ parent[key] = CommentedMap()
707
+ parent = _child(parent, key)
708
+ last = segments[-1]
709
+ if isinstance(parent, CommentedSeq):
710
+ index = int(last) if last != "-" else len(parent)
711
+ node = _yamlify(value, flow=_flow_like(parent))
712
+ if index == len(parent):
713
+ parent.append(node)
714
+ else:
715
+ parent[index] = _merge(parent[index], value)
716
+ elif isinstance(parent, CommentedMap):
717
+ parent[last] = _merge(parent[last], value) if last in parent else _yamlify(value)
718
+ else:
719
+ message = f"{pointer}: the parent is not a mapping or sequence"
720
+ raise ServiceError(message)
721
+ return _write(location, path, document, options)
722
+
723
+
724
+ def upsert(
725
+ location: Path,
726
+ section: str,
727
+ name: str,
728
+ definition: dict[str, Any],
729
+ options: EditOptions | None = None,
730
+ ) -> Result:
731
+ """Create or update the entry `name` of `section` (types, messages, interfaces, ...)."""
732
+ if section not in SECTIONS:
733
+ message = f"unknown section {section!r}; choose one of {', '.join(SECTIONS)}"
734
+ raise ServiceError(message)
735
+ return set_value(location, f"/{section}/{name}", definition, options)
736
+
737
+
738
+ def _delete(parent: object, key: str) -> None:
739
+ if isinstance(parent, CommentedSeq):
740
+ del parent[int(key)]
741
+ elif isinstance(parent, CommentedMap):
742
+ del parent[key]
743
+ else:
744
+ raise KeyError(key)
745
+
746
+
747
+ def remove(location: Path, pointer: str, options: EditOptions | None = None) -> Result:
748
+ """Delete the element at `pointer`."""
749
+ options = options or EditOptions()
750
+ segments = _segments(pointer)
751
+ if not segments:
752
+ message = "remove needs a pointer below the document root"
753
+ raise ServiceError(message)
754
+ path = _owner(location, segments)
755
+ document = fmt.load(path.read_text(encoding="utf-8"))
756
+ try:
757
+ _delete(_resolve(document, segments[:-1]), segments[-1])
758
+ except (KeyError, IndexError, ValueError) as error:
759
+ message = f"{pointer}: not found"
760
+ raise ServiceError(message) from error
761
+ return _write(location, path, document, options)
762
+
763
+
764
+ def load_plugins(path: Path | None) -> dict[str, ChecksumPlugin] | None:
765
+ """Load checksum plugins of the reference codec (None without a file)."""
766
+ return golden.load_plugins(path) if path is not None else None
767
+
768
+
769
+ # --- archives and previews ----------------------------------------------------------------------
770
+
771
+ ARCHIVE_README = """\
772
+ # {name}: generated code
773
+
774
+ Generated by WireForge {version}. Layout:
775
+
776
+ - `include/{name}/`: generated C++ headers (include paths: `include/`, `runtime/include/`);
777
+ - `include/{name}/generated_tests/`: GoogleTest project with coverage target, self-test, harnesses;
778
+ - `runtime/include/wireforge/`: the WireForge runtime (header-only);
779
+ - `model/`: the project definition (and its `user_includes` directories).
780
+
781
+ Build and run the tests:
782
+
783
+ ```sh
784
+ cmake -S include/{name}/generated_tests -B build -DCMAKE_BUILD_TYPE=Debug
785
+ cmake --build build
786
+ ctest --test-dir build --output-on-failure
787
+ ```
788
+ """
789
+
790
+
791
+ def find_plugins(location: Path) -> Path | None:
792
+ """Return the checksum plugin file of a project (`plugins/*.py` defining PLUGINS), if any."""
793
+ directory = (location if location.is_dir() else location.parent) / "plugins"
794
+ for path in sorted(directory.glob("*.py")) if directory.is_dir() else []:
795
+ if path.name != "__init__.py" and "PLUGINS" in path.read_text(encoding="utf-8"):
796
+ return path
797
+ return None
798
+
799
+
800
+ def generate_archive(location: Path, *, tests: bool = True) -> tuple[Result, bytes]:
801
+ """Return the zip of generated code, test project, runtime and model (self-contained)."""
802
+ with tempfile.TemporaryDirectory(prefix="wireforge-") as temporary:
803
+ root = Path(temporary)
804
+ model = root / "model"
805
+ shutil.copytree(location if location.is_dir() else location.parent, model)
806
+ runtime = root / "runtime" / "include"
807
+ shutil.copytree(test_project.RUNTIME_INCLUDE, runtime)
808
+ options = test_project.TestOptions(plugins=find_plugins(model), runtime=runtime)
809
+ result = generate_code(model, root / "include", options if tests else None)
810
+ if not result.ok:
811
+ return result, b""
812
+ name = str(result.data["project"])
813
+ (root / "README.md").write_text(
814
+ ARCHIVE_README.format(name=name, version=versioning.TOOL_VERSION), encoding="utf-8"
815
+ )
816
+ buffer = io.BytesIO()
817
+ with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
818
+ for path in sorted(root.rglob("*")):
819
+ if path.is_file() and "__pycache__" not in path.parts:
820
+ archive.write(path, f"{name}/{path.relative_to(root).as_posix()}")
821
+ data = buffer.getvalue()
822
+ result.data["bytes"] = len(data)
823
+ return result, data
824
+
825
+
826
+ def preview(location: Path) -> Result:
827
+ """Return the generated C++ files of a valid project (path -> text), writing nothing."""
828
+ project, issues = _load_valid(location)
829
+ if project is None:
830
+ return Result(ok=False, issues=issues, text=_issues_text(issues))
831
+ try:
832
+ content_hash = bundle.build_bundle(location)["bundle"]["content_hash"]
833
+ files = generate.render(project, content_hash)
834
+ except (bundle.BundleError, InterfaceError) as error:
835
+ return Result(ok=False, issues=issues, text=f"{error}\n")
836
+ return Result(ok=True, issues=issues, data={"files": files}, text="".join(files))
837
+
838
+
839
+ def wireshark_plugin(location: Path) -> tuple[Result, str]:
840
+ """Return the Wireshark Lua plugin of a valid project (and its protocol names in `data`)."""
841
+ plugin = wireshark.build(location)
842
+ if plugin.issues:
843
+ return Result(ok=False, issues=plugin.issues, text=_issues_text(plugin.issues)), ""
844
+ data = {"project": plugin.project, "protocols": list(plugin.protocols)}
845
+ text = (
846
+ f"Wireshark plugin of {plugin.project}: {', '.join(plugin.protocols) or 'no protocols'}\n"
847
+ )
848
+ return Result(ok=True, data=data, text=text), plugin.text
849
+
850
+
851
+ def wireshark_dissector(location: Path, output: Path) -> Result:
852
+ """Write the Wireshark Lua plugin of a valid project to `output`."""
853
+ result, text = wireshark_plugin(location)
854
+ if result.ok:
855
+ output.parent.mkdir(parents=True, exist_ok=True)
856
+ output.write_text(text, encoding="utf-8")
857
+ result.data["output"] = str(output)
858
+ result.text = f"wrote {output} ({', '.join(result.data['protocols'])})\n"
859
+ return result
860
+
861
+
862
+ # --- version history ----------------------------------------------------------------------------
863
+
864
+
865
+ def _git(directory: Path, *args: str) -> subprocess.CompletedProcess[str]:
866
+ git = shutil.which("git")
867
+ if git is None:
868
+ message = "git is not installed"
869
+ raise ServiceError(message)
870
+ return _run([git, "-C", str(directory), *args])
871
+
872
+
873
+ def history(location: Path, limit: int = 50) -> Result:
874
+ """Return the commits that changed the project directory (none outside a git repository)."""
875
+ directory = location if location.is_dir() else location.parent
876
+ completed = _git(
877
+ directory, "log", f"-{limit}", "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s", "--", "."
878
+ )
879
+ if completed.returncode != 0:
880
+ return Result(ok=True, data={"commits": []}, text="not a git repository\n")
881
+ commits = []
882
+ for line in completed.stdout.splitlines():
883
+ full, short, author, date, subject = line.split("\x1f")
884
+ commits.append(
885
+ {"commit": full, "short": short, "author": author, "date": date, "subject": subject}
886
+ )
887
+ text = "".join(f"{item['short']} {item['date']} {item['subject']}\n" for item in commits)
888
+ return Result(ok=True, data={"commits": commits}, text=text)
889
+
890
+
891
+ @contextmanager
892
+ def _checkout_revision(location: Path, revision: str) -> Iterator[Path]:
893
+ """Yield a temporary copy of the project directory as it was at a git revision."""
894
+ directory = (location if location.is_dir() else location.parent).resolve()
895
+ top = _git(directory, "rev-parse", "--show-toplevel")
896
+ if top.returncode != 0:
897
+ message = f"{directory} is not in a git repository"
898
+ raise ServiceError(message)
899
+ root = Path(top.stdout.strip())
900
+ relative = directory.relative_to(root).as_posix() or "."
901
+ with tempfile.TemporaryDirectory(prefix="wireforge-rev-") as temporary:
902
+ archive = Path(temporary) / "old.tar"
903
+ exported = _git(root, "archive", "--format=tar", "-o", str(archive), revision, relative)
904
+ if exported.returncode != 0:
905
+ message = f"cannot read {relative} at {revision}: {exported.stderr.strip()}"
906
+ raise ServiceError(message)
907
+ with tarfile.open(archive) as tar:
908
+ tar.extractall(temporary, filter="data")
909
+ yield Path(temporary) / relative
910
+
911
+
912
+ def compatibility_with_revision(
913
+ location: Path, revision: str, interfaces: list[str] | None = None
914
+ ) -> Result:
915
+ """Compare the project at a git revision (old) with the working copy (new)."""
916
+ directory = (location if location.is_dir() else location.parent).resolve()
917
+ with _checkout_revision(location, revision) as old:
918
+ result = compatibility(old, directory, interfaces)
919
+ result.data["revision"] = revision
920
+ return result
921
+
922
+
923
+ # --- importers ----------------------------------------------------------------------------------
924
+
925
+
926
+ def _flow_field(item: object) -> None:
927
+ """Write a field like the examples: nested mappings inline, the field inline when flat."""
928
+ if not isinstance(item, CommentedMap):
929
+ return
930
+ nested = [value for value in item.values() if isinstance(value, CommentedMap)]
931
+ for value in nested:
932
+ value.fa.set_flow_style()
933
+ if not nested:
934
+ item.fa.set_flow_style()
935
+
936
+
937
+ def _document_text(document: dict[str, Any]) -> str:
938
+ if isinstance(document.get("version"), str):
939
+ document = {**document, "version": DoubleQuotedScalarString(document["version"])}
940
+ node = _yamlify(document)
941
+ enums = node.get("enums") if isinstance(node, CommentedMap) else None
942
+ for enum in enums.values() if isinstance(enums, CommentedMap) else []:
943
+ values = enum.get("values") if isinstance(enum, CommentedMap) else None
944
+ if isinstance(values, CommentedMap):
945
+ values.fa.set_flow_style()
946
+ for section in ("types", "messages"):
947
+ entries = node.get(section) if isinstance(node, CommentedMap) else None
948
+ for entry in entries.values() if isinstance(entries, CommentedMap) else []:
949
+ fields = entry.get("fields") if isinstance(entry, CommentedMap) else None
950
+ for item in fields if isinstance(fields, CommentedSeq) else []:
951
+ _flow_field(item)
952
+ fmt.canonicalize(node)
953
+ return fmt.dump(node)
954
+
955
+
956
+ def import_icd(
957
+ source: Path,
958
+ destination: Path,
959
+ options: importers.Project,
960
+ messages: list[str] | None = None,
961
+ ) -> Result:
962
+ """Import an ICD table (.csv, .xlsx) or a C header (.h) as a new project in `destination`."""
963
+ if destination.exists() and any(destination.iterdir()):
964
+ message = f"{destination} is not empty"
965
+ raise ServiceError(message)
966
+ suffix = source.suffix.lower()
967
+ try:
968
+ if suffix == ".csv":
969
+ document = importers.from_csv(source.read_text(encoding="utf-8"), options, str(source))
970
+ elif suffix in {".xlsx", ".xlsm"}:
971
+ document = importers.from_xlsx(source, options)
972
+ elif suffix in {".h", ".hpp", ".hh"}:
973
+ text = source.read_text(encoding="utf-8")
974
+ document = importers.from_c_header(text, options, messages, str(source))
975
+ else:
976
+ message = f"{source.name}: import reads .csv, .xlsx or .h files"
977
+ raise ServiceError(message)
978
+ except importers.IcdImportError as error:
979
+ return Result(ok=False, issues=error.issues, text=_issues_text(error.issues))
980
+ destination.mkdir(parents=True, exist_ok=True)
981
+ root = destination / schema.ROOT_FILE_NAME
982
+ root.write_text(_document_text(document), encoding="utf-8")
983
+ checked = validate(destination)
984
+ checked.data["project"] = str(root)
985
+ checked.text = _issues_text(checked.issues) or f"imported {source.name} into {root}\n"
986
+ return checked
987
+
988
+
989
+ # --- traceability -------------------------------------------------------------------------------
990
+
991
+
992
+ def trace_report(location: Path, export: str = "markdown") -> Result:
993
+ """Trace every model element to its source line, generated symbol and tests."""
994
+ project, issues = _load_valid(location)
995
+ if project is None:
996
+ return Result(ok=False, issues=issues, text=_issues_text(issues))
997
+ content_hash = bundle.build_bundle(location)["bundle"]["content_hash"]
998
+ traced = trace.report(project, content_hash, load_plugins(find_plugins(location)))
999
+ if export == "csv":
1000
+ text = trace.to_csv(traced.rows)
1001
+ else:
1002
+ text = trace.to_markdown(traced.rows, project, content_hash, traced.llrs)
1003
+ data = {
1004
+ "content_hash": content_hash,
1005
+ "rows": [asdict(row) for row in traced.rows],
1006
+ "llrs": [asdict(row) for row in traced.llrs],
1007
+ }
1008
+ return Result(ok=True, issues=issues, data=data, text=text)
1009
+
1010
+
1011
+ # --- lifecycle documents ------------------------------------------------------------------------
1012
+
1013
+
1014
+ def _directory(location: Path) -> Path:
1015
+ return location if location.is_dir() else location.parent
1016
+
1017
+
1018
+ @dataclass(frozen=True)
1019
+ class DocsOptions:
1020
+ """Choices of `lifecycle_docs` beyond strict mode and results.
1021
+
1022
+ `export` lists extra formats (`reqif` writes `llr.reqifz`; the CSV files are always written).
1023
+ `source_url` is a permalink template with `{commit}`, `{path}` and `{line}`; it needs a clean
1024
+ working copy unless `allow_dirty` is set.
1025
+ """
1026
+
1027
+ export: tuple[str, ...] = ()
1028
+ source_url: str | None = None
1029
+ allow_dirty: bool = False
1030
+ since: str | None = None
1031
+ rule_findings: tuple[Path, ...] = ()
1032
+
1033
+
1034
+ def _baseline(
1035
+ project: Project, directory: Path, context: lifecycle.DocsContext, *, dirty: bool
1036
+ ) -> tuple[tuple[str, str], ...]:
1037
+ """Identify the baseline: commit, requirement snapshot, number lock, tool versions."""
1038
+ rows: list[tuple[str, str]] = []
1039
+ commit = vcs.commit(directory)
1040
+ if commit is not None:
1041
+ rows.append(("Git commit", commit + (" (uncommitted changes)" if dirty else "")))
1042
+ source = project.requirements_source
1043
+ if source:
1044
+ origin = f"{source.get('tool')} baseline {source.get('baseline')}"
1045
+ digest = source.get("sha256", "")[:16]
1046
+ detail = f"{origin} ({source.get('file')}, sha256 {digest})"
1047
+ rows.append(("High-level requirements", detail))
1048
+ lock = directory / llr_ids.LOCK_FILE
1049
+ if lock.exists():
1050
+ rows.append(("Requirement numbers", f"{lock.name} sha256 {_sha256(lock)[:16]}"))
1051
+ rows.append(("WireForge", versioning.TOOL_VERSION))
1052
+ rows.append(("Runtime", runtime_reqs.version(resources.runtime_include())))
1053
+ if context.results is not None:
1054
+ chain = context.results.toolchain
1055
+ rows.append(
1056
+ (
1057
+ "Compiler",
1058
+ f"{chain.get('cxx_compiler_id', '?')} {chain.get('cxx_compiler_version', '')}",
1059
+ )
1060
+ )
1061
+ return tuple(rows)
1062
+
1063
+
1064
+ def _sha256(path: Path) -> str:
1065
+ return hashlib.sha256(path.read_bytes()).hexdigest()
1066
+
1067
+
1068
+ def _links(directory: Path, options: DocsOptions) -> lifecycle.SourceLinks | None:
1069
+ if options.source_url is None:
1070
+ return None
1071
+ repository, commit = vcs.root(directory), vcs.commit(directory)
1072
+ if repository is None or commit is None:
1073
+ message = "permalinks need the project to be in a git repository"
1074
+ raise ServiceError(message)
1075
+ if vcs.dirty(directory) and not options.allow_dirty:
1076
+ message = "uncommitted changes; permalinks would not match (--allow-dirty to go on)"
1077
+ raise ServiceError(message)
1078
+ return lifecycle.SourceLinks(options.source_url, commit, repository)
1079
+
1080
+
1081
+ def _lifecycle(
1082
+ location: Path,
1083
+ *,
1084
+ strict: bool,
1085
+ results_dir: Path | None = None,
1086
+ options: DocsOptions | None = None,
1087
+ ) -> tuple[Result, dict[str, str], lifecycle.DocsContext | None]:
1088
+ options = options or DocsOptions()
1089
+ project, issues = _load_valid(location)
1090
+ if project is None:
1091
+ return Result(ok=False, issues=issues, text=_issues_text(issues)), {}, None
1092
+ content_hash = bundle.build_bundle(location)["bundle"]["content_hash"]
1093
+ context = lifecycle.context(project, content_hash, load_plugins(find_plugins(location)))
1094
+ directory = _directory(location)
1095
+ try:
1096
+ context = dataclasses.replace(
1097
+ context,
1098
+ results=test_results.load(results_dir) if results_dir is not None else None,
1099
+ deviations=tuple(test_results.load_deviations(directory)),
1100
+ )
1101
+ if options.rule_findings:
1102
+ inside, outside = rule_checkers.load(list(options.rule_findings))
1103
+ context = dataclasses.replace(
1104
+ context,
1105
+ rule_findings=tuple(inside),
1106
+ rules_provided=True,
1107
+ outside_scope=outside,
1108
+ )
1109
+ except test_results.ResultsError as error:
1110
+ raise ServiceError(str(error)) from error
1111
+ links = _links(directory, options)
1112
+ baseline = _baseline(project, directory, context, dirty=vcs.dirty(directory))
1113
+ context = dataclasses.replace(context, links=links, baseline=baseline)
1114
+ files = lifecycle.render(context)
1115
+ findings = lifecycle.strict_findings(context)
1116
+ found = [
1117
+ Issue(project.source.file, None, item.pointer, f"[{item.rule}] {item.message}")
1118
+ for item in findings
1119
+ ]
1120
+ data = {
1121
+ "files": sorted(files),
1122
+ "strict": [{"rule": item.rule, "message": item.message} for item in findings],
1123
+ "summary": lifecycle.summary(context),
1124
+ }
1125
+ ok = not (strict and findings)
1126
+ result = Result(ok=ok, issues=[*issues, *found] if strict else issues, data=data)
1127
+ return result, files, context
1128
+
1129
+
1130
+ def lifecycle_docs(
1131
+ location: Path,
1132
+ output: Path,
1133
+ *,
1134
+ strict: bool = False,
1135
+ results: Path | None = None,
1136
+ options: DocsOptions | None = None,
1137
+ ) -> Result:
1138
+ """Write the requirements, design, test and traceability documents to `output`.
1139
+
1140
+ Only files with the names of the documents are written; other files are left alone. With
1141
+ `strict` the result fails when a review would reject the model (rules DS-1 to DS-4). With
1142
+ `results` (the build directory of a test run) the documents include the test results,
1143
+ coverage, stack use and toolchain, and strict also applies rules DS-5 to DS-7. `options`
1144
+ adds export formats and permalinks.
1145
+ """
1146
+ result, files, context = _lifecycle(
1147
+ location, strict=strict, results_dir=results, options=options
1148
+ )
1149
+ if not files or context is None:
1150
+ return result
1151
+ output.mkdir(parents=True, exist_ok=True)
1152
+ for name, text in files.items():
1153
+ (output / name).write_text(text, encoding="utf-8", newline="\n")
1154
+ exported = _export(context, output, _directory(location), options or DocsOptions())
1155
+ if options and options.since:
1156
+ exported += _impact(location, output, context, options.since)
1157
+ lines = [f"wrote {len(files) + len(exported)} documents to {output}\n"]
1158
+ lines += [f"{item.message}\n" for item in result.issues if "[DS-" in item.message]
1159
+ result.text = "".join(lines)
1160
+ result.data["files"] = sorted([*result.data["files"], *exported])
1161
+ return result
1162
+
1163
+
1164
+ def _impact(
1165
+ location: Path, output: Path, context: lifecycle.DocsContext, revision: str
1166
+ ) -> list[str]:
1167
+ """Write impact.md and impact.csv: what changed since the revision."""
1168
+ plugins = load_plugins(find_plugins(location))
1169
+ new = impact.side(context.project, context.content_hash, plugins)
1170
+ with _checkout_revision(location, revision) as old_directory:
1171
+ old_project, issues = _load_valid(old_directory)
1172
+ if old_project is None:
1173
+ message = f"the project at {revision} is not valid:\n{_issues_text(issues)}"
1174
+ raise ServiceError(message)
1175
+ old_hash = bundle.build_bundle(old_directory)["bundle"]["content_hash"]
1176
+ old = impact.side(old_project, old_hash, load_plugins(find_plugins(old_directory)))
1177
+ compared = compatibility(old_directory, _directory(location))
1178
+ report = impact.compare(
1179
+ old, new, labels=(revision, "working copy"), compatibility=compared.text
1180
+ )
1181
+ (output / "impact.md").write_text(impact.to_markdown(report), encoding="utf-8", newline="\n")
1182
+ (output / "impact.csv").write_text(impact.to_csv(report), encoding="utf-8", newline="\n")
1183
+ return ["impact.csv", "impact.md"]
1184
+
1185
+
1186
+ def _export(
1187
+ context: lifecycle.DocsContext, output: Path, directory: Path, options: DocsOptions
1188
+ ) -> list[str]:
1189
+ """Write the extra export formats and return their file names."""
1190
+ names: list[str] = []
1191
+ for kind in options.export:
1192
+ if kind == "reqif":
1193
+ reqif.write_llrs(output / "llr.reqifz", context, directory)
1194
+ names.append("llr.reqifz")
1195
+ elif kind != "csv":
1196
+ message = f"unknown export format {kind!r}; choose csv or reqif"
1197
+ raise ServiceError(message)
1198
+ return names
1199
+
1200
+
1201
+ def runtime_docs(output: Path, *, strict: bool = False, results: Path | None = None) -> Result:
1202
+ """Write the dossier of the runtime library (requirements, design, tests, traceability).
1203
+
1204
+ `results` is the build directory of the repository's own coverage build.
1205
+ """
1206
+ try:
1207
+ context = lifecycle_runtime.context(build_dir=results)
1208
+ except ValueError as error:
1209
+ raise ServiceError(str(error)) from error
1210
+ files = lifecycle_runtime.render(context)
1211
+ findings = lifecycle_runtime.strict_findings(context)
1212
+ output.mkdir(parents=True, exist_ok=True)
1213
+ for name, text in files.items():
1214
+ (output / name).write_text(text, encoding="utf-8", newline="\n")
1215
+ data = {
1216
+ "files": sorted(files),
1217
+ "strict": [{"rule": item.rule, "message": item.message} for item in findings],
1218
+ }
1219
+ lines = [f"wrote {len(files)} documents to {output}\n"]
1220
+ lines += [f"[{item.rule}] {item.message}\n" for item in findings]
1221
+ return Result(ok=not (strict and findings), data=data, text="".join(lines))
1222
+
1223
+
1224
+ def lifecycle_archive(location: Path, *, strict: bool = False) -> tuple[Result, bytes]:
1225
+ """Return the documents of a project as a zip (empty bytes when the project is invalid)."""
1226
+ result, files, _ = _lifecycle(location, strict=strict)
1227
+ if not files:
1228
+ return result, b""
1229
+ buffer = io.BytesIO()
1230
+ with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
1231
+ for name, text in sorted(files.items()):
1232
+ archive.writestr(zipfile.ZipInfo(name, (1980, 1, 1, 0, 0, 0)), text)
1233
+ return result, buffer.getvalue()
1234
+
1235
+
1236
+ # --- requirements -------------------------------------------------------------------------------
1237
+
1238
+
1239
+ def _write_lock(location: Path, ids: IdMap) -> Path:
1240
+ path = _directory(location) / llr_ids.LOCK_FILE
1241
+ path.write_text(llr_ids.dump(ids), encoding="utf-8", newline="\n")
1242
+ return path
1243
+
1244
+
1245
+ def requirements_lock(location: Path, text: str | None = None) -> Result:
1246
+ """Write `llr_ids.lock`, which numbers the low-level requirements of a valid project.
1247
+
1248
+ New requirements get the next numbers, requirements that are gone move to `retired`, and a
1249
+ number is never changed or given again. `text` sets the id format of a new lock file.
1250
+ """
1251
+ project, issues = _load_valid(location)
1252
+ if project is None:
1253
+ return Result(ok=False, issues=issues, text=_issues_text(issues))
1254
+ keys = [item.key for item in llr.derive(project)]
1255
+ current = project.llr_ids
1256
+ updated = llr_ids.assign(keys, current, text=text or llr_ids.DEFAULT_FORMAT)
1257
+ before = set(current.ids) if current else set()
1258
+ added = [key for key in updated.ids if key not in before]
1259
+ retired = [key for key in before if key not in updated.ids]
1260
+ path = _write_lock(location, updated)
1261
+ data = {"file": str(path), "added": len(added), "retired": len(retired), "total": len(keys)}
1262
+ text_out = f"{path}: {len(added)} numbered, {len(retired)} retired, {len(keys)} in total\n"
1263
+ return Result(ok=True, issues=issues, data=data, text=text_out)
1264
+
1265
+
1266
+ def requirements_rename_key(location: Path, old: str, new: str) -> Result:
1267
+ """Keep the number of a requirement when its element is renamed (`old` key to `new` key)."""
1268
+ result = load_project(location)
1269
+ current = result.project.llr_ids if result.project is not None else None
1270
+ if current is None:
1271
+ message = "the project has no llr_ids.lock; run `wireforge reqs-lock` first"
1272
+ raise ServiceError(message)
1273
+ try:
1274
+ updated = llr_ids.rename(current, old, new)
1275
+ except llr_ids.LockError as error:
1276
+ raise ServiceError(str(error)) from error
1277
+ path = _write_lock(location, updated)
1278
+ return Result(ok=True, data={"file": str(path)}, text=f"{old} -> {new}: {updated.ids[new]}\n")
1279
+
1280
+
1281
+ def requirements_list(location: Path) -> Result:
1282
+ """List the low-level requirements with their displayed ids, kinds and parents."""
1283
+ project, issues = _load_valid(location)
1284
+ if project is None:
1285
+ return Result(ok=False, issues=issues, text=_issues_text(issues))
1286
+ rows = [
1287
+ {
1288
+ "id": llr.display_id(item.key, project.llr_ids),
1289
+ "key": item.key,
1290
+ "kind": item.kind,
1291
+ "parents": list(item.parents),
1292
+ "text": item.text,
1293
+ }
1294
+ for item in llr.derive(project)
1295
+ ]
1296
+ text = "".join(f"{row['id']}\t{row['key']}\n" for row in rows)
1297
+ return Result(ok=True, issues=issues, data={"requirements": rows}, text=text)
1298
+
1299
+
1300
+ def _ensure_include(root: Path, pattern: str) -> bool:
1301
+ """Add `pattern` to the `include` list of the root file; return whether it changed."""
1302
+ document = fmt.load(root.read_text(encoding="utf-8"))
1303
+ if not isinstance(document, CommentedMap):
1304
+ return False
1305
+ includes = document.get("include")
1306
+ if includes is None:
1307
+ document["include"] = [pattern]
1308
+ elif pattern in includes:
1309
+ return False
1310
+ else:
1311
+ includes.insert(0, pattern)
1312
+ fmt.canonicalize(document)
1313
+ root.write_text(fmt.dump(document), encoding="utf-8", newline="\n")
1314
+ return True
1315
+
1316
+
1317
+ def requirements_import(location: Path, file: Path, options: hlr_import.ImportOptions) -> Result:
1318
+ """Import the high-level requirements of a ReqIF or CSV file as a baseline snapshot.
1319
+
1320
+ The snapshot is `requirements/hlr_snapshot.yaml`; the root file includes it. A snapshot that
1321
+ was edited by hand is not overwritten unless `options.force` is set.
1322
+ """
1323
+ root = _root(location)
1324
+ if not root.exists():
1325
+ message = f"{root}: no such project file"
1326
+ raise ServiceError(message)
1327
+ target = root.parent / hlr_import.SNAPSHOT
1328
+ try:
1329
+ tool, requirements = hlr_import.read(file, options)
1330
+ hlr_import.check(requirements)
1331
+ except test_results.ResultsError as error:
1332
+ raise ServiceError(str(error)) from error
1333
+ if target.exists() and not options.force:
1334
+ existing = to_plain(schema.load_yaml(target))
1335
+ found = existing if isinstance(existing, dict) else {}
1336
+ source = found.get("requirements_source") or {}
1337
+ now = hlr_import.content_hash(found.get("requirements") or {})
1338
+ if source.get("content_sha256") != now:
1339
+ message = f"{target}: modified by hand or not a snapshot; import again with --force"
1340
+ raise ServiceError(message)
1341
+ document = hlr_import.snapshot(file, tool, requirements, options.baseline)
1342
+ yaml = YAML(typ="rt")
1343
+ yaml.default_flow_style = False
1344
+ buffer = io.StringIO()
1345
+ yaml.dump(document, buffer)
1346
+ target.parent.mkdir(parents=True, exist_ok=True)
1347
+ target.write_text(fmt.format_text(buffer.getvalue()), encoding="utf-8", newline="\n")
1348
+ included = _ensure_include(root, hlr_import.INCLUDE)
1349
+ checked = validate(location)
1350
+ data = {"file": str(target), "requirements": len(requirements), "tool": tool}
1351
+ text = f"imported {len(requirements)} requirements of {options.baseline} into {target}\n"
1352
+ return Result(
1353
+ ok=checked.ok, issues=checked.issues, data={**data, "include": included}, text=text
1354
+ )